読者です 読者をやめる 読者になる 読者になる

scramble cadenza

技術ネタのガラクタ置き場

Java 歴 23 分の Ruby エンジニアが Effective Java を読んで感動した話

イントロ

例外処理を書くことはよくやっているのだけれど、その時の主軸となる考え方について、今までなんとなくで行っていた部分が多かった。

毎回考えるポイントは例えば以下のような疑問。

  • どこのレイヤーで、どこまで例外処理を行えばよいのだろうか?
  • どの例外をキャッチし、どの例外を伝搬させればよいだろうか?
  • 前提条件をチェックし、失敗した場合、例外を出したほうがよいか、nil, false を返すほうがよいか?
  • 例外をどういう単位でラップさせるのが良いだろうか?
  • 例外をチェインし過ぎると却って煩雑になる気がする。どうすれば良いのだろうか。

しかし、この辺りの話って、API の設計だったり、仕様の影響もあるので、都度対応が異なってしまうもの。
したがって抽象化して理解することが難しく感じた。

とてもよく使ってるし、とても大事な事なことなのに。

そんな今更な事で悩んでいた時に、Effective Java という良い本を紹介してもらったので、例外処理の章を読んでまとめてみました。
(5 章まで読んで力尽きていたが、勇気を振り絞り、また読み進めてみた)

EFFECTIVE JAVA 第2版 (The Java Series)

EFFECTIVE JAVA 第2版 (The Java Series)

読書メモ

項目.57 例外状態の時だけ例外にする

// 行ってはならない
try { 
  int i = 0;
  while(true) 
    range[i++].climb();
} catch(ArrayIndexOutOfBoundsException e) {
}
  • 例外に基づくイディオムはパフォーマンスが悪い
    • そのため、例外の目的がわからない場合は使ってはいけない
  • 例外は例外的条件の時のみに使用する。通常の制御フローに対しては使ってはいけない
    • コードの保守性を落とすだけでなく、パフォーマンスも悪くなるので
  • 通常の制御に例外を使用することをクライアントに強制してはならない
    • 例えば、予測不能な状態でのみ呼び出されるメソッドは「状態依存 」である
      • e.g) Iterator#next()
    • 一般には状態を判別する状態検査メソッドを持つべき
      • e.g) Itereator#hasNext()
    • 状態検査メソッドが呼ばれること無く、状態依存のメソッドが呼ばれた場合は、そうとわかるような特別な値を返す

状態検査メソッドを使うか、区別できる戻り値を使うか

区別できる戻り値を使う場合

以下の条件を満たすとき。

  • 並行してアクセスされる場合
  • 外部要因で状態遷移する場合

状態検査メソッドと、その変更メソッドを呼び出す間に、状態が変更になる場合があるから。

状態検査メソッドを使う場合

上記以外のとき。

  • 若干読みやすい
  • 状態検査メソッドを呼び出すことを忘れると、例外が投げられるため、バグに気づきやすい。

から。

項目.58 回復可能な状態にはチェックされる例外を、プログラミングエラーには実行時例外を使用する

チェックされる例外(checked exception)

  • 回復可能な状態であるときに発生する例外
  • 回復に役立つ情報を提供するメソッドを持つと良い

実行時例外(runtime exception)

  • 回復可能でない状態である場合に発生する例外 = チェックされない例外
  • 実行を続けても役にたたない、むしろ害になるような場合に発生する例外
    • RuntimeException をサブクラス化する
  • 「回復可能かそうでないか」を判別できない場合も「実行時例外」として扱うべき

エラー(error)

  • 基本的には使わない
    • API のユーザーの混乱を招くだけだから
    • 得られるものは殆ど無いから

項目.59 チェックされる例外を不必要に使用するのを避ける

チェックされる例外は、API の使用者に例外処理を強制させ、信頼性を上げる

しかし、チェックされる例外を過剰に増やすと逆に API を使いにくくする。 API を使う側は、以下の2つのどれかを強いられるため、多少の負荷がかかるから。

  • 少なくとも1つ以上の catch が必要
  • 例外を外側に伝搬させる

チェックされる例外を使う場合

  • API の適切な使用では例外処理を防ぐことができない
  • API を使用しているプログラマが例外に直面したときに、何らかの有効な処理を行える

この2つの条件をみたす場合に、上記の負荷が正当化される。(ただし CloneNotSupportedException は例外) それ以外の場合はチェックされない例外のほうが適切。

チェックされる例外を、チェックされない例外に変更する方法

上記で述べたように、チェックされる例外を使用し過ぎると、負荷が高くなる。 その方法が「例外を投げるメソッドを分割して、状態検査メソッドと、それ以外の処理に分割する」というもの

ただし、以下の場合は NG。

  • 外部同期なしに平行してアクセスされる場合
  • 外部要因により、状態遷移する場合
  • actionPermitted メソッドが action メソッドの処理を行う必要がある場合
    • (パフォーマンス上の関係で、この分割を行う必要がないので不要となる)
// チェックされる例外を使用する場合の呼び出し
try {
  obj.action(args);
} catch(TheCheckedException e) {
  // 例外処理
}
// 状態検査メソッドとチェックされない例外を使用する場合の呼び出し
if (obj.actionPermitted(args)) {
   obj.action(args);
} else {
  // 例外処理
}

項目.60 標準例外を使用する

既存の例外を再利用する

  • プログラマが熟知している確立された慣例と一致するため、使用が容易になるから
  • 見慣れない例外でごちゃごちゃしないので、API を使用するプログラムが読みやすい
    • 例外クラスが少ないことは、クラスのロードに費やされる時間が少ないことを意味する

よくある具体例

  • IllegalArgumentException
    • 不適切な引数を渡した時に発生する例外
  • IllegalStateException
    • レシーバーのオブジェクトが不正な状態ならば発生する例外
  • NullPointerException
    • null が禁止されているパラメータに対して、null を渡した場合など
  • IndexOutOfBoundsException
    • index を表すパラメータで、範囲外の値を呼び出した場合など
  • ConcurrentModificationException
    • 並行して変更されようとしている
  • UnsupportedOperationException
    • 行おうとした操作をオブジェクトがサポートしていない場合

また、エラーに関連する情報を付与したい場合は、自由に例外をサブクラス化すること。 再利用する時の例外をどう選択するか、は必ずしも厳密でない場合がある

項目.61 抽象概念に適した例外をスローする

例外翻訳とは

「上位レイヤは下位レベルの例外をキャッチし、上位レベルの抽象概念の観点から説明可能な例外をスローする」こと。例えば以下の様な例を指す。

// イディオム
try {
} catch (LowerLevelException e) {
  throw new HighLevelException(...)
}
// AbstractSequentialList からの一例
public E get(int index) {
   ListIterator<E> i = listIterator(index);
  try {
    return i.next();
  } catch(NoSuchElementException e) {
    throw new IndexOutOfBoundsException("Index: " + index);
  }
}

上位レベルの例外から、下位レベルの問題をデバッグするためには、例外連鎖を用いる

  • 上位レベルから下位レベルの例外を取り出すためのアクセサを用いる
  • 具体的には、スーパークラスの連鎖可能なコンストラクタに引き渡す
    • 殆どの標準例外は連鎖可能なコンストラクタを持っている
try {
  // Do something
} catch (LowerLevelException cause) {
  throw new HighLevelException(cause);
}

class HighLevelException extends Exception {
   HighLevelException(Throwable cause) {
    super(cause);
  }
}

例外翻訳は何も考えないで例外を伝搬させるよりは優れてるが、乱用すべきではない

  • 可能であれば、下位レベルのメソッドを呼び出す前に、それが成功することを保証する
    • (状態検査メソッドのこと?)
    • 引数を引き渡す前に、正当性を検査する
  • 不可能であれば、上位レイヤに黙って処理させる
    • 下位レベルの問題から、上位レベルのメソッド呼び出し元を隔離する事が次善策
  • 呼び出し元を隔離出来ない場合は、例外連鎖を使う

項目.62 各メソッドがスローする全ての例外を文章化する

例外がスローされる条件を Javadoc@throw タグで正確に文章化する

  • スローする例外に気をつけているプログラマの手引にならないから
  • クラスやインターフェイスを効果的に使用することが困難だったり、あるいは不可能だったりするから

チェックされない例外についても、注意深く文章化するほうが懸命

  • ただし、強制はしない
  • 書くことで、メソッドが首尾よく実行されるための事前条件を記述できる
  • これは必ずしも達成可能であるとは限らない
  • throw タグを使わない
    • チェックされない例外と、チェックされる例外を視覚的に区別するため

項目.63 詳細メッセージにエラー記録情報を含める

例外の toString メソッドが例外の文字列表現である

  • 文字列表現 = クラス名 + 詳細メッセージ
  • エラーの原因を表す文字列で、できるだけ多くの情報をかえす事が重要

例外の原因となった全てのパラメータとフィールドの値を含んでいるべき

  • 例) IndexOutOfBoundsException は下限範囲、上限範囲、範囲内の収まらなかった実際の index を返す。これは診断にとても役立つ情報

例外の文字列表現と、エンドユーザーに対してわかりやすいメッセージを混同すべきではない

  • プログラマにとっては、エラーを解析する情報の内容のほうがはるかに重要
  • 以下の様にコンストラクタを作成することを推奨
    • プログラマがエラーを記録することが容易になるし、逆に記録しないことが困難になるから
    • 高品質な文字列表現を生成するコードを一箇所にまとめる事ができるから
    • アクセサを提供することで、エラーからの回復に役立てることができるかもしれないから
/*
IndexOutOfBoundsException を生成する
@params lowerBound 最も小さな正当なインデックス値
@params upperBound 最も大きな正当なインデックス値に 1 を足した値
@params index 実際のインデックス値 
*/
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index) {
  super("Lower bound: " + lowerbound +  ", Upper bound: " + upperBound + ", Index: " + index);
  this.lowerbound = lowerBound;
  this.upperBound = upperBound;
  this.index = index;
}

項目. 64 エラーアトミック性に努める

エラーアトミックとは

  • 「失敗したメソッド呼び出しは、オブジェクトをそのメソッド呼び出しの前の状態にしておく」という状態
    • なるべくエラーアトミックに保つことを意識する

エラーアトミックを保つための方法

  • 不変オブジェクトを設計する
    • 変更できないので、エラーが出ても同じ状態であるから
  • パラメータの正当性を検査する
    • オブジェクトの変更を行う前に例外をスローすることができるから
  • 失敗するかもしれない部分を、オブジェクトを変更する部分よりも前に行う
  • 操作の途中で発生する例外を捉えて、状態を戻す「回復コード」を書く
    • 一般的ではない
    • 主に永続的なデータ構造に対して使用される

ただし、エラーアトミック性は必ずしも達成できるとは限らない。例えば複数スレッドが同期なしに、同一オブジェクトを同時に変更する場合など。

メソッドがエラーをスローする場合は、一般的には回復不可能であり、エラーアトミック性を保持しなくてよい

  • たとえ回復可能でも、必ずしも望ましいとは限らない
  • 例外は回復可能である場合がある。対比に注意
  • メソッドの仕様の一部である例外は、エラーアトミック性を保持するべき
// 最初のサイズ検査が無くても例外を投げる
// しかし、それだと size フィールドを不正号な状態にしてしまい
// オブジェクトに対するその後の呼び出しを失敗させてしまう
public Object pop() {
  if (size == 0)
    throw new EmptyStackException();
  Object result = elements[—size];
  elements[size] = null; // 廃れた参照を取り除く
  return result;
}

項目.65 例外を無視しない

例外を無視してはいけない理由

  • 例外の目的が達成されないから
    • 例外の目的とは、例外的状態を処理させることを強制する、こと
    • 火災報知機を無視して、警報を切ってしまうのと同じ
    • 無視する場合はコメントに含んでいるべき
  • エラーをも無視してしまうから
    • 原因とは何も関係のないところでエラーになる可能性がある
    • 例外は、外に伝搬させるだけでも有益な情報を残してくれる
// ダメ、ぜったい
try {
} catch (SomeException e) {
}

感想

普段は ruby を書いているが、早速活かせそうな話ばかりだったので、試してみたい。

例外処理を書く時の指針が揃っていたし、その理由も納得できるものだった。特に「チェックされる例外」と「チェックされない例外」という考え方は自分が特に迷っていた部分だったので、参考になった。 例外連鎖を使うときにも指針ができた。

Java は型あるので、擬似コードが理解し易くていいですね。

こういうプログラミング全般?の話というか、プログラミングテクニックというか、そういう本って他にもあるのかな。
基礎力が無いのでもっと読んだほうが良い気がする。Effective Java も更に読み込んでいきたい。

2017/11/22 追記

かなりスペースを取ってしまってますが、冒頭に目次を追加しました。
忙しい人でも目次だけ読んで、気になったところだけ読めるようにする意図を込めてます。