set -e を書いたスクリプトが、失敗しても止まりませんでした。
原因は1語です。local を付けていました。
先に断っておくと、この挙動は POSIX の仕様どおりで、ShellCheck も SC2155 で警告を出します。ここで書くのは、dash と bash と zsh の3つで測った結果と、local 以外のどのキーワードで起きるかです。
先に結論
# これは終了コードが取れる
out=$(cmd)
echo $? # cmd の終了コード
# これは取れない。常に 0
local out=$(cmd)
echo $? # local の終了コード(0)
# 分ければ取れる
local out
out=$(cmd)
echo $? # cmd の終了コード
local を付けた瞬間、$? はコマンドのものではなく、local そのものの終了コードになります。
3つのシェルで測りました
終了コード7で終わるコマンドを使って、3通り試しました。
out=$(sh -c 'echo hi; exit 7')
echo "$?"
f() {
local out=$(sh -c 'echo hi; exit 7')
echo "$?"
}
f
f() {
local out
out=$(sh -c 'echo hi; exit 7')
echo "$?"
}
f
結果です。
| 書き方 | dash | bash | zsh |
|---|---|---|---|
out=$(cmd) |
7 | 7 | 7 |
local out=$(cmd) |
0 | 0 | 0 |
local out と分ける |
7 | 7 | 7 |
3つとも同じでした。シェルによる差はありません。
set -e と組み合わせると
こちらが本題です。4通り試しました。
set -e
out=$(sh -c 'exit 7')
echo "ここに来たら set -e が効いていない"
スクリプトは止まりました。終了コード7で終わります。想定どおりです。
set -e
f() { local out=$(sh -c 'exit 7'); echo "local付きなので通過"; }
f
echo "関数のあとも通過"
止まりませんでした。両方の echo が実行されて、スクリプトは終了コード0で終わります。
set -e が見ているのは、実行されたコマンドの終了コードです。local が0を返すので、失敗しなかったことになります。中で走った sh -c 'exit 7' は、コマンド置換の中で終わっていて、set -e の判定には出てきません。
残り2つは、受け止められる形です。
set -e
if out=$(sh -c 'exit 7'); then echo "成功"; else echo "失敗を検知 ($?)"; fi
set -e
out=$(sh -c 'exit 7') || echo "|| で受けた ($?)"
どちらも失敗を検知しました。$? に7が入ります。
dash と bash で、4通りとも同じ結果でした。
local だけではありません
同じことが、他のキーワードでも起きます。
f() { declare out=$(sh -c 'exit 7'); echo "$?"; } # 0
f() { readonly out=$(sh -c 'exit 7'); echo "$?"; } # 0
f() { export out=$(sh -c 'exit 7'); echo "$?"; } # 0
f() { typeset out=$(sh -c 'exit 7'); echo "$?"; } # 0
4つとも0でした。
宣言と代入を1行でやると、返ってくるのは宣言のほうの終了コードです。キーワードの種類ではなく、宣言と代入を同時にやったことが原因でした。
export は関数の外でも使うので、ここがいちばん踏みやすい気がします。
export TOKEN=$(get-token) # get-token が失敗しても気づけない
ShellCheck は拾ってくれます
書いたあとに掛けたら、出ていました。
$ shellcheck script.sh
SC2155 (warning): Declare and assign separately to avoid masking return values.
「宣言と代入を分けて、返り値が隠れるのを避けなさい」。そのままです。
#!/bin/sh で書いていると、もう1つ出ます。
SC3043 (warning): In POSIX sh, 'local' is undefined.
local は POSIX の範囲外です。dash では動きますが、仕様としては保証されていません。#!/bin/sh と書くつもりなら、そもそも local を使わないという判断もあります。
わたしは、掛ける前に書いていました。先に通していれば、公開前に気づいていた種類のものです。
どう書くか
宣言と代入を分けます。
f() {
local out
out=$(cmd) || return "$?"
printf '%s\n' "$out"
}
|| return "$?" を付けているのは、set -e に頼らないためです。関数が if や && || の条件として呼ばれると、その関数の中では set -e が効きません。dash・bash・zsh の3つで確かめました。呼ばれ方に左右されないよう、明示的に返しています。
POSIX の範囲で書くなら、local を使わずにこうします。
f() {
out=$(cmd) || return "$?"
printf '%s\n' "$out"
}
変数がグローバルになるので、名前を長めにするか、接頭辞を付けて衝突を避けます。
まとめ
-
local out=$(cmd)は、cmdの終了コードを消す。$?は常に0 -
declarereadonlyexporttypesetでも同じ -
set -eがあっても止まらない。宣言が成功しているため - 分けて書けば取れる。
local outしてからout=$(cmd) - ShellCheck の SC2155 が拾う。書いたら掛ける
-
#!/bin/shならlocal自体が POSIX 外(SC3043)
dash 0.5.12 / bash 5.2.21 / zsh 5.9 で確認しました。シェルによる差はありませんでした。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。