Анализ и создание шаблонов дизайна с использованием концепций функционального программирования для вашего же блага.

Я много чего хотел написать за последние несколько месяцев. Я подумал, что разговор о шаблонах проектирования, сочетающих ООП и ФП, будет действительно отличным местом для начала.

В этом посте мы рассмотрим вариант шаблона Builder, широко известного из книги GoF, с использованием некоторых абстракций и шаблонов функционального программирования. Как вы могли догадаться, добавление FP приведет к более чистому, более декларативному и выразительному коду с одним важным преимуществом исходного решения: Код даже не будет компилироваться, если объект не будет правильно построен.

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

Предостережения паттерна Строителя

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

case class Food(ingredients: Seq[String])

Да, в этом определении, скорее всего, отсутствует несколько абстракций, но этого будет достаточно, чтобы определить, что, например, Пицца представляет собой комбинацию «тесто», некоторого типа «сыр» и хотя бы одну «начинку».

Предположим, у нас есть типичный конструктор, взятый из книги GoF для этого класса. Не знаю, как в вашем районе, но здесь мы называем людей, которые готовят еду, «поварами». Скорее всего, наш шеф-повар будет выглядеть примерно так:

case class Chef {
    ...
  def build: Food =
    if (hasDoughCheeseAndToppings(ingredients)) Food(ingredients)
    else throw new FoodBuildingException("You tried to build a pizza without enough ingredients")
}

У этого подхода есть несколько недостатков, но я лично считаю, что худший из них заключается в том, что мы откладываем проверку конструктора до момента, когда он был создан непосредственно перед созданием de Pizza, то есть этот код не работает во время выполнения . Мы попытаемся увидеть, как мы можем применить некоторые концепции функционального программирования, чтобы разложить эту проблему и создать подход, который дает сбой во время компиляции.

Типы фантомов

Фантомный тип - это причудливое имя, которое мы даем типам, которые никогда не создаются приложением. Фактически, они просто используются компилятором для статической проверки свойств кода, но они никогда не влияют на код во время выполнения. Мне очень нравится определение, которое ребята из Scala используют в Dotty Docs:

Фантомный тип - это проявление абстрактного типа, которое не влияет на среду выполнения. Они полезны для подтверждения статических свойств кода с помощью свидетельств типа. Поскольку они не влияют на среду выполнения, компилятор может удалить их из результирующего кода, как только он покажет, что ограничения выполняются.

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

… Подождите, что?

Видите ли, мы привыкли слишком быстро попадать в проблему «Композиция вместо наследования», когда пытаемся смоделировать сущности, у которых есть состояния, определяющие их поведение, и мы по умолчанию используем композицию, чтобы избежать «использования наш единственный выстрел по наследству ». Но есть случаи, когда ни композиция, ни наследование полностью не решают наши проблемы. Рассмотрим следующий пример:

trait DoorState
case class Open() extends DoorState
case class Closed() extends DoorState
case class Door(state: DoorState) {
  def open = state match {
    case _: Open => throw new DoorStateException("You cannot open a door thats already open")
    case _ => Door(Open())
  }
def close = state match {
    case _: Closed => throw new DoorStateException("You cannot close a door thats already closed")
    case _ => Door(Closed())
  }
}

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

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

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

У вас есть случай Структурного состояния, когда объект меняет свою структуру (внутренний состав или свойства) в зависимости от данного состояния.

С другой стороны, есть некоторые объекты, которые зависят от состояния, чтобы делегировать ему поведение, например, когда банковский счет делегирует своему типу счета расчет процентной ставки. Я называю это поведенческим состоянием.

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

Как вы можете видеть в нашем примере Door, мы решили составить объект со статусом Closed или Open. Как оказалось, Состав - это шаблон Поведенческое состояние, в результате которого возникают проблемы, которые мы обсуждали ранее.

«Конечно! Вместо этого используйте наследование! » Как оказалось, наследование действительно является шаблоном Структурное состояние, поскольку оно определяет структуру, которая может изменять структуру объекта. Мы даже можем определить наши OpenDoor и CloseDoor таким образом, чтобы они могли переходить только в правильные состояния, без необходимости генерировать исключение. Но не так ли быстро! Здесь есть серьезное предостережение: Мы используем здесь нашу серебряную пулю, определяя наследование.

Тут на помощь приходят фантомные типы. Типы Pantom невероятно полезны, когда к нам приходят простые проблемы структурного состояния. Как вы помните, это типы , которые имеют значение только во время компиляции и определяют структуру объекта. Давайте посмотрим, чего мы можем достичь на нашем примере двери, сначала определив типы структурного состояния:

sealed trait DoorState
sealed trait Open extends DoorState
sealed trait Closed extends DoorState

Здесь мы определяем фантомные типы. Есть много способов добиться этого в Scala, и это довольно упрощенный способ: мы просто запечатываем типы, чтобы избежать их дальнейшего расширения.

Затем есть реализация класса Door:

case class Door[State <: DoorState](){
  def open(implicit ev: State =:= Closed) = Door[Open]()
  def close(implicit ev: State =:= Open) = Door[Closed]()
}

Выглядит хорошо, не правда ли? Давайте его немного расшифруем.

case class Door[State <: DoorState] получает параметрический тип State, который должен быть подтипом DoorState. Это ограничивает Структурное состояние двери как Открытое или закрыто.

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

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

implicit val context: Context = ...
def methodThatRequiresContext(str: String)(implicit context: Context) = ...

methodThatRequiresContext("foo")

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

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

def open(implicit ev: State =:= Closed) = Door[Open]()

Здесь мы просим компилятор «проверить наличие отношения равенства типов между параметром типа двери State и Closed», и «предоставить свидетельство этого». Что такое этот объект ev для? Ничего, но если компилятор может решить эту проблему, это означает, что состояние двери закрыто.

Интересно заметить, что происходит, если состояние двери не закрыто. Посмотрите, что показывает компилятор scala, когда мы пытаемся открыть уже открытую дверь:

scala> Door[Open]().open
<console>:17: error: Cannot prove that Open =:= Closed.

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

Удивительно, но нам удалось удалить условие if, которое проверяло во время выполнения структурное состояние первой двери, которую мы строим, путем делегирования проверки компилятору.

Мы удалили если. Мы спасли котенка.

Типы фантомов и паттерн-строитель

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

Помните создателя пиццы, которого мы определили ранее? Вернемся к этому. Напомним, мы определили, что пицца - это еда, в которой есть тесто, сыр и некоторые начинки, верно? Давайте начнем с попытки определить структурное состояние строителя.

Основная операция любого строителя - build, которая создает новый экземпляр данной сущности. Поведение этой операции очень похоже на поведение нашего Door примера: построитель может быть готов или нет (закрыт или открыт) для создания этого объекта.

Наш шеф-повар готов приготовить пиццу тогда и только тогда, когда у него есть ингредиенты, содержащие тесто, сыр и начинку. Если повару не хватает любого из этих элементов, он не сможет приготовить пиццу. Это можно увидеть как следующие фантомные типы:

object Chef {

  sealed trait Pizza
  object Pizza {
    sealed trait EmptyPizza extends Pizza
    sealed trait Cheese extends Pizza
    sealed trait Topping extends Pizza
    sealed trait Dough extends Pizza

    type FullPizza = EmptyPizza with Cheese with Topping with Dough
  }
}

Здесь мы определяем Pizza, который является супертипом, содержащим все элементы, и саму no-pizza (помните, что любой набор по определению содержит пустой набор). Тогда готовая к изготовлению пицца - это пустая пицца с сыром, начинкой и тестом.

Посмотрите, как определяется FullPizza? Scala позволяет нам определять псевдоним типа, который является объединением нескольких других типов. Это сделает наш код намного чище.

Теперь перейдем к реализации конструктора:

class Chef[Pizza <: Chef.Pizza](ingredients: Seq[String] = Seq()) {
  import Chef.Pizza._

  def addCheese(cheeseType: String): Chef[Pizza with Cheese] = new Chef(ingredients :+ cheeseType)

  def addTopping(toppingType: String): Chef[Pizza with Topping] = new Chef(ingredients :+ toppingType)

  def addDough: Chef[Pizza with Dough] = new Chef(ingredients :+ "dough")

  def build(implicit ev: Pizza =:= FullPizza): Food = Food(ingredients)
}

Здесь следует отметить два момента. Во-первых, добавление ингредиентов выполняется путем создания нового конструктора с параметрическим типом, дополненным ингредиентом. Затем мы создаем нового повара, у которого есть предыдущие ингредиенты с добавленными. Как видите, я определил возвращаемый тип каждой функции как Chef[Pizza with Ingredient], где Ingredient - это тип ингредиента, который я хотел добавить в этот микс. Это позволяет нам расширить параметрический тип Пицца заданным ингредиентом.

Во-вторых, мы используем то, что узнали из примера Door, как запросить у компилятора функции build свидетельство того, что Pizza действительно является FullPizza, иначе она не будет компилироваться:

scala> new Chef[Chef.Pizza.EmptyPizza]().addDough.build
<console>:18: error: Cannot prove that Chef.Pizza.EmptyPizza with Chef.Pizza.Dough =:= Chef.Pizza.FullPizza.

Но если добавить все ингредиенты:

scala> new Chef[Chef.Pizza.EmptyPizza]()
        .addCheese("mozzarella")
        .addDough
        .addTopping("olives")
        .build
res1: Food = Food(List(mozzarella, dough, olives))

Наконец-то! Нам удалось создать код построителя, который не только дает сбой во время компиляции, но и проверяется строго типизированным кодом. Одна интересная концепция, о которой я не хотел бы отказываться от упоминания, хотя я не буду слишком распространяться об этом, - это то, как Неизменяемость помогла нам в этом процессе. Конечно, это во многом связано с тем фактом, что мы используем концепцию функционального программирования для моделирования нашего решения. Не существует шаблона фантомных типов без создания новых экземпляров конструктора.

Вы можете увидеть полную версию этого кода здесь.

Заключение

Мы увидели, как изменить довольно стандартизированный шаблон Builder таким образом, чтобы он позволял нам выполнять проверку структуры наших сущностей во время компиляции. Мы достигли этого путем деконструкции структурного состояния Builder и использования фантомных типов для моделирования решения.

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

Я знаю, что может быть немного сложно расширить это решение для типичного ООП с небольшим или нулевым опытом использования FP и типов, но, пожалуйста, поверьте, что этот шаблон можно расширить многими способами.

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

Надеюсь, вам понравилось.