perl: system関数への丸投げはヤメよう


はじめに

perlのスクリプト中で外部コマンドの助けを借りたり下請けスクリプトに処理を委ねたいことがありますね。
system関数はそんなときに便利。
しかし、その場合は下請けの仕事ぶりをちゃんとチェックしましょう。
ここでは、そのための簡便な方法を提案します。

発端

※ 以下の話は事実に基づいてはいますが、便宜上いろいろ脚色は施されています。

発端は、ある人が持ち込んできた自作データ処理システムでした。とある様式の大容量な学術データを分析するものです。素晴らしく高効率で結果が出るので使ってみてほしいとのこと。

試用してみました。エラーなしで「結果」が出せましたが、明らかに妙です。結果は空。要素数ゼロ……。

システムの中を見てみました。いくつもの小さなperlスクリプトが互いに呼び出し合いながら処理をすすめる構造のようです。

あるperlスクリプトの中に、system関数を介して標準コマンドのsortを実行し、結果を一時ファイルから読み込むという処理が書かれていました。直接的な犯人はこいつでした。空きディスク容量不足により作業ファイルが作れなくなりsortがコケてしまっていたのです。

と、結論だけ読めば簡単な話に見えるかもしれません。しかしこの人の書いたスクリプトはエラーへの対応が無きに等しく、どんな異常なデータが来ても中身が空だろうとファイルそのものが存在してなかろうと淡々と処理して次にバトンを渡してしまいます。問題の根源が元請けの下請けの孫受けの曾孫受けのスクリプトの中のsystem関数の中のsortだと突き止めるのは大変な手間で、気づくのにエラく時間がかかってしまいました。

確かにsystem()は便利。だが…

大規模ソートなど外部のコマンドに下請けに出した方が効率が良さそうな処理は確かに存在し、そんなときにsystem関数を使って任意の下請けプログラムを実行させる方法はお手軽で便利です。

しかし、system関数にはちょっとした問題があります。下請けプログラムの呼び出しに失敗したり、あるいはDiskIO系のランタイムエラーなどで異常終了したりしても、system関数自身はエラーを吐くことはないのです。そして、スクリプト本体はそのまま走り続けようとします。

プログラムを常に疑え

特に、学術研究用のプログラムはそもそも未知のデータを相手にするわけです。「正しい」結果がどういうものなのかはやってみるまで分かりません。プレインテキストをHTML化するスクリプトみたいなのとは次元の違う難しさがそこにあります。とんでもない結果がでてもそれをトンデモナイとにわかに判断できず、結果を独り歩きさせることになりかねません。

だからこそ、「プログラムは必ず間違った答えを出す」ぐらいの心構えで、想定外のデータが来た場合にちゃんと対処できるのか、考えられるチェックポイントは可能な限り多面的にチェックすべきでしょう。

現実的な妥協案を考えてみた

とはいえ、まあ現実的には、エラーへの対応なんて、面倒で、なんか後ろ向きで、素人プログラマはやりたくないわけですよ。エラートラップの方法なんか勉強する暇があるなら、「本業」の勉強をしたいし、一回でも多く実験をやりたいわけです。

そんな人にも必要最小限のエラー対処をやってもらいたい。そこで、それを簡便に実現するための簡単なラッパー関数を提案します。

関数名はとりあえずmysystemとしておきましょう。この関数は標準関数であるsystem関数の代わりに使用します。すると内部でsystem関数が呼び出されるとともに、「system関数の戻り値をチェックし、下請けの仕事が成功したか否かが判定されます。エラーが出たらその時点でプログラムを強制停止させられます。また、「そのエラーがスクリプトのどこで起こったかをユーザーに訴える」ことができます。

sub mysystem{
  my($cmd, $opt)=@_;
  # defined $opt->{continue} -> anyway return, or die

  my ($package,$filename,$line) = ('','','');
  ($package,$filename,$line) = caller();
  my($subname) = (caller 1)[3];
  $subname = $subname || '';

  (defined $v) or print STDERR "system() in $filename '$subname' line $line\n$cmd\n";
  my $r = system($cmd);
  ($r>0) and $r = $r >> 8;

  ($r==0 or defined $opt->{continue}) and return($r);
  ($r!=0) and die "error code $r\n";

  # return=0: success
  # return=-1: failure in calling subshell (such as illegal command name)
  # return>0: error in subshell execution (such as 'No such file')
}

system関数の戻り値

UNIX-like OSやwindowsでは、外部コマンドが実行されると、終了時に「終了ステータス」と呼ばれる値が返されます。その値はlinuxではコマンド実行直後にecho $を実行することで確認できます。

$ ls >/dev/null
$ echo $?
0
$ lsss > /dev/null
zsh: command not found: lsss
$ echo $?                                                                                                               
127
$ ls /xxx > /dev/null
ls: cannot access /xxx: No such file or directory
$ echo $?                                                                                                             
2

system()は実行されるとこの終了ステータスを反映した値を返します。正確には、戻り値を8ビット右シフトした値がsystem関数の終了ステータスとなります。

内部で呼び出したコマンドが成功していれば戻り値は0です。これをチェックするだけでも「不適切に空のデータが返ってきたのに構わず処理を続行する」といった状況を抑止できます。

mysystem()はこのsystem関数の戻り値をチェックして、ゼロ以外ならエラーメッセージを出してスクリプトの実行を停止するように書かれています。通常のsystem関数呼び出しのようにそのまま次の処理に移ってしまうことはありません。何らかの理由で処理を止めずに続行したい場合(つまり通常のsystem関数呼び出しようにしたい場合)は、mysystem("コマンド", {continue=>1})のように第2引数にパラメータを設定します。

caller関数の活用

subの中でcaller関数を使うと、system関数がどこから呼び出されたかをチェックできます。つまり、呼び出し元となるmysystem()が書かれていた当該スクリプトの行番号が得られます。関数の中から呼ばれていればその関数の名前も得られます。デバッグに有効な情報です。

素人は本業のことしか考えない

今や学術界もビッグデータの時代です。従来のような根気だけにものをいわせた力づくのデータ処理では対処できず、計算機の力を借りないわけには行きません。そして、新しいアプローチで研究をとなったとき、本業がプログラマではない研究者が自分でプログラムを書く必要性も従来より高まっています。

そういう人が書いたプログラムはしばしば「うまくいっているときはとてもうまくいくが、暗黙の前提が満たされない状況下では何をしでかすかわからない」ものになります。期待通りの入力が、期待通りにメモリやディスクが空いている環境下で、期待通りの外部コマンドが存在しているときの対応しか、できていない。

計算機環境というものが実に多種多様で、自分とは背景の異なる人々は平気で「常識を外れた」思考で動くということに、実感をもっていない。本業が研究者だから、自分の知っている環境の下で研究がうまくいったときのことしか考えたくないのが本音なのです。

自分一人で使うならいいです。使ってもいいが自己責任だぜと宣言して、アーカイブだけ公開しておくというのも、一般論としては悪いこととはいいきれない。ですが、そのプログラムについて論文を書き、同業研究者にも広く積極的に使ってもらおうという状況でも尚このようなレベルで済ましていいのでしょうか。

製品版のプログラムと同等になどとまでは言いません。それはさすがに現実的ではない(本当に厳格性を期すなら、自分でやらずにプロに外注すべきです)。でも、たかだか2-3行の追加で済むような簡単なエラートラップだって可能なのです。それを仕掛けるぐらいの手間はかけようよと思います。