コンストラクタのメソッド利用で注意すること
Java入門ではさらっと以下のように書いた、コンストラクタでインスタンスメソッドを実行することについて掘り下げてみます。
コンストラクタからインスタンスメソッドを使用することは可能ですが、避けたほうが無難です。 コンストラクタの実行中はインスタンス自体が構築中のため、初期化が完了していない状態でメソッドが実行されることになります。
Javaエンジニア養成読本 [現場で役立つ最新知識、満載!] (Software Design plus)
- 作者: きしだなおき,のざきひろふみ,吉田真也,菊田洋一,渡辺修司,伊賀敏樹
- 出版社/メーカー: 技術評論社
- 発売日: 2014/11/11
- メディア: 大型本
- この商品を含むブログ (6件) を見る
文章だけで伝えるのはなかなか難しいものだとも思いますし、 本に書いたのに実際にこの問題を見た時に即解決できなくて悔しかった ので、 突っ込んでしっかり書くことにしました。くそう。。。
簡単なサンプルコード
コンストラクタでのインスタンスメソッド呼び出しが問題を起こすコードは、以下のようになります。
/* このコードは動作しません */ class Hoge { final String str; Hoge () { invoke(); this.str = "HOGERA"; } void invoke() { System.out.println(str.length()); } }
new Hoge()
とかするとNullPointerException
です。
Exception in thread "main" java.lang.NullPointerException at blog.constructor.Hoge.invoke at blog.constructor.Hoge.<init>
このくらい短いコードなら、流石にやらないでしょうが、
コンストラクタとinvoke
メソッドの間に数百行の隔たりがあればどうでしょう?
フィールドはfinal
だし、うっかり使ってしまうかもしれません。
作成時は問題がなかったとしても、不具合改修などでこの手の問題が起こしてしまったとしても、
その開発者を不注意だと責め立てることは、私には出来そうもありません。(自分もやっちゃうだろうし。)
少し複雑にしたサンプルコード
さらに問題を複雑にすると、冒頭のように1クラスに完結している形では問題なく動作しているにもかかわらず、 継承すると問題が起こるパターンも考えられます。擬似的なコードは次のようなものになります。
/* このコードは動作しません */ abstract class Parent { final String arg; Parent(String arg) { init(); this.arg = arg; } abstract void init(); } class Child extends Parent { Child(String arg) { super(arg); } @Override void init() { System.out.println(arg.toUpperCase(Locale.ROOT)); } }
new Child("xxx")
とかでNullPointerException
になります。
Exception in thread "main" java.lang.NullPointerException at blog.constructor.Child.init at blog.constructor.Parent.<init> at blog.constructor.Child.<init>
少し説明落ち着いてコードを追えば、コンストラクタ引数がフィールドに格納される前に、
そのフィールドを参照しているので、フィールド型のデフォルト値(参照型なのでnull
になる)となっているだけです。
しかし、このNullPointerException
に遭遇すると、おそらく「final
フィールドなのになぜ値が入っていないんだ?」となります。
コンストラクタの引数がnull
になっていないかや、どこかで受け渡し損ねてないかなどを確認するかもしれません。
デバッガでinit
メソッドにブレイクポイントを置いてみたりするかもしれません。いくら見ても、事実が示すようにフィールドはnull
です。
継承階層が深くなると、メソッドの呼び出しタイミングがわからなくなっていきます。 全てのコードの詳細を把握することは困難ですし、全ての人にそれを求めるべきではありません。 上記のようにinitメソッドが拡張ポイントのように扱われていると、フレームワークがコンストラクタの後に呼びだす初期化処理と想像しても、不思議ではないでしょう。
さらに複雑にした(略
もっと複雑に、もっとわかりにくくすることは可能です。もっともっと。 でも、書いても楽しくないので箇条書きで許してください。
- コンストラクタで呼ばれるメソッド(上記だと
init
)で例外が発生せず、そこから呼び出したメソッドで発生する。 - コンストラクタで呼ばれるメソッドで生成されたインスタンスにフィールドのデフォルト値が設定される。
- 対象がプリミティブ型
- 参照型であれば
NullPointerException
が発生することで助かります(そう、例外が発生すればまだ助かるのです。少なくとも不正な更新を行ったりしてデータを壊す可能性は下がります。ぬるぽはいい子です。)が、プリミティブ型ならおそらくデフォルト値でそのまますんなり動いてしまいます。
- 参照型であれば
- フィールド参照ではなく、そのフィールドを使用する別のメソッドを使用する。
- フィールドが
final
でなく、頻繁に書きかわる。
……こんなのデバッグしたくないです。
どうすれば?
Java入門ではこんな感じで書きました。
もしインスタンスメソッドを実行する必要があるのならば、privateメソッドやfinalメソッドのような 拡張されないメソッドにできないかを検討し、対象のメソッド内で他のメソッドを呼び出さないように気をつけましょう。
書いたことは書いたままなのですが、まだ他にも考えられます。
提供側として、どうすればよいか
もし実装クラスで拡張することを想定したインスタンスメソッドを、コンストラクタでどうしても実行したいのならば、どうすれば良いかを考えてみます。 まず、そのメソッドが何をするための拡張ポイントであり、どういうタイミングで実行されるかなど ドキュメントをしっかり書いてください 。 そして、一切のメンバを使用禁止にします。これは三階層以上の継承がある場合、二階層目のコンストラクタで初期化されるフィールドやそれを使用するメソッドが正常に動作しないタイミングで呼ばれてしまうためであり、そのことを具象化しようとしているクラスでの検知が困難だからです。 その上で、メソッドで使用する値は 全て メソッドの引数で渡しましょう。
これでようやく、気をつけて使っていれば大丈夫な可能性のある物体になるかもしれません。 しかし、間違った使い方のできるものは、間違った使い方をされるものです。 要約すると「やらせるな」ということです。考え直しましょう、真面目に。真剣に。悪いこと言わないから。
利用側として、どうすればよいか
さて、利用側の視点でどうすればいいかには触れてきませんでした。
これは いくら利用者が気をつけても、安全に設計できていないものを、安全に使用することはできない からです。 単純なケースであればテストコードなどで比較的容易に検出はできるのですが、「さらに複雑にした(略」あたりで書いているものは辛いです。
フレームワークやライブラリのコードを読むことは推奨したいのですが、必須ではありません。 特に仕事ではコストは確保されないでしょうし、そういうスキルも求められないでしょう。 なので、不具合に気づいたら修正依頼をするか、迂回するか、パッチを当てるくらいでしょうかね。
あとがき
Java入門 第2章「クラスを理解する」の最後の方に書いた コンストラクタでは注意してメソッドを使用する を掘り下げてみました。 コードある方がわかりやすいかな。どうだろう。どっちでもわかりにくいよ、と言われたらごめんなさいとしか言いようがない。 しかし全部このノリで書いたらページいくらあっても……難しい。
出版されてから1年経ちますが、内容はまだ古くなってません。 そのなかで、こんな感じの内容を入門と言い張ってます。入門だよ、うん。
バージョンアップ時に互換性を損ねてしまう変更
あるいは、利用者にバージョンアップをためらわせる変更の手引き。
前置き
うっかり互換性を損なってしまう変更について書いてみます。
知ってたらやらかさないし、なんてことのない事なのだけれど、 知らなかったら何が駄目なのかわかりづらいし、 原因を突き止めるのとかも難しいのかもしれない。と思ったので。
互換性の話なので、ライブラリやフレームワークなどの提供側としての変更になります。
動かない、けど再コンパイルすれば動く
こんなコードがあって、
interface Hoge<T extends java.io.Serializable> { T method(); }
これを使っている、こんなコードがある。
class Hogera { public static void main(String... args) { Hoge hoge = new Hoge<String>() { @Override public String method() { return "HOGE"; } }; System.out.println(hoge.method()); } }
コンパイルして実行すれば、まあ普通に動くよね。
% javac Hoge.java Hogera.java % java Hogera HOGE
ここで、元のコードをこう変更しちゃう。
interface Hoge<T> {
T method();
}
Hoge.java だけ再コンパイルして実行すると、動かない。
% javac Hoge.java % java Hogera Exception in thread "main" java.lang.NoSuchMethodError: Hoge.method()Ljava/io/Serializable; at Hogera.main(Hogera.java:9)
使う方のコードも再コンパイルするだけで動く。
% javac Hoge.java Hogera.java % java Hogera HOGE
説明
Javaは戻り値の型までを含めてメソッドは識別されます。
で、ジェネリクスで境界を使用していると、境界の型で扱われます。何もしていしてないと java.lang.Object ですね。
こういう時に型引数は考慮されません。つまり、 method()LT
とかにはならないの。
分かりづらくするためにジェネリクス使ってるけど、単に戻り型を拡げるだけでも十分だったりする。
String method(); // 変更前 Object method(); // 変更後(違うメソッドになる)
昔、似たような事を書いた(しかしタイトル微妙だな……)のだけれど、 正直なところ当時の私の見通しは甘くて、単にビルドツールを使えば解決するって問題ではありません。 今回もjavaファイルを直接扱っているから分かりづらいのだけれど、たとえば Hoge がjarで、 知らない誰かが作っているものだったら、一緒にコンパイルーなんてしませんよね。
こういう変更が入っているかも、と思うとバージョンアップできなくなります。 たまに、依存ライブラリ間でこういうことが起こったりします。 その瞬間のソースを眺めて脳内コンパイルしてると普通に動いちゃうので、知らないと原因特定が難しかったりする困った話。 クラスファイルを読んでたらわかるだろうけど、普通は添付されてるソース読むよね?
動く、けど再コンパイルできない
こんなコードがあって、
class Fuga { static void method(String str) { System.out.println("FUGA"); } }
これを使っている、こんなコードがある。
class Fugera { public static void main(String... args) { Fuga.method(null); } }
コンパイルして実行すれば、まあ普通に動くよね。
% javac Fuga.java Fugera.java % java Fugera FUGA
ここで、元のコードをこう変更しちゃう。
class Fuga { static void method(String str) { System.out.println("FUGA"); } static void method(Number str) { System.out.println("FUGAGA"); } }
Fuga.java だけ再コンパイルして実行すると、これは普通に動く。
% javac Fuga.java % java Fugera FUGA
使う方のコードも再コンパイル……できない。
% javac Fugera.java Fugera.java:4: エラー: methodの参照はあいまいです Fuga.method(null); ^ Fugaのメソッド method(String)とFugaのメソッド method(Number)の両方が一致します エラー1個
仕方ないので修正する。
class Fugera { public static void main(String... args) { Fuga.method((String)null); } }
ダサい。。。 けれど、これでコンパイルできるようになるし、コンパイル後は元通り動作する。うっかりNumberにキャストしたりすると動作が変わるから、「とにかくコンパイルが通るようにすればいい」みたいな対応をすると二次災害になります。 バグ修正時のバグ混入率は通常よりも高いって言いますし、慌てずに落ち着いて対応しましょう。
説明
nullなんて嫌いだ!
最初のと同じ感じだったりします。Javaはコンパイル時に呼び出す先のメソッドをコードよりも厳密に決定します。
メソッドをオーバーロードしたとしても別のメソッドとして扱うため、コンパイルできないようなオーバーロードを行っても、
コンパイル後のclassが実行される際は問題となりません。
既存のメソッドを修正するときは気をつけられるけれど、 メソッドを追加して今までコンパイルできていたものができなくなるってのは、油断しがちかもしれない。 そしてまだ同じクラスなら話は簡単なのだけれど、たとえばインタフェースに同じ名前のdefaultメソッドが足されたら同じことが起こる。 メソッド足すときも、やっぱり注意しなきゃです。
他
動作するかと、コンパイルできるかについて書いた。 2軸があるなら、4パターン出るから、あと2パターンも当然あります。 同じように書こう……と思ったのだけれど……
動かないし、再コンパイルもできない
何の互換も残ってないような。変更というより、破壊?
動くし、再コンパイルしても動く
普通に互換性保ててるから問題ないよね?
互換性、互換性、、、
現実で見たことのあるものから二つを挙げてみたけれど、もちろん互換性を損ねる変更なんてのはこれだけではありません。 というか、変更は必ず何かしらの互換性を損ねるでしょう。 先に挙げた通り、具象メソッドの追加も安全ではありません。 可視性を緩めることで損なわれる互換性もあります。
こういう変更をしてしまうと、一番最初に書いたように、利用者はバージョンアップをしたくなくなります。 すなわち、動いているものを変更したくなくなります。 なので提供側ならば全力で避けねばなりません。
だからといって、ありとあらゆる互換性を維持するなんて愚かな目標を立てるべきだと言いたいわけではありません。 それは変更を、つまり成長を阻害することとイコールになります。
そんな役に立たない妄想よりも、 変更がどのような互換性を損ねるかを把握し、 問題があった時にどう対応すれば良いか、その対応にどのくらいかかりそうか とかを 考えといた方がいいんじゃないかな、と思うわけです。
Javadocに何を書こうか
これは Java Advent Calendar 2015 の 16 日目の記事です。
昨日は @yukung さんの Java で引数の null チェックで迷った話 でした。明日は @mdstoy さんです。
ちなみに過去のJavaアドベントカレンダーで書いたもの。
- 2014年: Javaであまりしないコーディング
- 2012年: Date and Time APIを触ってみた
- 2011年: JUnitの知識を棚卸し #JJUG
……2013年は書いてないのか。そうか。
前置き
3年前になりますが、コメントについては書いたことがあります。
たまにスライド見返したりするのだけれど、今でもそれほど主張は変わらない感じです。
で、先に挙げた記事はインラインコメントがメインとなっていますので、 今回はドキュメントコメントについて書いてみることにします。
Javadocと言えば
Javadocと言えばHTMLのあいつ。 JavaDocとか言ったり、javadocというツールのことだったり、 Javadocが何を指してるのかよく分からなくなることもしばしばあるけれど、 とりあえずそのことについては考えずにここではJavadocの表記で統一します。
HTMLで出力したものをWebブラウザで見るので、 コメントの本文はHTMLタグでわかりやすくマークアップしなければならない。
また、Javadocタグを使えることろは積極的に使う。 そうすれば、ドキュメント間のリンクとかもやってくれる。 タグは頑張って覚えよう。そんなに種類もないし。
読み方を考えてみる
Javadocコメントを読む方法は以下の3つに大別できる。
一つは先にも挙げたように、インターネットを介して閲覧する場合。 Java SEやOSSライブラリのドキュメントを読むときはこの形が多いだろう。 また、ドキュメントの記述量が多い場合、IDEで読むのはつらいのでこの読み方をする。
次に、IDEで使用するクラスやメソッドのドキュメントを参照する場合。 この用途では、メソッドのコメントを読むことが主になる。 HTMLで読むものと対象は被るが、コーディング中ならばこちらの方法で読むと思われる。 利用するライブラリのソースが必要だけれど、たいてい xxx-sources.jar は手に入るから心配いらない。
最後に、ソースコードを直接見る場合。 自分たちで開発しているコードのドキュメントコメントはこの形で読むことが多い。 というか、わざわざHTMLで見るなんてしない感じ。 APIとして利用を想定されていないクラスやメソッドのコメントを読む場合もこれになる。 この見方だと、ドキュメントコメント以外のコメントやコードも目に入ってきます
書くことを考えてみる
では何を書けばいいかだけれど、昔はこんなこと書いてた。
このコメントに書かれていて嬉しいことは「何が達成できるか」です。どのように使うと、どうなるかをさくっと把握できるのがベストです。 このコメントを読む人がコードを読むことはそれほど考えなくていいです。
これは一番目、二番目の読み方をする時に言えることで、 三番目の読み方をする場合はコードと並べて読むこともあり、書く内容も変わる。 書く内容は非ドキュメントコメントに近いと思われ、実装理由やコードの要約が適切かもしれない。
読み方から書く内容は決まるかなと思ったのだけれど、読み方分類では書く内容を考えづらい。 なので、読み方よりも読者視点で書くことを考えてみることにする。
読者を考えてみる
コメントの想定読者は利用者と保守担当者くらいが考えられる。 これらの想定読者によって、書く内容も変わってくるはず。
想定読者が利用者、つまり公開APIのドキュメントコメントは、 それが何ができるもので、どのように使い、何に気をつければ良いかを記述する。 サンプルコードがあると良いことも多いのだけれど、 コメントはコンパイルできないし、基本的にリファクタリングに追随して修正されたりもしない。 ゆえに嘘になりやすく、誤ったサンプルコードを提示してしまう危険と隣り合わせになる。 それでもあって助かることも多いので、メインとなる公開APIには書いていきたいと思う。
想定読者が保守担当者、つまり公開APIを除く全てのドキュメントコメントは、 それが何ができるものかは同じだけれど、設計判断や拡張時に気をつけるべきことを記述する。 関連が強いクラスへのリンクをしっかり書いておくと助かることも多い。 privateのドキュメントコメントを書く場合、ほぼ間違いなく保守担当者向けだろう。
ドキュメントコメントにHTMLを使うべきか
素直に「JavadocコメントはHTMLでマークアップするもの」とやってしまうと、 主となる読者が「そのコードを保守する人」であるならば、HTMLは邪魔になる可能性が高い。 人間の目はHTMLを解釈しないので、プレーンなテキストの方が読みやすい。 また、コードを修正する時もHTMLタグを書くのは面倒だったりする。
想定読者が保守担当者のドキュメントコメントにHTMLを使用すると、苦労して書いて、苦労して読んで、苦労して修正することになる。 誰も嬉しくない。 そんなことがあるので、「HTMLを使うべきか」と問われると「状況によるし、使わなくてもいいんじゃないかな」になる。
とはいえHTML的に不正な状態だと文句言われるし、多少配慮はしたほうがいいかも。
あらためて、何を書こうか
Javadocに何を書こうか。
どうせ書くなら役にたつものがいいよね。 役に立てる人によって、書いてて欲しいことが変わるよなーとか、 たぶんプロダクトの性質によっても変わってくる。
でも「何を書くか」なんてところこそ、しっかり考えるべきなんだと思う。 考えて出した答えなら大丈夫なはず。 「書かなきゃいけないから書いた」だと、微妙なコメントになってること請け合い。
……色々書いたけど、結論が締まらない。まぁいいか。
ここまで書いてきたこととかは関係なく書くべきものもある。バージョンとかね。 この辺りはJavadocタグで書けるので、一通り眺めてどれを使うか確認しておくのが良いです。
JavaDoc.Next
あ、Javadocといえば JavaDoc.Next とかいうのもありますね。 詳細はよく知らないけれど、JDK9に入るみたいです。
JavaアドベントカレンダーなのにJavaっぽくなくて焦ったから、 JEPとか出して誤魔化しているわけではない。