はじめに
エラーメッセージが違うよね?程度でさらっと流してしまいがちですが、ls -l *.txt
でファイルが見つからなかった時の bash(zsh 以外)と zsh の(デフォルト設定での)処理には大きな違いがあります。この記事では基本的に ls
コマンドを例に解説していますがコマンドに依存する話ではないので他のコマンドでも同じです。
エラーメッセージの違い
空のディレクトリで bash と zsh のそれぞれで ls -l *.txt
を実行してみます。
bash の場合
# Linux (GNU) の場合
$ ls -l *.txt
ls: cannot access '*.txt': No such file or directory
# macOS (BSD) の場合
$ ls -l *.txt
ls: *.txt: No such file or directory
zsh の場合
$ ls -l *.txt
zsh: no matches found: *.txt
エラーメッセージが違うだけに思えるかもしれませんが、実はエラーメッセージを表示しているコマンドも違っています。エラーメッセージの ls:
や zsh:
から気づくかもしれませんが、bash の場合はエラーメッセージを出力しているのは ls
コマンドです。それに対して zsh の場合はエラーメッセージを出力しているのは zsh 自身です。
bash でエラーが表示されるまでの流れ
bash と書いていますが、元を辿れば Bourne シェルの仕様で zsh 以外のすべての POSIX シェルに当てはまります。
まず ls
コマンドを実行する前に、シェルのパス名展開が行われます。つまり *.txt
が実際にファイル名に展開されます。もしファイルがある場合は ls -l file1.txt file2.txt file3.txt
のように複数のファイルに展開されてから ls
コマンドが呼び出されます。
もしファイルが見つからない場合は *.txt
は *.txt
という文字列に展開され ls -l "*.txt"
を実行します。ls
コマンドは *.txt
という名前のファイルを開こうとしてそのファイルが見つからずエラーメッセージを出力します。
つまり ls: *.txt: No such file or directory
というエラーメッセージは *.txt
というパターンに一致するファイルが見つからなかったという意味ではなく *.txt
という名前のファイルまたはディレクトリが見つからなかったというエラーメッセージなのです。
よく考えられてる・・・のか?と疑問になるような仕様ですね。
zsh でエラーが表示されるまでの流れ
zsh は上記の仕様を改善しています。zsh がエラーメッセージを出しているところかも分かるように ls
コマンドは呼び出されていません。これは zsh がパス名展開でマッチするファイルが無ければ *.txt
という文字列に展開する代わりにくエラーにするような仕様になっているからです。
とてもシンプルな設計で、私にはこちらの方が合理的な仕様に思えます。ただし Bourne シェル・POSIX シェルとの完全な互換性は保たれていません。元々 zsh は互換性を重視するのではなく悪い仕様は改善するという考えを持っているので zsh 的には正しい方針と言えるでしょう。なおこれはデフォルトの動作であり互換性をもたせる方向に設定変更が可能です。
bash と zsh の仕様の違いによる影響
bash の仕様(何度も言いますが、正確には Bourne シェルの仕様であり、それを引き継いているすべての POSIX シェルに当てはまります)と zsh の仕様 の違いによって挙動が変わってしまう例があります。
例 touch *.txt
touch
の本来の使い方であるファイルのタイムスタンプを現在時刻に更新するという例です。もしこのコマンドを実行した時に *.txt
にマッチするファイルが見つからなかったらどうなるでしょうか?
zsh では話は簡単です。マッチしない時点でエラーとなるので touch
コマンドは呼び出されません。一方 bash では touch "*.txt"
が呼び出されます。つまり *.txt
という名前のファイルができてしまうのです。(注意 このファイルを削除する場合は rm "*.txt"
とクォートをしっかりしてから削除してください。クォートがないとすべての txt ファイルが削除されてしまいます。シェルの補完機能でファイルに補完させてから実行するのも良いと思います。)
デフォルトの挙動と変更方法
ここまでの内容は各シェルのデフォルト設定の話でした。この挙動はシェルオプションの設定で変更することができます。
まず大きくエラーにするかしないかという違いがあります。ここまでで解説している通り POSIX シェルでは「エラーにしない」という挙動で bash のデフォルトも同じ挙動です。一方 zsh では「エラーにする」というのがデフォルトの挙動です。この挙動を変更するには以下のコマンドを使用します。
POSIX(その他) | bash | zsh | |
---|---|---|---|
エラーにしない | デフォルト | デフォルト | setopt NO_NOMATCH |
エラーにする | - | shopt -s failglob | デフォルト |
エラーにならない場合は、どのシェルでもデフォルトでは「パターン文字列」に展開されます。これも一部のシェルでは設定または特殊な展開パターンを使って「空リスト」に展開することができます。
bash | ksh93 | yash | zsh | |
---|---|---|---|---|
空リストに設定 | shopt -s nullglob | - | set -o nullglob | setopt NULL_GLOB |
空リストに展開 | - | ~(N)*.txt | - | *.txt(N) |
なおシェルのオプションを有効にするコマンドがバラバラですが、その設定を解除するための方法は
-
set -o
⇔set +o
-
shopt -s
⇔shopt -u
-
setopt
⇔unsetopt
とこれまた全部バラバラなのが実にひどい所です。
余談ですが zsh の NO_NOMATCH
はオプション名が二重の否定となっておりややこしいです。setopt
または unsetopt
で出力されるオプション名が nonomatch
であるため、これを正式なオプション名と考え、setopt NO_NOMATCH
または unsetopt NO_NOMATCH
(注意 大文字小文字の違いは無視されアンダースコアは削除されて扱われます)で変更するというのが正しい扱い方だと思うのですが、NO
を一つ減らした setopt NOMATCH
や unsetopt NOMATCH
を反対意味で実行することもできてしまいます。例えば unsetopt NOMATCH
は setopt NO_NOMATCH
と同じ意味で nonomatch
オプションを有効にします。ただしもう一つ NO
を減らした setopt MATCH
や unsetopt MATCH
には対応しておらずエラーになります。
また bash は failglob
と nullglob
の両方が設定されている時、failglob
が優先されファイルが見つからない時にエラーになりますが、zsh は逆のように見え setopt NOMATCH
と setopt NULL_GLOB
の両方を設定してもエラーにはなりません。zsh のドキュメントには NULL_GLOB
は NOMATCH
をオーバーライドすると書かれていますが、内部実装的には setopt NOMATCH
は nonomatch
の設定を無効(デフォルト)にするもので NULL_GLOB
オプションのみが有効になっているからそういう挙動になったんだろうなと思っています。
疑問 空リストに展開するのがベストでは?
POSIX シェルの仕様ではファイルが見つからなかった場合、パターン文字列に展開されます。もしかしたらファイルが見つからなかった場合は、空リスト(nullglob)に展開するのがベストでは?と思った人がいるかもしれません。確かにパターン文字列に展開されるという仕様は直感的ではなくある種の罠です。
他の言語では何かが見つからない場合は空リスト(空配列)が返ってくるのが一般的ですが、シェルスクリプトの場合はそれだと都合が悪いのです。例えば以下ようなコード *.txt
でファイルが見つからなかった場合に空リストになってしまうと問題が出ます。
ls *.txt | xargs rm
このコードはカレントディレクトリで見つかった txt ファイルを全て削除するコードです。一般的に ls
コマンドの出力結果をパイプで他のコマンドに渡すのはあまり良くないやり方ですが、ここでは無視してください。
このコードで *.txt
が空リストに展開されてしまうと引数なしの ls
が実行されてしまいます。これはカレントディレクトリのすべてのファイルを列挙します。もうわかりましたね?ファイルが見つからない時に空リスト(nullglob)に展開するのがデフォルトであると、このコードは潜在的にカレントディレクトリのすべてのファイルを削除してしまう危険性があるのです。nullglob を有効にする場合は、この挙動に留意してコードを書く必要があります。
パターンに一致するファイルがない場合にパターン文字列に展開されるという仕様は実に「うーん、よく考えられてる・・・のか?」という仕様なのです。
また実はパターン文字列に展開される場合でも、少しだけ危険性があります。例えば rm a.txt b.txt c.txt
を実行しようと思って rm [a-c].txt
と書いた場合を考えてみます。もしいずれかのファイルが存在した場合は、そのファイルに展開されます。しかし見つからない場合は [a-c].txt
というパターン文字列に展開されてしまいます。その場合にもし [a-c].txt
という名前のファイルが存在したら、想定してないこのファイルを削除してしまいます。でもまあそんな名前のファイルなんて作らないよね?という設計なわけです。
とまあ、これらのことを考えると zsh のようにパターンに一致するファイルが見つからない場合はエラーにするのがベストなのですが、それだと互換性が保てないわけで、妥協してこのような仕様になっているのだと思います。
余談ですが、実は上記の ls *.txt
(ファイル名のみ表示)は ls
コマンドを使ってる意味はありません。なぜならシェルによって *.txt
がファイル名に展開されてそれを ls
で表示しているだけだからです。つまり代わりに printf '%s\n' *.txt
を実行してもファイル一覧を取得することができます。と言ってもタイプ数が ls
の方が少ないので私も使います。大抵は -al
などのオプションなどをつけますが。
シェルスクリプトで*.txt
を使うときの注意点
正確にはシェルスクリプトだけとは限りませんが、ターミナルから入力するときはエラーが出ても単に気にしないだけで終わりますが、シェルスクリプトの場合はもう少し作り込むことが多いので、シェルスクリプトの注意点としています。
以下のコードはカレントディレクトリから見つかった *.txt
にマッチするファイルの 1 行目を出力するコードです。
for file in *.txt; do
firstline=$(head -n 1 -- "$file")
printf '%s: %s\n' "$file" "$firstline"
done
もしこのコードで一つもファイルが見つからない場合の出力はどうなるでしょうか?おそらく多くの人が期待する結果はなにも出力されないことだと思いますが、実際には *.txt
は "*.txt"
という文字列に展開されますので firstline=$(head -n 1 -- "*.txt")"
を実行してしまいエラーメッセージが表示されてしまう上に、見つかってもいないファイル *.txt:
が出力されてしまいます。(もしくは set -e
をしている場合はエラーで終了してしまいます。)
これを回避するには、ファイルを参照する前にファイルの存在チェックをする必要があります。このコードはもし *.txt
という名前のファイル名があったとしても動作します。(もちろんこんな名前をつけるのはやめた方が良いと思いますが。)
for file in *.txt; do
[ -e "$file" ] || continue
firstline=$(head -n 1 -- "$file")
printf '%s: %s\n' "$file" "$firstline"
done
もしくは代わりに find
コマンドを使用します。
find . -name "*.txt" | while IFS= read -r file; do
firstline=$(head -n 1 -- "$file")
printf '%s: %s\n' "$file" "$firstline"
done
さいごに
普段はこんな細かいことなんか考える必要もないと思うのですが、シェルスクリプトで高い信頼性を求めるのであればこういった知識も必要になってきます。ということで。