« Yash 2 その 266 | トップページ | Yash 2 その 267 »

2013年5月 5日 (日)

検査例外と場合分けと多態性の話

昨日適当につぶやいたのをもう少し詳しく書いてみようと思ふ。ただし、内容はあまりまとまってゐない。

概略:

  1. 多態性による場合分けと instanceof による場合分け。多態性による場合分けの限界。
  2. 検査例外の健全性と限界。
  3. 検査例外の代替品としての直和型。直和型の定義の面倒さ。
  4. OCaml の多態バリアントの有用性と限界。

いくつかのオブジェクト指向プログラミング言語では、インスタンスの実際の型が何なのかを動的に判定する方法がある。例えば Java の instanceof 演算子や C# の is 演算子、あるいはより一般的な方法としてリフレクションなどがある。しかしこれらの機能を使ふのはアンチパタンであり、オブジェクト指向プログラミングにおいてはオブジェクトの多態性 (多相性) を利用して場合分けを行ふべしとの意見がある ([1][2])。オブジェクトの種類によって処理を変へる必要があるのならその処理の違ひは個個のサブクラスの実装内部に隠蔽されるべしといふ点においてその意見は正しい。

// Java で instanceof を使ふ例

void printValue(Object o) {
    if (o instanceof Integer)
        System.out.println(((Integer) o).intValue());
    else if (o instanceof Float)
        System.out.println(((Float) o).floatValue());
}

// Java で多態性を使ふ例

abstract class Value {
    public abstract void printValue();
}
class IntegerValue extends Value {
    final Integer value;
    public void printValue() { System.out.println(value.intValue()); }
}
class FloatValue extends Value {
    final Float value;
    public void printValue() { System.out.println(value.floatValue()); }
}

void printValue(Value v) {
    v.printValue();
}

ところで、Java や C# では例外を受け取った時にその例外の型によって処理を変へるといふことが普通に行はれてゐる。

// Java の例であるが、話を簡単にするためにここでは FileNotFoundException と
// AccessControlException の二種類の例外についてのみ考へる

InputStream openFile(File file) throws FileNotFoundException, AccessControlException { ... }

void writeToFile(File file, String data) {
    try {
        InputStream stream = openFile(file);
        ...
        stream.close();
    } catch (FileNotFoundException e) {
        System.err.println("File not found: " + file);
    } catch (AccessControlException e) {
        System.err.println("Permission error: " + file);
    }
}

この様に例外の種類ごとに catch を書くのはごく自然なコードに見えるかもしれないが、ところがこれは最初の instanceof 演算子でオブジェクトの型を動的に判別するのと本質的に同じなのである。といふのも、この writeToFile メソッドは以下のように書き換へることができるからである。

void writeToFile(File file, String data) {
    try {
        InputStream stream = openFile(file);
        ...
        stream.close();
    } catch (Throwable e) { // 任意の種類の例外を受け止める
        if (e instanceof FileNotFoundException) {
            System.err.println("File not found: " + file);
        } else if (e instanceof AccessControlException) {
            System.err.println("Permission error: " + file);
        }
    }
}

では、例外の種類を instanceof 演算子で動的に判別するのではなく、多態性を利用して例外の種類に応じた処理を行ふ様に書き換へられるかといふと、それは無理である。FileNotFoundException や AccessControlException は予めプラットフォームライブラリの API に定義されてゐる例外のクラスであり、後から自由に (多態な) メソッドを追加することはできないからだ。すなはちここに、多態性による処理の切り替への限界がある。既にあるクラス (例外に限らない) が定義されてゐて、そのクラスの定義を書き換へることができないとき、クラスの種類によって処理を変へるためには instanceof 演算子やリフレクションによって型を判別するしかない。


さて、Java の検査例外の仕組みは、投げられ得る例外がもれなく処理されてゐることを静的に確認・保証してくれる。上の例では、openFile メソッドは FileNotFoundException と AccessControlException の二種類の例外を投げる可能性があるので、その呼び出し元では少なくともその二種類の例外を catch するか throws 宣言する必要がある。もし writeToFile メソッドが catch または throws を忘れてゐたり、あるいは openFile メソッドがこれら以外の例外を投げようとしたときは、コンパイル時エラーになる。

一方、検査例外の仕組みが働く場所はとても限られてゐる。Java では Error と RuntimeException のサブクラスは検査されないし、C# では一切の例外が検査されない。また例外に限らず、Java の instanceof 演算子や C# の is 演算子によってオブジェクトの型を動的に判別する際には、判別がもれなく行はれてゐるかどうかまったく検査されない。例えば最初の printValue メソッドの例で言へば、このメソッドに Integer あるいは Float 以外の型のオブジェクトが渡されないことを型システムはまったく保証してくれない。

では、静的型付け関数型言語に広く普及してゐる直和型で値を定義するとどうか。この場合、既に与へられてゐる直和型の定義に対して後から場合分け処理を追加することができ、そして場合分けがもれなく行はれてゐることも型システムで保証できる。

(* OCaml の例 *)

type open_file_result =
  | Success of out_channel
  | FileNotFound
  | AccessControlError

val open_file : file -> open_file_result

let write_to_file file data =
  match open_file file with
  | Success channel ->
      ...;
      close_out channel
  | FileNotFound ->
      prerr_string "File not found\n"
  | AccessControlError
      prerr_string "Permission error\n"

ただ、多くの言語では直和型の取りうる値を増やしたり減らしたりすることによって別の直和型を作ることができないので、取りうる値の一部だけを場合分け処理するといふことがやりづらい。例えば、先ほどの例に出て来た openFile メソッドを呼び出す以下のようなメソッドを考へよう。

void createFile(File file) { ... }

InputStream openOrCreateFile(File file) throws AccessControlError {
    while (true) {
        try {
            return openFile(file);
        } catch (FileNotFoundException e) {
            createFile(file);
        }
    }
}

これは openFile を呼び出してその結果を返すが、FileNotFoundException が投げられた場合は createFile を呼び出して再度やり直すようになってゐる。AccessControlError が投げられた場合は、そのまま呼び出し元に伝はる。openFile メソッドが FileNotFoundException を投げたとしても、それは openOrCreateFile メソッドの中で catch されるので、openOrCreateFile メソッドの定義に throws FileNotFoundException は含まれてゐない。

これと同様のことを OCaml で書くと、以下の様になる。

val create_file : file -> unit

type open_or_create_file_result =
  | Success2 of out_channel
  | AccessControlError2

let rec open_or_create_file file =
  match open_file file with
  | Success ->
      Success2
  | FileNotFound ->
      create_file file;
      open_or_create_file file
  | AccessControlError ->
      AccessControlError2

Success2 とか AccessControlError2 とか不格好な名前のコンストラクタを定義したのは、同じ名前のコンストラクタを異なる型に使ふことができないからである。しかしもっと重大な問題は、型システムにおいて open_file_result と open_or_create_file_result といふ二つの直和型の間に何ら関係性が定義されないこと、そしてそれゆゑに open_file 関数と open_or_create_file 関数の戻り値の型の間に関係性がないことにある。

この例でやりたかったことは、open_file file の評価で FileNotFound が発生しなかったら結果をそのまま返すといふことであった。Java の例では、これは素直に return openFile(file); と書くことができた。しかし OCaml の例では、open_file file の評価結果に対して場合分けを行ひ、結果が FileNotFound であった場合のみならず Success や AccessControlError であった場合でもそれに応じた処理が必要になってゐる。しかし本来の理想としては、以下の様なコードが書きたいのである。

let rec open_or_create_file file =
  match open_file file with
  | FileNotFound ->
      create_file file;
      open_or_create_file file
  | x ->
      x  (* FileNotFound 以外の結果はそのまま返す *)

ところがこの様に書くと、open_or_create_file 関数の戻り値の型は open_file_result であると推論されてしまふ。open_or_create_file 関数が FileNotFound を返すことは実際にはありえないのに、型の上では open_or_create_file 関数が FileNotFound を返すことがありうるとされてしまふ。これは望んだ結果ではない。望ましいのは、FileNotFound を返すことがないことを処理系が自動的に認識し、open_file_result から FileNotFound を除いた直和型を自動的に生成し、それを open_or_create_file の戻り値の型とすることだ。


さて、関数を定義する度にいちいち関数の戻り値の型を定義するのは面倒である (上の OCaml の例では、open_file 関数の戻り値として open_file_result 型を、open_or_create_file 関数の戻り値として open_or_create_file_result 型を定義した)。OCaml の場合、多態バリアント (多相バリアント; polymorphic variant) を使ふと予め直和型を明示的に定義せずとも直和型の値を生成することができる。

val open_file :
  file -> [> `AccessControlError | `FileNotFound | `Success of out_channel ]

let rec open_or_create_file file =
  match open_file file with
  | `FileNotFound ->
      create_file file; open_or_create_file file
  | x ->
      x

ただしこの例では open_or_create_file 関数の戻り値の型は [> `AccessControlError | `FileNotFound | `Success of out_channel ] となってしまふ (すなはち、open_file 関数の戻り値と同じ)。open_or_create_file 関数が `FileNotFound を返さないことを型で示すには、結局すべての場合を処理しなくてはならない。

let rec open_or_create_file file =
  match open_file file with
  | `Success channel -> `Success channel
  | `FileNotFound -> create_file file; open_or_create_file file
  | `AccessControlError -> `AccessControlError

(* これで open_or_create_file の戻り値の型は
[> `AccessControlError | `Success of out_channel ]
となる *)

とはいへ、多態バリアントを使ふことで、関数を定義する度にいちいち戻り値の型を先に定義する必要はなくなる。


また、多態バリアントを使ふと複数の直和型を統合して一つの直和型を作ることも簡単にできる。

val do_something_1 : unit -> [> `Error1 | `Success ]

val do_something_2 : unit -> [> `Error2 | `Success ]

let do_something_1_and_2 () =
  match do_something_1 () with
  | `Success -> do_something_2 ()
  | x -> x

(* do_something_1_and_2 の戻り値の型は
[> `Error1 | `Error2 | `Success ]
となる *)

この例では、ある関数が内部で複数の異なる関数を呼ぶ際に、それぞれの関数が返しうる異なるエラーをそのまま外の関数の戻り値として返してゐる。多態バリアントではなく普通の直和型を使ふと、予め戻り値の型を定義する必要がある上に場合分けも複雑になる。

type do_something_1_result = Success1 | Error1

val do_something_1 : unit -> do_something_1_result

type do_something_2_result = Success2 | Error2

val do_something_2 : unit -> do_something_2_result

type do_something_1_and_2_result = Success' | Error1' | Error2'

let do_something_1_and_2 () =
  match do_something_1 () with
  | Success1 ->
      (match do_something_2 () with
       | Success2 -> Success'
       | Error2 -> Error2')
  | Error1 ->
      Error1'

|

« Yash 2 その 266 | トップページ | Yash 2 その 267 »

コメント

コメントを書く



(ウェブ上には掲載しません)




トラックバック

この記事のトラックバックURL:
http://app.cocolog-nifty.com/t/trackback/169172/57315437

この記事へのトラックバック一覧です: 検査例外と場合分けと多態性の話:

« Yash 2 その 266 | トップページ | Yash 2 その 267 »