Python的禅宗教导我们:"应该有一种--最好是只有一种--明显的方法来做"。是的,所以这一点绝对不适用于 Haskell,在那里我们通常有许多方法来解决任何特定的问题,每一种方法都有狂热的拥护者。这就是扎根于学术界的语言的本质:我们尝试各种方法,看看哪些方法最有效。
这就是为什么你将要看到的不是一种、两种,而是三种在Haskell中进行编译时评估的方法(可能还有更多,但我们不要太深奥了)。
为了开始,我们需要一个计算量很大的函数来作为我们的例子。现在大家可能都知道,Haskell的主要用例是计算斐波那契数(或者是阶乘?这里有一个慢得可怕的fib 的实现。
fib :: Natural -> Natural
fib 0 = 0
fib 1 = 1
fib k = fib (k - 2) + fib (k - 1)
main = print (fib 42)
当用-O2 编译时,这个程序在我的机器上运行了大约10秒钟才打印出答案,即267914296。之所以需要这么长时间,是因为有很多多余的重新计算,而且中间结果没有被记忆。这正是我们所需要的,看看GHC在编译时能做多好的事情。
模板Haskell
Haskell以Template Haskell的形式拥有极其强大的元编程设施。虽然不是特别符合人体工程学,但它可以完成工作,并提供在编译时执行任意代码的能力。
Template Haskell背后的想法是编写程序,生成其他程序,由其抽象语法树(AST)表示。由于我们的用例很简单,所以我们只需要了解两个活动部分:
- 一个值可以通过以下方式转换为相应的AST
[lift](https://hackage.haskell.org/package/template-haskell/docs/Language-Haskell-TH-Syntax.html#v:lift)函数 - 生成的AST可以通过拼接的方式插入到我们的程序中,表示为
$(…)
基本上,这是一个用$(lift (fib 42)) 替换fib 42 的问题。不幸的是,当我们试图这样做时,我们会看到以下错误:
EvalWithTemplateHaskell.hs:13:22: error:
• GHC stage restriction:
'fib' is used in a top-level splice, quasi-quote, or annotation,
and must be imported, not defined locally
• In the untyped splice: $(lift (fib 42))
幸运的是,这很容易解决(虽然很烦人)。如果我们把fib 的定义移到一个单独的模块中,它就可以在拼接中使用而不会有任何问题:
main = print $(lift (fib 42))
现在,构建这个程序需要10秒钟,但在运行时却能立即打印出结果。这是因为在拼接时(Template Haskell被执行时),它被转换为这样:
main = print 267914296
类型族
一个完全不同的方法是使用类型级编程。让我们把fib 改写成一个封闭的类型族:
type family Fib n where
Fib 0 = 0
Fib 1 = 1
Fib k = Fib (k - 2) + Fib (k - 1)
在这里,我们也利用DataKinds 的扩展来处理类型级的数字。现在我们可以在一个值的类型中得到我们想要的结果。比如说:
ghci> fib21 = Proxy :: Proxy (Fib 21)
ghci> :t fib21
fib21 :: Proxy 10946
不幸的是,当我用Fib 42 ,GHCi消耗了我所有的内存并死了。无论如何,我们在这里还没有完成:我们应该得到一个打印出结果的可执行文件。这就是KnownNat 类和它的方法natVal 的作用了:
main = print (natVal (Proxy @(Fib 42)))
有趣的是,这次它编译得非常快,只用了很少的内存。我甚至让它计算了Fib 202100 ,这是一个超过4万位的数字。很明显,这里面涉及到了某种还原缓存,否则我们的天真实现就会花上很长时间。
因此,我们从内存外崩溃到自动渐进式改进......通过做一些与类型族本身完全无关的事情。哦,天哪,类型检查的性能是不稳定的。我希望我们有一个好的类型级特性的成本模型,但目前让编译时间降低是一门黑暗的艺术。
虽然如此,我们最终的结果确实比用模板Haskell时要好得多 :-)
功能依赖性
类型族并不是欺骗类型检查器为你进行计算的唯一方法。还有一些功能依赖。它们的主要用途是改善多参数类型类的类型推理,但程序员滥用编译器的聪明才智是无止境的,所以我们开始吧:
class Fib (n :: Nat) (r :: Nat) | n -> r
instance Fib 0 0
instance Fib 1 1
instance {-# OVERLAPPABLE #-}
( Fib (k - 1) f1,
Fib (k - 2) f2,
EQUALS r (f1 + f2) ) => Fib k r
这里还有一些支持性的定义:
class EQUALS (a :: Nat) (b :: Nat) | a -> b, b -> a
instance EQUALS a a
fibVal :: forall n r. (KnownNat r, Fib n r) => Proxy n -> Natural
fibVal Proxy = natVal (Proxy @r)
这一次Fib ,既不是一个函数,也不是一个类型族,而是一个多参数类型类!它的第一个参数 ,是输入值。它的第一个参数n 是输入,它的第二个参数r 是输出,这是由函数依赖关系n -> r 。
实例起到了定义方程的作用。由于它们是无序的,但不是不相交的,我们必须利用{-# OVERLAPPABLE #-} pragma来降低最一般实例的优先级。
计算发生在约束解的过程中。这一次,我们并不幸运,试图计算第42个斐波那契数的时候,内存耗尽,失败了。作为一个安慰奖,我们仍然可以计算第21个斐波那契数。
main = print (fibVal (Proxy @21))
这需要大约3秒的时间来编译,并立即打印出结果。
总结
在Haskell中的编译时评估可以通过模板Haskell轻松实现。有时类型族会做得更好,但它们的性能是不可靠的。功能依赖是最深奥的方法,但它在有限的情况下也能发挥作用。
让我们知道哪种方法在你看来更有趣、更有前途,我们将在下一篇博文中更详细地介绍它