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

## はじめに

エラーメッセージが違うよね？程度でさらっと流してしまいがちですが、`ls -l *.txt` でファイルが見つからなかった時の bash（zsh 以外）と zsh の（デフォルト設定での）処理には大きな違いがあります。この記事では基本的に `ls` コマンドを例に解説していますがコマンドに依存する話ではないので他のコマンドでも同じです。

## エラーメッセージの違い

空のディレクトリで bash と zsh のそれぞれで `ls -l *.txt` を実行してみます。

**bash の場合**

```sh
# 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 の場合**

```sh
$ 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` でファイルが見つからなかった場合に空リストになってしまうと問題が出ます。

```sh
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 行目を出力するコードです。

```sh
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` という名前のファイル名があったとしても動作します。（もちろんこんな名前をつけるのはやめた方が良いと思いますが。）

```sh
for file in *.txt; do
  [ -e "$file" ] || continue
  firstline=$(head -n 1 -- "$file")
  printf '%s: %s\n' "$file" "$firstline"
done
```

もしくは代わりに `find` コマンドを使用します。

```sh
find . -name "*.txt" | while IFS= read -r file; do
  firstline=$(head -n 1 -- "$file")
  printf '%s: %s\n' "$file" "$firstline"
done
```

## さいごに

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