Когда мы разрабатываем модель предметной области, одной из проблем, которые нас интересуют, является абстракция реализации от 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. Вот модифицированный
Task
ADT ..
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.