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