-
entry001291
comments
酒の席の議論がなかなか面白かったのでこちらでも展開。 Java プログラミングを対象とした話です。
- null check は呼び側で行う?呼ばれ側で行う?
- null check を怠って NullPointerException を投げていいの?
つまり、
void methodA () { ... methodB (str); } void methodB (String str) { if (str != null) { return; } ... } (呼ばれ側主義)とするか、
void methodA () { ... if (str != null) { methodB (str); } } void methodB (String str) { ... } (呼び側主義)とするか。
呼ばれ側主義者の主張はこう。
- 呼び元で null check すると複数箇所に同じ check コードが分散し、コード量を増大させる
- null check をせずに NullPointerException が発生するのは恥ずかしい
- 事実これまで、呼ばれ側で null check しないと困ることが多かった(プラグマティズム)
一方、呼び側主義者の主張はこう。
- メソッド先頭に null check の if 文がずらずらと並ぶのは可読性を損ねる
- JDK のライブラリ群だって null を入れたら NullPointerException を投げている
この議論は平行線をたどり、お互い決定打が出ずなんとなくもやっとしたまま次回に持ち越されました。
ここから、その後にいろいろ考えた末の私見です。 ちなみに、ぼくは呼ばれ側主義穏健派の立場です。 呼ばれ側でもちろん行うべきであるが、さらに呼び元でもチェックするにやぶさかではない、と。
ぼくはこの話は、
- 前提条件をチェックすべきはどんなとき?チェックするならどこで?
- 前提条件を満たさなかった場合、呼び元にどう伝える?
という二つの話に分解すべきではなかろうかと考えています。 議題に現れる null check と NullPointerException という概念が、一見 null というキーワードでつながっているように見えますが、実は一方は処理の前提条件の話で、一方は処理失敗を伝える話なのではなかろうかと。
前提条件をチェックすべきはどんなとき?チェックするならどこで?処理失敗にも二種類あります。 実行前に発生が予見できないものと、できるものです。 前者の代表例は OutOfMemory など java.lang.Error のサブ・クラスとなっているものです。 後者は、前提条件が満たされていないまま処理が実行された場合が挙げられます。 例えば次のような処理の場合、
String name = item.getName();
item が非 null であることが処理の暗黙の前提条件となっています。 もし前提条件が破られた場合は例外が発生します。 この例外は、
if (item == null) { return; } String name = item.getName();のように、明確に前提条件を確認することで回避可能です。 特に、たとえば次のような場合、
- コストの高い処理を行う
- 副作用を伴う処理を行う
「やってみたけど途中でだめになりました」となった場合の影響が大きいため、処理の前に前提条件を確認し、条件が満たされていないのであれば、はじめから実行すべきではありません。 null check はこういった前提条件確認の典型で、他にも例えば値域や非零の確認なども考えられます。
次に、連続する処理の前提条件について考えてみます。 例えば次のような処理の場合を考えます。
... /* * アイテムを追加する */ // item の null check if (item == null) { return; } // 追加 ++ this.itemCount; // 副作用あり this.items.put(item.getId(), item); // null check のおかげで何事もなく完了 /* * 割引率を適用する */ // discount の値域チェック if (discount < 0 || 100 < discount) { // 割引率が 0-100 の範囲になければ例外 throw new IllegalArgumentException ("dicount should be between 0 and 100"); } // 適用 this.totalPrice += item.getPrice() * (1D - (double) discount / 100D); ...全体としては、アイテム追加と割引率適用の大きく二つの処理から構成されています。 アイテム追加の直前に item の null check が行われているので、アイテム追加処理は正常に完了します。 割引率の適用処理も直前に値域を判断しているので、定価を超えたりマイナスになったりすることはありません。 個々の処理は正常に完了します。 しかし、item が非 null かつ discount が負値の場合などには、アイテム追加処理の正常完了後に例外が発生してしまい、アイテム追加処理の副作用だけが残ってしまいます。 このように後続の処理が失敗すると、先行処理の取り消しが必要になるような場合があるため、前提条件の確認はできる限り早い段階で行うべきであると言えます。
... /* * 前提条件を確認する */ // item の null check if (item == null) { return; } // discount の値域チェック if (discount < 0 || 100 < discount) { // 割引率が 0-100 の範囲になければ例外 throw new IllegalArgumentException ("dicount should be between 0 and 100"); } /* * アイテムを追加する */ ++ this.itemCount; // 副作用あり this.items.put(item.getId(), item); // null check のおかげで何事もなく完了 /* * 割引率を適用する */ this.totalPrice += item.getPrice() * (1D - (double) discount / 100D); ...では、どこまで早い段階であれば良いのでしょうか。 これがまさに議論になった「呼び元側(より早い段階)で行うべき」か「呼ばれ側(それよりは遅い段階)で行うべき」かという論点に解を与える話かと思います。 先ほどの例に挙げた処理が、ひとつのメソッドであるとしましょう。
public void addItem(Item item, int discount) { /* * 前提条件を確認する */ // item の null check if (item == null) { return; } // discount の値域チェック if (discount < 0 || 100 < discount) { // 割引率が 0-100 の範囲になければ例外 throw new IllegalArgumentException ("dicount should be between 0 and 100"); } /* * アイテムを追加する */ ++ this.itemCount; // 副作用あり this.items.put(item.getId(), item); // null check のおかげで何事もなく完了 /* * 割引率を適用する */ this.totalPrice += item.getPrice() * (1D - (double) discount / 100D); }メソッドが呼ばれた時点で引数の値は確定しています。 前提条件の確認はできるだけ早く行うという原則に従い、確認は呼び出し直後よりも後に行うべきではありません。 では、それよりも早い時点ではどうでしょう。 呼び側が呼ぶ直前でも値は確定しています。 呼び側で前提条件を確認する場合を考えてみましょう。
public class Main { public void main (String [] args) { ... if (item == null) { ... } else if (discount < 0 || 100 < discount) { ... } else { cart.addItem(item, discount); } ... } } public class Cart { public void addItem(Item item, int discount) { /* * アイテムを追加する */ ++ this.itemCount; // 副作用あり this.items.put(item.getId(), item); /* * 割引率を適用する */ this.totalPrice += item.getPrice() * (1D - (double) discount / 100D); } }処理全体としては先ほどと変わりなく、前提条件が満たされた場合のみ処理が行われます。 しかし、クラスの堅牢性を考えると、この例は非常に大きな問題を孕んでいると言えます。 items と totalPrice の整合性に責任を持つべきは Cart(呼ばれ側)です。 しかしこの例では、Cart 自身は整合性を保つために必要な前提条件確認を行っていないため、悪意(もしくはミス)により不正な引数が与えられると壊れる、非常に脆弱なクラスとなっています。 引数としてどのような値を受け入れるかはクラスに属すべき情報であり、特に public メソッドはクラス内外の境界をなすため、引数のチェックは確実に行うべきです。
今回の議論の範囲からは少し外れますが、メソッドの先頭で前提条件を記述することで、そのメソッドが受け入れられる引数の範囲を "コードで" 明確化することができると考えています。 これは特にコードにアクセスできる開発者にとって、コードの可読性が高まるというメリットがあります。 もちろん、前提条件を確認すれば万事 OK なのではなく、別途明確に文書化もすべきですが。
ここまでをまとめると、
- 処理の前提条件はできる限り早くに確認すべし
- 堅牢性の観点から、前提条件の確認をクラスの外のみで行うべからず
- 上記二つより、特に public メソッドの引数は呼び出し直後に確認すべし
- 副次的に、読みやすくなるというメリットもあるよ
という主張になります。
前提条件を満たさなかった場合、呼び元にどう伝える?前提条件を満たさなかった場合には、呼び側にその旨を伝える必要があります。 その代表的な方法として以下のようなものが考えられます。
- 例外を投げる
- 戻り値として null を返す
- 戻り値としてエラーを示すオブジェクトを返す
- 引数として渡されたオブジェクトにエラーを示すオブジェクトを埋め込む
これらの方法は、伝えられる情報 / 呼び元への依存性 / コストなどがそれぞれ異なります。 こういったメリット / デメリットを考慮し、またはアプリケーションの設計方針に従って、前提条件が満たされていなかった旨を呼び元に戻す手段を決定します。
伝えられる情報 呼び元への依存性 コスト その他 例外を投げる ◎
デフォルトで発生場所 / スタック・トレースを戻し、さらに設計次第で柔軟に情報を戻せる◎
Java 言語使用で提供される機構であり、呼び元への依存性は低い×
new Exception() は比較的高コストJava っぽい 戻り値として null を返す ×
失敗した旨しか戻せない△
null の扱いについて、呼び元と共通理解をもつ必要がある◎
特に処理不要メソッドの正常な戻り値として null が返る可能性のあるメソッドでは使えない 戻り値としてエラーを示すオブジェクトを返す ○
設計次第で柔軟に情報を戻せる×
エラーを示すオブジェクトの設計について、呼び元と共通理解をもつ必要がある○
new Object() が必要引数として渡されたオブジェクトにエラーを示すオブジェクトを埋め込む ○
設計次第で柔軟に情報を戻せる×
エラーを示すオブジェクトの設計について、呼び元と共通理解をもつ必要がある○
new Object() が必要非常に Java っぽくない 議論に挙がった "NullPointerException" を投げるべきか否かも、この枠組みで考えるべきものです。 検討した結果 NullPointerException を投げるべき場合もあります。 しかし、NullPointerException は "null でした" という非常に汎用的な意味しか持ちません。 アプリケーション上では、それはもっと特化した意味を持つはずです(引数が不正であるとか、結果としてユーザー情報の取得が行えないとか)。 アプリケーション・コードは、それらの意味を適切に呼び側に伝える必要がある場合が殆どなので、NullPointerException をそのまま返すことはあまりないと言えるでしょう。 JDK のライブラリ群が NullPointerException を投げるのは、アプリケーションとは違って NullPointerException は文字通り NullPointerException の意味しかもてないためであると考えられます。 それに意味を加えるのは、JDK のライブラリを呼ぶ側のアプリケーション・コードの役割です。
ここまでをまとめると、
- 前提条件を満たさない場合は適切な方法でその旨を呼び側に戻すべし
- null check の結果を NullPointerException で戻すことも考えるべし
- しかし、アプリケーション・コードは NullPointerException ではなくより適切な情報を戻すべし
- 非常に汎用的なライブラリなどでは NullPointerException も辞さない覚悟で
といった主張です。
これまで、null check を呼ばれ側で行うことを当然のことと捉えていたため、それがなぜなのかを深く考えたことがありませんでした。 改めて分析したところ、やはり呼ばれ側で行うべきだとの思いを強くした次第です。 いかがでしょう。
MTEntryMore
上から下まで完全に同意だなー。特に「Cart 自身は整合性を保つために必要な前提条件確認を行っていないため、悪意(もしくはミス)により不正な引数が与えられると壊れる、非常に脆弱なクラスとなっています。」
似たような話では、JavaScriptで入力値チェックしたらサーバー側でしなくていいの?とか、アプリで一つのトランザクションになっていればDBで参照整合性制約つけなくていいの?とかがあるね。
呼び側でのチェックしたほうがエラーがすぐ返せて楽なんで呼び側でもやったほうがいいけど、どちらが必須かと言ったら呼ばれ側でのチェックだな。Ajaxなんかで入力エラーを返してくれるとすぐに修正できるからユーザー側はありがたいけど、サーバー側でチェックしなかったらHTTPリクエスト偽られて個人情報漏洩。
お久しぶりです。
さて、単純に「呼ばれる人より呼ぶ人の方が多い」ので、nullに気を使うべきは呼ばれる人の方が効率的である、じゃダメ?。
この辺の話ですね。事実、呼ぶ人の方が低スキルでしょ。
<LINK>
> rewse
会社でも大方の反応は「完全同意」でした(一部の呼び側原理主義者以外は)。
多分、当たり前のことを文章に起こしただけなんだろうね。
> あらいさん
お久しぶりです。ご無沙汰してます。
リンク先の記事、なかなか興味深いですね。
確かに、呼ばれる人と呼ぶ人の数の対比とスキル・レベルって言うのも
どちらで null check かけるかを考えるときに重要ですね。