Java/Androidにおける例外設計、あるいは「契約による設計」によるシンプルさの追求

  • 391
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

なぜ今Javaの例外処理か

Javaにおける「チェック例外」はSwift、Objective-C、Ruby、JavaScriptといったネイティブ・ウェブアプリ開発でよく用いられる他の言語には現れないものです。
SwiftにはOptionalやErrorTypeがありますが、Javaにおいてもnullやエラーのハンドリングの実装方法をうまくやる必要があります。
なぜ例外を握りつぶしたらいけないのか、なぜアサーションが望ましいのか、なぜチェック例外と非チェックを分けたのか、という点を考えてみたいと思います。

参考資料

例外設計における大罪 (契約プログラミングについて)
Effective Java読書会9日目 - 例外 (Javaにおける例外の扱いについて)
契約による設計から見た例外 (この記事の方がより詳しいけど難しいイメージ)

チェック例外と非チェック例外の違い

  • チェック例外→「回復可能な、ハンドリングすべき例外」
  • 非チェック例外→「APIが想定外の使われ方をした際の、ハンドリングすべきでない例外」

チェック例外

例えばファイルをダウンロードする際、通信状態などによっては失敗します。
ダウンロードが失敗するのはバグでも回復不可能なエラーでもなく、予めそれを想定すべきものなのでIOExceptionなどのチェック例外を用います。

チェック例外は呼び出すAPI側が指定する、必ずハンドリングすべき例外とも言えます。

void caller() {
    Uri uri = ...;
    String data;
    try {
        data = downloadFile(uri);
    } catch (IOException e) {
        ...
    }
    ...
}

String downloadFile(Uri uri) throws IOException {
    ...
}

非チェック例外

代表格といえば、10億ドルの誤りと名高い、憎きNullPointerExceptionですが、これは非nullの値に対してだけできる処理をnullに対して行おうとした、想定外の使われ方をしているケースです。

これらはキャッチすべきではありません。
なぜならキャッチしても回復不能(リトライや別の方法での実行などで解決できない)だからです。
その代わりに、「想定外の使い方をしている」というバグを直すべきなのです。
(強制的にキャッチさせるか否かを分けているのには、それなりの意味があるということだと思います。)

void caller() {
    repeatText(null);
}

void repeatText(String text) {
    return text + text; // nullならNPE!
}

キャッチすべきでない非チェック例外をどうするか

Android開発においては、「キャッチ(もしくはnullチェックなど)しないと、バグのせいでアプリが停止してしまい、ユーザ体験が劣悪なものになる」というコメントがよく聞かれます。
ユーザ体験という考え方は大変重要で、クラッシュするとそれだけでレビューに★1がつくことも珍しくありません。

このとき重要なのは、クラッシュしない、品質の高い状態を長期的に維持することです。この視点から、キャッチした場合とそうでない場合の違いを考えます。

キャッチ・チェックする→防衛的プログラミング

NPEなんてコード上でいくらでもチェックできます。やろうと思えばキャッチもできます。

String join(List<String> texts, String separator) {
    if (texts == null) return "";
    if (separator == null) return "";
    try {
        return joinImpl(texts, separator);
    } catch (NullPointerException e) {
        // joinImpl内で実装ミスがあるとNPEが出るかも・・?
        return "";
    }
}

上記のコードはありうるNPEを全て防いだ例です。しかしながらこれは下記の理由から冗長です。

  • 文字列のリストをjoinするということは、(使い方を間違えない限り)リストは必ず指定される。
  • (使い方を間違えない限り)区切り文字は指定される。
  • (実装に誤りがない限り=APIの使い方を間違えない限り)joinImpl内でNPEは発生しない。

もしかしたら、joinImpl()内でもさらにtexts == nullのチェックをしているかもしれません。これが重なると何回もnullチェックすることになってしまいかねません。

確かに冗長とはいえ、nullチェックすればクラッシュはしないので、当面はユーザ体験を損ねないでしょう。
しかし、冗長で複雑なコードは、いずれ品質の低下を招き、予期しないバグを発生させ、最終的にクラッシュを生み続ける結果になりかねません

キャッチせずバグを可視化する→契約による設計

このような複雑さに対処する手法として、契約による設計という考え方があります。メソッド呼び出しの前後に保証されているべき状態と、それは誰の責任で保証するのかを明確にすることで、ソフトウェア全体のシンプルさを最大化する考え方です。

単体テストをするとき、チェックするのはテスト対象モジュールのロジックであり、依存するモジュールにバグがあったときの動作ではありません。これと同様に、例外においても責任範囲を明確化するとわかりやすいです。

種類 例えば 誰が保証すべきか
事前条件 メソッド呼び出しの際の引数の値がnullでないこと 呼び出す側
不変条件 自然数は1以上であること 双方
事後条件 上2つを満たす時、join()メソッドは連結された文字列を返すこと
ダウンロードしたファイルの中身の文字列を返すかチェック例外を投げること
NPEを出さないこと
呼び出された側

これらを満たさないものはバグであり、アサーション(非チェック例外を投げるなど)をかけて握りつぶさず直すべきものとなります。

なぜ契約による設計なのか

ソフトウェアの品質と信頼性は、一貫性とシンプルさから生まれると、「契約による設計」の創始者は考えているようです。

Applying “Design by Contract” (Bertland Meyer)

In many existing programs, one can hardly find the islands of useful processing in oceans of error-checking code. In the absence of assertions, defensive programming may be the only reasonable approach. But with techniques for defining precisely each party’s responsibility, as provided by assertions, such redundancy (so harmful to the consistency and simplicity of the structure) is not needed.

(意訳)多くの既存プログラムではエラーチェックの海が広がっていて、意味のある処理の島を見つけるのがほとんど無理な状態だ。アサーションがなければ防衛的プログラミングが唯一の合理的な選択肢かもしれない。しかし、アサーションを用いてそれぞれの(訳注:例えばメソッドの)役割を注意深く定めるテクニックを用いれば、そのような((訳注:システム)構造の一貫性とシンプルさに非常に有害な)冗長性は不必要になる。

... key criterion is to maximize the overall simplicity of the architecture.

(訳)キーとなる基準はアーキテクチャ全体のシンプルさを最大化するかどうかだ。

契約による設計を用いた実践的開発

事前条件

呼び出し側で保証すべき状態を満たしていなければ、アサーションをかけて「キャッチしない」例外を発生させます。

先ほどのjoinの場合のNPEについて考えてみます。join()はnullが渡ってこず、joinImpl()が(事前条件を満たしている限り)NPEを発生させないと考えると、全てのチェックは不要になります(これじゃメソッド分ける意味もない・・)。

String join(List<String> texts, String separator) {
    return joinImpl(texts, separator);
}

目的が「シンプルさの最大化」であれば、あえて引数にnullを許容する設計は問題ありません。このとき、契約による設計の考え方を適用すると、呼び出し側でのnullチェックは不要です。

String join(@Nonnull List<String> texts, @Nullable String separator) {
    if (separator == null) separator = ""; // nullが入ってきたらすぐにデフォ値で上書きすると安心
    return joinImpl(texts, separator); // joinImpl()にはtextsとseparatorはnullではないという事前条件があると仮定
}

ただし、nullについて考慮すべき箇所を最小限にするために、null許容は極力避けるのがよいと個人的には考えています。例えば必須でないパラメータがある場合はオーバーロードを使いましょう。

String join(@Nonnull List<String> texts) {
    return join(texts, "");
}

String join(@Nonnull List<String> texts, @Nonnull String separator) {
    return joinImpl(texts, separator);
}

また、nullを許容していないのにNPEが出ないなど、意図的にチェックしないと事前条件を満たしてなくても通してしまう場合がありえます。このときはアサーションを用いて契約違反を呼び出し側に伝えます。Androidにおいてはassert文が実質使えないので、ifチェックで非チェック例外を投げます。

String appendSuffix(String text, String suffix) {
    if (suffix == null) throw new NullPointerException("suffix is null");
    return text + suffix; // suffixがnullでも例外が発生せず、"textの中身null"のような感じの文字列がreturnされる。
}

引数について多く論じましたが、状態も事前条件に含まれます。例えば、start()できる条件は「まだstart()されていないこと」という場合が挙げられます。

void start() {
    if (this.isStarted) {
        throw new IllegalStateException("Already started.");
    }
    ...
}

不変条件

不変条件を満たさないような状態が生まれる直前にアサーションをかけるのがよさそうです。
自然数の例だとこんな感じになります。

public class NaturalNumber {
    private int num;
    public NaturalNumber(int num) {
        if (num <= 0) {
            throw new IllegalArgumentException("'" + num + "' is not a natural number.");
        }
        this.num = num;
    }
    ...
}

事後条件

(Oracleのドキュメントだとこれもassertされてましたが・・)

呼び出された側のモジュールの振る舞いは、現代においては単体テストで保証するのが一般的ではないでしょうか。
適切にテストされていれば、事後条件を満たしているはずです。
事後条件を全て満たすことを単体テストで保証するべきだとも言えそうです。

Javaの例外の全般の話

JavaにおけるExceptionの扱い方についてはEffective JavaのExceptionsの章が詳しいです。Qiitaの記事に少し書かれています。Effective Java読書会9日目 - 例外

[追記] NPEになる可能性がある箇所を静的チェックで見つける

まだ言語機能のOptionalが使えるKotlin対応済んでいないみなさん

Javaには言語機能としてのOptionalはありません(型はあるけど・・)が、アノテーションをつけておくと静的解析ツールでNPEの可能性のある場所をある程度発見できます(あとKotlinでnullable/nonnull扱いできる!)。

Android Lintではandroid.support以下に入っている@Nullableを使います。

http://developer.android.com/intl/ja/tools/debugging/annotations.html

FindBugsを導入している環境では、javax.annotation以下に入ってる@CheckForNullを使います。

http://d.hatena.ne.jp/kaoskfos/20120307/p1

ただし、の箇所にアノテーションを付けない限り完璧な検出は難しいのと、@Nonnullと書いていてもnullが入ってくる

[追記] 議論になった話

注意:この先は長々と書いてます。上の内容がしっくりこなかった、疑問があった方に向けた内容です。

この話をチーム内で発表したところ、主にSwiftとの比較で議論になった傾向にあります(Swift篇はいつか別記事を書く・・・!)。

なぜnullを渡さないのか、最終的に値を使う場所でnullチェックしたほうが(外側は)考慮しなくて済むのでは

(Swiftの場合はOptionalにすればnil渡しても問題ないじゃないか、という指摘でした。)

シンプルさの最大化のためには、なるべくnullを渡せないメソッドにしたほうが良いと書きました。これは「nullのとき」のロジックが各所に散らばるのを避けるためです。
具体的には、下記のようなコードになることを避けるためです。

private void render() {
    View view = getView(); // nullable
    renderHoge(view);
    renderFuga(view);
    renderPiyo(view);
}

private void renerHoge(View view) {
    if (view == null) return;
    ...
}

private void renderFuga(View view) {
    if (view == null) return;
    ...
}

private void renderPiyo(View view) {
   if (vieガッ!!!!!!!

nullを許容するということは、「nullのときは〜〜という処理をする(しない)」という条件を追加することになります。nullチェックをnullableな値が返ったらすぐに行うことで、null非許容な境界を広げて全体のシンプルさを高められます

nullに意味がある場合は、前述のようにオーバーロードを使ったり、そもそも引数の数が多い場合はParameter Object + Builder Patternで対処することができます。null安全な境界を広げたいと言う話で、nullを渡すことが常に悪というわけではありません。

例外を投げる文化、アサーションを行う文化

Swiftを始めとする他言語には「例外を投げる文化がない」との指摘がありました。確かに、言語の中でJavaほど例外機能が発達している言語はないなと思います。しかし、アサーションに関しては特定の言語の文化というわけではありません。例えば、Objective-CではNSParameterAssert()、SwiftではfatalError()などを使うと思います。

JavaにおいてはfatalError()のようなざっくりしたアサーションではなく、具体的な失敗の原因を例外の型として表現しましょうという文化になっています。

  • NullPointerException
  • IllegalArgumentException
  • IllegalStateException

あと引数を受け取った時はNullPointerExceptionなのかIllegalArgumentExceptionなのか、という質問が出て、それに関してはAndroidのどこかのAPI(ごめんなさいどこか忘れました・・)ではNPEとIllegalを使い分けていたことを話しました。Illegalの方は「nullではないが不適切な値」のときに使われていました。

NPEなどの「しょぼい」エラーのときに落としたくないから、グローバルにキャッチしたい

理想的な回答は、非チェック例外は「回復不能」な状態なのでキャッチしてはならない(キャッチしてもできることがない)、です。

現実的な回答は、AndroidのフレームワークがActivityやFragment、Serviceに対してそのような機能を提供していないので難しい、です。

その代わり、クラッシュする直前にCrashlyticsなどに例外を渡す機能(Uncaught Exception Handler)があったり、Googleの管理コンソールでクラッシュの詳細が見れるようになっています。サーバサイドJavaでは、(ちょうどRailsのrescue_fromのように)全ての例外をキャッチしてユーザにエラーを表示するようなコードを書いていたとも聞きましたが、アプリにおいてはAndroid OSの責任で「問題が発生しました」画面を表示してくれます。

どうしてもキャッチしたい場合

例えばバックグラウンドに多数キューイングされる処理がRuntimeExceptionをぶん投げてくる場合などは、キャッチしないとアプリが起動するたびに落ちるとかになってしまうと思います。そういうケースでは確かにcatchしている箇所もあります。

ただ、この場合例外安全性が十分でない場合は、その後の挙動でリークやデータ破壊をする可能性がありますので大変危険です。例外が発生した際に何かが残ってしまうような処理を書かないよう気をつける必要があります(どの行で例外が発生するかはわからないので、例えばClosableは必ずtry-finallyでclose()する必要があります。)。

長期的にクラッシュしないコードをアサーションを使って生み出していきましょう・・!!