Javadoc の @throws に例外を網羅的に書くメリット

本記事は Javadoc に @throws を書く習慣の無い方に、@throws を書いてみようかな?と思っていただくことを目標に書きました。
2020.06.05

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

本記事は Javadoc に @throws を書く習慣の無い方に、@throws を書いてみようかな?と思っていただくことを目標に書きました。

前提

  • いわゆるコードの保守性よりもバグを出さないことの優先順位が上、という思想の元に、実際に開発に適用してみて割とよかった話、として記載しています
    • 開発しているアプリ(prismatix)の性質上そうしている、というところが大いにあると思うので、マッチしない環境も多々あると思います
      • Webアプリケーションで、ハンドリングされない例外があるとステータスコード500が返って、不要なアラートがバンバン飛んで辛くなるのは NG にしたい、みたいな背景があります
  • 以下のような開発対象や開発環境だと、あまりメリットがないやり方かもしれません
    • 構造的に開発者が明示的に例外をハンドリングする必要がない
    • ハンドリングの漏れに対して都度都度コードを直すコストが少ない
    • 開発者の人数が少ない、コードベースが小さい
    • メンテナンスについてあまり考える必要がない

参考

本記事を書くにあたって、例外処理について以下の記事を参考にさせていただきました。

そもそも @throws とは

Write Doc Comments for the Javadoc Tool によると、

The purpose of the @throws tag is to indicate which exceptions the programmer must catch (for checked exceptions) or might want to catch (for unchecked exceptions).

と記載されています。ざっくり言ってしまえば、「メソッドの呼び出し元がハンドリングする必要のある(可能性のある)例外を書くタグ」です。

例えば以下のようになります。

/**
 * しおりを挟みます
 * 
 * @param page 対象のページ番号
 * @throws OverMaxPageException 最終ページ数後ろのページ数にしおりを挟もうとした
 */
public void putBookmark(int page) {
    // 何かしらの処理
}

この場合、@throws タグでは putBookmark メソッドが OverMaxPageException をスローする可能性があることと、その条件を記載しています。 その他 Javadoc についての詳細な挙動やタグの種類については以下を参照ください。

以降、本題(@throws を書くと何が嬉しいのか?)となります。

本記事の概要

本記事で主張したいことは、完結に言えば以下のような話です。

// このメソッドは明らかに OverMaxPageException をスローする可能性があります。
public void putBookmark(int page) {
    if (this.page < page) {
        throw new OverMaxPageException("最終ページより後のページにしおりを挟もうとした");
    }
}

// このメソッドは、どのような例外をスローするのでしょうか?
public void execute() {
    service.method1();
    service.method2();
    service.method3();
    service.method4();
}

例外はハンドリングする必要がある

前提として、多くのケースで例外はハンドリングする必要があると思っています。 ここでいうハンドリングとは、スローされる例外に対してアプリケーションが適切な処理を行う、という意味です。 例えば API を2つ連続で呼ぶ場合に、2つ目の API 呼び出しが失敗したら1つ目をロールバックする、といった具合です。 API がネットワークを介したものであれば、失敗を予想してコードを書くことができるかもしれませんが、「失敗を考慮させる」ために @throws は非常に有効だと考えています。

例外を catch してどうこうする場合もあれば、何もしないでよい場合もあるかもしれません。 特定の例外に対する後処理をグローバルな例外ハンドラーで行っていて、開発者が完全に意識する必要がない場合は不要だと思います。 ですが、個人的にあらゆるコンテキストで開発者がまったく意識しなくても良い例外、は割と稀な気がします。 ここで少なくとも最低限必要なのは「何がスローされる可能性があるのか把握する」ことです。

そして、私がこの記事で主張したいのは、何がスローされる可能性があるのかを知るために @throws が網羅的に書いてあると嬉しいということです。

ちなみに How to Write Doc Comments for the Javadoc Tool には以下のように記載されています。

Since there is no way to guarantee that a call has documented all of the unchecked exceptions that it may throw, the programmer must not depend on the presumption that a method cannot throw any unchecked exceptions other than those that it is documented to throw. In other words, you should always assume that a method can throw unchecked exceptions that are undocumented.

ざっくり言うと「スローしうる非チェック例外全てがここに書いてあるとは保証できないので、ドキュメントに書いてない例外をスローされる可能性を想定する必要がある」ということですが、個人的にこの考え方をあまり理解できていません。 クラスパスに存在する全ての例外クラスがスローされる可能性を常に考慮する、はやりすぎだと思いますが、依存している先のコードを常に全部読むべき、ということになるのでしょうか。

確かに呼び出し元のコードを全て読むのは確実な方法だと思います。実際、挙動がいまいち理解できていなかったり、Javadoc がまともに書かれていないコードを触る時に呼び出し先のコードを読むのは一般的だと思います。ただし、これを常に全てのコードで行うのは、正直骨が折れます。(それを前提にしているプロジェクトは、それはそれで素晴らしいと思いますが) ゆえに、「何がスローされる可能性があるのかを知るために @throws が網羅的に書いてあると嬉しい」となります。

スローされる可能性のある例外を可視化したい

「スローされる可能性のある例外」を表現できるのは「ソースコードそのもの」か「何か他のドキュメント」です。 ソースコードについては、例外をスローする本来の意図、みたいなもの以外は確かに全ての情報が含まれてはいます。 が、これを追いかけるのは個人的に非常に面倒です。特に、呼び出すメソッドが依存しているメソッドのスローする例外、みたいなことを考えると、読む必要のあるコードの量がかなり凄まじいことになります。

以下のコードであれば putBookmark が OverMaxPageException をスローするのは、自明です。

public void putBookmark(int page) {
    if (this.page < page) {
        throw new OverMaxPageException("最終ページより後のページにしおりを挟もうとした");
    }
}

この場合はどうでしょうか。

public void execute() {
    service.method1();
    service.method2();
    service.method3();
    service.method4();
}

この service のメソッドがどのような状況でどのような例外をスローするのかどうかを知る必要がある場合、これらのコードを全て読む必要があります。 さらに、service の各メソッドがさらに違うメソッドに依存している場合、その依存先のコードも全て読む必要あります。

このようにして開発が進むほど、コードを書くために読む必要のあるコードがどんどん膨らんで行きます。 @throws を網羅的に書く場合はどうでしょうか?service の各メソッドの @throws を見るだけでこのメソッドの @throws は網羅的に書くことができますし、これはコードを常に全て読むよりもだいぶ簡単に思えます。

そして、更にこの「コードを全て読む必要のある状況」はめんどくさいがゆえに無視され、例外がハンドリングされないコードがいろいろ出てきがち、という感覚があります。 (ただし、@throws を網羅的に書いてもらうのもそれなりにめんどくさいので、定着するまでの啓蒙であったりレビューであったりの労力はそれなりに必要です)

チェック例外にすれば?

個人的に全部チェック例外にする場合の困るポイントがあまり見えていないんですが、現実的には難しいと思っていて、

あたりが思いつきます。特にライブラリの話があるのでこのテーマでのチェック例外の検討はあまり現実的ではない気がします。 呼び出し元が全部コンパイルエラーになる、については Javadoc でもある程度似たような問題が起こり得ますが、「常に必要ではない」というところに差があると思っています。

例えば以下の repository に null を渡すと例外がスローされる、という修正が行われたケースでも、updateUser メソッドがスローする例外は変わりません。

public Optional<User> updateUser(User user) {
    if (user == null) {
        return Optional.empty();
    }
    return Optional.of(repository.update(user));
}

ここらへんはいろいろ考え方があると思いますが、個人的には呼び出される側の挙動が何か変わった(=スローされる例外が増えた)場合、それが呼び出し元に伝わらないのはそれはそれでまずい、という立場です。(スローする可能性のある例外、は実装ではなく仕様だと思っており、ゆえに例外が増えた=仕様変更だと思っています)

他にも Lambda 式が書きづらいとかありますが、いずれにせよあまり現実的ではないかなあという感覚です。

まとめ

  • 例外はハンドリングする必要があり、そのためにはどういった例外がスローされるのか?を知る必要がある
    • ハンドリングしなくてよいケースでは、この話はマッチしないと思う
  • メソッドが直接スローする例外だけではなく、依存先のメソッドがスローする例外もわかりたい
  • そのための手段として、Javadoc の @throws をひたすら書くのははもろもろバランスの良い手段だと感じている
    • ただし、@throws を書くこと、書く習慣をつけること、のコストはやっぱりそれなりにかかる
  • 開発者に「失敗を意識したコードを書く」ことを促す手段としても、それなりに有効だと思う
  • チェック例外でこれを実現しようとするのは、現実的に結構厳しい
    • 自分が書くコード以外の世界がそうなっていない、というところのハードルが高すぎる

クラスメソッドの事業開発部では一緒に働く仲間を募集しています

現在私は事業開発部で prismatix というサービスの開発に携わっています。本記事は、prismatix のバグを少しでも減らすために書いていた文章をある程度汎化して書いたものです。 prismatix に関わるメンバーとして働く、ということにもし興味のある方がいましたら、こちらのページを見ていただけますと幸いです。

私からは以上です。