Type classes в Scala

в 13:45, , рубрики: factory, implicit, scala, Блог компании Тинькофф Кредитные Системы, метки: , ,

Type classes в Scala
В последнее время в сообществе Scala-разработчиков стали уделять всё большее внимание шаблону проектирования Type classes. Он помогает бороться с лишними зависимостями и в то же время делать код чище. Ниже на примерах я покажу, как его применять и какие у такого подхода есть преимущества.

Статься расчитана не только на программистов, пишущих на Scala, но и на Java — возможно, они получат для себя ответ, как, хотя бы в теории, выглядит решение для многих прикладных задач, в котором компоненты не связаны между собой и расширяемы уже после написания. Также это может быть интересно разработчикам и проектировщикам на любых других языках.

Предпосылки

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

Для решения этих задач в Java-мире существует несколько методов:

  • написать интерфейс и имплементировать его в самом классе (например, Comparable),
  • написать отдельный интерфейс, специфичный экземпляр которого будет получать объект класса и проводить необходимые действия (например, Comparator);
  • для отдельных задач даже есть специальные методы:
    • для создания объектов — шаблон Factory,
    • для связки со сторонними библиотеками используется Adapter,
    • для сериализации и чтения иногда используют Reflection. Это напоминает вскрытие живота из недавнего поста, но операции на животе тоже иногда нужны, сразу оговорюсь, Type classes не смогут его заменить.

Итак, по сути мы имеем сейчас две альтернативы: или жёстко связывать сторонний функционал с классом или выносить функционал в отдельный объект и потом заботиться о его передаче. В качестве примера возьмём пользователей приложения. Так может выглядеть добавление возможности сравнивать наших пользователей:

// Определение метода, которому нужно сравнение
def sort[T <: Comparable[T]](elements: Seq[T]): Seq[T]

// Определение класса
class Person(val id: Id[Person], val name: String) extends Comparable[Person] {

  def compareTo(Person anotherPerson): Int = {
    return this.name.compareTo(anotherPerson.name)
  }
}

// Использование
sort(persons)

Здесь хорошо, что конкретное применение сортировки выглядит очень ясно. Но логика сравнения жёстко связана с классом (а наверно никто не любит, что объекты модели зависят, например, от библиотеки формирующей XML и пишущей в БД). Кроме того появляется ещё одна проблема: нельзя определить больше одного способа сравнения — то есть если завтра мы захотим сравнивать пользователей по id в другом месте программы, ничего у нас не получится, переписать метод сравнения для закрытых классов также не удастся.

В Java для этой цели есть класс Comparator, он позволяет получить большую гибкость:

// Определение метода, которому нужно сравнение
def sort[T](elements: Seq[T], comparator: Comparator[T]): Seq[T]

// Определение класса
class Person(val id: Id[Person], val name: String)

trait Comparator[T] {
  def compare(T object1, T object2): Int
}

class PersonNameComparator extends Comparator[Person] {
  def compare(Person onePerson, Person anotherPerson): Int = {
    return onePerson.name.compareTo(anotherPerson.name)
  }
}

// Использование
val nameComparator = new PersonNameComparator()
sort(persons, nameComparator)

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

Использование фабрик и адаптеров подразумевает аналогичное управление жизненным циклом и передачей их экземпляров. И ещё нужно помнить все эти прикольные названия для в общем-то одной типовой задачи.

И тут появляются Type classes

Вот тут на помощь приходит возможность Scala неявно (implicit) передавать параметры. Давайте возьмём за основу наш предыдущий пример, но определим алгоритм иначе, будем передавать Comparator неявно:

def sort[T](elements: Seq[T])(implicit comparator: Comparator[T]): Seq[T]

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

implicit val personNameComparator = Comparator[Person] {
  def compare(Person onePerson, Person anotherPerson): Int = {
    return onePerson.name.compareTo(anotherPerson.name)
  }
}

Ключевое слово implicit отвечает за то, что значение будет использоваться при подстановке. Важно отметить, что наши неявные реализации должны быть stateless, поскольку в процессе работы программы создаётся всего один экземпляр каждого типа.

Теперь сортировку можно вызывать также, как это было в изначальном варианте с реализацией Comparable:

// Вызываем сортировку
sort(persons)

И это притом, что сам способ сравнения никак не связан с объектом модели. Мы можем помещать в область видимости любые способы сравнения и они будут использованы для наших объектов.

Чуть более интересный вариант возникает, когда хочется, чтобы параметр типа мог быть сам типизирован. То есть для Map[Id[Person],List[Permission]] мы хотим MapJsonSerializer, IdJsonSerializer, ListJsonSerializer и PermissionJsonSerializer, которые можно переиспользовать в любом порядке, а не PersonPermissionsMapJsonSerializer, аналоги которого мы будем писать каждый раз. В таком случае способ определения неявного объекта немного отличается, теперь у нас не объект, а функция:

implicit def ListComparator[V](implicit comparator: Comparator[V]) = new Comparator[List[V]] {
  def compare(oneList: List[V], anotherList: List[V]): Int = {
    for((one, another) <- oneList.zip(anotherList)) {
      val elementsCompared = comparator,compare(one, another)
      if(elementsCompared > 0) return 1
      else if(elementsCompared < 0) return -1
    }

    return 0
  }
}

Вот собственно и весь метод. Самая прелесть в том, что так можно получать всякие JSONParser’ы, XMLSerializer’ы вместе с PersonFactory, нигде не храня соответствия классов и объектов — компиллятор Scala всё сделает за нас.

В ТКС мы используем такой метод, например, чтобы оборачивать исключения в классы нашей модели. Type classes позволяют создавать экземлпяры исключений того типа, в который надо обернуть брошенное блоком. Если бы это делалось традиционным методом, пришлось бы создать и передавать фабрику исключений, так что проще было по старинке кидать исключения руками. Теперь всё красиво.

Что дальше?

На самом деле, тема Type classes тут не заканчивается и в качестве продолжения рекомендую видео Typeclasses in Scala.

Ещё более фундаментально вопрос изложен в этой статье.

Автор: vuspenskiy

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js