付け足し初期化
様々な理由 (ほとんどは良くない理由) から、クラスのすべてのフィールドを適切に初期化するのに十分な引数を取らないコンストラクターを持つクラス定義をしばしば目にします。そのようなコンストラクターの場合、クライアント・クラスでは相手のインスタンスを複数のステップで初期化する (未初期化のフィールドに値を設定する) 必要があり、コンストラクターを1回呼び出すだけでは初期化できません。このようにしてインスタンスを初期化する方法はエラーの発生しやすいプロセスであり、付け足し初期化 と呼ぶことにします。このプロセスに起因する何種類かのバグは、症状と対処法が似ているため、「付け足し初期化コード」バグ・パターンというグループにまとめることができます。
たとえば、次のコードについて考えてみましょう。
リスト1. 付け足し初期化の簡単な例
class RestrictedInt { public Integer value; public boolean canTakeZero; public RestrictedInt(boolean _canTakeZero) { canTakeZero = _canTakeZero; } public void setValue(int _value) throws CantTakeZeroException { if (_value == 0) { if (canTakeZero) { value = new Integer(_value); } else { throw new CantTakeZeroException(this); } } else { value = new Integer(_value); } } } class CantTakeZeroException extends Exception { public RestrictedInt ri; public CantTakeZeroException(RestrictedInt _ri) { super("RestrictedInt can't take zero"); ri = _ri; } } class Client { public static void initialize() throws CantTakeZeroException { RestrictedInt ri = new RestrictedInt(false); ri.setValue(0); } } |
残念ながら、このクラスのインスタンスの初期化シーケンスは、バグの発生しやすいものです。お気付きのとおり、上記のコードでは、初期化の第2ステップで例外がスローされます。そのため、そのステップの後に設定されているはずのフィールドが、設定されないままになります。
しかし、スローされた例外のハンドラーでは、そのフィールドが設定されていないことを知るすべがありません。その例外から回復する過程で、問題のRestrictedInt
のvalue
フィールドにアクセスすると、そのハンドラー自身がNullPointerException
でつまずいてしまいます。
そうなると、例外ハンドラーが存在しない場合よりも悪い結果になります。チェックされた例外には、少なくともその原因についての手掛かりが含まれているべきです。しかし、NullPointerException
の診断が困難であることは周知の事実です。その例外には、そもそもなぜ値がヌルであるのかについて情報がほとんど含まれていないからです。さらに、この例外は、未初期化のフィールドにアクセスしたときにだけ発生します。そのアクセスは、バグの原因 (フィールドがもともと初期化されていないこと) からは遠く離れたところで実行される場合がほとんどなのです。
もっとも、付け足し初期化のバグに起因するエラーは、これだけではありません。
上に戻る
付け足し初期化に起因するその他のエラー
起こり得るエラーには、次のようなものもあります。
どのように我々は光と色を見ていますか?
- 初期化コードを記述しているプログラマーが、必要な初期化ステップの1つを記述し忘れるかもしれません。
- 初期化ステップの中に順序に依存するものがある場合、そのことをプログラマーが知らないと、初期化ステートメントを定められた順序以外で実行してしまう可能性があります。
- 初期化するクラスが変更されて、新しいフィールドが追加されたり、古いフィールドが削除されたりすることがあります。その場合、すべてのクライアントのすべての初期化コードを修正して、フィールドを正しく設定するようにしなければなりません。修正するコードのほとんどは類似していますが、1箇所でもコピーすることを忘れると、バグを招きます。そのため、付け足し初期化コードは、いとも簡単に不良タイルになることがあります (このバグ・パターンの背景については、「不良タイル」バグ・パターンという私の記事を参照してください)。
付け足し初期化にはこのように多くの問題が伴うため、すべてのフィールドを初期化するコンストラクターを定義する方がずっと良いと言えます。前述の例では、RestrictedInt
のコンストラクターに、そのクラスのvalue
フィールドを初期化するためのint
引数を含めるべきです。クラスのコンストラクターでいずれかのフィールドを初期化しないままにするべき理由など、決して存在しません。クラスを最初から記述する場合、この原則に従うのは簡単なことです。
しかし、コンストラクターで一部のフィールドを初期化していないクラスを含む大規模なコード・ベースを取り扱う必要があり、コード・ベース全体に付け足し初期化コードが散らばっている場合にはどうでしょうか。私は、そのような状況を一度ならず経験しました。
上に戻る
自由が利かない場合
残念なことに、既存のコード・ベースに含まれるコンストラクターで一部のフィールドを初期化していないというケースには、大方のプログラマーが考えているよりも頻繁に直面します。既存のコード・ベースが大規模で、問題のあるクラスのクライアントが多い場合には、コンストラクターのシグニチャーを修正したくないと考えることでしょう。コードの単体テストが乏しい場合には、特にそうです。そうなると、文書化されていない(が維持すべき)不変性質を破ってしまうことは必定です。
多くの場合、その状況での最善の選択は、既存のコードを破棄して最初からやり直すことです。それは馬鹿げた話だと思えるかもしれませんが、そのようなコードに潜むバグを修正するのに費やす時間の長さを考えると、コードを書き直すのにかかる時間もさほど長くは感じられないケースがよくあるのです。私自身、この種の問題を抱えた大規模な既存のコード・ベースと格闘した挙句、最初からやり直しておけばよかったと思ったことが何度もあります。
しかし、既存のコードをどうしても破棄できない場合でも、次のような簡単な慣習を取り入れることにより、エラーの可能性をつぶすことができます。
- フィールドを (ヌル以外の) デフォルト値に初期化する。
- 追加のコンストラクターを組み込んで使用する。
- クラスに
isInitialized
メソッドを組み込む。 - デフォルト値を表す特別なクラスを構築する。
それでは、これらの慣習に従うべき理由を考えてみましょう。
フィールドにデフォルト値を設定すれば、そのクラスのインスタンスはいつでも、明確に定義された (well-defined) 状態になります。この慣習は、値を指定しない限りヌル値を取ることになる参照型の場合に、特に重要です。
なぜこうするべきかというと、無意味にヌル値を使用すると、必然的にNullPointerException
を発生させる結果になるからです。このNullPointerException
はくせものです。その理由の1つは、バグの本当の原因について情報をほとんど提供しないことです。もう1つの理由は、バグの実際の原因から遠く離れたところでこれらの例外がスローされる傾向があるということです。
この例外を完全に回避するにはコストがかかります。クラスがまだ完全には初期化されていないことを通知できるようにするためにヌル値を使用したいと考えている場合には、「ヌル・フラグ」バグ・パターンという私の記事を参考にしてください。
どのように私は私の家の外からクモの巣を削除しますか?
追加のコンストラクターを組み込んだ場合は、新しいコンテキストでそれを使用することができ、その場所には付け足し初期化を組み込む必要がありません。一部のコンテキストでその手法を使うしかないからといって、他のコンテキストでもわざわざその手法を使用しなければならないわけではありません。
クラスにisInitialized
メソッドを組み込めば、インスタンスの初期化が完了しているかどうかを素早く判別できます。付け足し初期化が必要なクラスを記述する場合には、このようなメソッドを用意するのは、ほとんどいつでも良い方法です。
これらのクラスを自分で保守していない場合でも、自分で記述するユーティリティー・クラスの中に、このようなisInitialized
メソッドを置くことができます。結局のところ、インスタンスが初期化されていないという結果を外部から認識可能であれば、その結果をチェックするメソッドを記述できます (時には、RuntimeException
をキャッチするという、通常なら賢明でない手法を利用する必要がありますが)。
フィールドにヌルを入力できるようにする代わりに、デフォルト値を表す特別なクラスを (ほとんどの場合にシングルトンで) 構築します。その後、デフォルト・コンストラクターの中で、これらのクラスのインスタンスをフィールドに入力します。このようにすると、NullPointerException
が発生する可能性を減らせるだけでなく、これらのフィールドが不適切にアクセスされた場合にどんなエラーを発生させるかを厳密に制御できます。
たとえば、前述のRestrictedInt
クラスは、次のように修正できます。
リスト2. RestrictedIntとNonValue
class RestrictedInt implements SimpleInteger { public SimpleInteger value; public boolean canTakeZero; public RestrictedInt(boolean _canTakeZero) { canTakeZero = _canTakeZero; value = NonValue.ONLY; } public void setValue(int _value) throws CantTakeZeroException { if (_value == 0) { if (canTakeZero) { value = new DefaultSimpleInteger(_value); } else { throw new CantTakeZeroException(this); } } else { value = new DefaultSimpleInteger(_value); } } public int intValue() { return ((DefaultSimpleInteger)value).intValue(); } } interface SimpleInteger { } class NonValue implements SimpleInteger { public static NonValue ONLY = new NonValue(); private NonValue() {} } class DefaultSimpleInteger implements SimpleInteger { private int value; public DefaultSimpleInteger(int _value) { value = _value; } public int intValue() { return value; } } |
この場合、このフィールドにアクセスするクライアント・クラスで、生成された要素に対してintValue
操作を実行するときは、まずDefaultSimpleInteger
にキャストする必要があります。NonValues
はその操作をサポートしていないからです。
上記の方法の利点は、キャストを忘れた場合に必ず、このメソッド呼び出しがデフォルト値に対しては機能しないことを (コンパイラー・エラーという形で) 思い起こさせてもらえることです。さらに、実行時には、このフィールドにアクセスしたときにデフォルト値が入っていると、ClassCastException
を受け取ることになります。この例外は、NullPointerException
を受け取るよりは、はるかに多くのことを伝えてくれます。ClassCastException
は、そこに実際に何があるかを知らせるだけでなく、そこに何があることをプログラムが期待しているかをも知らせてくれます。
不利な点は、パフォーマンスが犠牲になることです。フィールドにアクセスするたびに、プログラムはキャストを実行する必要があるからです。
コンパイル時のエラー・メッセージがなくてもよいのであれば、インターフェースSimpleInteger
にintValue
メソッドを組み込むという方法もあります。そうすれば、デフォルト値のクラスの中ではこのメソッドを、希望どおりのエラー (どんな情報でも、希望どおりに組み込める) をスローするメソッドとして実装できます。この方法の例として、次のコードをご覧ください。
どのようにパイは、今日使用されていますか?
リスト3. 例外をスローするNonValue
class RestrictedInt implements SimpleInteger { public SimpleInteger value; public boolean canTakeZero; public RestrictedInt(boolean _canTakeZero) { canTakeZero = _canTakeZero; value = NonValue.ONLY; } public void setValue(int _value) throws CantTakeZeroException { if (_value == 0) { if (canTakeZero) { value = new DefaultSimpleInteger(_value); } else { throw new CantTakeZeroException(this); } } else { value = new DefaultSimpleInteger(_value); } } public int intValue() { return value.intValue(); } } interface SimpleInteger { public int intValue(); } class NonValue implements SimpleInteger { public static NonValue ONLY = new NonValue(); private NonValue() {} public int intValue() { throw new RuntimeException("attempt to access an int from a NotAValue"); } } class DefaultSimpleInteger implements SimpleInteger { private int value; public DefaultSimpleInteger(int _value) { value = _value; } public int intValue() { return value; } } |
この方法の場合、ClassCastException
よりもさらに優れたエラー診断が提供されます。また、実行時にキャストが必要ないため、より効率的でもあります。しかし、この方法では、フィールドをアクセスするすべての地点で、フィールドの可能な値についてプログラマーに考えさせるということがありません。
どちらの方法を選ぶかは、一部は好みの問題であり、残りの一部はプロジェクトでどの程度のパフォーマンスと頑強性が要求されるかの問題です。
では次に、一見するとまったく間違いであるように見える技法を調べてみましょう。
上に戻る
例外をスローするだけのメソッドを組み込む
最初のうちは、この慣習は本質的に間違いで、直感に反すると感じることでしょう。クラスに組み込むメソッドは、データに対して何か意味のある操作を実行するべきだと思うからです。特に、オブジェクト指向プログラミングについてプログラマーに教えているとすれば、ここで紹介するようなクラスを組み込むことは、混乱を招きかねません。
たとえば、List
のクラス階層を定義する2通りの方法として、リスト4とリスト5の例を考えてみましょう。
リスト4. 汎用getterメソッドのないList
abstract class List {} class Empty extends List {} class Cons extends List { Object first; List rest; Cons(Object _first, List _rest) { first = _first; rest = _rest; } public Object getFirst() { return first; } public List getRest() { return rest; } } |
リスト5. インターフェースにgetterメソッドのあるList
abstract class List { public abstract Object getFirst(); public abstract Object getRest(); } class Empty extends List { public Object getFirst() { throw new RuntimeException("Attempt to take first of an empty list"); } public List getRest() { throw new RuntimeException("Attempt to take rest of an empty list"); } } class Cons extends List { Object first; List rest; Cons(Object _first, List _rest) { first = _first; rest = _rest; } public Object getFirst() { return first; } public List getRest() { return rest; } } |
オブジェクト指向言語を取り扱い始めたばかりのプログラマーにとっては、最初のバージョンのList
(汎用getterメソッドのないもの) の方が理解しやすいでしょう。直感的には、実際の作業を行わないメソッドをクラスに含めるべきではないからです。しかし、デフォルト値のクラスの取り扱いについて前のセクションで考慮した事柄が、この例にも同じように当てはまります。
コード中に毎回キャストを挿入するのはかなり煩わしい作業になりますし、コードが冗長になります。それに加えて、クラスのキャストはパフォーマンスの面で相当な影響が出ます。特に、List
のように多用されるユーティリティー・クラスでは、その影響はかなりのものです。
どのようなデザイン慣習の場合も同じですが、この慣習の背後にある動機付けを考慮に入れることにより、この慣習を最も適切に適用できます。その動機付けがいつも当てはまるとは限りませんから、当てはまらない場合には、この慣習を採用するべきではありません。
上に戻る
修正した方が賢明
バグ・パターンに関する以前の記事をお読みになった読者であれば、今回の「付け足し初期化コード」バグがこれまでとは少し異なっていることに気付いたかもしれません。今回は、このバグを単刀直入に修正するにとどまるのではなく、その根本原因を回避するたくさんの方法を紹介しました。それは、私が経験した多くのケースで、このバグを回避しなければならなかったからです。これは、決して楽しい経験ではありませんでした。
それでも、この記事で考えた通り、付け足し初期化をまったく避けられるのであれば、その方がはるかに良いことです。しかし、付け足し初期化を使わざるを得ないときでも、少なくとも自分で保護策を講じることができます。今回のバグ・パターンを要約すると、次のようになります。
- パターン名: 付け足し初期化コード
- 症状: 未初期化のフィールドの1つにアクセスした地点で
NullPointerException
が発生する。 - 原因: クラスのコンストラクターで一部のフィールドを直接に初期化していない。
- 治療法と予防策: コンストラクターの中ですべてのフィールドを初期化する。適当なよい値を使用できないときには、デフォルト値を表す特別なクラスを使用する。幾つか適当なよい値を使用できるときには、複数のコンストラクターを組み込んで複数のケースをカバーする。
isInitialized
メソッドを組み込む。
このあと数か月の間は、バグ・パターンの話題に戻ります。来月は、Java言語で発生する、プラットフォームに依存するバグのいくつかを取り上げます。一般に信じられていることとは反対に、Java言語はそのようなバグに対して免疫があるわけではないのです。
参考文献
- 「Bitter Java」の味 (developerWorks、2002年3月) の中で、Bruce Tateは、アンチパターン (問題に対する典型的なソリューションで、実際には否定的に結果になることが明らかなもの) がどのように、またなぜ必要であり、デザイン・パターンを補うものとなっているかを例証しています。
- エクストリーム・プログラミング (XP) の背後にある考え方の要約は、XPのサイトをご覧ください。
- JUnitのWebサイトは、プログラムのテスト方法について論じている、数多くの情報源から取られた興味深い記事へのリンクを提供しています。
- Eric Allenの 「Javaコードの診断」 の全記事をお読みください。バグ・パターンに関する完全装備の記事になっています。
- DrJava は、ライス大学のオープン・ソースのフリーJava IDEで、read-eval-printループを備えています。
- Javaテクノロジーに関するその他の参考文献は、developerWorks のJavaテクノロジー・ゾーンでご覧いただけます。
著者について
Eric Allen氏は、テクノロジーとコンピューター業界に関して、実践的な知識を幅広く持っています。コーネル大学ではコンピューター・サイエンスと数学の学士号を取得し、ライス大学ではコンピューター・サイエンスの修士号を取得し (CycorpでJava開発者主任としての実績も持つ)、現在は、ライス大学のJavaプログラミング言語チームの博士課程に在籍しています。Robert "Corky" Cartwright博士の助言のもと、氏は、主に、ソース・レベルとバイトコード・レベルでのJava言語のセマンティック・モデルと静的分析ツールの開発について研究しています。また、セマンティック形式論と型チェックによるセキュリティー・プロトコルの検証についても研究しています。
氏は、初心者向けに設計されたオープン・ソースのJava IDEであるDrJavaのプロジェクト・マネージャーおよび創立メンバーです。また、NextGenプログラミング言語 (付加的な実験機能を備えたJava言語の拡張版) に対するライス大学の実験的コンパイラーの開発主任でもあります。氏は、オンライン雑誌のJavaWorld でフォーラムの司会者を務めています。空き時間には、ライス大学のコンピューター・サイエンスの学生にソフトウェア・エンジニアリングを教えています。氏の連絡先は、ealleです。
この記事を評価する
コメント
上に戻る
0 件のコメント:
コメントを投稿