継承よりコンポジション

7 min読了の目安(約6500字IDEAアイデア記事

継承とサブタイピングを区別して使用する

継承とサブタイピングは同じ構文を使用するため、オブジェクト指向言語の初学者は混合して使用してしまいます。しかし、これらは区別され秩序正しく使用されるべきです。そうでなければ、リスコフの置換原則を違反する結果に陥ります。本章では、継承とサブタイピングを同時に扱うことで発生する課題を明らかにしていきます。

継承とは何か

まず、継承とは何かについて説明していきます。

継承とは、既存のプログラムを再利用するためのオブジェクト指向言語が提供する代表的な言語機能の一つです。例として、図形の矩形を扱う Rectangle クラスがあったとしましょう。以下は Scala で実装する例です。

class Rectangle(
  val x: Int,
  val y: Int,
  val width: Int,
  val height: Int
) {
  def area: Double = ...
  def path: Path = ...
}

続いて、あなたは色付きの矩形を扱う ColorRectangle クラスを自身で作成することにします。このとき、Rectangle クラスを継承してその機能を再利用することが可能です。

class ColorRectangle(
  x: Int,
  y: Int,
  width: Int,
  height: Int,
  val color: Color
) extends Rectangle(x, y, width, height)

しかし、この場合、あなたは ColorRectangle クラスのインスタンスは ColorRectangle 型を通じて使用されることを表明していることに注意する必要があります。つまり、クライアントプログラムは決して Rectangle 型を型として ColorRectangle クラスのインスタンスを扱いません。したがって、以下のようなクライアントプログラムは決して記述されません。

def someFunc(r: Rectangle): Unit = ...
someFunc(new ColorRectangle(...))

サブタイピングとは何か

続いて、サブタイピングとは何かについて説明していきます。

サブタイピングは、オブジェクト指向言語が提供する抽象化メカニズムの一つです。例として、図形に対する共通の操作(インタフェース)を提供する抽象クラス Shape クラスおよび描画ツール向けの操作(インタフェース)を提供する Drawable トレイト、それらを実装する Rectangle クラスを考えましょう。あなたは Scala で以下のように実装しました。

trait Drawable {
  def path: Path
}

abstract class Shape(
  val x: Int,
  val y: Int
) extends Drawable {
  def area: Double
}

class Rectangle(
  x: Int,
  y: Int,
  val width: Int,
  val height: Int
) extends Shape(x, y) {
  override def area: Double = ...
  override def path: Path = ...
}

この場合、あなたは Rectangle クラスのインスタンスは Shape 型あるいは Drawable 型を通じて使用されることを表明しています。したがって、描画ツールを実装するプログラマは、以下のようなプログラムを記述します。

def draw(ds: List[Drawable]): Unit =
  ds.foreach { d => ... }

val r1 = new Rectangle(...)
val r2 = new Rectangle(...)
val r3 = new Rectangle(...)
val ds = List(r1, r2, r3)
draw(ds)

リスコフの置換原則違反

このように、オブジェクト指向言語が継承とサブタイピングによって提供する機能は、それぞれ再利用と抽象化メカニズムであり異なります。これらを混同し、継承とサブタイピングを同時に実現しようと試みると、多くの場合、リスコフの置換原則を違反する結果に繋がります。

継承の説明で用いたプログラムをもう一度扱います。ただし、今回のあなたは、継承に加えてサブタイピングも同時に考えていることとします。つまり、ColorRectangle クラスのインスタンスを Rectangle 型を通じて使用することを許可しようとしています。

class Rectangle(
  val x: Int,
  val y: Int,
  val width: Int,
  val height: Int
) {
  def area: Double = ...
  def path: Path = ...
}

class ColorRectangle(
  x: Int,
  y: Int,
  width: Int,
  height: Int,
  val color: Color
) extends Rectangle(x, y, width, height)

これらのクラスを値クラスとするために、あなたはこれらのクラスに同値性(等価性)判定を行うメソッドを追加することにしました。Scala で同値性判定を行うには、すべてのクラスが暗黙に継承している Any クラスで定義されている、equals メソッドをオーバライドする必要があります。

まず、Rectangle クラスにて equals メソッドをオーバライドしました。

class Rectangle(
  val x: Int,
  val y: Int,
  val width: Int,
  val height: Int
) {
  def area: Double = ...
  def path: Path = ...

  override def equals(that: Any): Boolean =
    that match {
      case that: Rectangle =>
        this.x == that.x &&
        this.y == that.y &&
        this.width == that.width &&
        this.height == that.height
      case _ => false
    }
}

Rectangle 型の同値性判定は非常に単純です。比較対象が Rectangle クラスのインスタンスであり各フィールドの値が同値である場合に同値、そうでなければ非同値であると判定するだけです。同様のマナーで、あなたは ColorRectangle クラスの equals メソッドもオーバライドしました。

class ColorRectangle(
  x: Int,
  y: Int,
  width: Int,
  height: Int,
  val color: Color
) extends Rectangle(x, y, width, height) {
  override def equals(that: Any): Boolean =
    that match {
      case that: ColorRectangle =>
        this.x == that.x &&
        this.y == that.y &&
        this.width == that.width &&
        this.height == that.height &&
        this.color == that.color
      case _ => false
    }
}

この実装は正しいでしょうか?

実は、サブタイピングを考える場合にはこの実装は正しくありません。同値関係に求められる性質である反射律および対称律、推移律のうち、対称律を満たしていないためです。具体的に見るために、Rectangle クラスのインスタンス r1 および ColorRectangle クラスのインスタンス r2 を以下のように生成します。

val r1 = new Rectangle(1, 1, 2, 3)
val r2 = new ColorRectangle(1, 1, 2, 3, Blue)

この場合、r1 == r2 は true と評価されるのに対し、r2 == r1 は false と評価されてしまいます。これは同時に、リスコフの置換原則というオブジェクト指向設計で重要視されている原則を違反していることも意味します。例えば、r1 をキーとしてマップに値を格納したとき、r2 をキーとして格納した値を取り出せない場合があります(実装によっては逆の場合もあります)。

これを回避するために、あなたは、比較対象が ColorRectangle クラスのインスタンスであったら color フィールドを比較し、Rectangle クラスのインスタンスであったら color フィールドを比較しないように実装しました。

class ColorRectangle(
  x: Int,
  y: Int,
  width: Int,
  height: Int,
  val color: Color
) extends Rectangle(x, y, width, height) {
  override def equals(that: Any): Boolean =
    that match {
      case that: ColorRectangle =>
        this.x == that.x &&
        this.y == that.y &&
        this.width == that.width &&
        this.height == that.height &&
        this.color == that.color
      case that: Rectangle =>
        this.x == that.x &&
        this.y == that.y &&
        this.width == that.width &&
        this.height == that.height
      case _ => false
    }
}

この実装であれば正しいでしょうか?

実は、この実装も正しくありません。今度は推移律を犠牲にしているためです。具体的に見るために、ColorRectangle クラスのインスタンス r1 および Rectangle クラスのインスタンス r2、ColorRectangle クラスのインスタンス r3 を以下のように生成します。

val r1 = new ColorRectangle(1, 1, 2, 3, Blue)
val r2 = new Rectangle(1, 1, 2, 3)
val r3 = new ColorRectangle(1, 1, 2, 3, Red)

この場合、r1 == r2 は true と評価され、r2 == r3 も true と評価されるのに対し、r1 == r3 は false と評価されてしまいます。では、どうすれば解決できるでしょうか?

残念なことに、効果的な解決方法はありません。もっとも、継承とサブタイピングを同時に実現しようとしたことが過ちです。つまり、Rectangle クラスのインスタンスと ColorRectangle クラスのインスタンスを区別して使用する必要があります。

しかし、あなたが意図していなくても、クライアントプログラムが間違えて Rectangle クラスのインスタンスと ColorRectangle クラスのインスタンスを混在させてしまうかもしれません。これをも回避したい場合には、継承という言語機能を放棄する必要があります。幸いなことに、オブジェクト指向言語が提供する再利用のための言語機能は継承だけではありません。同様の目的で、我々はコンポジション(委譲)という言語機能を使用することができ、これはより洗練された好ましい結果に導きます。

次章では、コンポジションを使用した解決方法を示します。

継承よりコンポジションを使用する

前章で扱った Rectangle クラスを再び参照します。

class Rectangle(
  val x: Int,
  val y: Int,
  val width: Int,
  val height: Int
) {
  def area: Double = ...
  def path: Path = ...
}

前章と同様に、色付きの矩形を扱う ColorRectangle クラスを自身で作成することにします。ただし、今回は Rectangle クラスを継承するのではなく、コンポジションを使用することでその機能を再利用します。

class ColorRectangle(
  val x: Int,
  val y: Int,
  val width: Int,
  val height: Int,
  val color: Color
) {
  val rectangle: Rectangle =
    new Rectangle(x, y, width, height)
  def area: Double = this.rectangle.area
  def path: Path = this.rectangle.path
}

このバージョンの ColorRectangle クラスのインスタンスは、Rectangle クラスのインスタンスをフィールドに持っています。これにより、area メソッドと path メソッドの実装は、Rectangle クラスの実装を再利用することに成功しています。

さらに、型階層上は Rectangle 型と ColorRectangle 型に関係がないため、クライアントプログラムがこれらを混在させることを防ぐことが可能となります。また、Rectangle 型として扱う必要に迫られた場合には、rectangle フィールドにアクセスすることで Rectangle 型のビューを取得することが可能です。したがって、このバージョンの ColorRectangle クラスの設計は、非常に洗練された設計と言えます。