(以下、カノッサの屈辱(テレビ番組)のノリでお願いします)
コンピュータの理論を確立したチューリング。彼は、無限のテープの長さを持ち、単なる計算を行うだけのチューリングマシンを夢想した。
しかし、我々の生きる現実世界におけるコンピュータでのプログラムに於いては、計算の失敗=エラーが発生するのは避ける事ができない宿命である。ネットワーク接続失敗、ファイルが見つからない、メモリが確保出来なかった…等々。すべてのエラーを書き出すには、それこそ無限の長さの紙が必要であろう。プログラム進化の歴史は例外との戦いであると言っても過言ではない。今回の講義では、プログラミング言語の様々な進化のうちの「エラー処理」に着目し、その長い戦いの歴史を概観する。
まずは、いにしえの先エクセプション紀におけるエラー処理を見てみよう。
先エクセプション紀:エラーコード-1の時代
エクセプション(exception)と呼ばれる隕石が衝突する以前の時代は先エクセプション紀と呼ばれている。この時代での例外処理の特徴は、エラーコードという概念の存在である。数学の関数を模したプログラム言語の「関数」とよばれる生物は、値を一つだけ戻すことができる。当時地球を支配していたAPI関数たちはこの「戻り値」を用いてエラーが発生した旨を伝えることで、上位のプログラマにその処理を託した。
//int readNumberFromFile(const char* filename);
// ファイルを読んで、そこに書かれた数字を返す。失敗した場合は-1を返す。
// ex) number.txtに"12"と書かれていれば、12を返す。
int status = readNumberFromFile("number.txt");
これがこの時代における典型的な「失敗する可能性のある関数」の姿である。戻り値として計算が失敗した場合に特別な値を返すことでその旨を表現し、多くの場合、-1などマイナスの値が好んで使われた。これを「エラーコード」と呼ぶ。
しかし、上記の例を見れば分かる通り、正常な結果と失敗した結果を同じint型の変数一つで表してしまうことで、いくつか問題が発生した。例えば、上記のプログラムでは、もしもファイル中に「-1」と書かれていた場合、このAPI関数は「正常な結果として」-1を返してしまうかもしれない。プログラマにはそれを区別する事ができない。必ず非負の数値が書かれていることを要請し、マイナスの場合はすべてエラーとすれば問題は解決するが、APIに要求とされる機能によってはそれは許されないかもしれない。そのような場合、このように苦肉の策として追加のポインタを使うケースがあった。
// ファイルを読んで、そこに書かれた数字を返す。
// 失敗したか否かの情報がstatus内に格納され、失敗したら-1が格納される。
// int readNumberFromFile(const char*filename, int* status);
int status = 0;
int result = readNumberFromFile("number.txt", &status);
if(status == -1)....
このポインタを用いて間接的に値を返す手法によって、プログラマに結果の値を一つしか伝えることができない、という関数の問題を回避することができた。しかし、この方法には明らかに欠陥がある。「処理が失敗したか否か」も、「成功した場合のその結果」もどちらも「関数から返ってくる値」のはずであるが、この方法ではその対称性が崩れてしまっており、あたかもstatusは入力値のようにも見えてしまっている。このおかげで、せっかくエラーを報告しても無視されてしまう悲しい事故が散見された。また、どちらをポインタとして渡して格納させるかはライブラリごと、関数ごとに違い、勘違いして双方を入れ替えてしまう悲劇も発生していた。また、Javaなどのように「参照型」を渡すことができない言語においては、この方法を(直接は)使うことができない。
Windowsの一部のAPIやOpenGLなどでは、別の関数を呼びエラーの発生を確認するという方式も考えだされた。
// int readNumberFromFile(const char* filename);
int result = readNumberFromFile("number.txt");
int status = getError();
if(status == -1) ....
この方法では、失敗する可能性のあるAPI関数の呼び出しの直後に特定の関数を呼ぶことでエラーの発生を確認した。当然、エラーを確認するまえに別のAPI関数を呼び出してしまうと、エラーを正しく処理することができない。
// int readNumberFromFile(const char* filename);
int result = readNumberFromFile("number.txt");
int result2 = anotherFunction(); /* !ここでもエラーが発生するかもしれない ! */
// readNumberFromFileのエラーをもはや反映しない
int status = getError();
if(status == -1) ....
保守や機能拡張時などにおいては、その事をすっかり忘れてしまい、明後日なエラーメッセージを前に途方に暮れるという事態も珍しいことではなかった。
また、別の関数をわざわざ呼ばなければならない点はやはり手間が大きく、この方式ではポインタ方式よりも、さらに輪をかけてエラーが無視されることとなった。
エラーコード-1の亜種として、nullを返すAPIが棲息する地域もあった。Java大陸や、C++海溝の一部などに見られる化石などから明らかになっている。このような関数呼び出しの場合、一見エラーを返さないような計算に見える:
String data = client.readDataFromFile();
dataがnullであれば失敗である事を示し、そうでない場合は成功した結果を表すという方式である。
この方式も、エラーコードと同様、正しい結果としてnullが返ってくる可能性がある場合にうまく対処できない。さらに「null」というただひとつの値しか返すことができないため、結局失敗した理由を他の方法を用いてAPI使用者に伝えなければならないという問題があった。多くのAPIでは実装工数がないの魔法の一声で、その方法は提供しないこともあった。
エラーコードの体系や伝え方(nullを返す・関数を呼ぶ・エラーコードを使う・ポインタを渡す)は当然、APIの創造主や、APIごとにまちまちであり、APIを消費するプログラマはそれらを個別に理解し、正しく対処することが求められた。そしてもちろん、そこには言語処理系のサポートは一切ない。
結果、そのエラー判定コードが正しいことをコンパイラで事前に判定することができないため、プログラマのその日の体調や気分によってはバグが発生することとなった。
エクセプション隕石の衝突
地球上でエラーコードを持つ多様な生物が棲息していたある日、ついに事件は起こった。エクセプション隕石の衝突がそれである。エクセプション隕石は地球上の多種多様な生物に深刻なダメージを与えたが、特に構造化の三要素へのダメージは深刻であった。
当時のプログラミング言語の構文細胞において、順次・反復・分岐の三生物が共生し、細胞小器官となる進化が発生していた。この三つの器官をまとめて「構造化の三要素」と呼ぶ。
/* 順次(上から下に実行) */
int a=10;
int b=a*10;
/* 反復(繰り返し) */
int sum = 0;
for(int i=0;i<100;++i){
sum += i;
}
/* 分岐 */
if( isOdd(x) ){
printf("odd number!\n");
}
しかし、一部の言語はそれよりも原始的なgotoと呼ばれる器官を退化させることなく持ち続け、APIを消費する一部の生物種は、例外の処理に好んでgotoを用いた。
if( mayFail() == ERROR_FAIL ){
goto fail1;
}
...
fail1:
/* 失敗時の処理 */
エクセプション隕石は、この原始的なgotoと構造化の三要素との悪魔融合合体進化を発生させた。なぜこのような奇妙な進化が起こったのかは、未だに議論のあるところである。この合体進化した新たな器官は、隕石の名前にちなんで「エクセプション」と呼ばれた。
try {
int result = mayFail();
int result2 = mayFail2();
....
} catch (Error const& e) {
/* 例外時の処理 */
}
エクセプションは構造化の三要素のような姿を持ちつつ、goto文のような文脈を跳躍する特性も有する。APIを消費する上位のプログラマは、エラーの発生があたかも無いかのように処理を記述しつつ、最後にエラーに纏めて対処する事となった。
構造化の三要素とエラーコードを用いたAPI消費の多くのケースでは、ひとつでもエラーが発生した場合、次の処理を行うことは通常不可能であったため、多くのプログラムではエラーが発生したら、すぐにgoto文で失敗時の処理に移るということが見られた。
if( mayFail() == ERROR ){
goto error;
}
if( mayFail2() == ERROR ){
goto error;
}
if( mayFail3() == ERROR ){
goto error;
}
error:
return -1;
エクセプションでは、これらをcatch節 という仕組みを使うことで共通化した。すなわち、エラーが発生したら移行の処理は全てスキップし、catch節の中でそのエラーに纏めて対処するということである。
try {
int result = mayFail();
int result2 = mayFail2();
int result3 = mayFail3();
....
} catch (Error const& e) {
/* 例外時の処理 */
}
エクセプションを持つ言語は地球上で広く繁栄することとなった。エクセプション器官を持つプログラミング言語は、オブジェクト指向と呼ばれる骨格も同時に持つことが多く、オブジェクト指向とエクセプションには関係があると考えられているが、先述の議論から構造化プログラミングとの関係の方が深いという異論も存在する。
しかし、このエクセプションは悪魔融合合体であるがゆえに、黒魔術的弱点を有する。
多くのプログラムでは、構造化の三要素が守られる事を暗黙の前提としてソフトウェアが構築されていくが、エクセプションの仕組みではエラーが発生するとそれがgoto由来の文脈跳躍生によってほぼ無化されてしまう。
またエクセプションの仕組みは処理の流れを変えることに依存しているため、処理の流れが複数存在するスレッド環境などではさらに技巧を用いることが必要とされ、多くの場合それはうまく動作することは無かった。
これらの事に端を発した多臓器不全で亡くなる生物は少なくなく、「More Effective C++」などの当時を研究する古医学書では1章をこのエクセプション由来の疾患に割かれている。
さらに、「で、結局纏めて対処しろって言われてもどうすれば良いんだ…」との声も根強く、catch節には何も書かれない事態も珍しい物では無くなった。これを「例外潰し」と呼ぶ。
エラーコードの復権:地殻内空洞での”数学”との遭逢、そしてMaybe種とEither種
エクセプション隕石はエラーコード種を概ね絶滅させたが、地下3000mではほそぼそと生きながらえる事となった。地下3000mには酸素がほぼ存在しないため、好気性生物であったエラーコード種には進化が求められた。そのための遺伝子を提供したのが、地殻内空洞に棲息する「数学」と呼ばれる、一連の生物群である。
この生物種がエラーコード種に提供したのは、「モナド」と呼ばれる強力な遺伝子である。この特徴を詳しく見てみよう。
エラーコード種に起こった問題である「同じ型で成功と失敗を表されるので、正しい結果の”-1″と失敗を表す”-1″の区別がつかない」という欠点を、値の上位に成功か失敗かを表す仕組みである「Maybe」や「Either」を加えることで表現。さらに、nullの持つ「失敗としてただひとつの値しか返すことができない」という問題も、失敗時の型にその情報を付加できるようにすることで解決することとなった。
-- Leftが失敗、Rightが成功
data Either a b = Left a | Right b
--引数に応じて処理、失敗するかもしれない
mayFail :: ArgType -> Either String Result
-- ----------------
case mayFail arg of
Left a -> ... --aにはエラーの理由が
Right b -> ... --bには成功した結果が
思わぬ副作用として、エラーを無視することが出来なくなった。戻り値のMaybe/Eitherから結果を取り出すには、必ずエラーか否かをチェックする必要がある。
これは今までのプログラミング言語種でも可能であった。Cなら構造体と共用体で、C++ならクラスの仕組みを用いることで実現できる。しかし、それだけではエラーコードかどうかをチェックする今までの方法とあまり変わらず、煩雑なエラーチェックが必要になる。
すなわち、MaybeやEitherの導入だけでは、次のような非常に煩雑な記述が必要になってしまう、ということである。
--mayFailとmayFail2とmayFail3を使った処理
--この三つの関数は、どれも失敗するかもしれないのでEitherを返す。
func = case mayFail arg of
Left err -> return (Left "error on maybeFail")
Right ok -> case (mayFail2 ok arg2) of
Left err2 -> return (Left "error on maybeFail2")
Right ok2 -> case (mayFail3 ok2 arg3) of
Left err3 -> return (Left "error on maybeFail3")
Right ok3 -> return (Right ok3)
この状況に力を与えたのが、かの有名な「モナド」遺伝子である。
モナド遺伝子のbindの力とMaybeやEitherが共生することで、上のような煩雑なエラー処理は次のように至極単純化された。
func = do
ok <- mayFail arg
ok2 <- mayFail2 ok arg2
ok3 <- mayFail3 ok2 arg3
return (Just ok3)
表層を見ると、エラー処理が全て消え去ってしまっている。エクセプションではエラーを処理するために存在するcatch節が、完全に消滅している。しかし、エラーを「潰した」りはされない。もし、この関数を実行した際にmaybeFail2でエラーが発生した場合、その下位関数のエラー値がそのまま返され、続くmaybeFail3はスキップされる。
-- 途中でエラーが発生すれば、その結果が返される。潰れされない。
func -- -> Left "error occur on maybeFail2"
-- 最後まで成功すれば、最後にreturnした値が返される
func -- -> Right result
エクセプションにあった、構造化の三要素が無化されてしまう事によって生じうる問題はここでもまだ残っているが、MaybeやEitherは制御構造ではなく、単なる値であるため、エクセプションに比べて柔軟である。例えば、エクセプションを用いたプログラミング言語種では「何度か実行し、成功を試行する」といった仕組みを作るのは一般的に面倒であるが、Maybeを使えばこのように簡単である。
-- 渡された関数を何度か実行し、一度でも成功すればその結果を、
-- n回実行しても失敗するならNothingを返す
tryFunc :: Int -> ( Int -> Maybe Result ) -> Maybe Result
tryFunc n thunk =
if n <= 0 then Nothing
else
case (thunk n) of
Nothing -> tryFunc (n-1) thunk
Just x -> Just x
この関数を用いてラップすることで、既存のMaybeを返す関数も簡単に「N回試行」させることができるようになる。
func = do
ok1 <- mayFail
-- maybeFail2だけは10回だけ試行する。一回でも成功すればよい。
ok2 <- tryFunc 10 mayFail2
....
return (ok3)
このように、「失敗するかもしれない」処理の内側と外側の行き来が非常に簡単になったことで、エクセプションの問題も対処しやすくなっている。
まとめ
- エラーコードでは…
- 正しい結果の-1と、エラーコードの-1を区別するのが困難
- エラーコードのチェックが面倒くさい
- 大体エラーコードのチェックなんてしなくなってくる
- JavaやC++の例外機構では…
- 例外が発生すると以降の処理が全部スキップされてしまうことに起因するバグ
- 例外は潰されるもの
- MaybeやEitherでは…
- エラーを無視できない
- 下位で発生したエラーをそのまま上位に渡すのが簡単
- エラーが発生する処理とその周囲との行き来が簡単
私が現在理解したところによるMaybe/Either/Optionalの利点はこんな感じです。あと文中のHaskellがIOがどこでも起こせるかのような書き方ですが、IOと組み合わせると論点がぼやけそうなんです、ごめんなさい。ツッコミ、お待ちしています。
ちなみに
JavaでもC++でも、匿名クラスやlambda式を多用することでMaybe/Eitherモナドは使うことができます。C++だとこんな感じで実装できます。Javaでもバイトで書いたことがあるのですが、バイトで書いたので手元にコードはありません(ぉ