macOS で書いたスクリプトを、サーバーに置いたら結果が変わりました。エラーは出ていません。
原因は zsh と bash の差です。エラーになるものより、黙って違う値を返すもののほうが多いことが、調べてみて分かりました。
以下、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 を書いて、その通りに実行していれば起きません。macOS のターミナルで動いたから大丈夫、という確認の仕方をやめる、というだけの話でもあります。
確かめ方
今回は、同じスクリプトを両方に渡して出力を比べました。
echo "=== zsh ==="; zsh test.sh
echo "=== bash ==="; bash test.sh
これだけです。移植の前に一度通せば、bad substitution は全部出ます。黙って違う値になるほうは出ないので、そこは出力を並べて目で見るしかありませんでした。
わたしはこれを、移植で困ってから調べました。書いているときに調べていれば、もっと早く終わっていました。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。