シェルの変数・クォート・終了コード:bash と zsh の違いを実行で確認
変数、クォート、$?、set -euo pipefail、if/for/while、関数を macOS の bash 3.2 と zsh 5.9 の両方で実行。実際のエラーと strict モードがすり抜ける場面まで。
シリーズ:コマンドライン講座 4/6(中級)。 ターミナルでの移動やパイプは使えるようになった人が、そのコマンドを「黙って間違える」のではなく「失敗したら止まる」スクリプトにまとめられるようになるための回です。
手元のターミナルでは動いたのに、cron やCIで動かしたら空のレポートができていた、終了コード 0 なのに何もしていなかった。そんな経験がある人のための記事です。原因のほとんどは、変数の展開のされ方、$? に本当は何が入っているか、そして set -euo pipefail が捕まえないものの3つにあります。Mac の標準シェル zsh で試したスクリプトを、bash script.sh で実行すると同じ行でも挙動が変わるので、以下はすべて両方のシェルで実行しました。
環境: macOS 26、Apple Silicon Mac(M1 Pro)、zsh 5.9 と GNU bash 3.2.57(Apple 標準の /bin/bash)。作業フォルダは ~/demo/ws と表記します。Linux の多くは bash 5 系です。結果が変わる箇所は「ここでは未実行」と明記します。
シリーズの全体像は学習ロードマップに、前回は find・xargs・sed・awk です。今回は前回のワンライナーをスクリプトに包みます。
変数:= の前後に空白を入れない
シェルの変数は、名前に文字列を入れたものです。name=value で代入し、$name で読みます。初日にほぼ全員が踏む罠がこれです。
# t1.sh
name = demo
echo "exit: $?"
name=demo
echo "hello $name"
echo 'hello $name'
echo "file: ${name}_log.txt / $name_log.txt"
## bash
t1.sh: line 1: name: command not found
exit: 127
hello demo
hello $name
file: demo_log.txt / .txt
## zsh
t1.sh:1: command not found: name
exit: 127
hello demo
hello $name
file: demo_log.txt / .txt
空白があると、シェルは name = demo を「name というコマンドを引数 = と demo で実行」と読みます。そんなコマンドはないので終了コード 127 です。ほかにもこの数行から分かることがあります。
- ダブルクォートは変数を展開し、シングルクォートは文字どおり残します。
$name_logはname_logという別の(空の)変数です。境界は${name}_log.txtのように波かっこで示します。- 空の変数は既定ではエラーになりません。
.txtと出力して先に進みました。これは後でset -uで直します。
クォート:bash と zsh の違いを一度は自分の目で
「自分の環境では動く」の正体がこれです。フォルダには my report.txt と other.txt があります。日本語環境では「議事録 2026.txt」のような空白入りファイル名が普通にあるので、他人事ではありません。
# split.sh
f="my report.txt"
ls $f
echo "rc=$?"
ls "$f"
list="a b c"
for x in $list; do echo "item: $x"; done
arr=(one two three)
echo "first=${arr[1]} count=${#arr[@]}"
pat="*.txt"
echo $pat
## bash
ls: my: No such file or directory
ls: report.txt: No such file or directory
rc=1
my report.txt
item: a
item: b
item: c
first=two count=3
my report.txt other.txt
## zsh
my report.txt
rc=0
my report.txt
item: a b c
first=one count=3
*.txt
1画面に4つの違いがあります。
- 単語分割:bash はクォートなしの
$fを空白で分け、lsにmyとreport.txtの2引数を渡しました。zsh は既定で分割しません。 - グロブ展開:bash は変数の中の
*.txtをファイル名に展開し、zsh はそのまま表示しました。 - 文字列のループ:
for x in $listは bash では3回、zsh では1回回ります。リストが欲しいなら配列"${arr[@]}"を使います。 - 配列の添字:bash は 0 始まり(
${arr[1]}はtwo)、zsh は 1 始まり(one)です。
同じファイルを zsh -c 'emulate sh; source ./split.sh' で実行すると、zsh も bash と同じ結果になりました。つまり挙動を決めるのは「打ち込んだシェル」ではなく「実行するシェル」で、先頭の #!/bin/bash 行が効いてきます。
全部を解決するルールは1つ。展開はすべてクォートする("$f"、"${arr[@]}"、"$@")、リストは配列で持つ。これで bash と zsh の答えが揃います。
関数の引数も同じです。$* と $@ は似ていますが別物です。
# args.sh
count_args() { echo "$# args"; }
show() {
count_args $*
count_args "$*"
count_args "$@"
}
show "my report.txt" other.txt
## bash ## zsh
3 args 2 args
1 args 1 args
2 args 2 args
両方のシェルで2つの引数をそのまま渡せたのは "$@" だけでした。
終了コードと $?
すべてのコマンドは 0〜255 の終了コードを返して終わります。0 が成功、それ以外は何らかの失敗です。$? は直前のコマンドの終了コードなので、echo を挟む前にすぐ読みます。
# codes.sh
true; echo "true -> $?"
false; echo "false -> $?"
ls nope.txt; echo "ls missing -> $?"
grep -q zzz other.txt; echo "grep no match -> $?"
grep -q zzz nope.txt; echo "grep missing file -> $?"
nosuchcmd; echo "unknown command -> $?"
printf 'echo hi\n' > noexec.sh; ./noexec.sh; echo "not executable -> $?"
sh -c 'exit 300'; echo "exit 300 -> $?"
true -> 0
false -> 1
ls: nope.txt: No such file or directory
ls missing -> 1
grep no match -> 1
grep: nope.txt: No such file or directory
grep missing file -> 2
codes.sh: line 6: nosuchcmd: command not found
unknown command -> 127
codes.sh: line 7: ./noexec.sh: Permission denied
not executable -> 126
exit 300 -> 44
数値は bash と zsh で同じでした(zsh はメッセージが command not found: nosuchcmd、permission denied: ./noexec.sh の形式になるだけです)。
| コード | 意味 | 今回の例 |
|---|---|---|
| 0 | 成功 | true |
| 1 | 一般的な失敗・「いいえ」 | false、存在しないファイルへの ls、一致なしの grep |
| 2 | 使い方の誤り・本物のエラー(ツール依存) | 存在しないファイルへの grep |
| 126 | 見つかったが実行できない | chmod +x していない ./noexec.sh |
| 127 | コマンドが見つからない | タイプミス、PATH の問題 |
| 128+N | シグナル N で終了 | 第5回(130・137・143) |
意外な点が2つ。grep は一致なしで 1 を返し、これはエラーではありません。本当の失敗は 2 です。また終了コードは 256 で一周し、exit 300 は 44(300−256)になりました。自作のエラーには 125 以下を使います。
set -euo pipefail が捕まえるもの
既定のシェルスクリプトは失敗しても進みます。次のファイルはバグが3つあるのに終了コード 0 です。
# strict1.sh
echo "user is $USERNAME_TYPO"
cp nope.txt backup/
echo "after cp, rc=$?"
false | sort
echo "pipeline rc=$?"
echo "reached the end"
user is
cp: backup: No such file or directory
after cp, rc=1
pipeline rc=0
reached the end
[exit 0]
いわゆる strict モードは3つのオプションです。1行目に set -euo pipefail を書いたファイルでそれぞれ試しました。
| オプション | 捕まえるもの | 実際のメッセージ(bash 3.2) | zsh 5.9 | 終了 |
|---|---|---|---|---|
-e |
単純なコマンドの失敗 | cp: backup: No such file or directory |
同じ | 1 |
-u |
未設定変数の参照 | strict3.sh: line 2: USERNAME_TYPO: unbound variable |
strict3.sh:2: USERNAME_TYPO: parameter not set |
1 |
-o pipefail |
パイプライン途中の失敗 | (何も出さずに停止) | 同じ | 1 |
pipefail がないと、パイプラインの終了コードは最後のコマンドのものなので、sort が成功した false | sort は成功扱いでした。
-u の下で未設定時の既定値が欲しいときは明示します。${MISSING:-fallback} は fallback を出し、: "${DIR:?DIR is not set}" は DIR: DIR is not set で止まります(両シェルとも終了 1)。後者は rm -rf "$DIR/..." の前に置くべきガードです。
よくある間違い:set -e が全部止めてくれると思う
ここが検索結果の要約では得られない部分です。以下はすべて失敗しているのに、スクリプトは進み続けます。
# gotcha.sh
set -euo pipefail
f() { local out=$(false); echo "A: local hid the failure"; }
f
g() { false; echo "B: still running inside g"; }
if g; then echo "B2: g counted as success"; fi
false && echo no
echo "C: 'false && ...' did not stop the script"
echo "D: default -> ${MISSING:-fallback}"
n=$(printf 'a\nb\n' | grep -c zzz || true); echo "E: matches=$n"
count=0
((count++))
echo "F: never printed in bash"
## bash ## zsh
A: local hid the failure A: local hid the failure
B: still running inside g B: still running inside g
B2: g counted as success B2: g counted as success
C: 'false && ...' did not stop the script C: 'false && ...' did not stop the script
D: default -> fallback D: default -> fallback
E: matches=0 E: matches=0
F: never printed in bash [exit 1]
[exit 0]
実際に起きていること:
- A:
local out=$(false)の終了コードはlocal自身のもの(成功)です。対策:宣言と代入を分ける(local out; out=$(false))。こう書き直すと bash・zsh とも終了 1 で止まりました。 - B/B2:if の条件として呼んだ関数の中では、関数全体で
set -eが無効になります。gは最後のechoが成功したので 0 を返しました。対策:条件に使う関数では-eに頼らず、最後に明示的にreturnする。 - C:
&&・||の左側の失敗は仕様上-eの対象外です。逆にfalse && echo noがスクリプトの最終行だと、スクリプト全体の終了コードが 1 になる罠もあります(実行して確認済み)。 - E:
grep -cは一致なしで0を出して終了 1。pipefailだと止まるので|| trueが正解ですが、「一致なしで本当に問題ない」場所だけにします。 - F:
((count++))は元の値が 0 だと 1 を返します。zsh 5.9 は止まり、bash 3.2 は止まりませんでした($?は 1 でした)。新しい bash では止まります(ここでは未実行)。count=$((count + 1))と書けば、どのシェルでも同じ動きになります。
教訓は「strict モードを使うな」ではありません。strict1.sh の3つのバグは確実に捕まえます。strict モードは安全網であって、エラー処理ではないということです。失敗が重要なコマンドは明示的に確認します。
if ! cp "$src" "$dst"; then
echo "error: copy failed: $src" >&2
exit 1
fi
if・test・[[ ]]
if はコマンドを実行して終了コードを見ます。[ もコマンド(名前が [)なので、空白が必須で、空の変数で壊れます。
# tests.sh
answer=""
if [ $answer = yes ]; then echo yes; else echo "not yes"; fi
if [ "$answer" = yes ]; then echo yes; else echo "quoted: not yes"; fi
x=10
if [ "$x" -gt 9 ]; then echo "10 > 9 numerically"; fi
if [ "$x" \> 9 ]; then echo "string compare says 10 > 9"; else echo "string compare says 10 < 9"; fi
## bash
tests.sh: line 2: [: =: unary operator expected
not yes
quoted: not yes
10 > 9 numerically
string compare says 10 < 9
## zsh
tests.sh:2: parse error: condition expected: =
not yes
quoted: not yes
10 > 9 numerically
tests.sh:6: condition expected: >
string compare says 10 < 9
クォートしていない空の $answer が消えて [ = yes ] になり、エラーを出しつつ else に進みました。set -e がなければ誰も気づきません。また > は文字列比較("10" は "9" より前)で、zsh の [ は \> 自体を受け付けません。数値は -gt・-lt・-eq か (( x > 9 )) で比べます。bash/zsh 専用なら [[ $answer == yes ]] は分割されず安全ですが、POSIX の sh にはありません。
ループと、while read で消える変数
total=0
printf '3\n4\n5\n' | while read n; do total=$((total + n)); done
echo "pipe+while total=$total"
total=0
while read n; do total=$((total + n)); done < <(printf '3\n4\n5\n')
echo "process-substitution total=$total"
## bash ## zsh
pipe+while total=0 pipe+while total=12
process-substitution total=12 process-substitution total=12
bash ではパイプラインの各部分がサブシェル(シェルの子プロセスのコピー)で動くので、ループ内で更新した total は捨てられました。zsh はパイプラインの最後の部分を現在のシェルで動かすので 12 が残ります。どちらでも動く書き方は、パイプではなくリダイレクト(< file や < <(cmd))でループに渡すことです。
関数:終了コードと出力は別物
関数は引数を $1、$2…、"$@" で受け取り、return N(0〜255)で終了コードを返し、データは出力して返します。
greet() { echo "hi $1"; return 3; }
msg=$(greet tom); echo "msg=[$msg] rc=$?"
x=1; change() { x=2; local y=5; }; change; echo "x=$x y=${y:-unset}"
msg=[hi tom] rc=3
x=2 y=unset
local を付けない変数はグローバルです。2つの関数が両方 i を使って「ループカウンタが勝手に変わる」バグの典型的な原因です。
実測:組み込み算術とプロセス起動
外部プログラムを動かす $(...) は、そのたびに fork と exec が走ります。ループの中では効いてきます。10,000 まで3通りで数えました:i=$(expr "$i" + 1)(外部コマンド)、i=$(echo $((i + 1)))(サブシェルのみ)、i=$((i + 1))(組み込み)。それぞれ /usr/bin/time -p で3回測り、中央値です。
| 10,000 回のループ | bash 3.2 | zsh 5.9 |
|---|---|---|
$(expr ...) |
28.04 秒 | 25.22 秒 |
$(echo $((...))) |
7.32 秒 | 8.79 秒 |
$((...)) |
0.04 秒 | 0.02 秒 |
計測中のマシンは他の処理で混んでいた(8コアでロードアベレージ 9〜12 前後)ので絶対値は大きめで、回ごとの差は最大25%ありました。見てほしいのは比率で、毎回プロセスを起動すると約 700倍 遅くなります。expr 1回あたり約 2.8 ms です。ループ内の $(date)、$(basename ...)、$(cat file) も同じで、第6回ではログのタイムスタンプで測ります。
正直な限界とバージョンの注意
- 検証は macOS の bash 3.2.57 と zsh 5.9 だけです。bash 3.2 には連想配列(
declare -A)、mapfile、${x^^}がなく、実際にdeclare: -A: invalid option、mapfile: command not found、bad substitutionが出ました。3つとも bash 4.0 で追加されたものです(bash の NEWS による。ここでは未実行)。 set -e下で((count++))が止まるかはシェルとバージョンで違います。どちらにも頼らずcount=$((count + 1))と書きます。- この Mac の
/bin/sh(sh モードの bash 3.2)ではpipefailが使えました。Debian/Ubuntu の/bin/shは bash ではなく dash で、set -o pipefailが通るかは dash のバージョン次第です(ここでは未実行)。bash の機能を使うなら先頭に#!/bin/bashを書き、sh script.shではなくファイルとして実行します。 set -eの例外は実際にはもっとあります。上の一覧は今回再現できたものです。
練習問題
dir=$1のあとにrm -rf "$dir/cache"があるスクリプトを、引数なしで実行すると何が起きますか。1行で防ぐガードは?cat names.txt | while read n; do count=$((count+1)); done; echo $countを、bash でも正しい数を出すように書き直してください。
解答
$dirが空なのでrm -rf "/cache"になります。使う前に: "${1:?usage: $0 DIR}"を置きます(set -uとdir=$1でも、bash 3.2 で$1: unbound variableと出て止まることを確認しました)。count=0; while IFS= read -r n; do count=$((count + 1)); done < names.txt; echo "$count"。リダイレクトならループは現在のシェルで動き、IFS=と-rで先頭の空白やバックスラッシュも壊れません。
前回: 第3回 find・xargs・sed・awk ・ 次回: 第5回 プロセス・ジョブ・シグナル ・ 第6回:壊れにくいスクリプト・cron・ログ
よくある質問
スペースを含む変数をクォートしないと、bash では壊れて zsh では動くのはなぜですか?
bash はクォートしていない $f の値を空白で分割し、グロブも展開します。f="my report.txt" で ls $f と書くと ls my report.txt になり、2回エラーになります。zsh は既定ではクォートなしの変数を分割しないので同じ行が動きます。展開はすべて "$f" のようにクォートすれば、両方のシェルで同じ結果になります。
set -e を付ければ、失敗したコマンドで必ず止まりますか?
止まりません。if の条件の中、&& や || の左側、if から呼んだ関数の中、local x=$(cmd) で隠れた失敗では無視されます。今回すべて実行して、どれもスクリプトが続行することを確認しました。重要なコマンドは if ! cmd; then … のように明示的に確認します。
終了コード 126 と 127 の意味は?
127 はコマンドが見つからない(タイプミスや PATH の問題)、126 はファイルは見つかったが実行できない(多くは実行権限がない)という意味です。macOS の bash 3.2 と zsh 5.9 で同じ値になりました。