Type classes in Scala

Тайп класс – паттерн, пришедший из Haskell. В Scala тайп класс это трейт с хотя бы одним параметром типа. Этот параметр определяет конкретные типы, для которых определены экземпляры тайп класса.

Тайп классы позволяют расширить функционал существующих классов без использования наследования и не изменяя их код непосредственно.

А что плохого в наследовании? Например, мы определили некий трейт Greeting. Для каждого класса, реализующего данное поведение, придется унаследоваться от трейта:

trait Greeting {
  def greet: String
}

case class Person(name: String) extends Greeting[Person] {
  def greet: String = s"$name says: hello"
}

case class Cat(name: String) extends Greeting[Cat] {
  def greet: String = s"$name says: meow"
}

Если необходимо добавить поведение только собственным классам, такой способ вполне имеет правно на существование, но как быть, если нужно расширить функциональность класса, написанного не нами?

Можно попробовать написать так:

object Greeter {
  def greet(value: Any): String = value match {
    case Person(name, _) => s"$name says: hello"
    case Cat(name) => s"$name says: meow"
    case _ => ???
  }
}

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

Мы можем решить многие из перечисленных выше проблем следующим образом:

trait Greeter[A] {
  def greet(in: A): String
}

object PersonGreeter extends Greeter[Person] {
  def greet(person: Person) = s"${person.name} says: hello"
}

object CatGreeter extends Greeter[Cat] {
  def greet(cat: Cat) = s"${cat.name} says: meow"
}

Этот способ позволяет добавить нужное поведение чужим классам, а также различные варианты поведения для одного и того же класса. По сути трейт Greeter это тайп класс, а PersonGreeter и CatGreeter – экземпляры тайп класса.

_config.yml

Для классической реализации паттерна type class в Scala не хватает лишь одной важной детали –- implicits. Имплиситы позволяют использовать тайп классы с меньшим количеством boilerplate кода.

Type class interfaces

Type class interfaces – это обощенные методы, которые принимают экземпляры тайп классов в качестве неявных (implicit) параметров. Это можно реализовать с помощью interface objects и interface syntax:

Interface object:

Чтобы использовать такой объект, нужно импортировать нужный экземпляр тайп класса и вызвать соответствующий метод. Важно: экземпляр тайп класса должен быть объявлен как implicit.

object GreetUtil {
  def greet[A](someone: A)(implicit greeter: Greeter[A]): String =
    greeter.greet(someone)
}

object GreeterInstances {
  implicit val catGreeter: Greeter[Cat] = new Greeter[Cat] {
    def greet(cat: Cat): String = s"${cat.name} says: meow" 
  }
}

import GreeterInstances._
GreetUtil.greet(Cat("Simon"))

При вызове метода с неявными параметрами, компилятор попробует найти недостающие значения в implicit scope. Implicit scope состоит из значений соответствующего типа и отмеченных как неявные из локальной области видимости, импортов и объектов-компаньонов всех типов, упомянутых в вызове метода (в данном примере, Cat и Greeter). Если существует несколько инстансов максимального приоритета или же ни одного не найдено – будет ошибка компиляции.

Часто бывает удобнее использовать методы нужного экземпляра тайп класса непосредственно, не оборачивая каждый из них. Это достигается с помощью метода apply без параметров:

object GreetUtil {
  def apply[A](implicit greeter: Greeter[A]): Greeter[A] =
    greeter
}
GreetUtil[Cat].greet(Cat("Simon"))

Interface syntax

Другой способ использования тайп классов – interface syntax (extension methods, type enrichment, pimp-my-library pattern). Смысл в том, чтобы определить неявный класс-обертку над исходным классом с нужными методами, и импортировать этот класс в область видимости. Далее можно вызывать эти дополнительные методы у исходного класса без дополнительных усилий, как будто это его собственные методы. Всю необходимую работу сделает компилятор: обнаружив вызов метода, не определенного для класса, компилятор попробует найти в implicit scope класс, у которого есть искомый метод и который может быть сконструирован из значения исходного класса.

object GreeterSyntax {
  implicit class GreeterOps[A](value: A) {
    def greet(implicit g: Greeter[A]): String =
      g.greet(value)
  }
}

import GreeterSyntax._
Cat("Simon").greet

Context Bounds, implicitly

Часто, мы не используем экземпляры тайп классов непосредственно в теле метода, однако должны иметь их в области видимости, чтобы использовать в другом методе. В таких случаях для краткости используют context bounds. Вместо:

def greet[A](someone: A)(implicit greeter: Greeter[A]): String = ???

Пишем:

def greet[A: Greeter](someone: A): String = ???

Если все же нам необходимо обратиться к экземпляру тайп класса, можно использовать метод из стандарной библиотеки Scala – implicitly:

def implicitly[A](implicit value: A): A =
  value

Назначение implicitly довольно просто – он призывает (summons) значение указанного типа из implicit scope.

implicitly[Greeter[Cat]].greet(new Cat("Simon"))
Written on August 7, 2019