手元のスクリプトを別の環境に持っていけるか確かめようとして、zsh と bash に同じファイルを渡してみました。出力が8箇所で違いました。エラーは1つも出ていません。
エラーになるものより、黙って違う値を返すもののほうが多いというのが、並べてみて分かったことです。
以下、zsh 5.9 と bash 5.2.21 の両方で実行した結果です。同じスクリプトを両方に食わせて、出力を並べています。
黙って違う値になるもの
こちらが本題です。エラーが出ないので、実行しただけでは気づけません。
配列の添字が、1から始まるか0から始まるか
A=(alpha beta gamma)
echo "${A[1]}"
| 出力 | |
|---|---|
| zsh | alpha |
| bash | beta |
zsh の配列は1始まり、bash は0始まりです。**どちらも「ある要素」を返すので、間違いに見えません。**ループの中で使っていると、1つずれた結果が最後まで通ります。
${#A} が、要素数か文字数か
A=(alpha beta gamma)
echo "${#A}"
| 出力 | |
|---|---|
| zsh | 3 |
| bash | 5 |
zsh は要素数の3、bash は最初の要素 alpha の文字数の5を返します。どちらも整数なので、件数として使っていると気づきません。
$A が、全要素か最初の1つか
A=(alpha beta gamma)
echo "$A"
| 出力 | |
|---|---|
| zsh | alpha beta gamma |
| bash | alpha |
bash で書いたつもりの人は ${A[@]} と書くので出会いませんが、zsh で書いたコードを持っていくと、要素が2つ消えます。
クォートしない変数が、単語に分かれるかどうか
V="x y z"
for w in $V; do echo "[$w]"; done
| 出力 | |
|---|---|
| zsh |
[x y z] (1回) |
| bash |
[x] [y] [z] (3回) |
これがいちばん厄介だと思いました。zsh は変数展開で単語分割をしません。bash はします。ループの回数が変わります。
区切り文字で分けたい意図で書いていれば zsh で動かず、逆に分けたくないなら bash で壊れます。どちらの向きにも事故ります。
なお、コマンド置換 $(...) のほうは両方とも分割します。ここだけ挙動がそろっているので、余計に混乱しました。
${V:u} が、大文字化か素通りか
V=abcdef
echo "${V:u}"
| 出力 | |
|---|---|
| zsh | ABCDEF |
| bash | abcdef |
zsh の大文字化の記法です。bash はエラーを出さず、**そのまま返します。**大文字にしたつもりの文字列が、小文字のまま後続に流れます。
bash で大文字化するなら ${V^^} です。記法そのものが違います。
エラーになるもの
こちらは、実行すれば分かります。
${=V}(強制的に単語分割する)
V="x y z"
for w in ${=V}; do echo "[$w]"; done
zsh では3回まわります。bash では止まります。
line 7: ${=V}: bad substitution
${(j:,:)A}(配列を区切り文字で結合する)
A=(alpha beta gamma)
echo "${(j:,:)A}"
zsh では alpha,beta,gamma です。bash では同じく bad substitution になります。
わたしはこれを実際に使っていました。移植するなら IFS を使う書き方に直します。
IFS=,
echo "${A[*]}"
unset IFS
これは両方で alpha,beta,gamma になります。
挙動が違うが、気づきやすいもの
グロブが一致しなかったとき
for f in /nonexistent/*.txt; do echo "[$f]"; done
| 結果 | |
|---|---|
| zsh |
no matches found でスクリプトが止まる |
| bash | パターン文字列がそのまま1回まわる |
方向が正反対です。zsh は止まるので気づけますが、bash は存在しないファイル名を処理しようとします。[ -e "$f" ] で確かめるか、bash 側で shopt -s nullglob を入れます。
再帰グロブ **
for f in g/**/*.txt; do echo "[$f]"; done
g/a/b/c/deep.txt があるとき、こうなりました。
| 出力 | |
|---|---|
| zsh | g/a/b/c/deep.txt |
| bash |
g/**/*.txt (一致せず、パターンのまま) |
zsh は既定で再帰します。bash は shopt -s globstar が要ります。しかも一致しなかった場合はパターンがそのまま流れるので、上の項目と組み合わさって二重に事故ります。
両方で同じだったもの
安心して使える書き方も確かめました。
A=(alpha beta gamma)
for w in "${A[@]}"; do echo "[$w]"; done # 両方とも3回
echo "${#A[@]}" # 両方とも 3
V=abcdef; echo "${V:0:3}" # 両方とも abc
for w in $(echo "x y z"); do :; done # 両方とも3回
set -u も、メッセージは違いますが両方で止まります。
zsh : MISSING: parameter not set
bash: MISSING: unbound variable
echo -e は、今回の環境では両方ともエスケープを解釈しました。ただしここはビルトインの実装と設定に左右されるので、確実にしたいなら printf を使います。
移植するときの最低限
自分用にまとめると、こうなりました。
配列は必ず "${A[@]}" と書く。個数は ${#A[@]}。添字は使わない(どうしても要るなら、どちらのシェルで動かすかを先頭に書く)。変数を単語に分けたいときは、分けたい形の配列を先に作る。zsh 固有の記法(${=V}、${(j:,:)A}、${V:u})は使わない。グロブは、一致しない可能性を必ず考える。
そして、**シバンを書く。**今回の差は全部、#!/bin/zsh か #!/bin/bash を書いて、その通りに起動していれば出ません。手元で動いたから大丈夫、という確認の仕方をやめる、というだけの話でもあります。
追記:指摘を受けて直したところ
公開後にコメントで2つ指摘をいただきました。どちらも当たっていたので、記事を直しています。
シバンを書いていなかった
そもそもシバンを書いていなかったのではないか、という指摘です。そのとおりです。書いていませんでした。
そのうえで、書いても残る問題が2つあります。
ひとつ、シバンが効くのは ./script.sh で実行したときだけです。
./script.sh # シバンが読まれる
bash script.sh # シバンは無視される。bash で動く
sh script.sh # 同じく無視される
呼び方を自分で決められない場合があります。cron から呼ばれるとき、CI の設定に書かれているとき、他の人が実行するとき。
ふたつ、**置いた先に zsh が入っているとは限りません。**Ubuntu の最小構成には入っていませんでした。今回の検証でも apt-get install zsh から始めています。#!/bin/zsh と書いたスクリプトは、そこでは起動すらしません。
シバンは前提条件を宣言するものであって、環境を用意してくれるものではありませんでした。
OSの差ではなく、シェルの差だった
もう1つは、これは macOS か Linux かの話ではない、という指摘です。これも当たっています。Linux で zsh を動かせば zsh の結果になり、macOS で bash を動かせば bash の結果になります。
当初のタイトルは「macOSのzshで書いたスクリプトが、Linuxのbashで」でした。原因をOSに置いてしまっていたので、変えました。
同じ指摘の中で、配布や移植を考えるなら bash か POSIX sh で書くほうがよいという提案もいただきました。同意します。今回の8箇所は、結局その結論に行き着くための材料でした。zsh 固有の記法を使わなければ、この比較そのものが要りません。
ただし POSIX sh まで下げると、配列も "${A[@]}" も使えなくなります。手元の道具は bash、人に渡すものは sh、という切り方を試そうと思っています。
経緯については
なぜ想定と違うシェルで動いたのかを聞かれましたが、覚えていません。
書き出しを「サーバーに置いたら結果が変わりました」としていたのは、経緯として断定するには曖昧すぎました。直しています。確かなのは、両方のシェルに同じファイルを渡して比べた8箇所の出力だけです。そこは実測なので、値は変えていません。
指摘をくださったお二人に感謝します。
確かめ方
今回は、同じスクリプトを両方に渡して出力を比べました。
echo "=== zsh ==="; zsh test.sh
echo "=== bash ==="; bash test.sh
これだけです。移植の前に一度通せば、bad substitution は全部出ます。黙って違う値になるほうは出ないので、そこは出力を並べて目で見るしかありませんでした。
わたしはこれを、動かす先を変えようとしてから調べました。書いているときに調べていれば、もっと早く終わっていました。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。