40
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

シェルスクリプト&PowerShellAdvent Calendar 2023

Day 14

シェルスクリプトが速くなる! forkしない新しいコマンド置換がやってくる!(次期bash/zshの新機能)

Last updated at Posted at 2024-01-20

はじめに

シェルスクリプトを遅くする大きな原因は fork と exec です。この二つは OS のインターフェースである fork() 関数と exec() 関数のことで、シェルスクリプトからは、外部コマンドやバックグラウンドプロセスの実行、明示的なサブシェル (...)やコマンド置換 ret=$(...)、パイプの使用などで呼び出されます。

シェル関数はシェルの中で実行される関数であるため、単純にシェル関数を呼び出す場合には fork も exec も行われません。しかしシェル関数の出力を変数に代入しようとコマンド置換(var=$(func))を使うと、exec は行われませんが fork は行われてしまいます。その事に気づかず例えば回数の多いループの中でにコマンド置換を使うことがシェルスクリプトを遅くする原因の一つとなっていました。新しいコマンド置換である「関数置換(仮)」と「値置換(仮)」はこの問題を取り除く機能です。

関数置換 (Function Substitution: funsub) と値置換 (Value substitution: valsub) は mksh での呼び名(を日本語に訳したもの)です(ドキュメント)。bash での命名を優先したいと思っていますが最終的にどうなるのか現時点では不明です。最近は funsub と varsub(変数置換?)と呼んでいるような気がします。

対応シェルは以下のとおりです。まず ksh93 で funsub が実装され、それを mksh が取り込み valsub を追加、そして bash と zsh が現在開発中という流れです。リリースはまだですが開発版にすでに実装されています。

  • ksh93 - 93t (2008-08) funsub (※ valsub なし)
  • mksh - R42 (2013-02) funsub、R46 (2013-05) valsub
  • bash - 5.3(予定) funsub、valsub
  • zsh - 5.10(予定) funsub、valsub

注意: OpenBSD sh はコマンド名 ksh で実行できますが、その実体は ksh88 相当の pdksh です。ksh93 とは別物で機能は大幅に少ないので注意してください。

TL;DR

関数置換と値置換の登場によって、今までのコマンド置換を使った書き方は次のような書き方ができるようになります。コマンド置換のバリエーションと考えることが出来るので、書き方がそう大きく変わるわけではありません。

# 今までのコマンド置換を使った書き方(末尾の改行は削除される)
filepath="$(basename "$file")/file.txt"

# 関数置換を使った書き方(末尾の改行はzsh以外?は削除される)
filepath="${ fdirname "$file"; }/file.txt"

# 値置換を使った書き方(末尾の改行は保存される)
filepath="${| vdirname "$file"; }/file.txt"

ここで fdirnamevdirname はシェル関数です。外部コマンドを使うことも可能ですが、使ってもほとんど意味がないため事実上シェル関数の使用が前提です。

シェル言語の4つの置換機能

今回追加されるのは関数置換 (funsub) と 値置換 (valsub)の二つですが、ついでにシェルが持っているすべての置換機能についてまとめます。ちなみに変数展開やブレース展開などの展開機能と置換機能の違いですが、置換機能はコマンドや関数など任意のコードの実行を伴うものとして区別することができます。

# 展開(コードの実行を伴わない)
echo "$value ${value#prefix}"    # 変数展開
echo *.txt                      # パス名展開
cd ~/                           # チルダ展開
echo $((100 + 200))             # 算術式展開
echo {1..10}                    # ブレース展開

# 置換(コマンドやシェル関数など任意のコードの実行を伴う)
echo $(date)                    # コマンド置換
echo ${ func; }                 # 関数置換
echo ${| func; }                # 値置換
wc <(date)                      # プロセス置換

記号が多くて混乱するかもしれませんが、(いくつか例外がありますが)基本的なルールを知れば簡単に覚えることができます。まず $ はその場所に「値」を当てはめる時に使う記号です。今回の記事の例は戻り値を当てはめるものなので $ がつくものばかりです。

((i = i + 10))        # $ がつかないので計算するだけ 
echo $((i = i + 10))  # 計算結果の値を echo の引数に当てはめる

カッコの意味は次のとおりです。

{ ...; }    # ブレースはサブシェルなしの意味
( ... )     # 丸カッコはサブシェルありの意味
(( ... ))   # 丸かっこ二つは数値計算や数値に関するときに使う(サブシェルなし)
            # 例: for ((i=0; i<10; i++)) はループのインデックス変数が数値関係

あとは大体これらの組み合わせです。ちなみに { ...; } にセミコロンが必要な理由ですが、() はシェルのメタ文字なのに対して {} はアルファベットなどのただの文字と同じ扱いだからです。{} は文字としては特別な意味を持っておらず dodone と同じく予約語です。

コマンド置換 (Command Substitution - comsub)

コマンド置換は昔からある機能で置換機能の中で唯一 POSIX で標準化されているものです。元々は Bourne シェルでバッククォートを使う書式(現在は非推奨)で実装された機能です。コマンド置換はサブシェル(≒子プロセス)を伴うため遅いですが、その反面シェル関数内での変数の使用がグローバル変数にならないという特徴があります。

fn() {
  # コマンド置換経由で呼び出した場合はサブシェル内で実行されるので
  # 変数は呼び出し元から見えない(ローカル変数として考えることが出来る)
  # var=123 
  echo "str"
}

ret=$(fn)
echo "$(fn)"

# 参考: Bourneシェル(古いsh)の書き方(非推奨)
#   ネストしたときに「`」をエスケープしなければならず読みづらくなる
# ret=`cmd`

コマンド置換によるパフォーマンス低下は小さくはありません。bash、mksh、zsh では 15 倍以上の差がでます。

$ time bash -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do fn > /dev/null; done'
real    0m0.220s
user    0m0.171s
sys     0m0.048s

$ time bash -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=$(fn); done'
real    0m3.467s
user    0m2.783s
sys     0m0.992s

ksh93 では(条件を満たしている場合に)forkを行わない仮想サブシェル(サブシェルと同じ動きをするが子プロセスを作らない)に最適化されるのでシェル関数をコマンド置換で呼び出してもパフォーマンスはそれほど低下しません。

$ time ksh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do fn > /dev/null; done'
real    0m0.191s
user    0m0.118s
sys     0m0.073s

$ time ksh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=$(fn); done'
real    0m0.132s
user    0m0.105s
sys     0m0.024s

ちなみに ksh93 でも子プロセスを使わないと実現できないような機能を使った場合($! で子プロセスの PID を取得するなど)は fork が行われます。

シェルスクリプトでパフォーマンスの話をするとパフォーマンスを気にするぐらいなら他の言語を使えと言ってくる人がいるのですが、そもそも「コマンドを組み合わせたいのにパフォーマンスの問題のせいで、他の適してない言語を使わなきゃならない」問題を避ける話をしています。コマンドを組み合わせるのはシェルが適しています。パフォーマンスなどという些細な問題で、適しているシェル言語を使えないのは大問題です。最大のパフォーマンスを追求することに意味はありません。重要なのは実用にならなくなるほどの大きなパフォーマンス低下を避けることです。

コマンド置換の注意すべき仕様は、末尾の連続する改行が消えてしまうことです。

いくら改行を出力したとしてもコマンド置換経由だと消えてしまう
fn() {
  echo "str"
  echo
  echo
  echo
}

ret="$(fn)"
echo "[$ret]" # => [str]

補足ですがファイルの中身のすべてを変数に代入する場合には以下の方法が使えます。コマンド置換と似た書き方ですが、コマンド置換とは別ものと考えたほうが良いです。

# bash 5.2 以上は fork しないので速い
text=$(</etc/hosts)

# 機能的には以下とほぼ同等(末尾の連続する改行も削除される)
text=$(cat </etc/hosts)

# 後ろに別の命令をつけると挙動が変わる
#   bash、kshはファイルを読み込まずechoのみの効果
#   zshではファイルが読み込まれる(NULLCMDを調べよ)
text=$(</etc/hosts; echo "test")

ちなみに bash 5.2 系では上記はメモリリークのバグがあるので注意してください。5.2 よりも前のバージョンには問題がないのでおそらく fork しないようにしたときに入れてしまったバグでしょう。近いうちに修正バージョンがリリースされるんじゃないかと思っていますが少なくとも bash 5.2.26 時点ではまだパッチが適用されていないようです。

関数置換 (Function Substitution - funsub)

関数置換は bash と zsh で新しく追加される機能です。ksh93 と mksh では以前から使用可能でした。

fn() {
  # 関数置換を使用した場合はサブシェル内で実行されないので
  # ローカル変数を使うように注意する
  # local var=123 
  echo "str"
  # シェルによって動作が異なるため関数の中で exit を使用しないようにする
  # ksh、mksh は exit でシェルを終了せず、bash、zsh は終了する
}

ret=${ fn; }
#     ↑  ↑ 前にスペースが必要、後ろセミコロンは bash のみ必須
#         ↑  後ろスペースは省略可能
# 前にスペースが必要なのは変数展開 ${var} と区別するため

# 直接コードを書くことも出来る
ret=${ echo "str"; }

# 従来のコマンド置換と関数置換の書き方の比較
ret=$( func "$0" )
ret=${ func "$0"; }

関数置換は通常のコマンド置換と違いサブシェルを伴わないため速くなります。ksh93 はサブシェルで子プロセスを生成しないため他のシェルほど大きな差はでていませんが、それでも仮想サブシェルを使わないのでわずかに速くなるようです。

bash コマンド置換と関数置換の違い
$ time ./bash -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=$(fn); done'
real    0m4.781s
user    0m3.873s
sys     0m1.288s

$ time ./bash -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=${ fn; }; done'
real    0m0.811s
user    0m0.696s
sys     0m0.112s
ksh コマンド置換と関数置換の違い
$ time ksh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=$(fn); done'
real    0m0.160s
user    0m0.135s
sys     0m0.024s

$ time ksh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=${ fn; }; done'
real    0m0.089s
user    0m0.089s
sys     0m0.000s
mksh コマンド置換と関数置換の違い
$ time mksh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=$(fn); done'
real    0m2.361s
user    0m1.801s
sys     0m0.780s

$ time mksh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=${ fn; }; done'
real    0m0.511s
user    0m0.166s
sys     0m0.340s
zsh コマンド置換と関数置換の違い
$ time Src/zsh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=$(fn); done'
real    0m3.183s
user    0m2.407s
sys     0m1.072s

$ time Src/zsh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=${ fn; }; done'
real    0m0.877s
user    0m0.343s
sys     0m0.529s

関数置換もコマンド置換と同じように末尾の連続する改行が消えるという仕様があります。ただし現在開発中の zsh 版では末尾の連続する改行は消えません。これに関して末尾の連続する改行を削除するパッチがでていますが、このパッチは zsh モード(デフォルト?)は除くとなっています。最終的にどういう仕様になるかはまだ不明です。

値置換 (Value Substitution - valsub)

値置換は bash と zsh で新しく追加される機能です。mksh では以前から使用可能でした。値置換の特徴は、変数を使って値を返すため末尾の連続する改行が保存されることです。

fn() {
  REPLY="str"
  # すべてのシェル(未実装のkshを除く)でexitはシェルを終了するが
  # 関数で exit は使用しないほうが良いだろう
}

ret=${| fn; }
#     ↑   ↑  前に「|」が必要、後ろセミコロンは bash のみ必須
#      ↑   ↑ 前後のスペースは省略可能

# このように引数を渡すことも可能
echo "${| fn a b c; }"

# このような使い方もできるが便利な使い道はあるだろうか?
echo "${| REPLY=123; }"

値置換は標準出力ではなく REPLY 変数を使用して戻り値を返します。REPLY 変数とは元々拡張 POSIX シェルの read コマンドで変数を省略したときに暗黙的に使用される変数です。おそらく似たような用途として同じ変数を再利用したのだと思われます。ちなみに REPLY 変数は値置換の中で値置換を使うのように再帰的に使用しても問題ないように実装されているようです。

zsh では次のような形で REPLY 変数の代わりに別の変数が使えるようになっています。しかしこれだと値置換を使うたびに呼び出し側で関数内部の変数名を指定しなければならなくなるわけで何が嬉しいのかよくわかりません(なにか勘違いしている?)。どちらにしろ他のシェルと移植性がないので使わないほうが良い気がします。

fn() {
  result="str"
}

ret=${|result|fn;} # result にも値が入る

プロセス置換 (Process Substitution)

プロセス置換は bash、ksh、zsh、Busybox ash 1.34 以降で対応している機能ですが、dash、mksh、yash、FreeBSD sh、NetBSD、OpenBSD sh では使えず POSIX でも標準化されていない機能です。ここまで出てきた他の置換機能とは異なり、値をその場に内容を埋め込むものではなくファイルへの読み書きのようにプロセスを扱うもので、構文として埋め込む記号の $ の代わりに <> を使い、サブシェルを伴うので (...) を使った記号で表現します。

プロセス置換はプロセスをファイルのように扱う機能
$ sort -r -o 'out.txt' 'in.txt'

プロセス置換はプロセスをファイルのように扱う
$ sort -r -o >(tr 12345 ABCDE) <(seq 5)
E
D
C
B
A

プロセス置換はプロセスをファイル(名前付きパイプ)名に置換する
$ echo sort -r -o >(tr 12345 ABCDE) <(seq 5)
sort -r -o /dev/fd/63 /dev/fd/62

プロセス置換はファイル名相当なのでリダイレクトと共に使用可能
$ sort -r < <(seq 5) > >(tr 12345 ABCDE)

補足ですが yash では <(...)>(...) はプロセス置換ではなくプロセスリダイレクトと呼ばれる互換性のない別の機能です。ファイル名に置換されるのではなく直接リダイレクトとして扱われるため、リダイレクトする時の <> は不要ですがファイル名の代わりに使用することはできません。

yash はプロセス置換ではなくプロセスリダイレクト
$ sort -r <(seq 5) >(tr 12345 ABCDE)

その他のシェルの話とPOSIX

関数置換と値置換(とプロセス置換)は、当然ながら POSIX で標準化されたものではありません。しかし多くのシェルで実装された機能というのは、将来の POSIX で標準化される可能性が高くなります。これは POSIX という団体が(自分たちで新しい機能を発明するのではなく)既存の実装から移植性があるものを標準化していくことを目的としている団体だからです。

新しい機能はまず先進的な POSIX シェルである bash、ksh、zsh で実装され広まり、POSIX 標準化が決まった頃に最小の機能しか実装しない dash など実装するというの定番の流れです。そのような流れで標準化された機能に $'...'(ダラーシングルクォート)や set -o pipefail などがあります。標準化されるにしても 10 年ぐらいはかかると思われますが、今からできることもあります。

関数置換はあまりできることは思いつきませんが、値置換相当のものを最小機能の POSIX でエミュレートすることができます。以下にサンプルコードを紹介します。

# 値置換用関数と全く同じ仕様の関数
fn() {
  REPLY="str"
}

# 実証コード POSIXシェル用の値置換の関数を呼び出せる関数
valsub() {
  # $1 に現在の REPLY の値をバックアップ(値置換用関数の再帰呼び出しの対応用)
  set -- "${REPLY:-}" "$@"
  _valsub "$@"
  eval "$2=\$REPLY && REPLY=\$1 && return $?"
}

# REPLY のバックアップと戻り値の変数名を取り除いて関数を呼び出す
_valsub() { shift 2 && "$@"; }

# valsub 関数は echo "${|fn;}" のような使い方ができない点で値置換よりは劣る
valsub ret fn

# もちろん値置換に対応しているシェルではこれで良い
# ret=${|fn;}

値置換用の関数はそのまま使いまわすことができます。REPLY 変数を特殊な変数として使うというのは bash などで既定路線であるため、移植性を考えると非対応のシェルでも特殊な変数として扱う必要があります。valsub 関数は汎用関数です。使う側は値置換に対応していないシェルで関数を使う場合には valsub 関数経由で呼び出し値置換に対応していればもっと簡単な書き方をすることができます。トランスレータを作れば値置換を使ったコードを valsub 関数を使うコードに置き換えることも出来るでしょう。この方法によりどの POSIX シェルでも動作するシェル関数ライブラリを今から作ることが可能になります。このようなライブラリが広まれば POSIX も標準化する理由になるでしょう。

さいごに - シェルプログラミングに与える変化

関数置換と値置換は、すぐというわけではありませんがシェルプログラミングに大きな変化をもたらすものだと私は考えています。この二つは前提としてシェル関数を使います。外部コマンドを使うのであれば関数置換を使う意味はほとんどありませんし、値置換は REPLY 変数で値を戻すものであるため外部コマンドで実装することはできません。したがって対話シェルではほとんど意味を持たずシェルスクリプト限定の機能と言えるでしょう。(一応対話シェル環境でもシェル関数を定義すれば意味があります)

外部コマンドは便利なものですが問題点もあります。それは外部コマンド、特にUnixコマンドは移植性が低く複数の機能を提供しているということです。git コマンドや curl コマンドのようなコマンドは元から大きな機能を提供するものなのでそれはそれで問題ないのですが、単純な文字列処理やパス名処理の場合 sed などの重い多機能なコマンドは過剰すぎます。Unixコマンドの移植性の低さはシェススクリプトに移植性を下げています。処理をシェル関数で実装すれば移植性の問題は解決します。簡単なことには多機能のコマンドよりも単機能のシェル関数が必要です。私はこれまでどの POSIX シェルでも動くシェル関数を以下のような仕様で作ってきました。

path_dirname ret "/var/tmp/file.txt"
echo "$ret" # => /var/tmp

この仕様だと戻り値を受け取るための変数が必要という問題があります。しかし関数置換と値置換によってそれが必須ではなくなります。名前からは連想しにくいかもしれませんが、関数置換と値置換は「戻り値を返す関数」をシェルスクリプトの世界に持ち込むものなのです。

echo "${| path_dirname "/var/tmp/file.txt"; }" # => /var/tmp

以下の記事で私は sed コマンドの代わりに(単純な文字列処理には)変数展開を使用することを提案しました。

変数展開は覚えればそんなに難しいものではありませんが、記号なのであまりシェルスクリプトを書かない人には分かりづらいのも事実です。そういう場合(長くはなりますが)次のように書いて可読性を上げることができるようになります。

str="abcabcabc"

# 変数展開(str の a をすべて A に置換)
ret=${str//a/A}
# 上記と同等の処理を可読性が高い関数にすることが出来る
ret=${| replace_all "$str" a A; }

# その他の例
ret=${| trim "$str"; }

単純な変数展開ですむ場合はやはり変数展開を用いるべきだと思いますが、複雑な処理(例えば trim 処理)は関数にしてしまえば簡単に使えて可読性も向上します。関数置換も値置換もパフォーマンスを上げることが目的の一つであるため、呼び出しが遅い外部コマンドは適していません。いずれにしろ関数置換や値置換を使う場合には変数展開(や算術式展開などシェルの機能)を使ったシェルプログラミングに慣れる必要があります。

将来的のシェルスクリプトは次のようなコードから始まるようになっていくでしょう。

#!/usr/bin/env bash
set -eu -o pipefail

# 汎用の共通ライブラリの読み込み
. lib.sh

filepath="${ fdirname "$file"; }/file.txt"
filepath="${| vdirname "$file"; }/file.txt"

lib.sh にはさまざまな便利なシェル関数を詰め込むことができます。関数置換と値置換の登場は、それらを有効活用するためのシェル関数ライブラリがシェルプログラミングの世界に普及するきっかけになりえます。bash や zsh にとっては新しい機能なのですぐに使われるようになることはないと思いますが、これらの機能はシェルプログラミングを大きく進化させることになるでしょう。

40
36
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?