Template Haskell — это расширение Haskell, добавляющее в язык шаблоны. Шаблоны в Haskell представляют собой что-то вроде макросов Lisp , только со строгой статической типизацией . Другими словами, TH добавляет в язык возможность метапрограммирования , то есть, написания программ, которые генерируют код программы на этапе компиляции. Давайте же попробуем разобраться, как пользоваться TH и для решения каких задач он вообще может пригодиться.
Простой пример — время компиляции программы
Помните, как мы писали на Haskell программу, выводящую время своей компиляции ? Это прекрасный пример задачи, решаемой с помощью метапрограммирования, поскольку узнать время компиляции программы можно только во время ее компиляции. В прошлый раз для решения задачи мы использовали препроцессор. Давайте посмотрим, как справиться с той же задачей Template Haskell:
import Language . Haskell . TH
import Data . Time
localtimeTemplate :: Q Exp
localtimeTemplate = do
t <- runIO getCurrentTime
return $ LitE ( StringL ( show t ) )
Определенный здесь шаблон localtimeTemplate имеет тип Q Exp
. Тип Exp определен в модуле Language.Haskell.TH.Syntax и представляет собой абстрактное синтаксическое дерево (AST) кода на Haskell. Этот тип оборачивается в монаду цитирования Q , позволяющей генерировать уникальные имена переменных и функций в теле шаблона. Для вызова функций с побочными эффектами из монады Q предназначена функция runIO:
В данном примере с помощью runIO и getCurrentTime мы узнаем текущее время и генерируем AST, соответствующий строке с этим временем.
Рассмотрим программу, использующую этот шаблон:
import LocaltimeTemplate
main = putStrLn $ «Localtime: » ++ $ ( localtimeTemplate )
Смотрите, что происходит. Во время компиляции программы будет выполнен шаблон localtimeTemplate. Он вернет AST, представляющий строку с текущим временем, то есть, временем компиляции программы. С помощью вклейки (кода $ ( localtimeTemplate )
, кстати, скобочки здесь не обязательны) этот AST будет подставлен на место шаблона. В результате мы словно скомпилируем следующую программу:
Тут нужно обратить внимание на несколько тонких моментов. Во-первых, шаблоны не могут вклеиваться в тех же модулях, в которых они объявляются. Дело в том, что при компиляции модуля, вклеивающего шаблон, шаблон должен быть уже скомпилирован, чтобы его можно было выполнить. Во-вторых, между символом $ и скобочками или именем шаблона не должно быть пробела. Иначе во время компиляции мы получим ошибку parse error on input `$'
, поскольку GHC ошибочно примет доллар за функцию ( $ ) :: ( a -> b ) -> a -> b
.
Цитирующие скобки
Сейчас вы, конечно же, думаете о том, какой это ужас — вручную набирать AST при написании шаблонов. В действительности, этого делать не нужно. Template Haskell предоставляет цитирующие скобки , позволяющие легко и непринужденно получать AST для указанного кода на Haskell:
ghci> :m + Language.Haskell.TH
ghci> :t [| «string» |]
[| «string» |] :: Q Exp
Процитировав некий код, в данном случае — литерал строки, мы получили шаблон, возвращающий AST, соответствующий цитируемому коду. Чтобы увидеть сам AST, воспользуемся функцией runQ:
runQ :: Quasi m => Q a -> m a
ghci> runQ [| «string» |]
LitE (StringL «string»)
Если теперь вы посмотрите на код шаблона localtimeTemplate, то увидите, что этот шаблон возвращает такой же AST с точностью, собственно, до самой строки.
Интересно, что цитирующие скобки и вклейку можно комбинировать:
$( [| «string» |] ) :: [Char]
ghci> :t [| $( [| «string» |] ) ++ «aaa» |]
[| $( [| «string» |] ) ++ «aaa» |] :: Q Exp
ghci> :t $( [| $( [| «string» |] ) ++ «aaa» |] )
$( [| $( [| «string» |] ) ++ «aaa» |] ) :: [Char]
Выражения могут быть произвольной вложенности. Главное, чтобы вклейка и цитирующие скобки чередовались.
В действительности, Template Haskell предоставляет не один, а четыре вида цитирующих скобок:
-
[ | ... | ] :: Q Exp
или[ e | ... | ] :: Q Exp
для выражений; -
[ d | ... | ] :: Q [ Dec ]
для объявлений; -
[ t | ... | ] :: Q Type
для типов; -
[ p | ... | ] :: Q Pat
для сопоставления с образцом;
Рассмотрим еще один пример использования Template Haskell.
Более сложный пример — избавляемся от шаблонного кода
Допустим, имеется следующий код:
data LookupStatus = Ok | Warning | Error | Unknown
deriving ( Eq , Show )
userLookup :: Int -> LookupStatus -> IO ( )
userLookup uid =
genericLookup [ «test» , «application» , «userLookup» , show uid ]
topicLookup :: Int -> LookupStatus -> IO ( )
topicLookup tid =
genericLookup [ «test» , «application» , «topicLookup» , show tid ]
postLookup :: Int -> LookupStatus -> IO ( )
postLookup pid =
genericLookup [ «test» , «application» , «postLookup» , show pid ]
genericLookup :: [ String ] -> LookupStatus -> IO ( )
genericLookup path st =
putStrLn $ intercalate «/» path ++ » = » ++ show st
main = do
userLookup 123 Ok
topicLookup 456 Warning
postLookup 789 Error
Здесь объявляются три функции-«индикатора» — userLookup, topicLookup и postLookup. Каждая из этих функций принимает некоторый Id и состояние, которое может быть Ok, Warning, Error или Unknown. Такие индикаторы расставляются в коде и сообщают состояние сущностей нашего приложения в некий мониторинг.
Например, если кто-то открывает страницу с профилем пользователя номер 123 и такой пользователь действительно есть в системе, то происходит вызов userLookup 123 Ok
. Если такого пользователя нет, вызывается userLookup 123 Error
. Если все пользователи имеют статус Ok, в мониторинге горит зеленая лампочка. Если хотя бы один пользователь получает статус Error, загорается красная лампочка, а админам посылается SMS. Красные лампочки могут свидетельствовать о проблемах с сетью (недоступна СУБД) или, например, о наличии битых ссылок на сайте.
В реальном приложении таких индикаторов может быть не три, а три десятка или даже три сотни. Для простоты здесь все функции имеют одинаковые типы, но в реальном приложении они, скорее всего, будут разными, из-за особенностей бизнес-логики или чтобы, например, мы случайно не передали Id пользователя туда, где ожидается Id поста. Также для простоты genericLookup выводит сообщения в stdout, но в реальном приложении она, вероятно, будет ходить в некий REST API или посылать UDP пакеты. В общем, давайте представим, что тут имеется действительно много шаблонного кода, который не удается упростить путем использования классов типов или еще как. В таких случаях для того, чтобы не писать много шаблонного кода самим, имеет смысл написать шаблон, который напишет этот код за нас:
module SmartTemplate where
import Language . Haskell . TH
import Language . Haskell . TH . Quote
import Data . List
makeLookupFun :: String -> Q [ Dec ]
makeLookupFun prefix = do
let funNameStr = prefix ++ «Lookup»
d <- [ d |
userLookup :: Int -> LookupStatus -> IO ( )
userLookup uid =
genericLookup [ «test» , «application» , funNameStr , show uid ]
| ]
— report False (show d)
let funName = mkName funNameStr
[ SigD _ funSig , FunD _ funBody ] = d
d’ = [ SigD funName funSig , FunD funName funBody ]
return d’
makeLookupFunctions’ :: [ String ] -> Q [ Dec ]
makeLookupFunctions’ prefixList = do
lst <- mapM makeLookupFun prefixList
return $ concat lst
makeLookupFunctions =
QuasiQuoter
{ quoteDec = makeLookupFunctions’ . words
, quoteExp = undefined
, quotePat = undefined
, quoteType = undefined
}
data LookupStatus = Ok | Warning | Error | Unknown
deriving ( Eq , Show )
genericLookup :: [ String ] -> LookupStatus -> IO ( )
genericLookup path st =
putStrLn $ intercalate «/» path ++ » = » ++ show st
Шаблон makeLookupFun принимает префикс функции, например, "user"
или "topic"
и создает объявление соответствующей функции, скажем, userLookup или topicLookup. Поскольку это шаблон объявления функции, а не выражения, он имеет тип Q [ Dec ]
. Благодаря этому мы не сможем ошибочно вклеить шаблон, например, в тело функции.
AST будущего объявления функции получается с помощью цитирующих скобок [ d | ... | ]
. Благодаря тому, что в цитирующих скобах может быть использовано замыкание, тело функции уже сформировано верно. Все, что остается сделать шаблону, это найти и заменить в этом AST имя функции.
Чтобы знать, где искать и что заменять, неплохо бы сначала увидеть этот AST. Тут на помощь приходит функция report:
Эта функция выводит переданную вторым аргументом строку в stdout во время компиляции программы. Если первый аргумент функции равен True, то выводимое сообщение считается ошибкой и компиляция останавливается. Иначе считается, что мы вывели отладочное сообщение или предупреждение, и компиляция продолжается.
Итак, AST выглядит следующим образом:
Представьте, что все это пришлось бы вводить вручную! На первый взгляд вся эта мешанина выглядит несколько пугающе, но если присмотреться, оказывается, что тут имеется список, состоящий из двух элементов — объявления сигнатуры функции (SigD) и тела функции (FunD). Заметьте, что вместо указанного нами имени функции здесь используется userLookup_1627396638. Более того, если шаблон используется несколько раз, каждый раз будет использовано новое имя. Как уже отмечалось, уникальные имена генерируются благодаря монаде Q. Если бы ее не было, для избежания конфликтов имен при использовании шаблонов приходилось бы генерировать уникальные имена вручную!
Теперь все, что нам остается сделать, это создать имя при помощи функции:
…, заменить имя функции в AST и вернуть то, что получится в результате.
Фактически, все уже готово. Но чтобы не вызывать makeLookupFun три, тридцать или триста раз, нужно объявить небольшую вспомогательную функцию makeLookupFunctions’. Она принимает список префиксов имен функций и возвращает список объявлений соответствующих функций-индикаторов:
import SmartTemplate
— $(makeLookupFunctions’ [«user», «topic», «post»])
makeLookupFunctions’ [ «user» , «topic» , «post» ]
main = do
userLookup 123 Ok
topicLookup 456 Warning
postLookup 789 Error
Обратите внимание, что в данном случае вклейка шаблона возможна двумя способами — с использованием синтаксиса $(...)
или просто путем вызова шаблона. По типу функции, а также учитывая тот факт, что в Haskell функции вызываются только из каких-нибудь других функций, нетрудно безо всяких там долларов определить, что здесь имеется в виду вклейка.
Расширение QuasiQuotes
Еще одно расширение GHC, которое нельзя не упомянуть в контексте обсуждения Template Haskell — это QuasiQuotes. Благодаря ему мы можем переписать предыдущий код следующим образом:
import SmartTemplate
[ makeLookupFunctions |
user topic post
| ]
main = do
userLookup 123 Ok
topicLookup 456 Warning
postLookup 789 Error
Вот как это работает. Когда компилятор встречает в коде квазицитирование [makeLookupFunctions| user topic post |]
происходит вызов функции makeLookupFunctions. Эта функция должна возвращать значение типа QuasiQuoter:
data QuasiQuoter
= QuasiQuoter {quoteExp :: String -> Q Exp,
quotePat :: String -> Q Pat,
quoteType :: String -> Q Type,
quoteDec :: String -> Q [Dec]}
— Defined in `Language.Haskell.TH.Quote’
Как видите, это запись, имеющая четыре поля, по одному на каждый из возможных контекстов вклейки — выражение, объявление, тип и сопоставление с образцом. В зависимости от того, в каком контексте происходит квазицитирование, происходит вклейка шаблона, хранимого в одном из полей записи. При этом в качестве аргумента шаблону передается строка, указанная после вертикальной черты, то есть " user topic post "
.
Если сейчас вы вернетесь к объявлению функции makeLookupFunctions, то увидите, что шаблон, указанный в quoteDec, разбивает переданную в качестве аргумента строку с помощью функции words, а затем вызывает уже знакомый нам шаблон makeLookupFunctions’, работающий со списком строк.
Несмотря на то, что здесь мы просто разбили строку на слова, ничто не мешает воспользоваться каким-нибудь Parsec или Attoparsec и работать с куда более сложными грамматиками. При желании с помощью TH и QQ можно хоть собственный OCaml написать!
Полезные функции
Рассмотрим несколько полезных функций.
С помощью quoteFile можно превратить шаблоны, принимающие в качестве аргумента строку, в шаблоны, читающие эту строку из указанного внешнего файла.
Генерирует новое уникальное имя с заданным префиксом.
Возвращает информацию о сущности (классе типов, переменной, …) по ее имени. Как бы рефлексия в Haskell.
Своего рода try / catch. Сначала выполняется шаблон, переданный вторым аргументом. Если в нем встречается report True
, то вызывается шаблон, переданный первым аргументом.
Возвращает информацию о месте, где произошло вклеивание шаблона — имя файла, номер строки и так далее. Да, оказывается, монада Q отвечает не только за уникальные имена.
Выводит AST в более читаемом виде:
AppT (AppT ArrowT (ConT GHC.Types.Int)) (ConT GHC.Base.String)
ghci> pprint it
«GHC.Types.Int -> GHC.Base.String»
Также Template Haskell предоставляет бесчисленное количество небольших функций вроде dyn, funD, varE, предназначенных, по всей видимости, для облегчения построения AST вручную.
И еще пара небольших замечаний
В рамках данного поста как-то не пришлось к слову, но в TH предусмотрен специальный синтаксис для удобного получения имен функций и типов:
‘putStrLn :: Name
ghci> :t ‘False — имя конструктора типа
‘False :: Name
ghci> :t »Bool — имя типа (»a — имя переменной типа)
»Bool :: Name
Чтобы посмотреть, какой код генерируется при вклейке шаблонов, скажите:
Или, если вы используете ghc-mod , то можете сделать то же самое командой:
Эта возможность приходится весьма кстати во время отладки шаблонов.
Заключение
Дополнительные материалы по теме:
- Template Haskell на Haskell Wiki;
- Весьма годная презентация по Template Haskell ;
- Пример использования TH и QQ — DSL для создания парсеров ;
- Перевод туториала по Template Haskell на русский язык;
- В GHC 7.8 обещают запилить типизированный Template Haskell ;
Самое удивительное в Template Haskell — это то, что он учится за пару часов, но предоставляемые им возможности при этом практически безграничны . С помощью TH мы можем не только произвести какие-то расчеты на этапе компиляции или избавиться от шаблонного кода, но и добавить в Haskell изменяемые переменные, множественное наследование или блоки кода, в которых вычисления происходят строго. Фактически, TH предоставляет удобные средства для трансляции произвольного DSL в код на Haskell.