Java Generics Hell - リスコフの置換原則

Java Generics Hell アドベントカレンダー 3日目。

読者の推奨スキルとしてはOCJP Silverぐらいを想定している。

前回はオブジェクト指向の中核が(継承に限定せず幅広い意味での)ポリモーフィズムではないか、という話であった。
今回はそれらをコンパイル、つまり静的型チェックによって安全を目指す「型システム」について。その中でも「リスコフの置換原則」について取り上げる。

ヒューマンエラー

ボイラープレートと呼ばれるプログラミングの定型句ぐらいであれば機械による自動生成もありうるが、プログラミングというのは現代では基本的に人力の作業である。

人間は簡単な問題であってもときおり誤りをおかす。一桁の足し算のようなドリルでも、延々とやらせると時折ミスが混ざる。「簡単だからミスするはずがない」「ミスをするのはたるんでいるからだ」といった考え方ではこうしたミスを防ぐことはできない。単純作業であっても人間はミスをおかすものだ、という前提に立ち、どういうときにミスが起こりやすいのか、それをどうすれば防げるのか?ということを考える必要がある。

こうした、偶発的に人間が生じさせるミスをヒューマンエラーと呼ぶ。故意ではないし、能力不足でなくとも生じることがある。

さて、プログラミングが主に人力で行われる以上、ヒューマンエラーに基づくバグというのは一定確率で必ず生じる。ヒューマンエラー対策としては機械による補佐が効果的であり、型システムもまたそうした対策の一種足りうる。筆者は型システムによる静的チェックを重要している。余談となるがそのほかのヒューマンエラー対策としてはコンパイルによる型チェック以外の静的解析(バグパターンを検出したりできる)、自動テストとCI(継続的インテグレーション)によるバグの早期発見のための体制づくりなどが挙げられる。

型システム

さて、型システムだが、データがどういう種類のものかを宣言することで、データの種類の取り違えを防ぐものである。

Javaの場合はプリミティブ型(boolean, char, int等の数値型)と参照型があるが、ここでは主に参照型の話をしよう。Javaでは継承とポリモーフィズムを採用しており、継承の親子関係がある場合、子は親の代わりができることとされる。

これはリスコフの置換原則と呼ばれるもので、Javaオブジェクト指向では大原則となる。これに反すると設計がいろいろとおかしくなり、予期せぬバグのもととなる。

「子は親の代わりができること」つまり、親のクラスが提供する機能は子のクラスでもすべて提供されなくてはならない。親クラスで提供されるpublic メソッドを子のクラスで潰したいと思うことが生じたら、それは継承するべきだったのかを考え直す必要がある。このリスコフの置換原則に沿っているかを考える際の標語としてis-a関係という言われ方もする。子 is 親という言い方をしたときに概念として明らかに違うという場合は継承を避ける、という話だ。

ジェネリクスの変性、代入できるかどうかみたいな話をするときにこのリスコフの置換原則が前提となる。

ケーススタディ 不変クラス

リスコフの置換原則は単純だが重要な概念である。リスコフの置換原則に則っていなくともある程度はボロが出なかったりすることもあるのでうっかりすることもあるのだが、シチュエーションによってはボロが出る。きちんと対応しておかないとシステムの土台が崩れかねない。

ここではアンチパターン(つまり真似をしてはいけない失敗パターン)として、不変クラスを例に挙げよう。

不変クラスとは、最初にnewする際に値を決めたら、その後で値が変わらないものを言う。Javaで言えばjava.lang.Stringは不変クラスで、可変なのはjava.lang.StringBuilderクラスだ。StringとStringを結合したような場合、結合した別のインスタンスが返される。

String s1 = "hello ";
String s2 = "world!";
String s3 = s1 + s2;
System.out.println(s3);

このような場合、s1とs2を+で結合するが、この時返されるs3はs1やs2とは別のインスタンスである。なので

System.out.println(s1 == s3);
System.out.println(s2 == s3);

とすると、結果は共にfalseとなる。

一方、StringBuilderの場合は同じインスタンスでありながら値が変化する。

StringBuilder sb = new StringBuilder("hello ");
sb.append("world!");
System.out.println(sb);

Stringのような不変クラスは、同じインスタンスが複数のスレッドから参照されるようなケースでもインスタンスの値が変更されるタイミングを考慮する必要がない。またシングルスレッドでも不変であることが保証されると値が途中で変わっているのではないか?という可能性を考えなくて済むので単純になる。不変のインスタンスというのは計算するたびに新しいインスタンスを生成するのでなんだか面倒なように思うかもしれないが、バグ対策という点ではなかなか優秀である。不変で済むものは不変で済ませる、というのはプログラミングの良い習慣とされる。

StringとStringBuilderは同じjava.lang.CharSequenceというinterfaceを実装する兄弟のような関係にあるが、継承関係にはない。

ここまでが前置きである。

ケーススタディ アンチパターン

例としてx,y座標をもつjava.awt.Pointのようなクラスを考えてみよう。

変更可能なクラスと、不変のクラスという2種類のクラスを継承関係で作るとしよう。メソッドの有無だけに注目すると、変更可能なクラスは値の設定と値の取得のメソッドがある。一方不変のクラスは値の取得のメソッドだけしかない。

public class Point {
  int x;
  int y;

  public void setX(int x) { this.x = x; }
  public void setY(int y) { this.y = y; }

  public void getX() { return x; } 
  public void getY() { return y; } 
}

/** 不変のクラス */
public class ImmutablePoint {
  int x;
  int y;

  /** 不変なのでコンストラクタで値を設定する */
  public ImmutablePoint(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public void getX() { return x; } 
  public void getY() { return y; } 
}

不変クラス ImmutablePoint はコンストラクタで値を設定し、以後は値を更新することができない。getX(), getY()だけが提供される。継承に際して、子は親のメソッド全てを提供しなければならないのであった。ということは、Point が親となって子を ImmutablePoint とするとsetX(int) setY(int)が提供できないので駄目である。ImmutablePoint を親として Pointを子としてみよう。

public class Point extends ImmutablePoint {
  // (省略)
}

さてこの場合、ImmutablePoint 型変数にPoint型のインスタンスを格納することが出来る。

Point p = new Point();
ImmutablePoint ip = p;

// pのxを変更すると変数ipのxも変わる
p.setX(100);

そしてPoint型インスタンスを触ることでImmutablePoint 型変数の内容も変更することができてしまう。これが駄目な例だ。

概念としてのis-a関係

リスコフの置換原則はただ提供されるメソッドが親と同じものを子が提供してさえすればいいというものではない。

ここでは親は「不変なx,yの値を持つクラス」であった。子のPoint型はis-a関係であろうか? "Point is a ImmutablePoint"だろうか?「可変なx,yの値を持つクラス」は「不変なx,yの値を持つクラス」だろうか?

クラスの責務というのはただメソッドだけで決まるわけではない。「不変である」というクラスの責務として不変なのであれば、不変であることを想定して様々なコードが書かれる。そうしたコードで動くように子を作ろうとするならば、子もまた不変でなくてはならない。

まとめ

  • ヒューマンエラー対策として型システムは有用
  • Javaの型システムはリスコフの置換原則に基づいている
  • 提供されるメソッドだけではなく、クラス全体の責務を踏まえて継承関係を作る必要がある

今回はまずJavaの型システムで重要な前提となる「リスコフの置換原則」について取り上げた。Javaのような継承をもつプログラミング言語ジェネリクスについて考える際にはこのリスコフの置換原則は重要で、ジェネリクスでの変性について考える際にまた振り返ることになる。

次回も型システムについてである。Javaジェネリクスは通常の参照型と代入互換性に違いがある。その理由について迫ろう。