Фактический шаблон на самом деле значительно более общий, чем просто доступ к данным. Это упрощенный способ создания предметно-ориентированного языка, который дает вам 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.