😺

Scalaのジェネリクスの基本を復習する

に公開

Scalaのジェネリクスの基本を復習する

ジェネリクスとは

/** 不変スタック */
trait Stack[A] {
  def push(a: A): Stack[A]
  def pop(): (A, Stack[A])
}

多分、ジェネリクスについて調べている時点で、このようなコードを見たことがあると思います。ジェネリクスとは、型パラメータ(このコードにおけるA)を使って、クラスやメソッドが扱うデータの型を一般化する仕組みです。
例示したStackトレイトは、pushメソッドでA型のデータをスタックに積むことができ、popメソッドでA型のデータを取り出すことができる、ということを表しています。また、戻り値でそれぞれStack[A]を返すことで、スタックの状態を変更せずに新しいスタックを生成する、ということも表しています。
型パラメータAは、具体的に動作するプログラムを組むときに、具体的な型(例えばIntString)に置き換えられます。 つまり、Stack[A]の実装を再利用して、数値や文字列、その他任意の型のデータをスタックに積むことができる、ということです。

ジェネリクスのような仕組みを使って、プログラムで利用する型を一般化してメソッドを定義し、異なる特化した型の実装に再利用できることをパラメータ多相性とも言います。

val intList: List[Int] = List(1, 2, 3)
// List[Int] は map というメソッドを持っている
intList.map(_ * 2) // List(2, 4, 6)
// List[String] も map というメソッドを持っている
// List[Int] の map も List[String] の map も実装は同じ(再利用している)
val stringList: List[String] = List("a", "b", "c")
stringList.map(_.toUpperCase) // List("A", "B", "C")

リスコフの置換原則

リスコフの置換原則は、オブジェクト指向プログラミングにおいて、派生型のオブジェクトは基底型のオブジェクトの仕様に従わなければならない、という原則です。 オブジェクト指向をサポートする静的型つきのプログラミング言語であれば、基底型と派生型の関係において、リスコフの置換原則が型システムを通じてある程度守られるように設計されています。

静的型付きのプログラミング言語は型システムを通じて、コンパイル時に型の整合性をチェックします。型Aを持つ変数には型Aとその派生型を代入できますが、その逆や異なる型を代入することはできません。このような型システムの働きは、プログラムがリスコフの置換原則に従うことをサポートします。

静的型付き言語において、型が意味を持つのは「その型に期待される動作が、どの実装でも満たされる」という前提があるからです。リスコフの置換原則に従うことで、その前提が保証されます。 逆に言うと、この原則が守られていないと「この型は本当に安全だろうか?」と常に疑ってコードを書く必要があります。


trait Vehicle {
  def drive: Unit
}

class Car extends Vehicle { def drive: Unit = {...} }

// こういう代入ができないのはとても不便
val someVehicle: Vehicle = new Car
// 実はCarにはdriveが定義されていなくて乗れないことがある、とすると・・・?
someVehicle.drive()

変位指定(非変・共変・反変)

非変

継承関係を持つ型同士の代入においては、宣言された型とその派生型しか代入できない、という縛りは不自然に感じることはあまりないでしょう。

trait Animal
class Dog extends Animal
class Cat extends Animal

val dog: Dog = new Dog
val cat: Cat = new Cat
val animal: Animal = dog // OK
val secondAnimal: Animal = cat // これもOK

一方で、型パラメータを受け取る型の場合、特に断りがない場合は型パラメータで指定した型のみを受け入れる(派生型でも拒否される)、という挙動がScalaではデフォルトです。これを非変と言います。

class Container[A]
val container: Container[Dog] = new Container[Dog]
val secondContainer: Container[Animal] = container // コンパイルエラー

直感的には動物の入れ物に犬が入っていても特に問題はないので、このような挙動は不自然に感じるかもしれません。しかし、以下のようなケースを考えてみましょう。

1. 型パラメータを受け取る型が可変な場合

class Container[A](var value: A)

val container: Container[Dog] = new Container[Dog](new Dog)
val container2: Container[Animal] = container // 実際にはエラーになるが仮にOKだとすると…
val cat: Cat = new Cat
container2.value = cat // Container[Dog]であるcontainerにCat型の値が入ってしまい、型安全性が壊れる

型パラメータを受け取る型が可変なフィールドを持つ場合、派生関係のある型パラメータを持つコンテナの代入が可能だと、可変なフィールドに対して派生関係のない型の値の代入が許可されてしまって、型安全性が損なわれる可能性があります。上記の例では、Container[Dog]Container[Animal] の派生型とみなして Container[Animal] の仕様に従って Container[Dog] を操作できるとこうした問題が発生します。この様な場合は、型パラメータを持つ型同士での派生関係を認めないようにする方が安全なので、非変を指定することが適切です。

2. メソッドの引数として型パラメータを受け取る場合

以下のように、メソッドの引数に型パラメータで指定した型の値を受け取る場合も、基底型で宣言した変数に派生型の値を代入することができてしまうと、型安全性が損なわれる可能性があります。

trait Container[A] {
  def put(a: A): Unit
}

class DogContainer extends Container[Dog] {
  override def put(a: Dog): Unit = println("Dog is put")
}

val dogContainer: Container[Dog] = new DogContainer
val animalContainer: Container[Animal] = container // 実際にはエラーになるが仮にOKだとすると…
container.put(new Cat) // Container[Dog]であるcontainerにCat型の値が入ってしまい、型安全性が壊れる

3. メソッドの戻り値として型パラメータを返す場合

2.の例はContainer[Animal]で宣言された変数にContainer[Dog]のインスタンスを代入できることによってContainer[Dog]の仕様が破壊されてしまう、というものでした。逆にContainer[Dog]の変数にContainer[Animal]の変数を代入できる場合は、putを通じてContainer[Animal]の仕様が破壊されることはありません。

trait Container[A] {
  def put(a: A): Unit
}

class AnimalContainer extends Container[Animal] {
  override def put(a: Dog): Unit = println("Animal is put")
}

val animalContainer: Container[Animal] = new AnimalContainer
val dogContainer: Container[Dog] = animalContainer // コンパイルエラーだが、一旦コンパイルが通るものとする
dogContainer.put(new Cat) // 仮に許可されていたとしても、実体であるAnimalContainerの仕様は壊れない

しかし、ContainerがA型の値を返すメソッドを持つ場合はContainer[Dog]の変数へのContainer[Animal]の代入が問題になります。

trait Container[A] {
  def get: A
}

class AnimalContainer extends Container[Animal] {
  override def get: Animal = new Cat
}

val animalContainer: Container[Animal] = new AnimalContainer
val dogContainer: Container[Dog] = animalContainer // 実際にはエラーになるが仮にOKだとすると…
val dog: Dog = dogContainer.get // Container[Dog]の仕様に従うならばDog型の値が返ってくるべきだが、Cat型の値が返ってくる

1.の例は、2.と3.の例を組み合わせて説明することも可能でしょう。

これらの問題は、Container[Animal]の変数にContainer[Dog]の値を指定できたり、Container[Dog]の変数にContainer[Animal]の値を指定できたりすることによって引き起こされています。逆に言うと、そのような代入が禁止されていれば、これらの問題は発生しません。
コードのコメントにも書いていますが、これらの代入はScalaのデフォルトの挙動では許可されていません。

要するに、型パラメータを非変にすることで、一般化した型(ここでいうContainer[A])の代入には制約がかかりますが、その代わりに型安全性が破壊されるような問題を回避することができます。

共変

型パラメータを受け取る型が、その型パラメータの派生型を受け入れる場合、共変と言います。Scalaでは、型パラメータの前に+を付けることで共変を指定できます。

trait JuiceServer[+A] {
  def get: A
}

class OrangeJuiceServer extends Cage[OrangeJuice] {
  def get: OrangeJuice = new OrangeJuice
}

val orangeJuiceServer: JuiceServer[OrangeJuice] = new OrangeJuiceServer
val juiceServer: JuiceServer[Juice] = orangeJuiceServer // OK
val appleJuiceServer: JuiceServer[AppleJuice] = orangeJuiceServer // これはNG
val juice: Juice = orangeJuiceServer.get // ジュースが欲しい、に対してオレンジジュースを返すのはOK

共変の場合、基底型の型パラメータで宣言した変数に対して、派生型の型パラメータで宣言した値を代入することができます。また、非変の場合と違って、型パラメータで指定した型の値を返すメソッドを定義しても型安全性が破壊されることがないので、これは許可されています。 つまり共変の型パラメータを受け取る型は値の生産者としての振る舞いを持つことができます
ただし、派生型を型パラメータに指定したインスタンス(ここではJuiceServer[Juice])の仕様が破られてしまわないようにメソッドの引数や可変なフィールドに型パラメータで指定した値を受け取ることはできません。

// これはコンパイルエラー
// valueやsetを通じて、安全ではない操作が可能
// 例 (Juice型を迂回してオレンジジュースの入れ物にリンゴジュースを入れられる
abstract class JuiceServer[+A](var value: A) {
  def get: A
  def set(value: A): Unit
}

val orangeJuiceServer: JuiceServer[OrangeJuice] = new OrangeJuiceServer
val juiceServer: JuiceServer[Juice] = orangeJuiceServer // OK
juiceServer.value = new Apple // NG。Orange型にAppleを代入
juiceServer.set(new Apple) // NG。Orange型にAppleを代入

反変

型パラメータを受け取る型が、その型パラメータの基底型を受け入れる場合、反変と言います。Scalaでは、型パラメータの前に-を付けることで反変を指定できます。

class Container[-A]

val container: Container[Animal] = new Container[Animal]
val catContainer: Container[Cat] = container // これがOK

上記の例では、CatAnimalの派生型ですが、Container[Animal]Container[Cat]の派生型とみなされます。要するに共変の場合のルールとは逆なのですが、型パラメータの型の関係が逆転しているのがわかりづらいかもしれません。
共変な型パラメータを受け取る型の役割は値の生産者でしたが、反変な型パラメータを受け取る型の役割は値の消費者です。役割に着目して例を考えてみましょう。

// 調教師
class Trainer[-A] {
  def train(value: A): Unit = ()
}
// 動物の調教師は犬も猫も訓練できる
val animalTrainer: Trainer[Animal] = new Trainer[Animal]
animalTrainer.train(new Dog)
animalTrainer.train(new Cat)
// 動物の調教師は犬の調教師としても猫の調教師としても振る舞える
val dogTrainer: Trainer[Dog] = animalTrainer
dogTrainer.train(new Dog)
val catTrainer: Trainer[Cat] = animalTrainer
animalTrainer.train(new Cat)
// 犬に特化した調教師が動物の調教師や猫の調教師になったりするのはコンパイルエラー
val specificDogTrainer: Trainer[Dog] = new Trainer[Dog]
val animalTrainer2: Trainer[Animal]  = specificDogTrainer // NG。コンパイルエラー
val catTrainer2: Trainer[Cat]

反変の型パラメータを指定した場合、共変の時とは逆に、値の生産者として振る舞おうとすると型安全性が脅かされます。

trait Container[-A] {
  def get: A // これはNG
  def set(value: A): Unit // これはOK
}

class AnimalContainer extends Container[Animal] {
  def get: Animal = new Animal
  def set(value: Animal): Unit = println("Animal is set")
}

val container: Container[Animal] = new AnimalContainer
val dogContainer: Container[Dog] = container // 反変なのでOK
val dog: Dog = new Dog
container.set(new Cat)
val dog2: Dog = dogContainer.get // NG。実体としては猫が入っているのに犬を取り出そうとしてしまう

境界指定(上限境界と下限境界)

型パラメータは型の上限と下限をそれぞれ指定できます。

  • 上限境界

型パラメータACarの派生クラスであることはA <: Carのように表します。

def drive[A <: Car](vehicle: A) = { ... }
  • 下限境界

型パラメータABeefの基底クラスであることはA >: Beefのように表します。

def eat[A >: Beef](target: A) = { ... }
  • 上限境界と下限境界

型パラメータADogの基底クラスであり、Vertebrata(脊椎動物)の派生クラスでもあることはA >: Dog <: Vertebrataのように表します。

共変の変異指定と下限境界

先ほど少し触れましたが、共変の型パラメータを持つ時、共変の型パラメータとその派生型の型パラメータの型を引数に受け取るメソッドは型安全性を壊してしまう可能性があるので、定義することができません。

trait JuiceServer[+A] {
  def get: A
  /** これはNG */
  def set1(value: A): Unit
  /** これもNG */
  def set2[B <: A](value: B): Unit
}

val orangeJuiceServer: JuiceServer[OrangeJuice] = new OrangeJuiceServer
val juiceServer: JuiceServer[Juice] = orangeJuiceServer // 共変の場合、こういう代入が許される
juiceServer.set(new AppleJuice)// こういう操作ができると困る!!!

しかし、JuiceServer の例だとJuiceServersetを通じてOrangeJuiceAppleJuiceも受け取れない、というのも不便そうです。
トレイトの型パラメータとは直接関係のない型パラメータを受け取るメソッドを定義することはできるので、そこを出発点にJuiceServerの設計を洗練させてみましょう。

trait JuiceServer[+A] {
  def get: A
  def set[B](value: B): Unit
}

set(value: A)の型安全性が破壊されてしまう理由は、setAの派生型の実装に依存している可能性があるためです。AもしくはAの基底型に依存しているなら、この問題は発生しません。型パラメータB下限境界Aを指定することで、そのような制約を課すことができます。

trait JuiceServer[+A] {
  def get: A
  // B >: A は、BがA自身とAの基底型だけ受け取れるという意味(Bの下限境界がA)
  def set[B >: A](value: B): Unit
}

JuiceServerに何でも入れられるのも変なので、ここではさらにAの上限境界にJuiceを指定します。

// A <: Juiceは、AがJuiceの派生型だけ受け取れるという意味(Aの上限境界がJuice)
trait JuiceServer[+A <: Juice] {
  def get: A
  def set[B >: A](value: B): Unit // AがOrangeJuiceやAppleJuiceだったとしても上限境界の基底型であるJuiceの実装にしか依存できない
}

class OrangeJuiceServer extends JuiceServer[OrangeJuice]

val juiceServer: JuiceServer[Juice] = new OrangeJuiceServer
juiceServer.set(new AppleJuice())// OrangeJuiceServerのsetもJuice型を受け取る前提のメソッドなので型安全性は壊れない

ここでのsetのようなメソッドはAの上限境界の型にしか依存できないので、大抵の場合は基底クラスに実装を用意してあげるのが良いでしょう。Scalaの共変な型パラメータを標準コレクションの実装もそのようになっています。

sealed abstract class List[+A] {
  def ::[B >: A](x: B): List[B] = new ::(x, this)
}

final case class :: [+A](head: A, next: List[A]) extends List[A] { ... }

非変の変異指定と上限境界

非変の型パラメータを指定してした場合は、型パラメータの型とその基底型の型を返すメソッドを定義することはできません。

// 調教師
class Trainer[-A] {
  def train(value: A): Unit = ()
  
  /** これはNG */
  def get1: A = { ... }
  /** これもNG */
  def get2[B >: A]: B = { ... }
}

// 生物の調教師は犬も猫も訓練できる
val creatureTrainer: Trainer[Creature] = new Trainer[Creature]
creatureTrainer.train(new Dog)
creatureTrainer.train(new Cat)
// 生物の調教師は犬の調教師としても猫の調教師としても振る舞える
val dogTrainer: Trainer[Dog] = creatureTrainer

val dog: Dog = dogTrainer.get1 // creatureTrainerのget1はCreature型しか返せないのでNG
val vertebrata: Vertebrata = dogTrainer.get2[Vertebrata] // Dogから見たら基底型だが、Creatureから見たら派生型なのでNG

非変の型パラメータを持ち、上限境界にその型パラメータを指定して派生型を返すメソッドの定義は可能です。

class Trainer[-A] {
  def train(value: A): Unit = ()
  def get[B <: A]: B = { ... }
}

val creatureTrainer: Trainer[Creature] = new Trainer[Creature]
val dog: Dog = dogTrainer.get // DogもCreatureの派生型なのでこれはOK

定義することは可能ですが、このようなgetを実装しようとすると、あらかじめAの派生型について全て生成方法も知っておかないと実装することができません。
実用的には値を生成するクラスのインスタンスを引数に渡すような形になるでしょう。

class Calculator[-A] {
  
  def calculate[B <: A](op: Operator[B], x: B, y: B): B = op.run(x, y)
  
}

trait Operator[A] {
  def run(x: A, y: A): A
}

object IntAdder extends Operator[Int] {
  def run(x: Int, y: Int): Int = x + y
}

object LongMultiplier extends Operator[Long] {
  def run(x: Long, y: Long): Long =  x * y
}

val valueCalculator = new Calculator[AnyVal]

val r1: Int = valueCalculator.calculate(IntAdder, 1, 1) // 2
val r2: Long = valueCalculator.calculate(LongMultiplier, 9L, 9L) // 81L

まとめ

  • ジェネリクスは型パラメータを使ってクラスやメソッドが扱うデータの型を一般化する仕組み
  • リスコフの置換原則は、オブジェクト指向プログラミングにおいて、派生型のオブジェクトは基底型のオブジェクトの仕様に従わなければならない、という原則
    • 型の関係においてこの原則が守られていないと型を信じてプログラムを書くことができない
    • 静的型付きのプログラミング言語は型システムを通じて、プログラムがリスコフの置換原則を守ることをサポートする
  • 非変(A)は型パラメータを通じての派生関係を認めないことで型安全性を保証する
  • 共変(+A)や反変(-A)は型パラメータを通じての派生関係を認めることで、型パラメータを持つ型を柔軟に扱うことができる
    • 共変な型パラメータを持つ型は、その型の値の生産者としての振る舞いを持つ
    • 反変な型パラメータを持つ型は、その型の値の消費者としての振る舞いを持つ
  • 型パラメータにはパラメータの基底型・派生型について制約をかけることができる
    • 型パラメータABの派生型に限定したい場合は、A <: B(Aの上限境界がB)
    • 型パラメータABの基底型に限定したい場合は、A >: B(Aの下限境界がB)
    • 型パラメータABの基底型かつCの派生型に限定したい場合は、A >: B <: C
  • 型安全性のため、共変・非変のパラメータを持つ型ではメソッドの定義に制約がある
    • 共変の場合、共変の型パラメータとその派生型の型パラメータの型を引数に受け取るメソッドは定義不可
      • +Aと書いた場合、Aを引数に受け取ったり、B <: ABを引数に受け取ったりすることはできない
      • B >: ABは可
    • 非変の場合、型パラメータの型とその基底型の型を返すメソッドは定義不可
      • -Aと書いた場合、Aを戻り値として返したり、B >: ABを戻り値として返すことはできない
      • B <: ABは可

参考資料

Discussion