Typed Template Haskell的简短概述

245 阅读5分钟

欢迎来到我们关于模板Haskell的第二篇文章!

今天我们将快速了解一下类型化的模板哈士奇。这篇文章假设你已经对模板哈士奇(TH)有了一定的了解。如果这是你第一次接触TH,那么请先看看我们的模板Haskell介绍

在这篇文章中,我们将使用GHC 8.10.4。

为什么是类型化的TH?

类型化的TH,顾名思义,允许我们对元程序的正确性提供更强的静态保证。在非类型化的TH中,生成的表达式在拼接时,也就是在使用过程中,而不是在定义过程中,将被进行类型检查。用类型化的TH,这些表达式现在是在它们的定义处进行类型检查的。

就像计算机科学中的其他东西一样,与普通的TH相比,使用类型化的TH也有优点和缺点,下面列出了其中的一些。

优点:

  • 更大的类型安全保证。
  • 错误不会被推迟到使用时才出现,相反,它们会在其定义上被报告。

缺点:

  • 必须与[|| ||] quoter一起使用。
    • 这意味着我们不能轻易地直接使用Exp 构造函数。
    • 相比之下,对于无类型的quoter,我们可以使用[| |] ,或者直接调用Exp 构造函数。
    • 另外,你可以使用 unsafeCodeCoerce来解决这个问题,如果你愿意使用不安全的函数。
  • 只支持Exp 的类型化版本(不支持Dec,Pat, 等的类型化版本)。
    • 我们之前的TH教程不可能纯粹用类型化TH来写,因为它大量使用了Dec ,比如说。
  • 要求事先知道所使用的类型,这可能会限制你可以制作的TH程序的种类。

在我们开始之前,请确保你已经安装了 template-haskell包,以及启用了TemplateHaskell 语言扩展。

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

类型化表达式

在之前的教程中,我们了解到我们可以使用[e|...|] quoter(与[|...|] 相同)来创建Q Exp 类型的表达式。通过类型化TH,我们将使用[e||...||] (与[||...||] 相同)来创建Q (TExp a) 类型的表达式。

你可能会问,什么是TExp a ?它只是我们熟悉的Exp 的一个newtype 封装器。

type role TExp nominal
newtype TExp (a :: TYPE (r :: RuntimeRep)) = TExp
  { unType :: Exp
  }

TYPE (r :: RuntimeRep) 部分的含义对我们来说并不重要,简单地说,它允许GHC在运行时描述如何表示一些类型(盒装的,非盒装的,等等)。更多的信息,请看引力多态性(levity polymorphism)。

这使得我们可以使用我们熟悉的结构来表示Exp ,此外还有一个表示表达式类型的类型a 。这为我们的TH应用提供了更强的类型安全机制,这将导致编译器在构建过程中拒绝无效的TH程序。

在下面的例子中,template-haskell 高兴地接受了使用非类型表达式的42 :: String ,而类型化的对应程序则以类型错误拒绝了它。

>>> runQ [|42 :: String|]
SigE (LitE (IntegerL 42)) (ConT GHC.Base.String)

>>> runQ [||42 :: String||]
<interactive>:358:9: error:
    • Could not deduce (Num String) arising from the literal ‘42’
      from the context: Language.Haskell.TH.Syntax.Quasi m
        bound by the inferred type of
                   it :: Language.Haskell.TH.Syntax.Quasi m => m (TExp String)
        at <interactive>:358:1-23
    • In the Template Haskell quotation [|| 42 :: String ||]
      In the first argument of ‘runQ’, namely ‘[|| 42 :: String ||]’
      In the expression: runQ [|| 42 :: String ||]

类型化的拼接

就像我们有诸如$foo 这样的非类型接续,现在我们也有类型接续,写成$$foo 。然而,请注意,如果你的GHC版本低于9.0,你可能需要写成$$(foo)

例子:计算素数

作为一个例子,让我们考虑一下下面的函数,这些函数实现了质数的计算,直到某个数字。我们将制作两个版本,一个用普通的Haskell,另一个用Template Haskell,这样我们可以看到它们之间的差异。为了演示类型化TH中的技术,并与普通函数进行对比,该实现可能会有些冗长。

首先,创建一个包含两个函数的文件Primes.hs :一个是检查一个给定的数字是否是素数,另一个是生成素数,直到某个给定的极限。

module Primes where

isPrime :: Integer -> Bool
isPrime n
  | n <= 1    = False
  | n == 2    = True
  | even n    = False  -- No even number except for 2 is prime
  | otherwise = go 3
  where
    go i
      | i >= n         = True  -- We saw all smaller numbers and no divisors, so it's prime
      | n `mod` i == 0 = False
      | otherwise      = go (i + 2)  -- Iterate through the odd numbers

primesUpTo :: Integer -> [Integer]
primesUpTo n = go 2
  where
    go i
      | i > n     = []
      | isPrime i = i : go (i + 1)
      | otherwise = go (i + 1)

第一个函数检查一个数字是否有任何除数。如果它有任何除数(除了1和它本身),那么这个数就是复合数,该函数返回False ,否则它将继续测试更多的除数。如果我们达到一个大于或等于输入的数字,这意味着我们已经检查了所有较小的数字,没有发现除数,所以这个数字是素数,函数返回True

第二个函数简单地迭代了所有的数字,收集所有的素数。我们从2开始,因为它是第一个质数。

请记住,这些函数是非常低效的,所以请确保使用更优化的版本来处理任何严重的问题!

现在是我们的模板Haskell版本。像往常一样,让我们创建两个文件,TH.hsMain.hs ,通过这个例子来工作。

这就是应该在TH.hs

{-# LANGUAGE TemplateHaskell #-}

module TH where

import Language.Haskell.TH

import Primes (isPrime)

primesUpTo' :: Integer -> Q (TExp [Integer])
primesUpTo' n = go 2
  where
    go i
      | i > n     = [||[]||]
      | isPrime i = [||i : $$(go (i + 1))||]
      | otherwise = [||$$(go (i + 1))||]

一般来说,它和普通版本的内容是一样的。现在唯一的区别是,我们返回一个Q (TExp [Integer]) ,并在类型化表达式quoter中生成我们的列表。

我们把对go 的递归调用包裹在拼接中。由于go 的类型是Q (TExp [Integer]) ,如果我们不对它进行拼接,我们就会尝试在IntegerQ (TExp [Integer]) 上使用 cons 操作符 (:),这样就不会进行类型检查。一个错误信息可以很好地描述这个问题。

>>> :l TH
[2 of 2] Compiling TH               ( TH.hs, interpreted )
Failed, no modules loaded.
TH.hs:15:21: error:
    • Couldn't match type ‘Q (TExp [Integer])’ with ‘[Integer]’
      Expected type: Q (TExp [Integer])
        Actual type: Q (TExp (Q (TExp [Integer])))
    • In the Template Haskell quotation [|| (go (i + 1)) ||]
      In the expression: [|| (go (i + 1)) ||]
      In an equation for ‘go’:
          go i
            | i > n = [|| [] ||]
            | isPrime i = [|| i : $$(go (i + 1)) ||]
            | otherwise = [|| (go (i + 1)) ||]
   |
15 |       | otherwise = [||(go (i + 1))||]
   |                     ^^^^^^^^^^^^^^^^^^

事实上,我们可以把上面的分支简单地写成go (i + 1) ,而不使用quoter。试试吧!

现在我们可以像这样在GHCi中使用我们的新函数。

>>> $$(primesUpTo' 100)
[2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97]

如果我们愿意的话,我们也可以把它作为一个无类型的模板Haskell定义来检查,使用unType 函数。

>>> runQ (unType <$> primesUpTo' 10)
InfixE (Just (LitE (IntegerL 2))) (ConE GHC.Types.:) (Just (InfixE (Just (LitE (IntegerL 3))) (ConE GHC.Types.:) (Just (InfixE (Just (LitE (IntegerL 5))) (ConE GHC.Types.:) (Just (InfixE (Just (LitE (IntegerL 7))) (ConE GHC.Types.:) (Just (ConE GHC.Types.[]))))))))

或者,更简单地说:

2 : 3 : 5 : 7 : []

如果我们在定义中犯了任何错误,例如,使用下面的定义,我们忘记了一个递归调用。

primesUpTo' :: Integer -> Q (TExp [Integer])
primesUpTo' n = go 2
  where
    go i
      | i > n     = [||[]||]
      | isPrime i = [||i||]  -- We forgot to build a list here
      | otherwise = go (i + 1))

那么我们就会立即遇到一个类型错误。

>>> :r
[2 of 2] Compiling TH               ( TH.hs, interpreted )
Failed, no modules loaded.
TH.hs:14:21: error:
    • Couldn't match type ‘Integer’ with ‘[a]’
      Expected type: Q (TExp [a])
        Actual type: Q (TExp Integer)
    • In the Template Haskell quotation [|| i ||]
      In the expression: [|| i ||]
      In an equation for ‘go’:
          go i
            | i > n = [|| [] ||]
            | isPrime i = [|| i ||]
            | otherwise = [|| $$(go (i + 1)) ||]
    • Relevant bindings include
        go :: Integer -> Q (TExp [a]) (bound at TH.hs:43:5)
   |
14 |       | isPrime i = [||i||]
   |                     ^^^^^^^

我们的primesUpTo' 将在编译时生成素数列表,现在我们可以在运行时使用这个列表来检查数值。

有了这个,我们就可以创建我们的Main.hs ,在那里我们可以尝试我们的代码。

{-# LANGUAGE TemplateHaskell #-}

import TH

main :: IO ()
main = do
  let numbers = $$(primesUpTo' 10000)
  putStrLn "Which prime number do you want to know?"
  input <- readLn  -- n.b.: partial function
  if input < length numbers
    then print (numbers !! (input - 1))
    else putStrLn "Number too big!"

就这样了!一个使用类型化TH的非常简单的程序。在GHCi中加载Main.hs ,当它被加载几秒钟后,运行我们的main 函数。一旦被要求输入,输入一个数字,如200,要求输入第200个质数。该函数应该输出1223这个正确的结果

>>> main
Which prime number do you want to know?
200
1223

同样,我们的算法是相当低效的,这可能需要几秒钟的时间来编译(因为它在编译时生成数字),为了进一步改进,可能有一个不那么幼稚的生成素数的算法是个好主意,但为了教育目的,现在就可以了。

一个更短的实现

如前所述,我们可以用更简单的方式来实现上述函数,比如说:

primesUpTo :: Integer -> [Integer]
primesUpTo n = filter isPrime [2 .. n]

而相应的TH函数为:

primesUpTo' :: Integer -> Q (TExp [Integer])
primesUpTo' n = [|| primesUpTo n ||]

有了这个,你就可以在野外使用类型化的模板哈士奇了。

注意事项

类型化的Template Haskell在解决重载问题上可能会有一些困难。令人惊讶的是,下面的内容并没有进行类型检查。

>>> mempty' :: Monoid a => Q (TExp a)
... mempty' = [|| mempty ||]

>>> x :: String
... x = id $$(mempty')
<interactive>:549:11: error:
    • Ambiguous type variable ‘a0’ arising from a use of ‘mempty'’
      prevents the constraint ‘(Monoid a0)’ from being solved.
      Probable fix: use a type annotation to specify what ‘a0’ should be.
      These potential instances exist:
        instance Monoid a => Monoid (IO a) -- Defined in ‘GHC.Base’
        instance Monoid Ordering -- Defined in ‘GHC.Base’
        instance Semigroup a => Monoid (Maybe a) -- Defined in ‘GHC.Base’
        ...plus 7 others
        (use -fprint-potential-instances to see them all)
    • In the expression: mempty'
      In the Template Haskell splice $$(mempty')
      In the first argument of ‘id’, namely ‘$$(mempty')’

在这种情况下,注释mempty' 可能会解决它。

>>> x :: String
... x = id $$(mempty' :: Q (TExp String))

>>> x
""

存在一个描述该问题的公开票据,但如果你遇到一些奇怪的错误,最好把它记在心里。