プログラマというお仕事

プログラマは職人だ!プロならもっと腕を磨け。
世の中ヘボいプログラマが多すぎる。
少々過激な意見でも言っちゃうよ。

オブジェクト指向

オブジェクトは常に正しい

オブジェクト指向超入門〜第6回〜

オブジェクト指向のポリシーでもうひとつ重要な点は、保持しているデータは常に矛盾や破綻がないという事です。どっかのバカがムチャクチャな使い方をしても、決して13月42日のような日付になってしまってはいけません。それは使う側ではなくクラス自身に、そのような事態になってしまう可能性を排除する責任があるのでした。
その為に変数をprivateにし、アクセスメソッドでは渡された引数の値を適切にチェックして、変数同士が矛盾しないように処理する必要があります。そのようにして考えられる穴を出来る限り塞いでおくほど「堅牢な」クラスになります。クラスが堅牢になるほどシステムのバグは少なくなり、またバグが起こってもその原因が特定の箇所に限定され修正しやすくなります。

ではクラスのメソッドをこのようにしっかり作っておいて破綻の可能性を排除すれば、オブジェクトが予期しない状態になるのを100%防げるでしょうか。どんなにメソッドを頑丈に作っても、オブジェクトの状態が不定になる可能性がひとつだけ残っています。それはオブジェクトをnewした直後です。

MyDate today = new MyDate();
today.addDay(10); // どうなる?

オブジェクトを生成した直後には正しい日付は保持されていません。このクラスではnewした後に setYy(2008); setMm(12); などを呼んでもらう事を想定しています。これは言ってみれば使用者に使い方を強制している事になります。しかしオブジェクト指向では「どこでどんな無茶な使い方をされるかわかったもんじゃない」「使用者は分別を持ってくれるはず、なんてのはハナから信用しない」という思想が根底にあります。だからオブジェクトが矛盾した状態にならないようにするのはクラス自身の責任なのでした。であればnewした直後でさえも破綻の可能性を排除するのはやはりクラスの責任です。コンストラクタはその為に導入された文法です。

pulic MyDate() {
  // 2000年1月1日を初期値とする
  yy = 2000;
  mm = 1;
  dd = 1;
}

普通は本日の日付を取得してきて初期値とする、などが実用的な実装なのでしょうが、とにかく「変な」日付が保持される可能性はこれで完全に排除できる訳です。コンストラクタはこのようなオブジェクト指向のポリシーを貫くための文法です。太古の昔から言われている「変数は宣言したらまず初期化しよう」という初心者用の教科書に書いてあるようなくだらない仕事をするために導入されたのではありません。
わかってない人はコンストラクタで単純な初期化しかしない場合が見受けられます。この場合、型がintだからという単純な理由で変数にゼロを代入するといった具合です。0年0月0日という日付オブジェクトが出来上がるようではコンストラクタの役割を果たしていません。

例外について考える

オブジェクト指向超入門〜第7回〜

例外というのはエラーが発生した時の通知の方法であって、オブジェクト指向とは直接関係のない文法のように思われますが、実はオブジェクト指向にとって無くてはならない大事な機能です。今回は例外について考えてみます。

まず例外の文法について今一度確認しましょう。C++言語で例外が登場した頃、入門書などでは「エラー処理に埋もれた見にくいコードをスッキリ見やすくするため」の文法なのだという説明がよく書かれていました。

 ret = funcA();
 if(ret != 0) {
   // エラー処理
   return;
 }
 ret = funcB();
 if(ret != 0) {
   // エラー処理
   return;
 }
 ret = funcC();
 if(ret != 0) {
   // エラー処理
   return;
 }

このように関数内でのエラーの発生を戻り値で通知しようとすると、コードがエラー処理だらけになってしまい、肝心の処理内容が見にくくなってしまいます。そこで以下のようにエラー処理を一箇所にまとめてスッキリさせようという事です。

 try {
   funcA();
   funcB();
   funcC();
 } catch(Exception) {
   // エラー処理
   return;
 }

これはこれで確かにメリットの1つではありますが、この為に例外という文法を作ったのだと考えるのは少し違います。上記のメリットは例外を使う事により結果的にもたらされた副次的な効果、言ってみれば副作用的なものであって、例外には実はもっと本質的な役割があると考えるべきです。

その役割とは何か。エラーの発生を無視できないよう強制させるのが、その最大の目的です。例えばエラーの発生を戻り値で返す前者のコードで
 funcA();
と、ただ関数を呼び出すだけで戻り値をチェックしないようなコードを書いたとしましょう。これは何事もなくコンパイルを通りますし、エラーが起こらない以上は正常に実行できます。しかしもし関数の中でエラーが発生した場合、せっかくエラーの発生を呼び元に知らせているのに、呼び出した側はそれをあっさり無視して実行を継続しようとします。関数内で起きたエラーに対する適切な対処を何もしないままプログラムの実行を継続した場合、そのエラーを放ったらかしてたのが原因で後続の処理で意図しない重大な不具合が起きるかもしれません。そしてそのような不具合は往々にしてデバッグが困難な、やっかいな類のバグになります。

プログラマの方ならお分かりでしょうが、不具合の事象が発生した箇所とそれの根本原因となった箇所が遠く離れているほど原因究明が難しくなりがちですよね。

「エラーコードを返すような関数を呼び出す場合は、呼び出し側で必ずエラーの有無をチェックする事」というようなコーディング規約を作って、それをプロジェクトの全メンバーに周知すれば解決する問題でしょうか。チームでシステム開発をしているプロの方ならばお分かりでしょうが、そんな規約は気休め程度にすらなりません。プロジェクト内には破壊的なコードを平気で書いてくれるヘボプログラマがゴロゴロいます。個々のプログラマの良心なんてものに期待してはいけないというのは、オブジェクト指向のポリシーのひとつなのでした。(「構造体の問題点」を参照)

じゃあどうすればいいのか。例外を使う後者のコードでtry〜catchを書かなかった場合、コンパイルを通してくれません。(嘘です。言語によってはコンパイルできます。でもエラー発生時にはちゃんと?落ちてくれます。この辺の詳しい話は後日書きます。)例外を返す関数を呼び出す場合は、必ずその例外をキャッチしてエラー処理を書く事を強制されるのです。

もちろん以下のような逃げ道はあります。

 try {
   funcA();
 } catch(Exception) {
   // 何も対処せず、エラーを無視
 }

しかしこれは意図的にエラーを無視していると言えます。処理内容によっては、無視してしまって構わない事が分かっているエラーというのは少なからずある訳で、このようなコードになっていれば、少なくともエラーの可能性を知っていながら(それ相応の理由があって)あえて無視しているのだろうな、という想像ができます。(とは言ってもこのようなコードを、単なるサボリで書いてしまうプログラマが実はいたりします。これについても別途書きます。)

そもそも前者のコードで戻り値をチェックしない理由は、最初から戻り値の事なんて気にしていない、という場合の方が多いのではないでしょうか。つまり「意図せず」に無視してしまっているのです。それに比べれば意図せずうっかり無視してしまうのを防げる後者の方が、思わぬバグを入れ込む可能性を少なくする意味で優れていると思います。

例外とオブジェクト指向の切っても切れない関係

オブジェクト指向超入門〜第8回〜

前回の記事では、例外という文法が導入された最大の目的は「エラーの発生を無視できない」よう強制させる事だと書きました。この事がオブジェクト指向とどう関係するのかについて解説します。

オブジェクト指向の重要なポリシーの1つに、保持しているデータが矛盾や破綻している状態のオブジェクトは一瞬たりとも存在してはいけない、というのがありました。(「オブジェクトは常に正しい」参照)従ってnewした直後にはすでに、各メンバ変数に正しい値が詰まっているオブジェクトが生成されている必要があり、その為にコンストラクタがあるという話でした。ではそのコンストラクタの実行中に何らかの致命的なエラーが発生して、正しい値をメンバ変数に代入してやる事が出来ない事態が発生したらどうしたらいいでしょうか。
例えば、外部の設定ファイルに書いてある値を読み込んでそれをメンバ変数の初期値にするという仕様で、その設定ファイルの読み込み時にIOエラーが起こって値を取得できなかったら...この例の場合はもちろんデフォルト値を代入するなどの手も考えられますが、そういった回避手段が無い事態が起こった場合、仕方なくエラーを返すしかないケースもあるでしょう。

ところで、オブジェクト指向言語の文法では「コンストラクタは戻り値を返さない」という事になっています。voidとも違って、そもそも型の記述さえしません。本当にコンストラクタは戻り値を返さないのでしょうか?戻り値を返せないので、コンストラクタでエラーが発生した場合、他の手段でそれを通知しないといけません。その為に例外が導入されたのでしょうか。そもそも何故そんな制限のある文法になっているのでしょう。

これらの疑問に対する答えは、オブジェクトを生成する以下のコードの記述の仕方を見れば自然と分かります。
 Hoge hoge = new Hoge();
そう!コンストラクタは戻り値を返すのです。その戻り値とは「自クラスのインスタンス」です。コンストラクタの定義に型の指定を書かないのは、戻り値が無いからなのではなく、戻り値の型は自分のクラスだと決まっているのでわざわざ書く必要が無いからです。

もしインスタンスとは別にエラーコードを(第2の?)戻り値として返せると仮定してみましょう。new のコードの記法がどうなるのかは置いといて、コンストラクタ内で致命的なエラーが発生しそれを戻り値で通知した場合、Hogeクラスの変数hogeには不完全な状態のインスタンスが代入されてしまいます。これは上記のオブジェクト指向のポリシーに反して、破綻した状態のオブジェクトが一瞬存在してしまう事になります。さらには返されたエラーコードを無視して後続の処理を続ければ、そのオブジェクトは一瞬どころか堂々とメモリ上に居座り続ける事も出来てしまいます。オブジェクト指向のポリシーから考えると、これはどうしても避けなければならない由々しき事態です。

つまり、コンストラクタでエラーが起こった場合、戻り値を返せないから仕方なく例外で通知するのではなく、戻り値があろうが無かろうが例外を投げなければならないのです。例外を投げた場合、上記 hoge = の代入は実行されることなく、直ちに例外ハンドラへ飛ばされます。変数hogeの指すメモリ上に不完全なインスタンスが一瞬でも生成される事はなくなる訳です。
これが「オブジェクト指向のポリシーを貫く上で例外は無くてはならない機能」だと前回の記事の冒頭で書いた理由です。

例外を邪険にしないでっ

オブジェクト指向超入門〜第9回〜

例外はエラーの発生を無視できないよう強制する為の機能だと書きました。これをうっとおしいと感じるプログラマは多いようです。

ここに、AAExceptionを投げるfuncA()、BBException()を投げるfuncB()、CCException()を投げるfuncC()の3つの関数があって、それを順に呼びたいとします。まじめに書くと以下のようになります。

 try {
   funcA();
   funcB();
   funcC();
 } catch(AAException) {
   // エラー処理
 } catch(BBException) {
   // エラー処理
 } catch(CCException) {
   // エラー処理
 }

このように全ての例外をちゃんとcatchするには、それぞれの関数でどんな例外が投げられるのか、APIドキュメントを1つひとつ調べながらコーディングする必要があります。それに同じようなcatchブロックを何回も書かないといけません。これはメンドイって事で、以下のようにサボってしまうプログラマが少なからずいます。

 try {
   funcA();
   funcB();
   funcC();
 } catch(Exception) { // 3つの例外をまとめてキャーッチ!
   // エラー処理
 }

このやり方は正しいでしょうか。どの例外が発生してもエラー処理で行うべき対処が同じなのであれば、これで構わないように思えます。しかし、これはやってはいけません。面倒であってもちゃんと例外の種類ごとにそれぞれcatchしてやる必要があります。繰り返します。
Exceptionでまとめてキャーッチは絶対やっちゃ駄目!

何故でしょう?
このコードで発生する例外は3種類であるように見えますが、実はもっと多くの種類の例外が発生する可能性があります。言語の仕様にもよりますが、プログラマが意図しない想定外の例外が発生する可能性が常にあるのです。

そもそも例外が投げられる可能性があるという場合、それはあらかじめ想定された事態だと言えます。例えばファイルから値を読み込んで変数に代入する処理があったとして、そのファイルが存在しない等でオープンに失敗する可能性は、コーディング時にあらかじめ想定しておくべき事象です。だからオープンできなかった場合は throw IOException() と書いておくのですね。
ところが throw 〜Exception(); なんてどこにも書いてないはずの処理中に思わぬ例外が発生する事があります。いわゆる実行時例外です。NullPointerとかですね。この手の例外はキャッチしてはいけないんです。

は!?キャッチしちゃいけないって、どういう事?
もし3つの関数内のどこかでNullPointerが発生した場合、後者のコードではそれもキャッチします。そしてエラー処理をした後に後続の処理を続ける事になりますが、それは危険な事ではないでしょうか。このコードでのcatchはNullPointerの可能性まであらかじめ想定していたでしょうか。想定していたのは3種類の例外だけで、それ以外はいわば想定外の事態なはずです。なのにプログラムの実行を続けてしまって本当に大丈夫なのでしょうか。本来ならファイルから読み込んだ値がセットされているはずの変数が、例外発生により処理がスキップされてしまった為におかしな値が入った状態のまま実行を続けた結果、ずっと後の方になって思わぬ不具合が起こったとしたら...これは非常にタチの悪いバグです。
変数に不正な値がセットされる原因となった箇所と、不具合の事象が発生した箇所が遠く離れてしまうと、バグの調査が難しくなります。そもそも変数の値が不正になる直接の原因が何なのかを特定するのも極めて困難です。というかNullPointerが発生したという事にすら気付けません。

Exception(全ての例外の基底クラス)をキャッチするという事は、想定外の例外が発生した場合でも、その事実をもみ消してしまう事を意味するのです。だからまとめてキャッチ、っていうサボリはしてはいけないんです。じゃあ実行時例外が発生した場合はどうするのか。私に言わせれば、いさぎよくプログラムを落としてしまってもいいくらいです。

例外を面倒で邪魔くさいもんだと思わないであげて下さいね。

例外のポリシー

オブジェクト指向超入門〜第10回〜

現在のプログラミング言語における2大メジャーであるJAVAとC#の2つを例に、例外に対するポリシーを考えてみようと思います。文法を表面的に見ただけではちょっとした違いにしか思えないかもしれませんが、そのちょっとした違いの中に、それぞれの言語の設計者の思想の違いが見て取れます。

まずはJAVAから。
JAVAでは例外クラス(ここではthrowで投げる事の出来るクラスという意味とします)の最上位にいるのはThrowableというクラスであり、これを継承したのがExceptionとErrorの2つ。Exceptionをさらに継承したのがRuntimeExceptionとその他のクラス、さらにさらにRuntimeExceptionの派生クラスとしていくつかの例外がJDKに定義されています。この継承ツリーを図示すると以下のようになります。

Trowable
 |
 +Error
 | |
 | +ホニャララError
 |   ・
 |   ・
 |
 +Exception
   |
   +RuntimeException
   |  |
   |  +ナンチャラException
   |     ・
   |     ・
   +Runtime以外のその他大勢Exception
       ・
       ・

JAVAで例外をthrowする関数を書いた場合、その関数宣言でthrows 〜Exceptionと書かないといけないという文法になっています。これは「オレは〜Exceptionを投げるぜ」と宣言する必要があるという意味です。なぜそんな事を宣言しないといけないかというと、関数を呼び出す側に対して、その例外をキャッチする事を強制させる必要があり、その為にはどんな例外をキャッチしなきゃいけないかを呼び出し側に教えてあげなきゃならないからです。
一方呼び出す側ではその例外をキャッチするか、またはもう1つの選択肢としてキャッチせずに放ったらかすという事もできます。ただし放ったらかす場合は自身の関数宣言でも同じようにthrows 〜Exceptionを書かないとコンパイラに怒られます。これは当たり前の話で、A→B→Cという呼び出し階層があったとして、Cが例外を投げたときにBがそれを無視すればその例外はAにそのまま投げられる訳で、その場合にAから見れば「Bが〜Exceptionを投げてきた」ように見えるのだから、BはAに対して「オレは〜Exceptionを投げまっせ」とあらかじめ断っておく責任がありますよね。
これは非常に理にかなった文法で、これこそが例外の存在理由だと前回の記事でも書いているとおりなのですが、実は上記のツリー構造でErrorとRuntimeExceptionの下にぶら下がっている例外クラスは「キャッチしなくていいしthrows宣言もいらない」という文法になっています。平たく言うと「何の断りもなく突然想定外の例外が投げられるかもしれないよ」という事です。ErrorとRuntimeExceptionだけが何故そんな特別扱いなんでしょうか。

これは例外の発生原因によってクラス階層を分けているからです。
Error配下のクラスは主にハードウェアや実行環境の不具合が原因で起こる事態で発生します。例えばVMのヒープが枯渇してしまいクラスをこれ以上生成できなかったので、newしてる箇所でOutOfMemoryErrorが投げられる、といったケースです。
RuntimeExceptionは主にプログラムのバグが原因で想定外の(本来起こってはいけないはずの)不具合が発生した場合に投げられます。おそらく一番有名なのは、インスタンスがまだ未生成のクラス型変数を介してメンバをいじろうとした場合に起きるNullPointerですね。また値がゼロの変数で割り算をしようとした時に投げられるArithmeticException(いわゆるゼロ割り)などもプログラマなら一度は見た事があるんじゃないでしょうか。

これら2つに共通して言えるのは、コーディング時にあらかじめ想定しておけない、まさに「例外的な」事象が起こった場合に発生するものだという事です。前者のErrorはハードウェアや特定の実行環境の都合で起こるものであり、プログラムを書いている時点では、どんな環境でどんな不都合が起こるかは事前に予想が付きません。それ故にプログラム内のどこでどんな不具合の可能性を考慮してあらかじめエラー処理を実装しておけばよいのかという判断が出来ません。
もう1つのRuntimeExceptionの方はというと、本来起こってはいけないはずのエラーです。null経由でメンバを参照しようとしたりゼロで割り算しようとしたりするのは、そんなプログラムを書いたプログラマの責任です。要するにただのバグであり、そのプログラムを書いた本人はバグだと思わずに書いている訳ですからエラーの可能性など考えていないので、当然エラー処理をあらかじめ実装しておく事などありません。

このように考えるとErrorやRuntimeExceptionは事前に予想できないのだからthrows宣言なんて書けるはずもなく、従ってキャッチもできない、という話になるのです。じゃあ実際にそれが起こってしまった場合どうなるかというと、誰もキャッチしていないので、もちろんプログラムが落ちるんですが、これはむしろ落ちてくれた方がいいんです。落ちてくれればその事実に気付いて原因を探り、バグなのであればプログラムを修正するなり、環境が原因であれば設定を見直すなどの対処がすぐにできます。それなのにExceptionをまとめてキャッチしているためにこれら事象に気付かず、エラーが発生したという事実をもみ消して無かった事にしてしまっても、潜在的な不具合に目をつぶって無駄な延命措置をしているだけに過ぎません。病気と一緒で早期発見早期治療が大事なのであって、見て見ぬふりで問題を先送りしていい事など何も無いんです。