Shell Script Advent Calendar 2016の12月17日エントリーです(途中途切れてるので日数計算は不明)。
初級者向けの基礎知識として、シェルの展開順序について書きます。ここではbashを例に説明しますが、基本は各種シェルに共通していると思います。
展開と実行の順序
端末やシェルスクリプトなどでシェルを使うときには、展開順序を意識していないと思わぬ失敗をすることがあります。展開(expansion)とは、ファイル名のパターン(ワイルドカード)やシェル変数などの処理です。
展開のメカニズムは普通のプログラミング言語のモデルと大きく異なる点の一つです。たとえば、多くのプログラミング言語の文法では変数は値の一種として扱われますが、シェルの文法では変数が展開されてから実際の処理が実行されます。このあたりの感覚は、C言語のプリプロセッサマクロなどに近いかもしれません。
bashのmanpageで「展開」(expansion)の項を見てみましょう。ここには、コマンドラインが次に挙げる順序で展開されると書かれています。
-
ブレース展開(brace expansion):
{foo,bar}や{0..5}など -
チルダ展開(tilde expansion):
~/.bashrcや~someoneなど -
パラメータ・変数展開(parameter and variable expansion):
$fooなど -
算術式展開(arithmetic expansion):
$((…)) -
コマンド置換(command substitution):
$(…)や`…` - 単語分割(word splitting)
-
パス名展開(pathname expansion):
*、?、[]など -
クォートの削除(quote removal):
'や"など
これらの展開の後で、コマンドに引数が分割された(ARGV的な)形で渡されて、実行されます。
それぞれの詳しい意味についてはmanpageやほかの記事などをご参照ください。以下では、展開順序を意識していないと誤解することがあるケースを紹介します。
echo Helloとecho 'Hello'とecho "Hello"はまったく同じ
ほかのプログラミング言語の経験がある人が「Hello」と出力するシェルスクリプトを書くと、echo "Hello"のように書きがちです。いや、この書き方自体はまったく問題ありませんが、"Hello"と書く心理には、ほかのプログラミング言語での文字列リテラルの癖があるかと思います。
展開順序を見ると、クォートの削除(8番)のあとに、コマンドに引数が渡されます。つまり、上の3つの記述ではコマンドから見ると、同じくクォートのない「Hello」という文字列が引数に渡されるわけです。
同じ理屈により、echo 'H'elloもecho "He"lloも同じ結果になります。補完などの都合で'/some/path to/'*のような表記になったときも、1つのパス表記に見えないかもしれませんが、/some/path\ to/*と同じです。
「カレントディレクトリにある「-r」というファイルを消すコマンドは?」という問題に「rm "-r"」という答えが間違っているのも、同じ理由です。
ファイル名に空白が含まれる可能性を考える
ほかのプログラミング言語の経験がある人が文字列リテラルに" "を付けがちというパターンの反対で、変数のクォートを忘れがちというパターンもあります。
上の展開順序で、パラメータ・変数展開(3番)よりあとに単語分割(6番)が実行されることに注意します。
たとえばcp $file /path/to/somewhereという記述は、$fileにpen pineappleという空白を含むファイル名が入っていると、cp pen pileapple /path/to/somewhereと展開されます。つまり、pen、pineappleという2つのファイル名が指定されたことになってしまいます。
echo $(yes '' | head -3 | nl -ba)はどうなるか
$(…)の中のコマンドだけを実行すると、次のような出力となります。
1
2
3
そして、上のように$(…)の中に入れてechoすると、次のような出力となります。
1 2 3
これは前項と似た原因で、コマンド置換(5番)によってコマンドの結果がコマンドラインに展開されたあとで、単語分割(6番)が実行されるからです。単語分割では、改行や空白がいくつ並んでも1つの分割場所と見なします。これにより「1」「2」「3」という3つの引数がechoに渡されて、空白区切りで出力されるわけです。
もし改行や複数の空白を保持して扱いたいのであれば、echo "$(yes '' | head -3 | nl -ba)"のように$(…)をダブルクォートで囲んで、1つの値とします。
このケースはコマンド置換のほか、改行を含むコマンドの出力を変数に入れて使う場合などにも遭遇します。そのような変数の使いかたが望ましいかどうかはまた別の問題ですが。
反対に、引数として展開されることを前提にすると、次のようなこともできます。
mkdir $(cut -d: -f1 /etc/passwd)
mv foo{,.orig}というイディオム
これは、ファイル名をちょっと変えるときによく使われるイディオムです。mv foo{,.orig}はブレース展開(1番)によりmv foo foo.origと展開されます。最終的にはmvコマンドにfooとfoo.origという引数が渡されて実行されます。つまり、fooのあとに.origを追加することを意味しています。
ブレース展開は一番最初に適用されるため、ほかの展開との組み合せも、展開順序を理解していればわかりやすいでしょう。たとえば、rm path/to/{_*,*.bak}はrm /path/to/_* /path/to/*.bakと展開されたあと、*についてパス名展開(7番)がなされてから、コマンドに渡ります。
ls *はただの整形
たとえば、カレントディレクトリにapple、pen、pineappleの3つのファイルだけがあるとします。ls *はパス名展開(7番)によりls apple pen pineappleと展開されて実行されます。つまり、lsコマンドのファイルの一覧を得る機能を使わず、引数を出力するというだけのものになります。これは、lsで充分です。
さらに、コマンド置換で$(ls *)や`ls *`という記述(両者はほぼ同じ意味)を見掛けることがありますが、これはやめたほうがいいでしょう。
まず、上記の通りls *は*を展開したリストを整形して(パイプに出力する場合は1行ずつにして)出力する意味しかありません。そして、これをサブシェルで実行する$(…)によって出力結果がコマンドラインに展開されたあとで、単語分割(6番)が実行されます。
つまり、単に*と書くのとほぼ同じことを回りくどく実行しているだけです。
さらに、次項のような問題もあります。
for x in $(ls) …がなぜ問題か
カレントディレクトリのファイルを順に処理するのに、for x in $(ls) …やfor x in $(ls *) …のような記述(あるいは$( )を` `にした記述)を見掛けることがあります。これはやめたほうがいいでしょう。
コマンドラインで試してみましょう。カレントディレクトリにファイルがない状態でapple pen(途中に空白あり)とpineappleという2つのファイルを作ります。
$ touch 'apple pen' pineapple
$ ls
apple pen pineapple
この2つのファイルを順にfileコマンドにかけようと、次のように実行してみます。
$ for x in $(ls); do file "$x"; done
すると、エラーになってしまいました。
apple: cannot open `apple' (No such file or directory)
pen: cannot open `pen' (No such file or directory)
pineapple: empty
これは、コマンド置換(5番)でapple pen penpineappleと展開されたあとに単語分割(6番)が実行されることにより、for文のxにはapple、pen、penpineappleの3つが順に与えられるためです。
かわりに*をそのまま与えます。パス名展開(7番)は単語分割(6番)より後です。
$ for x in *; do file "$x"; done
今度はうまくいきました。
apple pen: empty
pineapple: empty
ちなみに、「ファイル名に空白が含まれる可能性を考える」の項で書いたように、fileコマンドに与えるときに$xをダブルクォートで囲むのを忘れずに。
まとめ
シェルの展開順序を意識していないと、思わぬところで想定しない結果になることがあります。変数やコマンド置換などの挙動でアレッと思ったら、シェルの展開順序を確認してみましょう。