Статьи

DSL с эндо — моноиды бесплатно


Когда мы разрабатываем модель предметной области, одной из проблем, которые нас интересуют, является абстракция реализации от API уровня пользователя.
Помимо упрощения опубликованного контракта, это также разъединяет реализацию и позволяет выполнять постфактумную оптимизацию без какого-либо влияния на API уровня пользователя.

Рассмотрим класс, подобный следующему …

// a sample task in a project
case class Task(name: String)
 
// a project with a list of tasks & dependencies amongst the
// various tasks
case class Project(name: String,
                   startDate: java.util.Date,
                   endDate: Option[java.util.Date] = None,
                   tasks: List[Task] = List(),
                   deps: List[(Task, Task)] = List())

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

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

В этой статье я расскажу о нескольких функциональных абстракциях, которые останутся позади пользовательских API, и в то же время обеспечат композиционную мощь для подключения DSL. Этот пост вдохновлен этой
статьей, в которой обсуждается похожий дизайн DSL с использованием Endo и Writers в Haskell.

Давайте рассмотрим проблемы один за другим. Нам нужно
накапливать задачи, которые принадлежат проекту. Таким образом, нам нужна абстракция, которая помогает в этом
накоплении, например, конкатенация в списке, или в наборе, или в карте … и т. Д. Одна из абстракций, которая приходит в голову, это a,
Monoidкоторая дает нам ассоциативную двоичную операцию между двумя объектами типа, которые образуют моноид

trait Monoid[T] {
  def append(m1: T, m2: T): T
  def zero: T
}

А
Listявляется моноидом с конкатенацией как
append. Но поскольку мы не хотим предоставлять конкретную структуру данных клиентскому API, мы можем говорить в терминах моноидов.

Другая необходимая нам структура данных — это некая форма абстракции, которая предложит нам операцию записи в моноид.
WriterМонада является примером этого. На самом деле комбинация a
Writerи a
Monoidдостаточно сильна, чтобы создать такой DSL. Тони Моррис использовал эту комбинацию для реализации
функции регистрации .

for {
  a <- k withvaluelog ("starting with " + _)
  b <- (a + 7) withlog "adding 7"
  c <- (b * 3).nolog
  d <- c.toString.reverse.toInt withvaluelog ("switcheroo with " + _)
  e <- (d % 2 == 0) withlog "is even?"
} yield e

Мы могли бы использовать эту же технику здесь. Но у нас есть проблема —
Projectэто не моноид, и у нас нет определения
zeroдля a,
Projectкоторое мы можем использовать, чтобы сделать его a
Monoid. Есть ли что-то, что могло бы помочь нам получить моноид,
Projectт.е. позволить нам использовать
Projectв моноиде?

Введите
Endo.. эндоморфизм, который является просто функцией, которая принимает аргумент типа
Tи возвращает тот же тип. В Scala мы можем утверждать это как …

sealed trait Endo[A] {
  // The captured function
  def run: A => A
  //..
}

Scalaz определяет
Endo[A]и предоставляет множество вспомогательных функций и синтаксических сахаров для использования эндоморфизмов. Среди других его свойств,
Endo[A]обеспечивает естественный моноид и позволяет нам использовать
Aв
Monoid. Другими словами, эндоморфизмы
Aформы моноида под композицией. В нашем случае мы можем определить
Endo[Project]как функцию, которая принимает
Projectи возвращает
Project. Затем мы можем использовать его с
Writer(как указано выше) и реализовать накопление задач в
Project.

Упражнение: Реализовать регистратор Тони Морриса без побочных эффектов, используя Endo.

Вот как мы хотели бы накапливать задачи в нашем DSL.

for {
  _ <- task("Study Customer Requirements")
  _ <- task("Analyze Use Cases")
  a <- task("Develop code")
} yield a

Давайте определим функцию, которая добавляет
Taskк
Project..

// add task to a project
val withTask = (t: Task, p: Project) => p.copy(tasks = t :: p.tasks)

и использовать эту функцию , чтобы определить DSL API
,
taskкоторый делает
Endo[Project]и передает его как
Monoidв
Writerмонаде. В следующем фрагменте показано
(p: Project) => withTask(t, p)отображение из
Project => Project, которое преобразуется в
Endoи затем передается в
Writerмонаду для добавления в список задач объекта
Project.

def task(n: String): Writer[Endo[Project], Task] = {
  val t = Task(n)
  for {
    _ <- tell(((p: Project) => withTask(t, p)).endo)
  } yield t
}

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

// add project dependency
val withDependency = (t: Task, on: Task, p: Project) =>
  p.copy(deps = (t, on) :: p.deps)

.. и определить функцию
dependsOnдля нашего DSL, которая позволяет пользователю добавлять явные зависимости между задачами. Но на этот раз вместо того, чтобы сделать его автономной функцией, мы сделаем это методом класса
Task. Это только для получения бесплатного синтаксического сахара в DSL. Вот модифицированный
TaskADT ..

case class Task(name: String) {
  def dependsOn(on: Task): Writer[Endo[Project], Task] = {
    for {
      _ <- tell(((p: Project) => withDependency(this, on, p)).endo)
    } yield this
  }
}

Наконец, мы определяем последний API нашего DSL, который склеивает воедино создание Проекта и добавление задач и зависимостей без прямой связи пользователя с некоторыми из основных артефактов реализации.

def project(name: String, startDate: Date)(e: Writer[Endo[Project], Task]) = {
  val p = Project(name, startDate)
  e.run._1(p)
}

И мы можем наконец создать
Projectвместе с задачами и зависимостями, используя наш DSL ..

project("xenos", now) {
  for {
    a <- task("study customer requirements")
    b <- task("analyze usecases")
    _ <- b dependsOn a
    c <- task("design & code")
    _ <- c dependsOn b
    d <- c dependsOn a
  } yield d
}

Если вам интересно, у меня есть целый
рабочий пример в моем репозитории на github.