Фактична картина насправді є значно загальнішою, ніж просто доступ до даних. Це легкий спосіб створення мови, що залежить від домену, що дає вам AST, а потім мати одного або декількох перекладачів, щоб "виконати" AST, як вам завгодно.
Безкоштовна частина монади - це просто зручний спосіб отримати AST, який ви можете зібрати за допомогою стандартних засобів монади Haskell (наприклад, нотація) без необхідності писати багато спеціального коду. Це також гарантує, що ваш 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
Насправді це єдиний дійсний спосіб зробити його Функтором, тому ми можемо використовувати deriving
для створення екземпляра автоматично, включивши DeriveFunctor
розширення.
Наступний крок - сам Free
тип. Саме це ми використовуємо для представлення нашої структури AST , побудованої на основі DSL
типу. Ви можете думати про це як список на рівні типу , де "мінуси" просто вкладають функтор, наприклад 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
для кожного наступного поля і нехай нотація зробить усе сантехнічне:
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 ()
. У той час як стара підпис приймає 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, використовуючи вільну структуру монади, щоб зробити всю сантехніку. Ми можемо використовувати до-нотацію та стандартні функції монади з нашим DSL. Тоді, щоб насправді його використовувати, ми мусимо якось його інтерпретувати; оскільки дерево в кінцевому підсумку є лише структурою даних, ми можемо інтерпретувати його, як нам подобається, для різних цілей.
Коли ми використовуємо це для управління доступом до зовнішнього сховища даних, він дійсно схожий на шаблон сховища. Він проміжний між нашим сховищем даних та нашим кодом, розділяючи два. Дещо, однак, є більш конкретним: "сховище" - це завжди DSL з явним AST, який ми можемо потім використовувати, як би ми хотіли.
Однак сама закономірність є більш загальною. Він може використовуватися для багатьох речей, які не обов'язково включають зовнішні бази даних або сховища. Це має сенс, де ви хочете тонкий контроль ефектів або декількох цілей для DSL.