欢迎来到我们关于模板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教程不可能纯粹用类型化TH来写,因为它大量使用了
- 要求事先知道所使用的类型,这可能会限制你可以制作的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.hs 和Main.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]) ,如果我们不对它进行拼接,我们就会尝试在Integer 和Q (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
""
存在一个描述该问题的公开票据,但如果你遇到一些奇怪的错误,最好把它记在心里。