懒惰评估为何有用?

时间:2008-11-05 15:00:41

标签: haskell functional-programming lazy-evaluation

我一直想知道懒惰评估为何有用。我还没有任何人以有道理的方式向我解释;最重要的是它最终沸腾到“相信我”。

注意:我不是指记忆。

22 个答案:

答案 0 :(得分:90)

主要是因为它可以更有效 - 如果不打算使用它们,则不需要计算值。例如,我可以将三个值传递给一个函数,但是根据条件表达式的顺序,实际上只能使用一个子集。在像C这样的语言中,无论如何都会计算所有三个值;但是在Haskell中,只计算了必要的值。

它还允许像无限列表这样的很酷的东西。我不能在像C这样的语言中拥有无限列表,但在Haskell中,这没有问题。无限列表在数学的某些领域经常使用,因此有能力操纵它们是有用的。

答案 1 :(得分:69)

懒惰评估的一个有用示例是使用quickSort

quickSort [] = []
quickSort (x:xs) = quickSort (filter (< x) xs) ++ [x] ++ quickSort (filter (>= x) xs)

如果我们现在想要找到列表的最小值,我们可以定义

minimum ls = head (quickSort ls)

首先对列表进行排序,然后获取列表的第一个元素。但是,由于懒惰的评估,只计算头部。例如,如果我们采用列表[2, 1, 3,]的最小值,则quickSort将首先过滤掉小于2的所有元素。然后它就做了quickSort(返回单例列表[1])已经足够了。由于懒惰的评估,其余的从不排序,节省了大量的计算时间。

这当然是一个非常简单的例子,但对于非常大的程序,懒惰的工作方式相同。

然而,所有这些都存在缺点:预测程序的运行时速度和内存使用量变得更加困难。这并不意味着懒惰的程序速度较慢或占用更多内存,但很高兴知道。

答案 2 :(得分:64)

我觉得懒惰的评价对很多事情很有用。

首先,所有现有的懒惰语言都是纯粹的,因为很难用惰性语言来解释副作用。

使用纯语言可以使用等式推理来推断函数定义。

foo x = x + 3

不幸的是,在非延迟设置中,与懒惰设置相比,更多语句无法返回,因此在ML等语言中这不太有用。但是在懒惰的语言中,你可以安全地推理平等。

其次,像Haskell这样的懒惰语言中不需要像ML中的“价值限制”这样的东西。这导致语法的大量整理。 ML喜欢的语言需要使用var或fun等关键字。在Haskell中,这些事情可以归结为一个概念。

第三,懒惰让你可以编写非常实用的代码,可以分解。在Haskell中,通常编写一个函数体,如:

foo x y = if condition1
          then some (complicated set of combinators) (involving bigscaryexpression)
          else if condition2
          then bigscaryexpression
          else Nothing
  where some x y = ...
        bigscaryexpression = ...
        condition1 = ...
        condition2 = ...

这可以让你通过理解函数体来“自上而下”工作。 ML类语言强制您使用严格评估的let。因此,你不敢把let子句“提升”到函数的主体,因为如果它昂贵(或有副作用),你不希望它总是被评估。 Haskell可以明确地将细节“推送”到where子句,因为它知道该子句的内容只会根据需要进行评估。

在实践中,我们倾向于使用警卫并进一步崩溃:

foo x y 
  | condition1 = some (complicated set of combinators) (involving bigscaryexpression)
  | condition2 = bigscaryexpression
  | otherwise  = Nothing
  where some x y = ...
        bigscaryexpression = ...
        condition1 = ...
        condition2 = ...
第四,懒惰有时会提供更优雅的某些算法表达。 Haskell中的一个懒惰的“快速排序”是一个单行,并且有一个好处,如果你只看前几个项目,你只需支付与选择那些项目的成本成比例的成本。没有什么可以阻止您严格执行此操作,但您可能不得不每次重新编码算法以实现相同的渐近性能。

第五,懒惰允许您在语言中定义新的控制结构。你不能写一个新的'if .. then .. else ..'就像用严格的语言构造一样。如果您尝试定义如下函数:

if' True x y = x
if' False x y = y

在严格的语言中,无论条件值如何,都将评估两个分支。考虑循环时会变得更糟。所有严格的解决方案都要求语言为您提供某种引用或明确的lambda构造。

最后,同样,在类型系统中处理副作用的一些最佳机制,例如monad,实际上只能在懒惰的环境中有效表达。这可以通过比较F#的工作流程与Haskell Monads的复杂性来见证。 (你可以用一种严格的语言来定义一个单子,但不幸的是,由于缺乏懒惰,你经常会失一个单一的法律,而工作流程通过比较来获得大量的严格包袱。)

答案 3 :(得分:28)

正常的订单评估与惰性评估之间存在差异(如在Haskell中)。

square x = x * x

评估以下表达式......

square (square (square 2))

......热切评价:

> square (square (2 * 2))
> square (square 4)
> square (4 * 4)
> square 16
> 16 * 16
> 256

...正常的订单评估:

> (square (square 2)) * (square (square 2))
> ((square 2) * (square 2)) * (square (square 2))
> ((2 * 2) * (square 2)) * (square (square 2))
> (4 * (square 2)) * (square (square 2))
> (4 * (2 * 2)) * (square (square 2))
> (4 * 4) * (square (square 2))
> 16 * (square (square 2))
> ...
> 256

...懒惰的评价:

> (square (square 2)) * (square (square 2))
> ((square 2) * (square 2)) * ((square 2) * (square 2))
> ((2 * 2) * (2 * 2)) * ((2 * 2) * (2 * 2))
> (4 * 4) * (4 * 4)
> 16 * 16
> 256

那是因为懒惰的评估会查看语法树并进行树转换......

square (square (square 2))

           ||
           \/

           *
          / \
          \ /
    square (square 2)

           ||
           \/

           *
          / \
          \ /
           *
          / \
          \ /
        square 2

           ||
           \/

           *
          / \
          \ /
           *
          / \
          \ /
           *
          / \
          \ /
           2

...而正常的订单评估只进行文本扩展。

这就是为什么我们在使用延迟评估时会变得更强大(评估比其他策略更频繁地终止),而性能相当于急切评估(至少在O表示法中)。

答案 4 :(得分:25)

如果你相信Simon Peyton Jones,懒惰的评价并不重要本身,而只是作为一件“发型衬衫”,迫使设计师保持语言纯洁。我发现自己同情这种观点。

理查德·伯德,约翰·休斯以及较小范围的拉尔夫·欣兹能够通过懒惰的评价做出惊人的事情。阅读他们的作品将有助于你欣赏它。关于良好的起点是Bird's magnificent Sudoku求解者和休斯关于Why Functional Programming Matters的论文。

答案 5 :(得分:25)

与CPU相关的懒惰评估与与RAM相关的垃圾收集相同。 GC允许您假装您拥有无限量的内存,因此可以根据需要在内存中请求尽可能多的对象。运行时将自动回收不可用的对象。 LE允许您假装您拥有无限的计算资源 - 您可以根据需要进行尽可能多的计算。运行时不会执行不必​​要的(对于给定的情况)计算。

这些“假装”模特的实际优势是什么?它会从管理资源中释放开发人员(在某种程度上),并从您的源中删除一些样板代码。但更重要的是,您可以在更广泛的上下文中有效地重用您的解决方案。

想象一下,你有一个数字S和一个数字N的列表。你需要找到最接近列表S的数字N数字M.你可以有两个上下文:单个N和一些N的列表L(每个的ei N in L你在S中查找最接近的M)。如果使用延迟评估,则可以对S进行排序并应用二进制搜索以找到最接近的M到N.对于良好的延迟排序,单个N和O将需要O(大小(S))步骤(ln(大小(S))* (大小(S)+大小(L)))均匀分布的L的步骤。如果您没有懒惰的评估来实现最佳效率,则必须为每个上下文实现算法。

答案 6 :(得分:13)

考虑一个tic-tac-toe计划。这有四个功能:

  • 移动生成功能,它接收当前电路板并生成每个应用一次移动的新电路板列表。
  • 然后有一个“移动树”功能,它应用移动生成功能来导出所有可能的板位置。
  • 有一个minimax函数可以遍历树(或者可能只是它的一部分)以找到最佳的下一步。
  • 有一个棋盘评估函数可以确定其中一个玩家是否赢了。

这创造了一个明确的关注点分离。特别是移动生成函数和板评估函数是唯一需要理解游戏规则的函数:移动树和极小极大函数完全可重用。

现在让我们尝试实现国际象棋而不是井字游戏。在“渴望”(即常规)语言中,这将不起作用,因为移动树将不适合存储器。因此,现在需要将板评估和移动生成函数与移动树和极小极大逻辑混合在一起,因为必须使用极小极大逻辑来决定生成哪些移动。我们漂亮干净的模块化结构消失了。

然而,在惰性语言中,移动树的元素仅在响应minimax函数的要求时生成:在我们让minimax在顶部元素上松散之前,不需要生成整个移动树。所以我们干净的模块化结构仍然适用于真实的游戏。

答案 7 :(得分:12)

在讨论中我还有两点我不相信。

  1. Laziness是并发环境中的同步机制。它是一种轻量级且简单的方法,可以创建对某些计算的引用,并在许多线程中共享其结果。如果多个线程尝试访问未评估的值,则只有其中一个执行它,而其他线程将相应地阻塞,一旦可用,就会接收该值。

  2. 懒惰是在纯粹环境中分摊数据结构的基础。这是Okasaki在 Purely Functional Data Structures 中详细描述的,但基本思想是懒惰评估是一种受控制的变异形式,对于允许我们有效地实现某些类型的数据结构至关重要。虽然我们经常谈到懒惰迫使我们穿纯洁的运动衫,但另一种方式也适用:它们是一对协同语言特征。

答案 8 :(得分:9)

当您打开计算机时,Windows禁止在Windows资源管理器中打开硬盘驱动器上的每个目录,并且不要启动计算机上安装的每个程序,直到您指示需要某个目录或某个程序是需要,那是“懒惰”的评价。

“懒惰”评估是在需要时执行操作。当它是编程语言或库的一个特性时非常有用,因为通常更难以自己实现惰性求值,而不仅仅是预先计算所有内容。

答案 9 :(得分:8)

  1. 它可以提高效率。这是一个看起来很明显的,但实际上并不是最重要的。 (另请注意,懒惰也可以杀死效率 - 这个事实并不是很明显。但是,通过存储大量临时结果而不是立即计算它们,你可以耗尽大量的RAM。)

  2. 它允许您在普通用户级代码中定义流控制结构,而不是将其硬编码到语言中。 (例如,Java有for个循环; Haskell有一个for函数.Java有异常处理; Haskell有各种类型的异常monad.C#有goto; Haskell有连续monad .. 。)

  3. 它允许您从算法中分离生成数据的算法,以决定生成多少数据。您可以编写一个函数来生成一个名义上无限的结果列表,另一个函数可以根据需要处理该列表中的大部分内容。更重要的是,您可以拥有五个生成器函数和五个使用者函数,并且您可以有效地生成任何组合 - 而不是手动编码5 x 5 = 25个组合的函数两个动作一下子。 (!)我们都知道脱钩是件好事。

  4. 它或多或少地迫使您设计功能语言。采取捷径总是诱人的,但是在一种懒惰的语言中,最轻微的杂质会使你的代码狂野不可预测,这极大地阻碍了快捷方式。

答案 10 :(得分:6)

考虑一下:

if (conditionOne && conditionTwo) {
  doSomething();
}

只有当conditionOne为true conditionTwo为true时,才会执行doSomething()方法。 在conditionOne为false的情况下,为什么需要计算conditionTwo的结果?在这种情况下,对条件2的评估将浪费时间,特别是如果您的条件是某些方法过程的结果。

这是懒惰评价兴趣的一个例子......

答案 11 :(得分:6)

懒惰的一个巨大好处是能够使用合理的摊销边界编写不可变数据结构。一个简单的例子是不可变堆栈(使用F#):

type 'a stack =
    | EmptyStack
    | StackNode of 'a * 'a stack

let rec append x y =
    match x with
    | EmptyStack -> y
    | StackNode(hd, tl) -> StackNode(hd, append tl y)

代码是合理的,但是在最佳,最差和平均情况下,附加两个堆栈x和y需要O(x长度)时间。附加两个堆栈是一个单片操作,它接触堆栈x中的所有节点。

我们可以将数据结构重写为惰性堆栈:

type 'a lazyStack =
    | StackNode of Lazy<'a * 'a lazyStack>
    | EmptyStack

let rec append x y =
    match x with
    | StackNode(item) -> Node(lazy(let hd, tl = item.Force(); hd, append tl y))
    | Empty -> y

lazy通过暂停其构造函数中的代码评估来工作。使用.Force()进行评估后,将返回值并在每个后续.Force()上重复使用。

对于延迟版本,追加是一个O(1)操作:它返回1个节点并暂停列表的实际重建。当您获得此列表的头部时,它将评估节点的内容,强制它返回头部并使用其余元素创建一个暂停,因此取列表的头部是O(1)操作。

因此,我们的惰性列表处于不断重建状态,在遍历其所有元素之前,您不需要为重建此列表付出代价。使用懒惰,此列表支持O(1)consing和append。有趣的是,由于我们不会在访问节点之前对其进行评估,因此完全有可能构建一个具有潜在无限元素的列表。

上面的数据结构不需要在每次遍历时重新计算节点,因此它们与.NET中的vanilla IEnumerables明显不同。

答案 12 :(得分:5)

延迟评估对数据结构最有用。您可以定义一个数组或向量,以归纳方式仅指定结构中的某些点,并根据整个数组表示所有其他点。这使您可以非常简洁地生成数据结构并具有高运行时性能。

要查看此操作,您可以查看名为instinct的神经网络库。它大量使用懒惰评估优雅和高性能。例如,我完全摆脱了传统的命令式激活计算。一个简单的懒惰表达式为我做了一切。

这用于例如activation function以及反向传播学习算法(我只能发布两个链接,因此您需要在{{1}中查找learnPat函数自己模块)。传统上两者都需要更复杂的迭代算法。

答案 13 :(得分:4)

此代码段显示了懒惰和非懒惰评估之间的区别。当然,这个斐波纳契函数本身可以进行优化,并使用延迟评估而不是递归,但这会破坏这个例子。

假设我们 MAY 必须使用20个第一个数字作为某些东西,而不是懒惰的评估,所有20个数字都必须预先生成,但是,通过延迟评估,它们将根据需要生成只要。因此,您只需在需要时支付计算价格。

示例输出

Not lazy generation: 0.023373
Lazy generation: 0.000009
Not lazy output: 0.000921
Lazy output: 0.024205
import time

def now(): return time.time()

def fibonacci(n): #Recursion for fibonacci (not-lazy)
 if n < 2:
  return n
 else:
  return fibonacci(n-1)+fibonacci(n-2)

before1 = now()
notlazy = [fibonacci(x) for x in range(20)]
after1 = now()
before2 = now()
lazy = (fibonacci(x) for x in range(20))
after2 = now()


before3 = now()
for i in notlazy:
  print i
after3 = now()

before4 = now()
for i in lazy:
  print i
after4 = now()

print "Not lazy generation: %f" % (after1-before1)
print "Lazy generation: %f" % (after2-before2)
print "Not lazy output: %f" % (after3-before3)
print "Lazy output: %f" % (after4-before4)

答案 14 :(得分:4)

其他人已经提出了所有重要理由,但我认为有助于理解懒惰问题的有用练习是尝试用严格的语言编写fixed-point函数。

在Haskell中,定点函数非常简单:

fix f = f (fix f)

这扩展到

f (f (f ....

但是因为Haskell是懒惰的,那无限的计算链是没有问题的;评估是“从外到内”完成的,一切都很有效:

fact = fix $ \f n -> if n == 0 then 1 else n * f (n-1)

重要的是,重要的是fix不是懒惰,而是 f 是懒惰的。一旦你已经获得了严格的f,你就可以将手放在空中并放弃,或者将它展开,然后将其弄得乱七八糟。 (这很像诺亚所说的图书馆严格/懒惰,而不是语言)。

现在想象一下在严格的Scala中编写相同的函数:

def fix[A](f: A => A): A = f(fix(f))

val fact = fix[Int=>Int] { f => n =>
    if (n == 0) 1
    else n*f(n-1)
}

你当然会得到一个堆栈溢出。如果您希望它工作,您需要按需要调用f参数:

def fix[A](f: (=>A) => A): A = f(fix(f))

def fact1(f: =>Int=>Int) = (n: Int) =>
    if (n == 0) 1
    else n*f(n-1)

val fact = fix(fact1)

答案 15 :(得分:3)

我不知道您目前的想法,但我发现将懒惰评估视为库问题而不是语言功能是有用的。

我的意思是在严格的语言中,我可以通过构建一些数据结构来实现延迟评估,并且在惰性语言(至少是Haskell)中,我可以在需要时请求严格。因此,语言选择并不会使您的程序变得懒惰或非懒惰,而只会影响您默认获得的程序。

一旦你想到这一点,那么想想你编写一个数据结构的所有地方,你可以在以后使用它来生成数据(在此之前不要过多地看待它),你会看到很多用于懒惰评估。

答案 16 :(得分:2)

我使用的最有用的延迟评估是一个按特定顺序调用一系列子函数的函数。如果这些子函数中的任何一个失败(返回false),则调用函数需要立即返回。所以我可以这样做:

bool Function(void) {
  if (!SubFunction1())
    return false;
  if (!SubFunction2())
    return false;
  if (!SubFunction3())
    return false;

(etc)

  return true;
}

或者,更优雅的解决方案:

bool Function(void) {
  if (!SubFunction1() || !SubFunction2() || !SubFunction3() || (etc) )
    return false;
  return true;
}

一旦开始使用它,您将看到越来越频繁地使用它的机会。

答案 17 :(得分:2)

如果没有懒惰的评价,你就不会写这样的东西:

  if( obj != null  &&  obj.Value == correctValue )
  {
    // do smth
  }

答案 18 :(得分:2)

除此之外,懒惰语言允许多维无限数据结构。

虽然scheme,python等允许使用流的单维无限数据结构,但只能遍历一个维度。

懒惰对same fringe problem很有用,但值得注意的是该链接中提到的协同程序连接。

答案 19 :(得分:2)

懒惰的评估是穷人的等式推理(理想情况下,可以预期从所涉及的类型和操作的属性中推导出代码的属性)。

效果很好的示例:sum . take 10 $ [1..10000000000]。我们不介意减少到10个数字的总和,而不是仅仅一个直接和简单的数值计算。如果没有懒惰的评估,这将在内存中创建一个巨大的列表,只是为了使用它的前10个元素。它肯定会非常慢,并可能导致内存不足错误。

例如,它不如我们所希望的那样伟大:sum . take 1000000 . drop 500 $ cycle [1..20]。这实际上将总计1 000 000个数字,即使是在循环中而不是在列表中;仍然简化为一个直接的数值计算,几乎没有条件和几个公式。哪个比总计1 000 000个数字要好得多。即使在循环中,也不在列表中(即在砍伐森林优化之后)。


另一件事是,它可以用tail recursion modulo cons样式进行编码,只能正常工作

比照related answer

答案 20 :(得分:1)

如果通过“懒惰的评价”,你的意思是像梳理布尔,就像在

   if (ConditionA && ConditionB) ... 

那么答案很简单就是程序消耗的CPU周期越少,运行的速度就越快......如果一大块处理指令对程序的结果没有影响,那么它是不必要的,(和因此浪费时间来执行它们......

如果是otoh,你的意思是我称之为“懒惰的初始化者”,如:

class Employee
{
    private int supervisorId;
    private Employee supervisor;

    public Employee(int employeeId)
    {
        // code to call database and fetch employee record, and 
        //  populate all private data fields, EXCEPT supervisor
    }
    public Employee Supervisor
    { 
       get 
          { 
              return supervisor?? (supervisor = new Employee(supervisorId)); 
          } 
    }
}

嗯,这种技术允许使用该类的客户端代码避免为Supervisor数据记录调用数据库的需要,除非使用Employee对象的客户端需要访问管理程序的数据...这使得实例化的过程成为可能。员工速度更快,但是当您需要Supervisor时,第一次调用Supervisor属性将触发数据库调用,数据将被提取并可用...

答案 21 :(得分:0)

摘自Higher order functions

  

让我们找到可以被3829整除的100,000以下的最大数字。   为此,我们只是过滤了我们所知道的一系列可能性   解决方案就在于此。

largestDivisible :: (Integral a) => a  
largestDivisible = head (filter p [100000,99999..])  
    where p x = x `mod` 3829 == 0 
  

我们首先列出所有低于100,000的数字,然后降序。   然后我们通过谓词过滤它,因为数字是排序的   以下降的方式,满足我们的最大数量   谓词是筛选列表的第一个元素。我们甚至没有   需要使用有限列表作为我们的起始集。那个懒惰的人   再次行动。因为我们最终只使用了过滤后的头部   如果过滤列表是有限的或无限的,那么无关紧要。   当找到第一个适当的解决方案时,评估将停止。