655
517

いい加減シェルスクリプトで [ $? -eq 0 ] や [ $? -ne 0 ] なんて エラー処理を書くのはやめよう!

Last updated at Posted at 2024-08-20

はじめに

[ $? -eq 0 ][ $? -ne 0 ] は冗長でデメリットしかありません。非常に多く見かける書き方ですが、1979 年に Bourne シェルが広く公開された時からこのようなコードは必要ありませんでした。実際に当時はこのような書き方は使われておらず、このような書き方をしなければならなかった歴史的な経緯などはありません。これはなぜか広まってしまった良くない書き方です。

優れたコードとは無駄がないシンプルなコードです。丁寧なコードとは無駄な処理を書くことではありません[ $? -eq 0 ][ $? -ne 0 ] は書かないほうが、簡単で読みやすくわかりやすくなります。優れた文法を持つシェルは短いコードで正しく動作し、良い書き方は最短の時間と最小の手間で目的を達成することができます。コマンドのエラー処理を簡潔に書くことができるのが、シェル言語の優れている点の一つでありシェルスクリプトを書く理由の一つです。

補足: この記事は主に if の話をしていますが whileuntil にも当てはまります。また 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とthenの間に複数のコマンドを書くことができる
if
   echo "foo"
   echo "bar"
   echo "baz"
then
   :
fi

残念ながら複数のコマンドを書いたとしても、if は最後に実行したコマンドの終了ステータスしか参照しないので意味はありませんが、ifthen の間にはどんなものでも書くことができるということを覚えておいてください。例えば以下のようなコードを書くことができ、そのコードのエラー処理を行うことができます。

コマンド置換のエラー処理を行うことができる
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 で何もする必要がない場合は、何もしないコマンド : を実行するか、|| を使った少し異なる書き方をする必要があります。

then で何もする必要がない場合は、何もしないコマンド : を実行する
if echo test | grep '['; then
  : # POSIX 準拠で書く場合は then の中を省略することはできない
else
  echo "終了ステータス: $?" # 終了ステータスを取れる
fi

# 少々技巧的になるが、短く書きたいならこのような方法もある
echo test | grep '[' || {
  echo "終了ステータス: $?" # 終了ステータスを取れる
}

個人的には then が省略可能な文法だったらなぁと思わなくもないですが、実際書いてみると誤読しそうなので書き方を工夫するか、新しいキーワードを使った文法ができれば理想的かもしれません。

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 はコマンドを実行することができず、変数の値を評価するものなので次のように書くことしかできません。

C シェルでの書き方
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の機能は根本的に違う

ifcase を似たような機能だと思っていないでしょうか?

  • if はコマンドを実行して、その成否で処理を分岐する機能です
  • case は文字列を比較する機能です

「コマンドの実行」と「文字列の比較」。シェル言語にとってはこの 2 つは全く別の機能です。シェル言語の if はコマンドを実行し、そのコマンドの実行が正常終了したかエラー終了したかで処理を分岐できるように設計されています。

[ $? -eq 0 ][ $? -ne 0 ] をなくすことのもう一つのメリットは、シェルスクリプトは 0 が真だっけ? 1 が真だっけ? と悩まなくて良くなることです。シェル言語も他の言語と同じように真は 1(正確には 0 以外) です。そして他の言語でもプログラムが正常終了したときの終了ステータスは 0 です。

真となる計算式の値は1であることがわかる
$ 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 ] を使ってはダメだとまで言うつもりはありませんが(実際私もごく稀な例で使っている場合があります)、本来は必要ないということを忘れないようにしてください。終了ステータスを変数に代入しなければならないのであれば、不必要に冗長なコードである可能性が高いです。シェルは簡潔に正しくコマンド実行のエラー処理をかけるように設計されています。しかしそれも良い書き方を知らなければ台無しになってしまいます。

655
517
29

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
655
517