Template Haskell简介

476 阅读9分钟

Haskell模板的简单介绍

在这篇文章中,我们将探索Template Haskell的一些错综复杂的问题,并建立一个实际的例子,向你介绍Haskell的元编程能力。

我们将首先使用Template Haskell来生成一些函数,而不是手动编写,以了解其基本机制是如何工作的。然后我们将通过一个更实际的例子,用TH生成模板代码,为这些类定义和实例。

什么是Template Haskell?

Template Haskell,也被称为TH,是一组通过template-haskell包公开的函数和数据类型,它允许程序员以编程方式操作Haskell代码。Template Haskell允许的一些事情是。

  • 程序性地生成新的函数或数据类型。
  • 检查某些Haskell结构将生成什么。
  • 在编译时执行代码。

模板Haskell主要用于生成模板代码和自动编译的某些方面。在这篇文章中,我们将专注于前者,并使用TH为我们生成模板。

开始使用

我们将从一个简单的例子开始,这个例子可能是矫揉造作的,但希望能对理解TH的工作原理有所帮助。作为第一步,让我们看看如何用TH生成基本的函数,而不是手动编写它们。

引用和准引用

模板Haskell为我们提供了一个简单的工具来处理其机器的各个部分:引号。有了它们,我们可以在编译时取一个Haskell表达式,用它来创建一个抽象语法树(或AST)。它们为在许多情况下编写模板Haskell代码提供了一个捷径,也可以用来帮助我们检查生成的代码。

此外,Haskell提供了一种机制来扩展语言,除了本文探讨的Template Haskell中的引号外,还有更多的引号,称为准引号,即eptd

了解一些可以使用的报价机制以及它们产生的结果是很重要的。首先,用template-haskell 包启动GHCi,并导入Language.Haskell.TH 模块,这是我们对本文的主要兴趣。注意,我们还需要激活TemplateHaskell 语言扩展。

>>> import Language.Haskell.TH
>>> :set -XTemplateHaskell

现在,我们已经为我们的第一个例子做好了准备。我们将从学习[e| ... |] quoter开始,它接收一些Haskell表达式并返回代表该特定表达式的AST。

>>> runQ [e|1 + 2|]
InfixE (Just (LitE (IntegerL 1))) (VarE GHC.Num.+) (Just (LitE (IntegerL 2)))

通过使用runQ :: Quasi m => Q a -> m a ,我们可以提取我们的引语,然后由GHCi打印出来。我们将在后面看到,IOQQuasi 类型类的两个实例,因此我们能够从我们的交互式解释器中使用它。

这样做的结果是一个代表我们表达式的AST,当画成一棵实际的树时,它的视觉效果会更好。

                InfixE
               /  |   \
           Just  VarE  Just
             /    |     \
         LitE  GHG.Num.+ LitE
           /               \
   IntegerL                 IntegerL
         /                   \
        1                     2

值得注意的是,Just 已经出现在操作数中。像[e|(1 +)|] 这样的表达式,它的第二个操作数是Nothing!

到目前为止,我们一直在使用引号来写Template Haskell。但是我们也可以使用在template-haskell 库中定义的普通Haskell构造函数。例如,与其写[e|(4, 2)|] ,我们还不如打出完整的AST来获得同样的结果。

>>> runQ (pure (TupE [Just (LitE (IntegerL 4)), Just (LitE (IntegerL 2))]))
TupE [Just (LitE (IntegerL 4)),Just (LitE (IntegerL 2))]
               TupE
                |
                :
               / \
           Just   \
             /     :
         LitE     / \
           /  Just   []
   IntegerL     |
         /    LitE
        4       |
             IntegerL
                |
                2

Template Haskell遵循一个很好的AST的命名模式,表达式将以E为后缀,模式以P为后缀,字面以L为后缀,声明以D为后缀,类型以T为后缀。我们也有相当于一些树的准定理,即epdt

事实上,使用[e| ... |] 的工作非常普遍,我们可以直接使用[| ... |] 来代替。

到目前为止,我们一直在使用e 来生成Haskell表达式,尽管它对创建其他的Haskell结构也很感兴趣,比如声明。闲话少说,让我们使用Template Haskell来生成一个声明,它相当于下面的Haskell代码。

decl :: Int
decl = 1 + 2

既然我们有一个声明,我们可以使用d 准引号来实现。

>>> runQ [d|
...         decl :: Int
...         decl = 1 + 2
...       |]
[ SigD decl_0 (ConT GHC.Types.Int)
, ValD (VarP decl_0) (
    NormalB (  -- NormalB is the body of a declaration without pattern guards
      InfixE (Just (LitE (IntegerL 1))) (VarE GHC.Num.+) (Just (LitE (IntegerL 2)))
    )
  ) []
]

template-haskell 包为所有由ExpPatDecType准查询器生成的类型提供可理解的文档。

拼接

到目前为止,我们已经用TH分析了由各种Haskell构造产生的树。虽然这很有趣,但它并不那么有用,因为TH的主要目的是生成可以被编译和使用的Haskell代码。为此,我们可以使用拼接,这将从本质上允许我们调用我们用TH做出的定义。

-- A function which takes two other functions and composes them, as (.):
>>> compose :: Q Exp
... compose = [|\left right x -> left (right x)|]

>>> $compose (* 2) (+ 1) 0
2

$compose 是怎么回事? 这是一个拼接,它允许我们使用一个给定的Template Haskell定义。紧接着$ 的表达式将评估TH定义,并返回一个我们可以使用的实际Haskell定义。接合器将尝试注入任何一种给定的代码,所以要确保它是正确的。正如下面所演示的,我们可以在Template Haskell中注入一个绑定变量。

>>> x = 20
>>> compose' :: Q Exp
... compose' = [|\left right -> left (right x)|]
>>> $compose' (* 2) (+ 1)
42

x 由于Template Haskell是在编译时进行评估的,如果我们用一个没有被绑定的变量,如z ,来代替Variable not in scope: z ,在拼接生成时,我们会得到一个错误。任何畸形的表达式都会像其他正规的Haskell表达式一样被报告。换句话说,我们分析Template Haskell声明就像分析其他普通Haskell声明一样。

阶段限制

拼接的一个注意事项存在于它们在一些源文件中可能被定义和使用的地方。为了直观地了解这个问题,我们将创建一个名为Main.hs 的新文件,内容如下。

{-# LANGUAGE TemplateHaskell #-}

import Language.Haskell.TH

someSplice :: Q [Dec]
someSplice = [d|y = 0|]

x :: Int
x = 42

$someSplice

z :: String
z = show x

现在试着在GHCi中加载它。

>>> :l Main.hs
[1 of 1] Compiling Main             ( Main.hs, interpreted )
Failed, no modules loaded.
Main.hs:11:1: error:
    GHC stage restriction:
     ‘someSplice’ is used in a top-level splice, quasi-quote, or annotation,
      and must be imported, not defined locally
   |
11 | $someSplice
   | ^^^^^^^^^^^

哎呀!我们遇到了一个阶段性的限制。如果你曾经遇到过一个使用TH的项目,你可能会注意到,往往有一个单独的模块来处理所有与TH有关的事情。为了解决这个错误,让我们把someSplice 的定义移到一个新的TH.hs 文件中。

{-# LANGUAGE TemplateHaskell #-}

module TH where

import Language.Haskell.TH

someSplice :: Q [Dec]
someSplice = [d|y = 0|]

并改变Main.hs ,将其导入。

{-# LANGUAGE TemplateHaskell #-}

import Language.Haskell.TH

import TH

x :: Int
x = 42

$someSplice

z :: String
z = show x

而现在我们应该能够加载Main ,并使用y

另一个限制来自于声明组。值得注意的是,如果我们交换xz 的声明位置。

z :: String
z = show x

$someSplice

x :: Int
x = 42

并再次加载文件,我们会发现一个问题:

Main.hs:8:10: error:
    • Variable not in scope: x
    • ‘x’ (line 13) is not in scope before the splice on line 10
  |
8 | z = show x
  |

每当我们插入一个拼接,文件就会被拼接之前和之后的所有声明所分割。在我们的第一个例子中,x 是在z 的范围内,但不是相反的,但在交换了两个声明后,z 是在x 的范围内,但不是相反的。为了减轻这个问题,如果可能的话,我们经常尝试在文件的最底部插入所有的拼接。

-ddump-splices

看看用Template Haskell生成了哪些代码是很有用的。我们可以使用-ddump-splices 来查看输出。

>>> :set -ddump-splices

如果我们再次加载Main (这次的声明顺序是正确的),我们应该看到拼接的生成输出。

>>> :r
[2 of 2] Compiling Main             ( Main.hs, interpreted )
Main.hs:10:1-11: Splicing declarations
    someSplice ======> y_a65Q = 0
Ok, two modules loaded.

输出显示我们的y 被声明为0

例子:生成实例

让我们创造一个简单的例子来最好地理解TH的工作原理。有一件事可能会很有用,那就是提取一些元组的第三、第四、第五等元素的方法。lens 库的用户可能已经找到了诸如_1,_2,_3 等方法来处理任意大小的元组。让我们试着用TH来自动生成这些实例。

我们将使用以下方法。首先,我们将为任意大小的图元定义类型库Tuple2,Tuple3, ... 。它们中的每一个都有方法_1,_2,_3, ... 用于访问图元的元素。之后,我们将为每个(a, b),(a, b, c),(a, b, c, d), 等生成实例。

首先,让我们先创建一个文件TuplesTH.hs

{-# LANGUAGE TemplateHaskell #-}

module TuplesTH where

import Control.Monad (unless)
import Data.Traversable (for)
import Language.Haskell.TH

TH如何将元组的元组表示为AST?我们可以查看文档,但我们也可以查看GHCi。

>>> runQ [d|x :: (a,b,c); x = undefined|]
[ SigD x_8 (AppT (AppT (AppT (TupleT 3) (VarT a_5)) (VarT b_6)) (VarT c_7))
, ValD (VarP x_8) (NormalB (VarE GHC.Err.undefined)) []
]

如果你的变量名称不一样,也不用担心。对我们来说,相关的部分是AppT (AppT (AppT (TupleT 3) (VarT a_5)) (VarT b_6)) (VarT c_7)

                    AppT
                   /    \
               AppT      VarT
              /    \      \
          AppT      VarT   c_7
         /    \      \
   TupleT      VarT   b_6
       /        \
      3          a_5

我们必须为我们的类实例声明模仿这种结构。不仅如此,我们的方法将有这样的格式:(a, b, ..., t, ...) -> t ,其中t 是我们元组的第n个类型。再次,让我们看看什么是函数的Template Haskell等价物。

>>> runQ [d|x :: a -> b; x = undefined|]
[ SigD x_15 (AppT (AppT ArrowT (VarT a_13)) (VarT b_14))
, ValD (VarP x_15) (NormalB (VarE GHC.Err.undefined)) []
]

我们看到,一个箭头(->) 被表示为ArrowT 。请记住,a -> b(->) a b 是一样的。

                AppT
               /    \
           AppT      VarT
          /    \      \
    ArrowT      VarT   b_14
                 \
                  a_13

有了这个,我们可以创建一个生成元组类的函数。我们有一个声明,所以通过查找 Dec的文档,我们找到了我们用于声明类的构造函数:ClassD Cxt Name [TyVarBndr ()] [FunDep] [Dec] 。这个类应该有这样的形式:class TupleX t r | t -> r where _X :: t -> r ,其中X 是元组中的元素数量。有了这些信息,我们可以声明一个函数,它将为我们生成这样的类。

generateTupleClass :: Int -> Q [Dec]
generateTupleClass size = do
  unless (size > 0) $
    fail $ "Non-positive size: " ++ size'
  pure [cDecl]
  where
    size' = show size
    className = mkName ("Tuple" ++ size')
    methodName = mkName ('_' : size')

    t = mkName "t"
    r = mkName "r"

    -- class TupleX t r | t -> r where
    cDecl = ClassD [] className [PlainTV t, PlainTV r] [FunDep [t] [r]] [mDecl]
    --   _X :: t -> r
    mDecl = SigD methodName (AppT (AppT ArrowT (VarT t)) (VarT r))

我们只处理至少有1个元素的情况,所以如果少于这个元素,我们会抛出一个错误。我们使用mkName 函数来创建我们的类的名称(TupleX),它的方法(_X),以及tr 类型变量。

对于类的声明,我们使用带有以下字段的ClassD 构造函数:

  • [] :: Ctx - 一个空的约束数组,因为我们不对我们的类型施加进一步的限制。
  • className :: Name - 被声明的类的名称。
  • [PlainTV t, PlainTV r] :: [TyVarBndr ()] - 我们的类型类的绑定,即 和 。t r
  • [FunDep [t] [r]] :: [FunDep] - 一个格式为 的功能依赖。t -> r
  • [mDecl] :: [Dec] - 我们的类的声明,包含 ,一个类型的方法 。_X t -> r

现在让我们创建一个Main.hs ,并测试一下我们做的东西。

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TemplateHaskell #-}

import TuplesTH

$(generateTupleClass 3)

如果我们现在在GHCi中加载这个,我们会得到:

>>> :l Main
[2 of 2] Compiling Main             ( Main.hs, interpreted )
Ok, two modules loaded.

>>> :i Tuple3
type Tuple3 :: * -> * -> Constraint
class Tuple3 t r | t -> r where
  _3 :: t -> r
  {-# MINIMAL _3 #-}
        -- Defined at Main.hs:8:3

Hooray!我们用TH做了一个类声明。现在我们来生成一些实例。按照与ClassD 相同的方式,我们可以使用InstanceD ,在Dec 中有一个构造函数,其形式为InstanceD (Maybe Overlap) Ctx Type [Dec] 。实例的AST应该是什么样子的呢?让GHCi再次来拯救我们吧!

>>> runQ [d|instance Tuple3 (a, b, c) c where _3 (_, _, c) = c|]
[InstanceD
  Nothing
  []
  (AppT (AppT (ConT Main.Tuple3) (AppT (AppT (AppT (TupleT 3) (VarT a_4)) (VarT b_5)) (VarT c_6))) (VarT c_6))
  [FunD
    Main._3 [Clause [TupP [WildP,WildP,VarP c_7]] (NormalB (VarE c_7)) []]]]

这有点冗长,但我们可以注意到两件有用的事情。第一个是我们有一个嵌套的序列AppT ,就像之前的元组类型。现在有趣的是,元组是如何被表示为一个AppT other_app_ts some_type_var 的序列,最后是TupleT 3 。我们可以使用foldlAppT 节点应用于一些VarT 变量,将TupleT 3 作为我们的折叠的初始值。在这之后,我们只需要在AppT (AppT (ConT our_class_name) result_of_our_fold) 里面应用折叠的结果。

第二件要注意的事情是,InstanceD 也期望有一个声明,在我们的例子中是一个函数,表示为FunD 。我们希望我们的元组有一个模式,其中第X个元组元素有一个变量模式,其他的是通配符。我们可以创建一个函数,获取我们想要生成getter的元素的索引和元组的总大小,并生成一个访问这个元素的实例。

考虑到这一点,我们可以创建我们的元组类型(t1, t2, ...) ,并生成一个类型TupleX (t1, t2, ...) tX ,这是我们的类的实例声明,其对应的方法声明是一个函数_X (_, _, ..., x, ...) = x ,对应于我们的存取器。

generateTupleInstance :: Int -> Int -> Q [Dec]
generateTupleInstance element size = do
  unless (size > 0) $
    fail $ "Non-positive size: " ++ element'
  unless (size >= element) $
    fail $ "Can't extract element " ++ element' ++ " of " ++ size' ++ "-tuple"
  pure [iDecl]
  where
    element' = show element
    size' = show size
    className = mkName ("Tuple" ++ element')
    methodName = mkName ('_' : element')

    x = mkName "x"

    vars = [mkName ('t' : show n) | n <- [1..size]]

    signature = foldl (\acc var -> AppT acc (VarT var)) (TupleT size) vars

    -- instance TupleX (t1, ..., tX, ...) tX where
    iDecl = InstanceD Nothing [] (AppT (AppT (ConT className) signature) (VarT $ mkName ('t' : element'))) [mDecl]
    --   _X (_, _, ..., x, ...) = x
    mDecl = FunD methodName [Clause [TupP $ replicate (element - 1) WildP ++ [VarP x] ++ replicate (size - element) WildP] (NormalB $ VarE x) []]

如果我们现在将$(generateTupleInstance 3 5) 添加到我们的Main 模块中,并在GHCi中加载它,我们可以看到这确实生成了一个实例。

>>> :i Tuple3
type Tuple3 :: * -> * -> Constraint
class Tuple3 t r | t -> r where
  _3 :: t -> r
  {-# MINIMAL _3 #-}
        -- Defined at Main.hs:8:3
instance Tuple3 (t1, t2, t3, t4, t5) t3 -- Defined at Main.hs:9:3

>>> _3 (42, "hello", '#', [], 3.14)
'#'

最后,让我们再添加一个TH函数,它将为所有62个元素以下的图元(这是GHC允许的最大图元大小)生成实例。我们希望为所有M元组生成N个实例,使N≤M。

generateTupleBoilerplate :: Int -> Q [Dec]
generateTupleBoilerplate size =
  concatFor [1..size] $ \classDeclIndex -> do
    cDecl <- generateTupleClass classDeclIndex
    iDecls <- for [1..classDeclIndex] $ \instanceDeclIndex ->
      generateTupleInstance instanceDeclIndex classDeclIndex

    pure $ concat (cDecl : iDecls)
  where
    concatFor xs = fmap concat . for xs

现在把你的Main 改为包含$(generateTupleBoilerplate 62) ,然后就可以了!我们有了图元的所有实例。

练习

  1. 改变TupleX 的定义,使每个类的声明也包含一个Tuple(X-1) 的约束。例如,class Tuple2 a => Tuple3 a,class Tuple1 a => Tuple2 a 等。注意Tuple1 不应该有任何约束。
  2. 创建一个mapX 方法来映射一个元组的第X个元素,类似于fmap