Фактический шаблон на самом деле значительно более общий, чем просто доступ к данным. Это упрощенный способ создания предметно-ориентированного языка, который дает вам AST, а затем наличие одного или нескольких интерпретаторов для «выполнения» AST, как вам нравится.
Свободная часть монады - это просто удобный способ получить AST, который можно собрать, используя стандартные средства монады Haskell (например, do-notation), без необходимости написания большого количества пользовательского кода. Это также обеспечивает компоновку вашего DSL : вы можете определить его по частям, а затем структурировать части, что позволит вам воспользоваться преимуществами обычных абстракций Haskell, таких как функции.
Использование бесплатной монады дает вам структуру компонуемого DSL; все, что вам нужно сделать, это указать кусочки. Вы просто пишете тип данных, который охватывает все действия в вашем DSL. Эти действия могут делать что угодно, не только доступ к данным. Однако, если вы указали все свои обращения к данным как действия, вы получите AST, который определяет все запросы и команды к хранилищу данных. Затем вы можете интерпретировать это так, как вам нравится: запустить его для действующей базы данных, запустить для макета, просто записать команды для отладки или даже попытаться оптимизировать запросы.
Давайте рассмотрим очень простой пример, скажем, хранилища значений ключей. Сейчас мы будем рассматривать ключи и значения как строки, но вы можете добавить типы, приложив немного усилий.
data DSL next = Get String (String -> next)
| Set String String next
| End
nextПараметр позволяет нам комбинировать действия. Мы можем использовать это, чтобы написать программу, которая получает «foo» и устанавливает «bar» с этим значением:
p1 = Get "foo" $ \ foo -> Set "bar" foo End
К сожалению, этого недостаточно для значимого DSL. Так как мы использовали nextдля композиции, тип p1такой же длины, как наша программа (т.е. 3 команды):
p1 :: DSL (DSL (DSL next))
В этом конкретном примере nextтакое использование кажется немного странным, но это важно, если мы хотим, чтобы наши действия имели переменные другого типа. Мы могли бы хотеть печатать getи set, например.
Обратите внимание, как nextполе отличается для каждого действия. Это намекает на то, что мы можем использовать его для создания DSLфунктора:
instance Functor DSL where
fmap f (Get name k) = Get name (f . k)
fmap f (Set name value next) = Set name value (f next)
fmap f End = End
Фактически, это единственный действительный способ сделать его Functor, поэтому мы можем использовать его derivingдля автоматического создания экземпляра, включив DeriveFunctorрасширение.
Следующим шагом является сам Freeтип. Это то, что мы используем для представления нашей структуры AST , построенной поверх DSLтипа. Вы можете думать об этом как о списке на уровне типов , где «cons» - это просто вложенный функтор, например DSL:
-- compare the two types:
data Free f a = Free (f (Free f a)) | Return a
data List a = Cons a (List a) | Nil
Таким образом, мы можем использовать Free DSL nextпрограммы разных размеров одинакового типа:
p2 = Free (Get "foo" $ \ foo -> Free (Set "bar" foo (Free End)))
Который имеет гораздо более приятный тип:
p2 :: Free DSL a
Однако фактическое выражение со всеми его конструкторами все еще очень неудобно в использовании! Вот где появляется часть монады. Как следует из названия «свободная монада», Freeона является монадой, если f(в данном случае DSL) является функтором:
instance Functor f => Monad (Free f) where
return = Return
Free a >>= f = Free (fmap (>>= f) a)
Return a >>= f = f a
Теперь мы кое-что получаем: мы можем использовать doнотацию, чтобы сделать наши выражения DSL более привлекательными. Вопрос только в том, что поставить next? Итак, идея состоит в том, чтобы использовать Freeструктуру для композиции, поэтому мы просто поместим Returnдля каждого следующего поля и позволим do-notation выполнить всю сантехнику:
p3 = do foo <- Free (Get "foo" Return)
Free (Set "bar" foo (Return ()))
Free End
Это лучше, но все равно немного неловко. У нас Freeи Returnповсюду. К счастью, есть образец, который мы можем использовать: способ, которым мы «поднимаем» действие DSL Free, всегда один и тот же - мы оборачиваем его Freeи подаем заявку Returnна next:
liftFree :: Functor f => f a -> Free f a
liftFree action = Free (fmap Return action)
Теперь, используя это, мы можем написать хорошие версии каждой из наших команд и иметь полный DSL:
get key = liftFree (Get key id)
set key value = liftFree (Set key value ())
end = liftFree End
Используя это, вот как мы можем написать нашу программу:
p4 :: Free DSL a
p4 = do foo <- get "foo"
set "bar" foo
end
Уловка в том, что, хотя p4выглядит как маленькая императивная программа, на самом деле это выражение имеет значение
Free (Get "foo" $ \ foo -> Free (Set "bar" foo (Free End)))
Таким образом, свободная монадная часть шаблона дала нам DSL, который создает синтаксические деревья с хорошим синтаксисом. Мы также можем написать составные поддеревья, не используя End; например, у нас может быть followключ, который получает ключ, получает его значение и затем использует его в качестве самого ключа:
follow :: String -> Free DSL String
follow key = do key' <- get key
get key'
Теперь followможно использовать в наших программах так же, как getили set:
p5 = do foo <- follow "foo"
set "bar" foo
end
Таким образом, мы получили хорошую композицию и абстракцию для нашего DSL.
Теперь, когда у нас есть дерево, мы попадаем во вторую половину шаблона: интерпретатор. Мы можем интерпретировать дерево так, как нам нравится, просто сопоставляя его с шаблоном. Это позволило бы нам написать код для реального хранилища данных IO, а также для других вещей. Вот пример против гипотетического хранилища данных:
runIO :: Free DSL a -> IO ()
runIO (Free (Get key k)) =
do res <- getKey key
runIO $ k res
runIO (Free (Set key value next)) =
do setKey key value
runIO next
runIO (Free End) = close
runIO (Return _) = return ()
Это с радостью оценит любой DSLфрагмент, даже тот, который не заканчивается end. К счастью, мы можем создать «безопасную» версию функции, которая принимает только закрытые программы end, установив для сигнатуры типа ввода значение (forall a. Free DSL a) -> IO (). В то время как старая подпись принимает a Free DSL aдля любого a (например Free DSL String, Free DSL Intи т. Д.), Эта версия принимает только тот, Free DSL aкоторый работает для всех возможных - aчто мы можем создать только с помощью end. Это гарантирует, что мы не забудем закрыть соединение, когда закончим.
safeRunIO :: (forall a. Free DSL a) -> IO ()
safeRunIO = runIO
(Мы не можем просто начать с предоставления runIOэтого типа, потому что он не будет работать должным образом для нашего рекурсивного вызова. Однако мы можем переместить определение runIOв whereблок safeRunIOи получить тот же эффект, не раскрывая обе версии функции.)
Запуск нашего кода IO- не единственное, что мы можем сделать. Для тестирования мы можем захотеть запустить его State Mapвместо чистого . Написание этого кода - хорошее упражнение.
Так что это бесплатный образец монады + интерпретатора. Мы делаем DSL, используя все преимущества свободной структуры монады. Мы можем использовать do-нотацию и стандартные функции монады с нашим DSL. Затем, чтобы фактически использовать это, мы должны как-то интерпретировать это; поскольку дерево в конечном итоге представляет собой просто структуру данных, мы можем интерпретировать ее так, как нам нравится для разных целей.
Когда мы используем это для управления доступом к внешнему хранилищу данных, это действительно похоже на шаблон Repository. Он является посредником между нашим хранилищем данных и нашим кодом, разделяя их. В некотором смысле, однако, это более конкретно: «хранилище» - это всегда DSL с явным AST, который мы затем можем использовать так, как нам нравится.
Однако сам шаблон более общий, чем этот. Он может быть использован для многих вещей, которые не обязательно связаны с внешними базами данных или хранилищем. Это имеет смысл везде, где вы хотите точный контроль эффектов или нескольких целей для DSL.