关于haskell:为什么懒惰的评估lazy evaluation有用?

Why is lazy evaluation useful?

我一直在想,为什么懒惰的评价是有用的。我还没有人用一种有意义的方式向我解释;大多数情况下,最终都会演变成"相信我"。

注:我不是指记忆。


主要是因为它可以更有效——如果不使用值,就不需要计算它们。例如,我可以将三个值传递给一个函数,但根据条件表达式的序列,实际上只能使用一个子集。在C语言中,所有三个值都将被计算;但是在Haskell中,只计算必要的值。

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


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

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

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

1
minimum ls = head (quickSort ls)

它首先对列表进行排序,然后获取列表的第一个元素。但是,由于计算比较慢,只计算头部。例如,如果我们取列表中的最小值,那么Quicksort将首先过滤掉所有小于2的元素。然后它会快速排序(返回单例列表[1]),这已经足够了。由于懒惰的评估,剩下的永远不会排序,节省了大量的计算时间。

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

然而,这一切都有一个缺点:很难预测程序的运行时速度和内存使用情况。这并不意味着懒惰的程序会变慢或占用更多的内存,但最好知道。


我发现懒惰的评价对很多事情都有用。

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

纯语言允许您使用等式推理来解释函数定义。

1
foo x = x + 3

不幸的是,在非惰性设置中,返回的语句比惰性设置中的语句多,因此在像ML这样的语言中,这种方法不太有用。但是在惰性语言中,您可以安全地推断出相等性。

其次,在像haskell这样的懒惰语言中,不需要很多诸如ml中的"值限制"之类的东西。这导致了大量的语法混乱。类似于ML的语言需要使用诸如var或fun之类的关键字。在哈斯克尔,这些东西只能归结为一个概念。

第三,懒惰可以让您编写非常有功能性的代码,这些代码可以分块理解。在haskell中,通常编写如下函数体:

1
2
3
4
5
6
7
8
9
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子句,因为它知道该子句的内容将只在需要时进行计算。

在实践中,我们倾向于使用防护装置,并进一步折叠:

1
2
3
4
5
6
7
8
foo x y
  | condition1 = some (complicated set of combinators) (involving bigscaryexpression)
  | condition2 = bigscaryexpression
  | otherwise  = Nothing
  where some x y = ...
        bigscaryexpression = ...
        condition1 = ...
        condition2 = ...

第四,懒惰有时为某些算法提供更优雅的表达。哈斯克尔的一个懒惰的"快速排序"是一条直线,它的好处是,如果你只看前几个项目,你只需支付与选择这些项目成本成比例的成本。没有什么可以阻止您严格地执行此操作,但是您可能需要每次重新编码算法以获得相同的渐进性能。

第五,懒惰允许您在语言中定义新的控制结构。如果……然后…否则..'就像用严格的语言构造。如果试图定义如下函数:

1
2
if' True x y = x
if' False x y = y

在严格的语言中,无论条件值如何,都将对两个分支进行评估。当你考虑循环的时候情况会更糟。所有严格的解决方案都需要该语言为您提供某种报价或显式lambda构造。

最后,在同样的情况下,处理类型系统中副作用的一些最好的机制,例如monads,实际上只能在懒惰的环境中有效地表达出来。这可以通过比较F的工作流与haskell monads的复杂性来证明。(您可以用严格的语言定义monad,但不幸的是,由于缺乏懒惰和工作流程,您经常会违反一两条monad定律,相比之下,这会带来很多严格的负担。)


正常顺序评估和懒惰评估之间有区别(如Haskell)。

1
square x = x * x

正在计算以下表达式…

1
square (square (square 2))

…带着热切的评价:

1
2
3
4
5
6
> square (square (2 * 2))
> square (square 4)
> square (4 * 4)
> square 16
> 16 * 16
> 256

…正常顺序评估:

1
2
3
4
5
6
7
8
9
> (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

…带着懒惰的评价:

1
2
3
4
5
6
> (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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
square (square (square 2))

           ||
           \/

           *
          / \
          \ /
    square (square 2)

           ||
           \/

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

           ||
           \/

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

…而正常顺序评估只进行文本扩展。

这就是为什么在使用懒惰的评估时,我们变得更强大(评估比其他策略终止的频率更高),而性能相当于渴望的评估(至少在O符号中)。


与CPU相关的延迟评估与与与RAM相关的垃圾收集相同。GC允许您假装拥有无限的内存,从而根据需要请求内存中的任意多个对象。运行时将自动回收不可用的对象。le允许您假装拥有无限的计算资源—您可以根据需要进行任意多的计算。运行时不会执行不必要的(对于给定的情况)计算。

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

假设您有一个数字s和一个数字n的列表。您需要从列表s中找到最接近数字n的数字m。您可以有两个上下文:单个n和一些n的列表l(即,对于L中的每个n,您查找S中最接近的m)。如果使用lazy evaluation,则可以对s进行排序并应用二进制搜索,以查找最接近n的m。对于良好的lazy排序,对于均匀分布的l,需要对单个n和o(l n(size(s))*(size(s)+size(l))步骤执行o(size(s))。如果没有lazy evaluation以获得最佳效率,则必须为每个上下文实现算法。


如果你相信西蒙·佩顿·琼斯,那么懒惰的评价本身并不重要,只是作为一件"发衫",迫使设计师保持语言的纯净。我对这一观点表示同情。

理查德·伯德、约翰·休斯和拉尔夫·欣茨都能用懒惰的评价做出惊人的事情。阅读他们的作品会帮助你欣赏它。良好的起点是伯德伟大的数独解决方案和休斯的论文为什么函数式编程很重要。


考虑一个井字井字程序。它有四个功能:

  • 一种移动生成功能,它接受当前的电路板并生成新电路板的列表,每个新电路板应用一个移动。
  • 然后有一个"移动树"函数,该函数应用移动生成函数来从这个函数派生出所有可能的板位置。
  • 有一个minimax函数可以遍历树(或者可能只是其中的一部分),以找到最佳的下一步。
  • 有一个董事会评估功能,用于确定其中一名球员是否获胜。

这就创建了一个清晰的关注分离。尤其是移动生成函数和棋盘评估函数是唯一需要了解游戏规则的函数:移动树和minimax函数是完全可重用的。

现在让我们试着执行国际象棋而不是井字游戏。在"渴望"(即传统)的语言中,这不起作用,因为移动树不适合记忆。因此,现在需要将Board Evaluation和Move Generation函数与Move Tree和Minimax逻辑混合在一起,因为必须使用Minimax逻辑来决定要生成哪个移动。我们干净的模块化结构消失了。

然而,在一种懒惰的语言中,移动树的元素只根据minimax函数的要求生成:在我们释放minimax之前,不需要生成整个移动树。所以我们干净的模块化结构仍然适用于真正的游戏。


这里还有两个我认为还没有在讨论中提到的问题。

  • 惰性是并发环境中的同步机制。创建对某些计算的引用并在多个线程之间共享其结果是一种轻量级且简单的方法。如果多个线程试图访问一个未计算的值,那么只有其中一个线程会执行该值,其他线程将相应地阻塞该值,并在该值可用时接收该值。

  • 惰性是在纯设置中摊销数据结构的基础。冈崎在纯功能数据结构中详细描述了这一点,但其基本思想是,延迟评估是一种受控的突变形式,这对于我们高效地实现某些类型的数据结构至关重要。虽然我们经常说懒惰迫使我们穿纯发衬衫,但另一种方式也适用:它们是一对协同语言特性。


  • 当您打开计算机时,Windows会避免在Windows资源管理器中打开硬盘上的每个目录,也不会启动安装在计算机上的每个程序,直到您指出需要某个目录或需要某个程序,这就是"懒惰"评估。

    "懒惰"评估是在需要时执行操作。当它是编程语言或库的一个特性时,它是有用的,因为通常情况下,单独实现懒惰的评估比简单地预先计算所有东西要困难得多。


  • 它能提高效率。这看起来很明显,但实际上并不是最重要的。(还要注意,懒惰也会扼杀效率——这一事实并不是显而易见的。但是,通过存储大量的临时结果而不是立即计算它们,您可以使用大量的RAM。)

  • 它允许您用普通的用户级代码定义流控制结构,而不是将其硬编码到语言中。(例如,Java具有EDCOX1)4个循环;Haskell具有EDCOX1×4的函数。Java有异常处理;Haskell有各种类型的异常单元格。c有goto;haskell有继续的monad…)

  • 它允许您将生成数据的算法与决定要生成多少数据的算法分离开来。您可以编写一个函数来生成一个概念上无限的结果列表,以及另一个函数来处理尽可能多的列表。更重要的是,您可以有五个生成器函数和五个使用者函数,并且您可以有效地生成任何组合-而不是手动编码5 x 5=25函数,这些函数同时组合两个操作。(!)我们都知道脱钩是件好事。

  • 它或多或少地迫使您设计一种纯粹的函数式语言。总是有人想抄近路,但在一种懒惰的语言中,最轻微的不洁会使代码变得非常不可预测,这会严重妨碍走捷径。


  • 考虑一下:

    1
    2
    3
    if (conditionOne && conditionTwo) {
      doSomething();
    }

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

    这就是懒惰的评估兴趣的一个例子…


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

    1
    2
    3
    4
    5
    6
    7
    8
    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中的所有节点。

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

    1
    2
    3
    4
    5
    6
    7
    8
    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()上重用。

    对于lazy版本,appends是一个o(1)操作:它返回1个节点并挂起列表的实际重建。当您得到这个列表的头部时,它将评估节点的内容,强制它返回头部,并用剩余的元素创建一个悬挂,所以取列表的头部是一个O(1)操作。

    所以,我们的懒惰列表处于不断重建的状态,在遍历所有元素之前,您不需要为重建这个列表支付任何费用。使用laziness,这个列表支持o(1)考虑和附加。有趣的是,由于在节点被访问之前我们不会对其进行评估,因此完全可以用可能无限的元素构造一个列表。

    上面的数据结构不需要在每次遍历时重新计算节点,因此它们与.NET中普通的IEnumerable截然不同。


    惰性评估对于数据结构最有用。可以定义一个数组或向量,感应地只指定结构中的某些点,并用整个数组表示所有其他点。这使您能够非常简洁地生成数据结构,并具有较高的运行时性能。

    要看到这一点,你可以看看我的神经网络库,叫做本能。它充分利用了对优雅和高性能的懒惰评价。例如,我完全摆脱了传统的强制激活计算。一个简单的懒惰的表情对我来说是万能的。

    例如,在激活函数和反向传播学习算法中(我只能发布两个链接,因此您需要自己在AI.Instinct.Train.Delta模块中查找learnPat函数)。传统上两者都需要更复杂的迭代算法。


    这段代码显示了懒惰评估和非懒惰评估之间的区别。当然,这个斐波那契函数本身可以被优化,使用延迟计算代替递归,但这会破坏示例。

    让我们假设我们可能必须将前20个数字用于某些事情,而不是延迟评估,所有20个数字都必须预先生成,但是,使用延迟评估,它们将只在需要时生成。因此,您只需在需要时支付计算价格。

    样本输出

    1
    2
    3
    4
    Not lazy generation: 0.023373
    Lazy generation: 0.000009
    Not lazy output: 0.000921
    Lazy output: 0.024205
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    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)


    其他人已经给出了所有重要的原因,但我认为一个有用的练习来帮助理解为什么懒惰很重要,那就是尝试用严格的语言编写一个定点函数。

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

    1
    fix f = f (fix f)

    这扩展到

    1
    f (f (f ....

    但是因为haskell是懒惰的,所以无限的计算链是没有问题的;评估是"从外到内"的,所有的工作都非常出色:

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

    重要的是,重要的不是fix懒惰,而是f懒惰。一旦你已经得到了一个严格的f,你可以把你的手扔到空中然后放弃,或者ETA扩大它并把东西弄得乱七八糟。(这很像诺亚所说的,图书馆是严格/懒惰的,不是语言)。

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

    1
    2
    3
    4
    5
    6
    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参数:

    1
    2
    3
    4
    5
    6
    7
    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)

    我不知道你现在是怎么想的,但是我发现把懒惰的评估看作是一个图书馆的问题而不是语言特性是很有用的。

    我的意思是,在严格的语言中,我可以通过构建一些数据结构来实现懒惰的评估,在懒惰的语言中(至少是haskell),我可以在需要的时候要求严格。因此,语言选择并不会真正使程序变得懒惰或不懒惰,而是简单地影响默认情况下得到的结果。

    一旦你这样想了,那么想想你写了一个数据结构的所有地方,你以后可以用它来生成数据(在那之前不用看太多),你会发现很多用于懒惰的评估的用途。


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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    bool Function(void) {
      if (!SubFunction1())
        return false;
      if (!SubFunction2())
        return false;
      if (!SubFunction3())
        return false;

    (etc)

      return true;
    }

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

    1
    2
    3
    4
    5
    bool Function(void) {
      if (!SubFunction1() || !SubFunction2() || !SubFunction3() || (etc) )
        return false;
      return true;
    }

    一旦你开始使用它,你会看到越来越多的机会使用它。


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

    尽管Scheme、python等允许使用流的一维无限数据结构,但只能沿一维遍历。

    懒惰对于同样的边缘问题是有用的,但是值得注意的是在这个链接中提到的协程连接。


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

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

    例如,它并没有我们想要的那么好:sum . take 1000000 . drop 500 $ cycle [1..20]。它实际上是1000 000个数字的总和,即使是在一个循环中而不是在一个列表中;但它仍然应该简化为一个直接的数值计算,只有很少的条件和很少的公式。这比总结1000 000个数字要好得多。即使是在一个循环中,而不是在一个列表中(即在森林砍伐优化之后)。

    另一件事是,它可以用尾部递归的modulo-cons样式编写代码,而且它只起作用。

    参见相关答案。


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

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


    如果说"懒惰的评价",你的意思是像在梳理的布尔,像在

    1
       if (ConditionA && ConditionB) ...

    答案就是,程序消耗的CPU周期越少,运行速度越快…如果一大块处理指令对程序的结果没有影响,那么无论如何执行它们是不必要的(因此也是浪费时间的)。

    如果Otoh,你是指我所说的"懒惰的初始值设定项",如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    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));
              }
        }
    }

    好吧,这种技术允许使用类的客户机代码避免调用主管数据记录的数据库,除非使用Employee对象的客户机需要访问主管的数据…这使得实例化员工的过程更快,但是当您需要主管时,对主管属性的第一个调用将触发数据库调用,并且数据将被提取并可用…


    摘自高阶函数

    Let's find the largest number under 100,000 that's divisible by 3829.
    To do that, we'll just filter a set of possibilities in which we know
    the solution lies.

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

    We first make a list of all numbers lower than 100,000, descending.
    Then we filter it by our predicate and because the numbers are sorted
    in a descending manner, the largest number that satisfies our
    predicate is the first element of the filtered list. We didn't even
    need to use a finite list for our starting set. That's laziness in
    action again. Because we only end up using the head of the filtered
    list, it doesn't matter if the filtered list is finite or infinite.
    The evaluation stops when the first adequate solution is found.