起こりうるエラーは必ず起こる

テストの主な利点は、テストを実行している時ではなく、テストについて考え、テストを作り出している時に生み出される

David Thomas, Andrew Hunt『達人プログラマー 第2版』村上雅章(訳)

今回扱うのは私のプログラミングでの失敗談だ。
最も重要な結論はタイトルの通りだが、いくつか過程で得られた知見もあったので備忘録としてまとめておく。

シェルスクリプト不都合な真実

シェルスクリプトはさっと書いて動かせる便利なスクリプト言語だが、「不都合な真実」とでもいうべき仕様がちらほらと散見される*1。今回ハマったのはif文の分岐処理だ。

まず、下の簡単なif文についてみてみよう。

#!/bin/bash
FLG1="true"
if  "$FLG1" ; then
	echo "FLG1: TRUE"
fi
FLG2="false"
if  "$FLG2" ; then
	echo "FLG2: TRUE"
fi

これの出力結果は以下のようになる

FLG1: TRUE

世にあるその他のプログラミング言語と共通の、一般的なbooleanによるifの分岐に見える。
では次はどうだろうか。

#!/bin/bash
FLG1="true"
if  [ "$FLG1" ] ; then
	echo "FLG1: TRUE"
fi
FLG2="false"
if [ "$FLG2" ] ; then
	echo "FLG2: TRUE"
fi

これを実行するとこうなる。

FLG1: TRUE
FLG2: TRUE

両方出力されてしまった。

まず、挙動が異なる原因は、testコマンドの挙動によるものである。シェルスクリプトでは「if []」は「if test」に解釈される。
では、「test true」も「test false」も同じ結果になったのは何故だろうか。これはどちらのコマンドも0の終了ステータスを返すからである。

The test-commands list is executed, and if its return status is zero, the consequent-commands list is executed
Bash Reference Manual

というわけで、最初の例を見て条件式のtrue, falseで分岐したと思っていたのが実は間違いだったということになる。bashには実はtrue, falseというコマンドがあり、それぞれ終了ステータスとして0, 1を返したためそのように見えただけだったのだ*2

エラーとif文のコンボ

これを踏まえて本題に入ろう。私がシェルで今回実装しようとしたのは、何かコマンドを打って、得られた結果から特定の情報を取得したい場面であった。
例えば、curlを打って返ってきたresponseのデータや、データを出力したテキストファイルの中身などをコマンドで取得し、その結果がCMD変数に格納されたとする。CMD変数内の情報を得て、それに応じて処理を変えようとしたのが以下のコードだ。

#!/bin/bash
# success: trueなら成功
if [ $(cat $CMD | grep "success:" | awk '{print $2}') != "true" ] ; then
        echo "失敗"
	# 失敗時の処理
else
	# 成功時の処理
fi

これでコードを走らせたところ、しばらくうまくいっていたのだが、ある日後続処理がうまくいっていないことに気づいた。
しかし、ログを見ても「失敗」のメッセージは出ていない。一体なぜ…と思っていたら、調べてみるとまず失敗しているのはCMDに結果を格納するコマンドであることが判明した。

$(cat $CMD | grep "success:" | awk '{print $2}') != "true" # <-これがエラー

というわけで、ifの条件式部のコマンドでエラーが出たため終了ステータスとして失敗を表す1が返され、elseの方の処理が走ったということであった。これを踏まえると、実は下のようにコードを変えると、元々やりたかった処理を実現しながら、CMD変数部分のエラーもキャッチできるようになることがわかる*3

#!/bin/bash
# success: trueなら成功
if [ $(cat $CMD | grep "success:" | awk '{print $2}') = "true" ] ; then
	# 成功時の処理
else
        echo "失敗"
	# 失敗時の処理
fi

コード部だけ見れば全く同じ処理をしているように見えるのだが、不思議なものである。

それは本当に失敗しないか?

これが今回私が遭遇したエラーであり、直接の原因としてはシェルスクリプトのifの仕様を詳しく把握していなかったことにあるのだが、重要なのはどうして最初に実装した時に気付けなかったかだろう。

後から考えてみれば、今回必要だったのは、例えばどんなデータがここに入ってくるのか、この処理が失敗したらどうなるかというテスト駆動的思考だったのではないかと思う。全ての処理は失敗、あるいは想定外の値が入りうる、とまではいかなくても、想定したフローの中でどういうことが起こりうるのか、そして、起こりうることは必ず起こると想定してエラー処理などを組むべきであった。

そういえば大学の頃にバグを見つけるのが異様にうまかった友人がいたが、今度氏に会った時にどうやってバグを見つけているのか聞いてみても良いかもしれない。バグを知り仕様を知れば百戦危うからず…とまではいかないかも知れないが。

*1:もちろんこれは私のわがままである。インターネットでもシェルスクリプトJavaScriptの「おかしな」仕様について面白おかしく囃し立てる人々がいるが、未定義動作とバグ以外は仕様は仕様でしかない。ある値を渡して何を結果として返して欲しいかは人と場合によるとしか言うことはできないし、人のイメージや直感ほど当てにならないものはないだろう。

*2:これはシェルにおいて0が成功を、1が失敗を表すステータスであるため、整合性をとるためにそうなっていると考えられる。

*3:もちろん、それぞれ違うタイプのエラーなので失敗時の処理にCMD変数を表示するなどのエラー調査をやりやすくする工夫は必要。