惰性求值

据说Haskell是一种惰性求值的语言,

表达式的求值策略是按需求值,即call-by-need。


但是到底什么才是按需的,

求值到什么程度。

刚开始学习的时候,总是很模糊。


随着学习的深入,才慢慢了解,

Haskell规范中,并没有涉及惰性求值,

只说它是一种non-strict language

求值策略取决于具体的实现。


这是怎么回事呢?

又要从头开始了。


non-strictness

non-strict function,是指称语义中的概念,

指称语义将每一段代码,与一个数学对象相对应,

借此来研究程序的语义。


一开始我们认为,程序中的函数,

会有直接的数学函数与之对应。


其实不然,

因为,程序中的很多函数,在处理某些参数的时候,

行为是“未定义的”,得不到有用的信息,

这样的数学函数称为部分函数(partial function)。


这里尽量不要提及“不能终止的”

因为指称语义并不考虑求值过程。


例如:

partialFn :: Integer -> Integer

partialFn 0 = 0

partialFn n = test $ n + 1


可以看到partial只在参数为0的时候是有意义的。

所以,与程序中的函数对应的,

就不是普通意义上的数学函数了,

还需要扩充定义域和值域


我们为每个集合增加一个新的值“”,称为bottom,

用来表示“未定义”。

函数f(n)={0,n=0;⊥,n≠0},就能表示partialFn了。


Haskell用undefined来表示⊥,

undefined也确实包含在所有类型中,

> :t undefined

> undefined :: a


通常情况下,

如果一个数学函数的参数是⊥,则结果就是⊥。

这样的函数称为strict function


而Haskell中函数则不同。

例如:

nonStrictFn :: Integer -> Integer

nonStrictFn 0 _ = 0


nonStrictFn 0 undefined > 0


函数nonStrictFn在参数包含undefined的时候,

并没有得到undefined,而是得到了一个明确的值0,

它对应的数学函数是non-strict function


函数的指称语义是non-strict function,

这样的语言,称为non-strict language。


lazy evaluation

求值,是操作语义中的概念。

能实现non-strict指称语义的求值策略并不是唯一的


GHC是Haskell目前最流行的编译器,

它使用了惰性求值(lazy evaluation)。


例如:

x = 1

y = 2

z = (x, y)


> :sprint x

> x = _

> :sprint y

> y = _

> :sprint z

> y = _


这里“:sprint”只是打印表达式,而不会求值它。

“_”用来表示“未求值的”,或称为一个“thunk”。


thunk在使用的时候,并不一定被完全求值

> let first = (u, _) = u

> first z

> 1


> :sprint x

> x = 1

> :sprint y

> y = _

> :sprint z

> y = (1, _)


first函数,只求值了第一个参数,

第二个参数,仍然是未求值的。


实际上,thunk的求值是分层次的

为了尽量少的求值,每一次只将表达式求值为WHNF(weak head normal form),

其中的子表达式,仍然是未求值的thunk。


如果不能满足需要,

就会继续将对应的sub-thunk,再求值为WHNF。


Weak head normal form

一个WHNF(weak head normal form),

是表达式求值的一种结果形式。


WHNF,只将表达式求值到,

最外层的值构造器或者lambda抽象为止。


例如:以下表达式是WHNF,

(1 + 1, 2 + 2),最外层是一个值构造器,(,)

-> 2 + 2,最外层是一个lambda抽象,

'h' : ("e" ++ "llo"),最外层是一个值构造器,(:)


以下表达式不是WHNF:

1 + 2,最外层是加法函数调用,(+)

(-> x + 1) 2,最外层是一个匿名函数调用

"he" ++ "llo",最外层是列表的连接函数调用,(++)


因此,

表达式(1 + 1, 2 + 2)会按需首先求值为(thunk1,thunk2),

如果还需要thunk1的值,thunk1会求值为2。


例如:

testFn1 _ = 1

testFn2 (, ) = 2

tettFn3 (u, _) = 3

这3个函数会导致表达式(1 + 1, 2 + 2)进行不同层次的求值。


seq

为了控制求值程度,

GHC内置了seq函数。


> :t seq

> seq :: a -> b -> b


它表示,在结果求值为WHNF之前

会先将seq的第一个参数求值为WHNF


例如:

u = 1

v = (u, u)


x = 1

y = seq x (x, x)


> :sprint u

> u = _

> :sprint v

> v = _


> :sprint x

> x = _

> :sprint y

> y = _


我们定义一个函数,用来把参数求值WHNF,

> let whnf (, ) = ()


则,

> whnf v

> ()

> whnf y

> ()


> :sprint u

> u = _

> :sprint v

> v = (, )


> :sprint x

> x = 1

> :sprint y

> y = (1, 1)


使用seq控制求值深度,

可以防止创建过多的thunk。


结语

当我们在REPL中输入一个表达式时,

实际上调用了print函数,

它会对表达式完全求值。


因此,表达式求值的层次问题,

是很难从REPL中看出来的


另一方面,WHNF将求值过程分成了一系列节点,

能清晰的刻画求值程度,

这可能是我们学习Haskell入门的起点吧。


参考:

Haskell/Denotational semantics

Haskell/Laziness

Haskell: What is Weak Head Normal Form?

Parallel and Concurrent Programming in Haskell

How Lazy Evaluation Works in Haskell

Lazy Evaluation of Haskell