LoginSignup
3
1

More than 1 year has passed since last update.

ls -l *.txt でファイルが見つからない時の bash (POSIX shell) と zsh の微妙で大きな違いと罠

Last updated at Posted at 2021-09-12

はじめに

エラーメッセージが違うよね?程度でさらっと流してしまいがちですが、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 -oset +o
  • shopt -sshopt -u
  • setoptunsetopt

とこれまた全部バラバラなのが実にひどい所です。

余談ですが zsh の NO_NOMATCH はオプション名が二重の否定となっておりややこしいです。setopt または unsetopt で出力されるオプション名が nonomatch であるため、これを正式なオプション名と考え、setopt NO_NOMATCH または unsetopt NO_NOMATCH (注意 大文字小文字の違いは無視されアンダースコアは削除されて扱われます)で変更するというのが正しい扱い方だと思うのですが、NO を一つ減らした setopt NOMATCHunsetopt NOMATCH を反対意味で実行することもできてしまいます。例えば unsetopt NOMATCHsetopt NO_NOMATCH と同じ意味で nonomatch オプションを有効にします。ただしもう一つ NO を減らした setopt MATCHunsetopt MATCH には対応しておらずエラーになります。

また bash は failglobnullglob の両方が設定されている時、failglob が優先されファイルが見つからない時にエラーになりますが、zsh は逆のように見え setopt NOMATCHsetopt NULL_GLOB の両方を設定してもエラーにはなりません。zsh のドキュメントには NULL_GLOBNOMATCH をオーバーライドすると書かれていますが、内部実装的には setopt NOMATCHnonomatch の設定を無効(デフォルト)にするもので 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

さいごに

普段はこんな細かいことなんか考える必要もないと思うのですが、シェルスクリプトで高い信頼性を求めるのであればこういった知識も必要になってきます。ということで。

3
1
0

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
3
1