company_banner

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(object1: T, object2 T): Int
}

class PersonNameComparator extends Comparator[Person] {
  def compare(onePerson: Person, anotherPerson: Person): 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(onePerson: Person, anotherPerson: Person): 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.

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

Comments 18

    0
    механизм языка интересный, а вот «пример» и «мотивация» использования (на примере той же сортировки и компаратора — «решение» заменить явную связь неявной ради экономии 1 аргумента при вызове) пока не убеждает (за восторженным текстом видно скорей желание внедрить чего-нибудь ещё нового, чем решить реальную проблему)

    «И ещё нужно помнить все эти прикольные названия для в общем-то одной типовой задачи.» — обычная софистика. Понв глубокий смысл принципов SOLID отпадает необходимость помнить и знать эти «прикольные» названия.
    А вот создание неявных зависимостей в коде — как раз потребует или помнить или каждый раз читать от начала до конца код, когда создаешь или меняешь implict объект.
      0
      Это экономит не только аргумент в вызове, но и избавляет от необходимости создавать и поддерживать жизненый цикл таких вспомогательных объектов. А явные связи мешают модуляризировать приложения. По поводу реальной проблемы, есть пример с обёрткой исключений, в собственном проекте тоже понадобились тайпклассы. Желание внедрять новое не всегда предосудительно.

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

      Очень рекомендую видео, которое добавил в конце статьи. Там приводится развитие идеи, при котором всё ещё больше встаёт на свои места.

      +1
      Интересно почему это называется Type classes, если это всего лишь обычные Implicit parameters?
        0
        “Another thing to know about implicit parameters is that they are perhaps most often used to provide information about a type mentioned explicitly in an earlier parameter list, similar to the type classes of Haskell”

        Типа частный случай implicit параметров, которые предоставляют расширенную информацию о предыдущих аргументах функции. Если я правильно понял)
          +1
          Как раз для ответа на этот вопрос в конце статьи есть ссылка на видео. Там суть в том, что все классы для которых реализованы определённого рода адаптеры образуют некое множество классов с определённым свойством (сравнимое, выражение, число, сериализуемое в json), всё это множество, как я понимаю, и называется type class. Алгоритм объявляется для любого класса из этого множества (по сути надтипа).
            0
            Implicit parameters — это способ реализации.
            Type classes — это способ объединить общим интерфейсом несвязанные между собой типы, ни чего про этот интерфейс не знающие.
            0
            Подскажите, это из этой темы, что

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

            можно записать просто в виде:

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


            В последнее время часто встречаю вторую конструкцию, но как-то провтыкал, когда она появилась. Правильно ли я понял её назначение?
              0
              Да, да, из этой. Только во втором случае, чтобы таки получить comparator придётся вызвать implicitly[Comparator[T]].
                0
                Хм, если нужно вызывать implicitly[Comparator[T]], то зачем объявлять [T : Comparator]?
                  0
                  [T: Comparator] означает что Т подтип Comparator. А в данном примере это не так. Поэтому такой код не корректен. Если вы понимаете о чем я:)
                    0
                    ан нет. это я не понимаю о чем я). сорри
                    перепутал [T : Comparator] с [T <: Comparator]
                    0
                    Вот тут хорошо о том, когда следует применять context bounds, а когда целиком implicit parameters
                    stackoverflow.com/a/2982293

                      0
                      Одно дело интерфейс, а другое — реализация. Если в определении не будет требования на нахождение в тайпклассе Comparator, то, если метод, например, абстрактный, на этапе компилляции нельзя будет проверить наличие необходимой реализации компаратора для объекта.
                      Если писать только в сигнатуре метода, то неясно к чему же обращаться для сравнения.

                      В общем одно дело — интерфейс, простой и понятный. Другое — реализация, которая может быть немного хитрой, учитывая, что разработчики библиотек чуть более искушённые люди, чем писатели прикладного кода.
                      Тут упростили интерфейс за счёт усложнения реализациии.
                        0
                        А, всё, понял. Просто я видел примеры, где доступ к неявному аргументу не нужен, он просто пробрасывался другой функции. В случае же, когда нам нужен этот аргумент, мы вызываем impicitly[...]. Теперь ясно.
                  0
                  def compare(Person onePerson, Person anotherPerson): Int

                  Чувствуется java бэкграунд :). Так в скале не пропрет.
                  Видимо имелось в виду

                  def compare(onePerson: Person, anotherPerson: Person): Int
                    0
                    Есть такое, у нас joint проект: пишем и на Java, и на Scala, перепутал :) Сейчас уже правильно.
                      0
                      а я параллельно на CoffeeScript много пишу, так постоянно путаю где нужно точки при вызове метода ставить, а где нет :) и скобочки еще там

                      тогда уж и вот тут:
                      implicit val personNameComparator = Comparator[Person] {

                      наверное нужно new поставить:
                      implicit val personNameComparator = new Comparator[Person] {
                        0
                        Я начинаю, кажется понимать Rúnar'а Óli, который если такое встречается, говорит: «Упс, ну это кейс-класс» или «А да, ну у меня там куча неявных преобразований...» :)

                    Only users with full accounts can post comments. Log in, please.