zshで『bashなら動くのに』となる単語分割の罠——forループとffmpegのオプション変数
macOSのデフォルトシェルはzsh。bashのつもりで書いたスクリプトが、エラーも出さずに黙って別の動きをすることがある。for x in $VAR が1回しか回らない問題と、ffmpegのオプションを変数にまとめたら Trailing garbage で落ちる問題。根っこは同じ。
macOS のデフォルトシェルは zsh になった。bash のつもりで書いたスクリプトが、エラーも出さずに黙って別の動きをすることがある。原因はほぼ「クオートで囲っていない変数の扱い」の 1 点に集約する。
先に共通の原因を書く。bash は、クオート(")で囲っていない変数を空白で区切って複数の引数にする(これを単語分割と呼ぶ)。zsh はこれをしない。 だから VAR="a b c"; cmd $VAR と書くと、bash は a b c の 3 引数、zsh は a b c の 1 引数になる。この違いが、次の 2 つの症状を生む。
落とし穴1:for x in $VAR がzshだと1回しか回らない
事象
VAR="a b c"
for x in $VAR; do echo "$x"; done
- bash:
a/b/cと 3 回まわる - zsh:
a b cが丸ごと入って 1 回で終わる
ループが空振りするのに、エラーは一切出ない。だから気づきにくい。
原因
zsh は $VAR を分割せず 1 語として for のリストに渡す。リストの要素が 1 個なので、1 回しかまわらない。
解決策
# ① リストは変数に詰めず直接列挙する(一番素直)
for x in a b c; do echo "$x"; done
# ② どうしても変数経由なら ${=VAR} で分割を明示する
for x in ${=VAR}; do echo "$x"; done
# ③ 配列にする(おすすめ)
arr=(a b c)
for x in "${arr[@]}"; do echo "$x"; done
落とし穴2:ffmpegの共通オプションを変数にまとめたら Trailing garbage で落ちる
事象
複数の ffmpeg 呼び出しでエンコード設定を使い回そうと、オプションを変数にまとめた。
ENC="-c:v libx264 -pix_fmt yuv420p -movflags +faststart"
ffmpeg -i in.mp4 $ENC out.mp4
zsh でこう落ちる。
Trailing garbage at the end of a stream specifier
原因
zsh は $ENC を分割しないので、-c:v libx264 -pix_fmt yuv420p -movflags +faststart という文字列全体が 1 個の引数として ffmpeg に渡る。ffmpeg は -c:v の値としてこの塊を受け取り、c:v の後ろをストリーム指定子(処理対象の映像/音声トラックを指定する記法)として解釈しようとして壊れる。bash なら単語分割されて意図どおり複数オプションになるので、bash で書いたスクリプトを zsh でまわすと踏む。
解決策
for のときと同じで、分割を明示するか配列にする。
# ① 分割を強制する
ffmpeg -i in.mp4 ${=ENC} out.mp4
# ② 配列(空白や特殊文字を含む引数にも強い・こちらが安全)
enc=(-c:v libx264 -pix_fmt yuv420p -movflags +faststart)
ffmpeg -i in.mp4 "${enc[@]}" out.mp4
補足
- 根っこは「zsh は未クオート変数を単語分割しない」の一点だ。
forでも ffmpeg でも curl でも、変数に複数の引数を詰めて$VARで展開する書き方は zsh で崩れる - 迷ったら 配列 +
"${arr[@]}"が最も安全。空白やクオートを含む引数もそのまま保てる setopt shwordsplitで zsh を bash 寄りの分割挙動にもできるが、スクリプト全体の挙動が変わる。局所的に${=VAR}か配列で対処するほうが事故が少ない
※ 本記事にはアフィリエイトリンクが含まれます。