Введение в Scala.

С нуля до распределенных приложений.

Обобщенные типы. Вариантность

Создатель языка Скала, Мартин Одерски, был создателем обобщенных классов в языке Java. Не удивительно, что этот механизм усилен и улучшен в Скала.

Что такое обобщенные типы? Это обобщение функциональности типа относительно некоторого другого. Это очень абстрактное объяснение, которое проще пояснить на примере.

Допустим, мы моделируем некоторый интерпретатор и нам нужен класс объединяющий переменную с ее именем:

case class NamedInt(name: String, get: Int)

Если мы захотим создать классы “именованная строка” или “именованное число с плавающей точкой”, то нам придется создавать для этого отдельные классы NamedString и NamedDouble.

Обобщенные типы

Для упрощения этого механизма используются обобщенные типы:

case class Named[T](name: String, get: T)
val namedDouble = Named("y", "1.5")
val namedString = Named("name", "some string")

Ковариантность

Представим следующую иерархию классов животного мира:

abstract class Animal(val tongue: String) {
  val age: Int
  def isAlive: Boolean
}

case class Dog(age: Int) extends Animal("bark") {
  def isAlive = age < 13
}

case class Cat(age: Int, diet: String) extends Animal("meow") {
  def isAlive = age < 15
}

object ShroedingersCat extends Cat(0, "neutrino") {
  val rng = new Random()
  override def isAlive = rng.nextDouble() < 0.5
}

И функцию, которая заставляет именованное животное говорить (без насилия):

def speak(na: Named[Animal]): Unit = {
  val animal = na.get
  if (animal.isAlive)
    println(s"${na.name}: ${animal.tongue}")
}

Код приведенный выше вызывает ошибку компиляции, которая гласит, что class Named is invariant in type T.

Это значит, что мы не можем передавать Named[Cat] в Named[Animal], т.к. класс Named не подчиняется иерархии наследования его аргументов, т.е. он инвариантен относительно своего аргумента.

В данном случае мы можем это исправить добавив один символ +:

case class Named[+T](name: String, get: T)

Теперь класс Named подчиняется той же иерархии наследования, что и его аргумент и Named[Cat] будет подклассом Named[Animal].

Неизменяемые объекты

Есть много неочевидных случаев, когда делать тип ковариантным приводит к ошибкам. В частности, ковариантные типы должны быть неизменямыми. В языке Скала это предотвращается на этапе компиляции.

В C#, например, тип массива объектов ковариантен, т.е. Dog[ ] является производным от Animal[ ] и это ведет к возможности следующих ошибок:

void replaceCat(Animal[] cats, firstCat: Cat) {
  animals[0] = cat;
}
Dog[] dogs = ...;
replaceCat(dogs); // dogs are animals
dogs[0].bark(); // BOOM! KotopesDoesntExistException

Контравариантность

Это противоположность ковариантности, тип имеет иерархию наследования обратную его аргументам. Для чего это может быть нужно?

Введем класс ветеринара, который будет лечить наших животных.

class Vet[A]

def treatDogs(vet: Vet[Dog]) {}

val commonVet = new Vet[Animal]()

treatDogs(commonVet) // error: class Vet is not contravariant in type A

Казалось бы, животный ветеринар должен уметь лечить и собак, т.к. они подкласс всех животных, то есть нам нужна обратная иерархия наследования. Это достигается добавлением - к шаблонному аргументу относительно которого класс должен быть контравариантным:

class Vet[-A]
// ...
treatDogs(commonVet) // all fine

Другие примеры

Класс может быть одновременно ковариантен к одним типам и контравариантен к другим.

abstract class Function[-T, +R] {
  def apply(arg: T): R
}

Хорошей визуализацией понятий вариантности является следующая картинка: My helpful screenshot