はじめに
[ $? -eq 0 ]
や [ $? -ne 0 ]
は冗長でデメリットしかありません。非常に多く見かける書き方ですが、1979 年に Bourne シェルが広く公開された時からこのようなコードは必要ありませんでした。実際に当時はこのような書き方は使われておらず、このような書き方をしなければならなかった歴史的な経緯などはありません。これはなぜか広まってしまった良くない書き方です。
優れたコードとは無駄がないシンプルなコードです。丁寧なコードとは無駄な処理を書くことではありません。[ $? -eq 0 ]
や [ $? -ne 0 ]
は書かないほうが、簡単で読みやすくわかりやすくなります。優れた文法を持つシェルは短いコードで正しく動作し、良い書き方は最短の時間と最小の手間で目的を達成することができます。コマンドのエラー処理を簡潔に書くことができるのが、シェル言語の優れている点の一つでありシェルスクリプトを書く理由の一つです。
補足: この記事は主に if
の話をしていますが while
や until
にも当てはまります。また set -e
(errexit) を使うことでもっと簡潔に書くことが出来ますが、補足説明が多くなってしまい話がぶれてしまうので省略しています。本記事の論点は [ $? -eq 0 ]
や [ $? -ne 0 ]
を書くぐらいなら書かない方が良いということです。
じゃあどう書くの?
こうです。
シンプルイズベスト
if cp from to; then
echo "コピーに成功しました"
else
echo "コピーに失敗しました"
fi
# 失敗したときだけ処理を行う場合
# (補足: POSIXで標準化されているが、Bourneシェルは非対応)
if ! cp from to; then
echo "コピーに失敗しました"
fi
これは私が考案した新たな書き方や新しいシェルで使えるようになった新しい方法などではありません。Bourne シェルの開発者が公式に解説している if
の典型的な使い方です。後者の !
を使った書き方は Bourne シェルでは非対応で Bourne シェルから見れば新しい方法ですが、!
は 1992 年の POSIX で標準化されており現在の sh はすべて対応しています。
以下は [ $? -eq 0 ]
を使った無駄なことをしているコードです。Bourne シェルの開発者による解説にはこのようなコードは出てきません。
冗長なだけでメリットなし(デメリットはある)
cp from to
if [ $? -eq 0 ]; then
echo "コピーに成功しました"
else
echo "コピーに失敗しました"
fi
なぜ無駄なのか? それは cp
コマンドの終了ステータスで直接分岐すればいいことに対して、わざわざ [
コマンド(test
コマンドの別名)を使って、終了ステータスを比較して終了ステータスを生成しているからです。cp
コマンドが正常終了したか調べるために、わざわざ別の test
([
) コマンドを呼び出して調べるのは無駄な処理でしかありません。
ここで少し考えて欲しいのですが「終了ステータスを 0 と比較する」のは本当にあなたがやりたいことなのでしょうか? おそらく違うはずです。本当にやりたいことはコマンドの実行が正常終了したかで分岐することであって、終了ステータスの値なんかどうでも良いはずです。
以下のような書き方もできますが、&&
と ||
が混在した三項演算子風の書き方は基本的に避けることを推奨します。
cp from to && echo "コピーに成功しました" || echo "コピーに失敗しました"
なぜなら A && B || C の時、A が成功して B が失敗すると C が実行されてしまうからです。この書き方が成立するのは B が必ず正常終了する場合のみです。ほとんどの場合 echo
コマンドは正常終了するのですが、標準出力が閉じられている場合や、alias
やシェル関数で置き換えられているときに失敗する可能性があります。私はこの書き方を単純な変数代入にのみ使用するようにしています。コマンド置換を伴わない限り、変数代入が失敗することはありません。
cp from to && msg="コピーに成功しました" || msg="コピーに失敗しました"
ShellCheck を使おう!
[ $? -eq 0 ]
や [ $? -ne 0 ]
なんて書くのをやめようと言っているのは、私だけではありません。良いシェルスクリプトを書くのに必須の ShellCheck でも同じように指摘されます。
$ shellcheck script.sh
In script.sh line 4:
if [ $? -eq 0 ]; then
^-- SC2181 (style): Check exit code directly with e.g.
'if mycmd;', not indirectly with $?.
For more information:
https://www.shellcheck.net/wiki/SC2181 -- Check exit code directly with e.g...
詳細な理由は以下参照
[ $? -eq 0 ]
や [ $? -ne 0 ]
を書くのをやめる? そんなルール聞いた事ないと言う人は ShellCheck を使っていないことの証拠です。良いシェルスクリプトの書き方を ShellCheck を使って学んでください。
if...thenは他の言語のtry...catchに近い
JavaScript などの言語は try ... catch
と呼ばれる例外処理の機能を持っています。
try {
// 例外が発生する可能性がある処理
} catch (error) {
// 例外が発生した時の処理
}
try ... catch
は数値などを比較する機能ではなくエラー処理のための機能であることは明白ですが、実はシェル言語の if
はどちらかと言えば try ... catch
に近い機能なのです。if
の改行の位置を変えるとこのようになります。
if
# エラーが発生する可能性がある処理
then
# エラーが発生しない時の処理
else
# エラーが発生した時の処理
fi
if ... then
は、JavaScript の try ... catch
と同じように ...
の間に複数の処理(関数やコマンド)を書くことができます。
if
echo "foo"
echo "bar"
echo "baz"
then
:
fi
残念ながら複数のコマンドを書いたとしても、if
は最後に実行したコマンドの終了ステータスしか参照しないので意味はありませんが、if
と then
の間にはどんなものでも書くことができるということを覚えておいてください。例えば以下のようなコードを書くことができ、そのコードのエラー処理を行うことができます。
if now=$(date); then
echo "現在日時は $now です"
else
echo "エラーで now を取得できませんでした"
fi
if echo "test" | grep "e"; then
echo "文字列 e が見つかりました"
else
echo "文字列 e が見つかりません"
fi
パイプラインのエラー処理に関する補足ですが、デフォルトではパイプラインの最後のコマンド(上記の例では grep
コマンド)の終了ステータスしか参照しないので注意してください。いずれかのコマンドでエラーになったことを検出したい場合は set -o pipefail
を有効にしてください。有効にすると最後にエラーになったコマンドの終了ステータスを参照するようになります。
bash と mksh 専用の PIPESTATUS
変数は(どうしても必要がない限り)使用しないでください(zsh の pipestatus
も同様)。PIPESTATUS
変数が使えるのであれば set -o pipefail
も使えますし、set -o pipefail
は POSIX で標準化されており高い移植性があります。
$? を変数に保存する必要はない
grep
コマンドは文字列が見つからないときに終了ステータスを 1 にし、エラーが発生したときに 2 にします。終了ステータスが 1 か 2 かで、文字列が見つからない場合とエラーを区別することができます。そこでこのようなコードを思いつくかもしれませんが、このコードは正しく動きません。
終了ステータスが取れないのでうまく動かない
echo test | grep '[' # 意図的にエラーが発生する正規表現を書いている
if [ $? -eq 0 ]; then
echo "文字列が見つかりました"
elif [ $? -eq 1 ]; then
echo "文字列が見つかりません"
else
# ここに来てほしいが、ここには来ない
echo "エラーが発生しました(終了ステータス: $?)"
fi
上記のコードを実行するとエラーが発生しているのにエラーが発生したことを検出できていません。なぜなら終了ステータスは [ ... ]
を実行したときに変更されてしまうからです。
$ ./grep.sh
grep: Invalid regular expression
文字列が見つかりません
良いシェルスクリプトの書き方を知らない人は、この問題を $?
を変数に保存することで解決しようとします。
echo test | grep '['
ret=$?
if [ $ret -eq 0 ]; then
echo "文字列が見つかりました"
elif [ $ret -eq 1 ]; then
echo "文字列が見つかりません"
else
echo "エラーが発生しました(終了ステータス: $ret)"
fi
$ ./grep.sh
grep: Invalid regular expression
エラーが発生しました(終了ステータス: 2)
しかし、これは [ $? -eq 0 ]
なんて無駄なコードを書くから、無駄に変数が必要になってしまっているのです。良い書き方をすれば、この程度のことで $?
を変数に保存する必要なんてありません。(もちろん処理の内容によっては変数に保存しなければいけない場合もあります)
じゃあどう書くの?(2回目)
こうです。
シンプルイズベスト
if echo test | grep '['; then
echo "文字列が見つかりました"
else
case $? in
1) echo "文字列が見つかりません" ;;
2) echo "エラーが発生しました(終了ステータス: $?)" ;;
esac
fi
なぜ、これで $?
が書き換わらないのか? その理由は $?
を参照するまでの間に他のコマンドを実行していないからです。もちろん次のような書き方でも構いません(記事の趣旨は [ $? -eq 0 ]
や [ $? -ne 0 ]
は不要という話なので初出自は書いていませんでした) 。
# set -e (errexit) を有効にしている場合は末尾に「&& :」をつける
# echo test | grep '[' && :
echo test | grep '['
case $? in
0) echo "文字列が見つかりました" ;;
1) echo "文字列が見つかりません" ;;
2) echo "エラーが発生しました(終了ステータス: $?)" ;;
esac
無駄をなくせば無駄な変数は必要なくなり可読性も向上します。シェルスクリプトはよく設計されたプログラミング言語です。
補足ですが、!
を使った場合は終了ステータスは取得できません。0 以外の終了ステータスは !
の効果で反転されて、すべて 0 に変換されてしまうからです。
if ! echo test | grep '['; then
echo "then の終了ステータス: $?" # 必ず 0
else
echo "else の終了ステータス: $?" # 必ず 1
fi
もし then
で何もする必要がない場合は、何もしないコマンド :
を実行するか、||
を使った少し異なる書き方をする必要があります。
if echo test | grep '['; then
: # POSIX 準拠で書く場合は then の中を省略することはできない
else
echo "終了ステータス: $?" # 終了ステータスを取れる
fi
# 少々技巧的になるが、短く書きたいならこのような方法もある
echo test | grep '[' || {
echo "終了ステータス: $?" # 終了ステータスを取れる
}
個人的には then
が省略可能な文法だったらなぁと思わなくもないですが、実際書いてみると誤読しそうなので書き方を工夫するか、新しいキーワードを使った文法ができれば理想的かもしれません。
# [このコードは動かない] thenが省略できたとしたら・・・?(誤読しそう)
if echo test | grep '['; else
echo "終了ステータス: $?" # 終了ステータスを取れる
fi
# [このコードは動かない] thenが省略できたとして、この書き方のほうが良いかも?
if echo test | grep '['
else
echo "終了ステータス: $?" # 終了ステータスを取れる
fi
# [このコードは動く] then を省略できなくても書き方を工夫するだけで十分かも?
if echo test | grep '['; then :
else
echo "終了ステータス: $?" # 終了ステータスを取れる
fi
Bourne シェルに [ ... ]
は必須ではない
if
とよく組み合わせて使う [ ... ]
は、実際には [
コマンドで、test
コマンドの別名です。もともと Bourne シェルでは test
([
) コマンドは外部コマンドで、/bin/test
(/bin/[
) という実行ファイルを呼び出していました。後期の Bourne シェルでは test
([
) コマンド はシェルに組み込まれ、現在の Bourne シェル後継のシェルはすべてシェルに組み込んでいますが、もともとの設計では test
([
) コマンドはシェルに組み込むまでもない重要ではないコマンドでした。
外部コマンドの呼び出しはシェルにとって遅い処理です。Bourne シェルはパフォーマンスを考慮して設計されており、遅くなるようであれば最初から対策していたはずです。test
([
) コマンドはコマンドを実行した後のエラー処理に使うものではないので、あまり使わない機能はシェルに組み込む必要がないというのが当初の設計上の判断だったのでしょう。もちろんファイル属性などの比較([ -f file.txt ]
など)には使いますが、そこまで必要なものではありませんよね?また文字列の比較には case
を使えば十分です。
なぜ [ $? -eq 0 ]
のような書き方が広まってしまったのでしょうか? その原因の一つは C シェルにあるかもしれません。C シェルの if
はコマンドを実行することができず、変数の値を評価するものなので次のように書くことしかできません。
cp from to
if ($status == 0) then
echo "コピーに成功しました"
else
echo "コピーに失敗しました"
endif
昔は C シェルもよく使われていたので、この書き方を Bourne シェルでもするようになったのかもしれません。C シェルは Bourne シェルよりも速いと言われていたことがありますが、いちいち終了ステータスを 0 と比較するようなコードを比較していたからである可能性があります。0 との比較はシェルに機能が組み込まれている C シェルでは速く、昔の Bourne シェルでは遅い書き方です。しかし Bourne シェルではそもそも不要な処理です。シェルに数値比較の機能が組み込まれた今では昔の話ですが、Bourne シェル用に無駄のないコードを書いていれば、Bourne シェルは速かったのです。test
([
) コマンドがシェルに組み込まれた理由には、C シェルのような書き方をしても問題ないようにするためだったのかもしれません。
Bourne シェルと C シェルは、ほぼ同じ時期に開発されたシェルです。時系列については以下の記事を参照してください。
シェル言語のifとcaseの機能は根本的に違う
if
と case
を似たような機能だと思っていないでしょうか?
-
if
はコマンドを実行して、その成否で処理を分岐する機能です -
case
は文字列を比較する機能です
「コマンドの実行」と「文字列の比較」。シェル言語にとってはこの 2 つは全く別の機能です。シェル言語の if
はコマンドを実行し、そのコマンドの実行が正常終了したかエラー終了したかで処理を分岐できるように設計されています。
[ $? -eq 0 ]
や [ $? -ne 0 ]
をなくすことのもう一つのメリットは、シェルスクリプトは 0 が真だっけ? 1 が真だっけ? と悩まなくて良くなることです。シェル言語も他の言語と同じように真は 1(正確には 0 以外) です。そして他の言語でもプログラムが正常終了したときの終了ステータスは 0 です。
$ echo $(( 5 < 10 ))
1
シェル言語の if
は実行したコマンドの成否(つまり終了ステータス)で分岐するものだということを理解することで、シェル言語は真偽値を示す数値が反対とかいう的はずれな話を忘れることができます。
whileでは [ $? -eq 0 ] を省略してるよね?
この記事の話は while
(や until
)にも当てはまります。典型的なファイル読み込みのコードは、read
コマンドを実行して成功する限り繰り返すという意味です。
count=0
# while は read コマンドを実行し、成功する限り繰り返す
while read -r line; do
count=$((count + 1))
done
echo "$count"
while
でいつもやっていることなのだから、if
で[ $? -eq 0 ]
や [ $? -ne 0 ]
の省略することが、難しい(理解できない)ってことはないでしょう。
while read -r line; [ $? -eq 0 ]; do
count=$((count + 1))
done
[ $? -eq 0 ]
や [ $? -ne 0 ]
は省略したほうが簡潔でわかりやすいんです。
さいごに
どんな場合でも [ $? -eq 0 ]
や [ $? -ne 0 ]
を使ってはダメだとまで言うつもりはありませんが(実際私もごく稀な例で使っている場合があります)、本来は必要ないということを忘れないようにしてください。終了ステータスを変数に代入しなければならないのであれば、不必要に冗長なコードである可能性が高いです。シェルは簡潔に正しくコマンド実行のエラー処理をかけるように設計されています。しかしそれも良い書き方を知らなければ台無しになってしまいます。