Мои первые эксперименты с Template Haskell

23 сентября 2013

Template Haskell — это расширение Haskell, добавляющее в язык шаблоны. Шаблоны в Haskell представляют собой что-то вроде макросов Lisp, только со строгой статической типизацией. Другими словами, TH добавляет в язык возможность метапрограммирования, то есть, написания программ, которые генерируют код программы на этапе компиляции. Давайте же попробуем разобраться, как пользоваться TH и для решения каких задач он вообще может пригодиться.

Простой пример — время компиляции программы

Помните, как мы писали на Haskell программу, выводящую время своей компиляции? Это прекрасный пример задачи, решаемой с помощью метапрограммирования, поскольку узнать время компиляции программы можно только во время ее компиляции. В прошлый раз для решения задачи мы использовали препроцессор. Давайте посмотрим, как справиться с той же задачей Template Haskell:

module LocaltimeTemplate where

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 :: IO a -> Q a

В данном примере с помощью runIO и getCurrentTime мы узнаем текущее время и генерируем AST, соответствующий строке с этим временем.

Рассмотрим программу, использующую этот шаблон:

{-# LANGUAGE TemplateHaskell #-}

import LocaltimeTemplate

main = putStrLn $ "Localtime: " ++ $(localtimeTemplate)

Смотрите, что происходит. Во время компиляции программы будет выполнен шаблон localtimeTemplate. Он вернет AST, представляющий строку с текущим временем, то есть, временем компиляции программы. С помощью вклейки (кода $(localtimeTemplate), кстати, скобочки здесь не обязательны) этот AST будет подставлен на место шаблона. В результате мы словно скомпилируем следующую программу:

main = putStrLn $ "Localtime: " ++ "2013-09-21 07:33:13.101347 UTC"

Тут нужно обратить внимание на несколько тонких моментов. Во-первых, шаблоны не могут вклеиваться в тех же модулях, в которых они объявляются. Дело в том, что при компиляции модуля, вклеивающего шаблон, шаблон должен быть уже скомпилирован, чтобы его можно было выполнить. Во-вторых, между символом $ и скобочками или именем шаблона не должно быть пробела. Иначе во время компиляции мы получим ошибку parse error on input `$', поскольку GHC ошибочно примет доллар за функцию ($) :: (a -> b) -> a -> b.

Цитирующие скобки

Сейчас вы, конечно же, думаете о том, какой это ужас — вручную набирать AST при написании шаблонов. В действительности, этого делать не нужно. Template Haskell предоставляет цитирующие скобки, позволяющие легко и непринужденно получать AST для указанного кода на Haskell:

ghci> :set -XTemplateHaskell
ghci> :m + Language.Haskell.TH
ghci> :t [| "string" |]
[| "string" |] :: Q Exp

Процитировав некий код, в данном случае — литерал строки, мы получили шаблон, возвращающий AST, соответствующий цитируемому коду. Чтобы увидеть сам AST, воспользуемся функцией runQ:

ghci> :t runQ
runQ :: Quasi m => Q a -> m a
ghci> runQ [| "string" |]
LitE (StringL "string")

Если теперь вы посмотрите на код шаблона localtimeTemplate, то увидите, что этот шаблон возвращает такой же AST с точностью, собственно, до самой строки.

Интересно, что цитирующие скобки и вклейку можно комбинировать:

ghci> :t $( [| "string" |] )
$( [| "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.

Более сложный пример — избавляемся от шаблонного кода

Допустим, имеется следующий код:

import Data.List

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

{-# LANGUAGE TemplateHaskell #-}

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:

report :: Bool -> String -> Q ()

Эта функция выводит переданную вторым аргументом строку в stdout во время компиляции программы. Если первый аргумент функции равен True, то выводимое сообщение считается ошибкой и компиляция останавливается. Иначе считается, что мы вывели отладочное сообщение или предупреждение, и компиляция продолжается.

Итак, AST выглядит следующим образом:

[SigD userLookup_1627396638 (AppT (AppT ArrowT (ConT GHC.Types.Int)) (AppT (AppT ArrowT (ConT SmartTemplate.LookupStatus)) (AppT (ConT GHC.Types.IO) (ConT GHC.Tuple.())))),FunD userLookup_1627396638 [Clause [VarP uid_1627396639] (NormalB (AppE (VarE SmartTemplate.genericLookup) (ListE [LitE (StringL "test"),LitE (StringL "application"),LitE (StringL "userLookup"),AppE (VarE GHC.Show.show) (VarE uid_1627396639)]))) []]]

Представьте, что все это пришлось бы вводить вручную! На первый взгляд вся эта мешанина выглядит несколько пугающе, но если присмотреться, оказывается, что тут имеется список, состоящий из двух элементов — объявления сигнатуры функции (SigD) и тела функции (FunD). Заметьте, что вместо указанного нами имени функции здесь используется userLookup_1627396638. Более того, если шаблон используется несколько раз, каждый раз будет использовано новое имя. Как уже отмечалось, уникальные имена генерируются благодаря монаде Q. Если бы ее не было, для избежания конфликтов имен при использовании шаблонов приходилось бы генерировать уникальные имена вручную!

Теперь все, что нам остается сделать, это создать имя при помощи функции:

mkName :: String -> Name

…, заменить имя функции в AST и вернуть то, что получится в результате.

Фактически, все уже готово. Но чтобы не вызывать makeLookupFun три, тридцать или триста раз, нужно объявить небольшую вспомогательную функцию makeLookupFunctions’. Она принимает список префиксов имен функций и возвращает список объявлений соответствующих функций-индикаторов:

{-# LANGUAGE TemplateHaskell #-}

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. Благодаря ему мы можем переписать предыдущий код следующим образом:

{-# LANGUAGE TemplateHaskell, QuasiQuotes #-}

import SmartTemplate

[makeLookupFunctions|
    user topic post
  |]

main = do
  userLookup 123 Ok
  topicLookup 456 Warning
  postLookup 789 Error

Вот как это работает. Когда компилятор встречает в коде квазицитирование [makeLookupFunctions| user topic post |] происходит вызов функции makeLookupFunctions. Эта функция должна возвращать значение типа QuasiQuoter:

ghci> :i 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 :: QuasiQuoter -> QuasiQuoter

С помощью quoteFile можно превратить шаблоны, принимающие в качестве аргумента строку, в шаблоны, читающие эту строку из указанного внешнего файла.

newName :: String -> Q Name

Генерирует новое уникальное имя с заданным префиксом.

reify :: Name -> Q Info

Возвращает информацию о сущности (классе типов, переменной, …) по ее имени. Как бы рефлексия в Haskell.

recover :: Q a -> Q a -> Q a

Своего рода try / catch. Сначала выполняется шаблон, переданный вторым аргументом. Если в нем встречается report True, то вызывается шаблон, переданный первым аргументом.

location :: Q Loc

Возвращает информацию о месте, где произошло вклеивание шаблона — имя файла, номер строки и так далее. Да, оказывается, монада Q отвечает не только за уникальные имена.

pprint :: Ppr a => a -> String

Выводит AST в более читаемом виде:

ghci> runQ [t| Int -> String |]
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 предусмотрен специальный синтаксис для удобного получения имен функций и типов:

ghci> :t 'putStrLn -- имя функции
'putStrLn :: Name
ghci> :t 'False    -- имя конструктора типа
'False :: Name
ghci> :t ''Bool    -- имя типа (''a - имя переменной типа)
''Bool :: Name

Чтобы посмотреть, какой код генерируется при вклейке шаблонов, скажите:

ghc -ddump-splices File.hs

Или, если вы используете ghc-mod, то можете сделать то же самое командой:

ghc-mod expand File.hs

Эта возможность приходится весьма кстати во время отладки шаблонов.

Заключение

Дополнительные материалы по теме:

Самое удивительное в Template Haskell — это то, что он учится за пару часов, но предоставляемые им возможности при этом практически безграничны. С помощью TH мы можем не только произвести какие-то расчеты на этапе компиляции или избавиться от шаблонного кода, но и добавить в Haskell изменяемые переменные, множественное наследование или блоки кода, в которых вычисления происходят строго. Фактически, TH предоставляет удобные средства для трансляции произвольного DSL в код на Haskell.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.