Scala中的按名称调用与Haskell中的懒惰评估



Haskell懒惰的评估永远不会比热切的评估花费更多的评估步骤。

另一方面,Scala的按名称调用求值可能需要比按值调用更多的求值步骤(如果短路的好处被重复计算的成本所抵消)。

我认为直呼其名大致相当于懒惰的评价。那么,为什么在时间保证上会有这样的差异呢?

我猜测,也许Haskell语言指定在评估过程中必须使用记忆;但既然如此,Scala为什么不这么做呢?

评估策略的名称有一定的广度,但大致可以归结为:

  • 按名称调用参数几乎只是以调用函数时的任何(未赋值)形式替换到函数体中。这意味着它可能需要在体内进行多次评估。

    在Scala中,您可以将其写成:

    scala> def f(x:=> Int): Int = x + x
    scala> f({ println("evaluated"); 1 })
    evaluated
    evaluated
    2
    

    在Haskell中,您没有内置的方法来实现这一点,但您可以始终将按名称调用值表示为() -> a类型的函数。不过,这有点模糊,因为引用透明性——你将无法像使用Scala那样测试它(编译器可能会优化掉调用中的"按名称"部分)。

  • 按需调用(lazy…有点像)在调用函数时不计算参数,但在第一次调用时需要。此时,它也被缓存。之后,每当再次需要参数时,都会查找缓存的值。

    在Scala中,你不会声明你的函数参数是惰性的,而是声明惰性:

    scala> lazy x: Int = { println("evaluated"); 1 }
    scala> x + x
    evaluated
    2
    

    在Haskell中,默认情况下所有函数都是这样工作的。

  • 按值调用(热切,几乎每种语言都是这样做的)参数在调用函数时进行求值,即使函数最终没有使用这些参数。

    在Scala中,函数默认是这样工作的。

    scala> def f(x: Int): Int = x + x
    scala> f({ println("evaluated"); 1 })
    evaluated
    2
    

    在Haskell中,您可以在函数参数上使用bang模式强制执行这种行为:

    ghci> :{
    ghci> f :: Int -> Int
    ghci> f !x = x
    ghci> :}
    

所以,如果按需调用(lazy)执行的评估与其他策略一样多或一样少,为什么要使用其他策略呢

懒惰评估是很难推理的,除非你有引用透明度,因为你需要准确地计算出你的懒惰值是在什么时候评估的。由于Scala是为与Java进行互操作而构建的,因此它需要支持命令式、副作用式编程。因此,在许多情况下在Scala中使用lazy不是一个好主意。

此外,lazy还有一个性能开销:您需要有一个额外的间接性来检查是否已经评估了值。在Scala中,这转化为更多的对象,这给垃圾收集器带来了更大的压力。

最后,在有些情况下,惰性评估会留下"空间"泄漏。例如,在Haskell中,通过将数字相加来从右边折叠一个大的数字列表是一个坏主意,因为Haskell在评估它们之前会建立一系列对(+)的懒惰调用(而实际上你只需要它有一个累加器。即使在简单的上下文中,你也会遇到空间问题的一个著名例子是foldrvsfoldlvsfoldl'

我不知道Scala

为什么没有 结果做了"适当的"惰性评估——很可能实现起来并不那么简单,尤其是当你希望语言与JVM顺利交互时。

按名称调用(正如您所观察到的)并不等同于延迟求值,而是将a类型的参数替换为() -> a类型的参数。这样的函数包含与普通a值相同数量的信息(类型是同构的),但要真正达到该值,您总是需要将函数应用于()伪参数。当你对函数求值两次时,你会得到两次相同的结果,但每次都必须重新计算(因为自动记忆函数是不可行的)。

惰性求值相当于用行为类似以下OO类的类型的参数替换a类型的参数:

class Lazy<A> {
function<A()> computer;
option<A> containedValue;
public:
Lazy(function<A()> computer):
computer = computer
, containerValue = Nothing
{}
A operator()() {
if isNothing(containedValue) {
containedValue = Just(computer());
}
return fromJust(containedValue);
}
}

这本质上只是一个基于名称-函数类型的特定调用的记忆包装器。不太好的是,这个包装器在根本上依赖于副作用:当第一次计算懒惰值时,必须对containedValue进行变异,以表示该值现在已知。Haskell在其运行时的核心就已经对这种机制进行了严格的测试,并对线程安全性等进行了良好的测试。但在一种试图尽可能公开地使用命令式VM的语言中,如果这些虚假的突变与显式的副作用交织在一起,可能会引起巨大的头痛。特别是,因为lazyness真正有趣的应用程序不仅仅有一个函数参数lazy(这不会给你带来太多好处),而是将lazy值分散到深层数据结构的整个主干中。最后,这不仅仅是一个延迟函数的求值晚于进入懒惰函数,而是懒惰数据结构被消耗时对这些函数的嵌套调用(实际上,可能是无限多!)的整个洪流

因此,Scala通过默认情况下不让任何东西变得懒惰来避免这种危险,尽管正如Alec所说,它确实提供了一个lazy关键字,基本上是在值中添加了一个类似上面的记忆函数包装器。

这可能很有用,但并不适合注释。

您可以在Scala中编写一个函数,它的行为类似于Haskell的按需调用(对于参数),方法是按名称调用参数,并在函数开始时对其进行惰性求值:

def foo(x: => Int) = {
lazy val _x = x
// make sure you only use _x below, not x
}

最新更新