精华 lazy.js 惰性求值实现分析
发布于 1 个月前 作者 luobotang 536 次浏览 来自 分享

背景:惰性求值?

来看一个 lazy.js 主页提供的示例:

var people = getBigArrayOfPeople();
var results = _.chain(people)
  .pluck('lastName')
  .filter(function(name) { return name.startsWith('Smith'); })
  .take(5)
  .value();

上例中,要在非常非常多的人里面,找出 5 个以 Smith 开头的 lastName。如果在上面的 pluck() 和 filter() 的过程中,每一步都产生了临时数组,也就是说对上一步返回的数据执行了一次循环、处理的过程,那么整个查找的过程可能会花费很长的时间。

不采用上面的这种写法,单纯为了性能考虑,可以这样处理:

var results = [];
for (var i = 0; i < people.length; ++i) {
  var lastName = people[i].lastName;
  if (lastName.startsWith('Smith')) {
    results.push(value);
    if (results.length === 5) {
      break;
    }
  }
}

首先,对于原始数据,只做一次循环,单次循环中完成所有的计算。其次,由于只需要 5 个最终结果,所以,一旦得到了 5 个结果,就终止循环。

采取这种处理方法,对于比较大的数组,在计算性能上应该会有明显的提升。

不过,如果每次遇到这种类似的情形,为了性能,都要手写这样的代码,有点麻烦,而且代码不清晰,不便于理解、维护。第一个例子中的写法要好些,明显更易读一些,但是对于性能方面有些担忧。

所以,如果可以结合第一个例子中的写法,但又能有第二个例子中的性能提升,岂不是很好?

接下来再说说“惰性求值”了。

Lazy evaluation - wikipedia https://en.wikipedia.org/wiki/Lazy_evaluation

In programming language theorylazy evaluation, or call-by-need is an evaluation strategy which delays the evaluation of an expression until its value is needed (non-strict evaluation) and which also avoids repeated evaluations (sharing).

用我的话来表达,惰性求值就是:对于一个表达式,在不需要值的时候不计算,需要的时候才计算

JavaScript 并非从语言层面就支持这样的特性,而是要通过一些技术来模拟、实现。

首先,不能是表达式,表达式在 JS 中是立即求值的。所以,要像第一个例子中的那样,将求值的过程包装为函数,只在需要求值的时候才调用该函数。

然后,延迟计算还需要通过“精妙的”设计和约定来实现。对于第一个例子,pluck()、filter()、take() 方法在调用时,不能直接对原始数据进行计算,而是要延迟到类似 value() 这样的方法被调用时再进行。

在分析 Lazy.js 的惰性求值实现前,先总结下这里要讨论的借助惰性求值技术来实现的目标:

  • 良好的代码可读性
  • 良好的代码执行性能

而对于 Lazy.js 中的惰性求值实现,可以总结为:

  • 收集计算需求
  • 延迟并优化计算的执行过程

分析:Lazy.js 的惰性求值实现

与 Underscore、Lo-Dash 不同,Lazy.js 只提供链式的、惰性求值的计算模式,这也使得其惰性求值实现比较“纯粹”,接下来就进入 Lazy.js 的实现分析了。

先看下使用 Lazy.js 的代码(来自 simply-lazy - demo):

Lazy([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
  .map(i => i * 2)
  .filter(i => i <= 10)
  .take(3)
  .each(i => print(i))

// output:
// 2
// 4
// 6

注:为了书写方法,函数定义采用 ES6 的 “=>” 语法。

这是一个有点无聊的例子,对 1 - 10 先进行乘 2 运算,然后过滤出小于 10 的值,再取前 3 个结果值输出。

如果对上述代码的执行进行监测(参考:借助 Proxy 实现回调函数执行计数),会得到以下结果:

demo - simply-lazy

map() 和 filter() 的过程都只执行了 3 次。

先关注 map() 调用,显然,这里没有立即执行计算,因为这里的代码还不能预知到后面的 filter() 和 take(3),所以不会聪明地知道自己只需要执行 3 次计算就可以了。如果没有执行计算,那么这里做了什么,又返回了什么呢?

先说答案:其实从 Lazy() 返回的是一个 Sequence 类型的对象,包含了原始的数据;map() 方法执行后,又生成了一个新的 Sequence 对象,该对象链接到上一个 Sequence 对象,同时记录了当前步骤要执行的计算(i => i * 2),然后返回新的 Sequence 对象。后续的 filter() 和 take() 也是类似的过程。

上面的代码也可以写成:

var seq0 = Lazy([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
var seq1 = seq0.map(i => i * 2)
var seq2 = seq1.filter(i => i <= 10)
var seq3 = seq2.take(3)

// 求值
seq3.each(i => print(i))

在最后一个求值时,已经有一个 Sequence 链了:

seq0 <- seq1 <- seq2 <- seq3

在 seq3 调用 each() 方法执行求值前,这些链上的 seq 都还没有执行计算。那么计算的过程是怎样的呢?其实就类似于最前面第二个例子那样,在一个循环中,由链上的最后一个 seq 开始,依次向前面一个 seq 获取值,再将值返回给调用方(也就是下一个 seq)前,会应用当前 seq 的计算。

获得第一个最终结果值的过程为:

[1] seq3 调用 seq2 获取第 1 个值
[2] seq2 调用 seq1 获取第 1 个值
[3] seq1 直接从 seq0 的 source 属性对应的原始数值取值(第 1 个值为  1)
[4] seq1 得到 1,应用计算(i => i * 2),得到 2,返回给调用方(seq2)
[5] seq2 得到 2,应用计算(i => i < 10),满足条件,将 2 返回给调用方(seq3)
[6] seq3 得到 2,应用计算(已返回值数目是否小于 3),满足条件,将 2 返回给调用方(seq3.each)

最终,seq3.each() 得到第 1 个值(2),应用计算(i => print(i)),将其输出。然后继续获取下一个值,直到在某个过程中调用终止(这里是 take() 计算中已返回 3 个值时终止)。

除了 map()、filter()、take(),Lazy.js 还提供了更多的计算模式,不过其惰性计算的过程就是这样了,总结为:

  • Lazy() 返回初始的 Sequence 对象
  • 惰性计算方法返回新的 Sequence 对象,内部记录上一个 Sequence 对象以及当前计算逻辑
  • 求值计算方法从当前 Sequence 对象开始,依次向上一个 Sequence 对象获取值
  • Sequence 对象在将从上一个 Sequence 对象获得的值返回给下一个 Sequence 前,应用自身的计算逻辑

Lazy.js 的 Sequence 的设计,使得取值和计算的过程形成一个长链,链条上的单个节点只需要完成上传、下达,并且应用自身计算逻辑就可以了,它不需要洞悉整体的执行过程。每个节点各司其职,最终实现了惰性计算。

代码有时候胜过万语千言,下面通过对简化版的 Lazy.js(simply-lazy)的源码分析,来更进一步展示 Lazy.js 惰性计算的原理。

实现:简化版的 Lazy.js

Lazy.js 可以支持进行计算的数据,除了数组,还有字符串和对象。simply-lazy 只提供了数组的支持,而且只支持三种惰性求值方法:

  • map()
  • filter()
  • take()

分别看这个三个方法的实现。

(一)map

Lazy() 直接返回的 Sequence 对象是比较特殊的,和链上的其他 Sequence 对象不同,它已经是根节点,自身记录了原始数据,也不包含计算逻辑。所以,对这个对象进行遍历其实就是遍历原始数据,也不涉及惰性计算。

simple-lazy 中保留了 Lazy.js 中的命名方式,尽管只支持数组,还是给这个类型取名为 ArrayWrapper:

function ArrayWrapper(source) {
  var seq = ArrayLikeSequence()
  seq.source = source
  seq.get = i => source[i]
  seq.length = () => source.length
  seq.each = fn => {
    var i = -1
    var len = source.length
    while (++i < len) {
      if (fn(source[i], i) === false) {
        return false
      }
    }
    return true
  }
  seq.map = mapFn => MappedArrayWrapper(seq, mapFn)
  seq.filter = filterFn => FilteredArrayWrapper(seq, filterFn)
  return seq
}

simple-lazy 为了简化代码,并没有采用 Lazy.js 那种为不同类型的 Sequence 对象构造不同的类的模式,Sequence 可以看作普通的对象,只是按照约定,需要支持几个接口方法而已。像这里的 ArrayWrapper(),返回的 seq 对象尽管来自 ArrayLikeSequence(),但自身已经实现了大多数的接口。

Lazy 函数其实就是返回了这样的 ArrayWrapper 对象:

function Lazy(source) {
  if (source instanceof Array) {
    return ArrayWrapper(source);
  }
  throw new Error('Sorry, only array is supported in simply-lazy.')
}

如果对于 Lazy() 返回的对象之间进行求值,可以看到,其实就是执行了在 ArrayWrapper 中定义的遍历原始数据的过程。

下面来看 seq.map()。ArrayWrapper 的 map() 返回的是 MappedArrayWrapper 类型的 Sequence 对象:

function MappedArrayWrapper(parent, mapFn) {
  var source = parent.source
  var length = source.length
  var seq = ArrayLikeSequence()
  seq.parent = parent
  seq.get = i => (i < 0 || i >= length) ? undefined : mapFn(source[i])
  seq.length = () => length
  seq.each = fn => {
    var i = -1;
    while (++i < length) {
      if (fn(mapFn(source[i], i), i) === false) {
        return false
      }
    }
    return true
  }
  return seq
}

这其实也是个特殊的 Sequence(所以名字上没有 Sequence),因为它知道自己上一级 Sequence 对象是不包含计算逻辑的原始 Sequence 对象,所以它直接通过 parent.source 就可以获取到原始数据了。

此时执行的求值,则是直接在原始数据上应用传入的 mapFn。

而如果是先执行了其他的惰性计算方法,对于得到的结果对象再应用 map() 呢, 这个时候就要看具体的情况了,simply-lazy 中还有两种相关的类型:

  • MappedSequence
  • IndexedMappedSequence

MappedSequence 是更通用的类型,对应 map() 得到 Sequence 的类型:

function MappedSequence(parent, mapFn) {
  var seq = new Sequence()
  seq.getIterator = () => {
    var iterator = parent.getIterator()
    var index = -1
    return {
      current() { return mapFn(iterator.current(), index) },
      moveNext() {
        if (iterator.moveNext()) {
          ++index
          return true
        }
        return false
      }
    }
  }
  seq.each = fn => parent.each((e, i) => fn(mapFn(e, i), i))
  return seq
}

来看这里的求值(each)过程,是间接调用了其上级 Sequence 的 each() 来完成的。同时还定义了 getIterator() 接口,使得其下级 Sequence 可以从这里得到一个迭代器,对于该 Sequence 进行遍历。迭代器在 Lazy.js 中也是一个约定的协议,实现该协议的对象要支持 current() 和 moveNext() 两个接口方法。从迭代器的逻辑中,可以看到,当 MappedSequence 对象作为其他 Sequence 的上级时,如果实现“上传下达”。

而 IndexedMappedSequence 的实现要简单些,它的主要功能都来源于“继承” :

  function IndexedMappedSequence(parent, mapFn) {
    var seq = ArrayLikeSequence()
    seq.parent = parent
    seq.get = (i) => {
      if (i < 0 || i >= parent.length()) {
        return undefined;
      }
      return mapFn(parent.get(i), i);
    }
    return seq
  }

IndexedMappedSequence 只提供了获取特定索引位置的元素的功能,其他的处理交由 ArrayLikeSequence 来实现。

而 ArrayLikeSequence 则又“继承”自 Sequence:

function ArrayLikeSequence() {
  var seq = new Sequence()
  seq.length = () => seq.parent.length()
  seq.map = mapFn => IndexedMappedSequence(seq, mapFn)
  seq.filter = filterFn => IndexedFilteredSequence(seq, filterFn)
  return seq
}

Sequence 中实现的是更一般意义上的处理。

上面介绍的这些与 map 有关的 Sequence 类型,其实现各有不同,的确有些绕。但无论是怎样进行实现,其核心的逻辑没有变,都是要在其上级 Sequence 的值上应用其 mapFn,然后返回结果值给下级 Sequence 使用。这是 map 计算的特定模式。

(二)filter

filter 的计算模式与 map 不同,filter 对上级 Sequence 返回的值应用 filterFn 进行判断,满足条件后再传递给下级 Sequence,否则继续从上级 Sequence 获取新一个值进行计算,直到没有值或者找到一个满足条件的值。

filter 相关的 Sequence 类型也有多个,这里只看其中一个,因为尽管实现的方式不同,其计算模式的本质是一样的:

function FilteredSequence(parent, filterFn) {
  var seq = new Sequence()
  seq.getIterator = () => {
    var iterator = parent.getIterator()
    var index = 0
    var value
    return {
      current() { return value },
      moveNext() {
        var _val
        while (iterator.moveNext()) {
          _val = iterator.current()
          if (filterFn(_val, index++)) {
            value = _val
            return true
          }
        }
        value = undefined
        return false
      }
    }
  }
  seq.each = fn => {
    var j = 0;
    return parent.each((e, i) => {
      if (filterFn(e, i)) {
        return fn(e, j++);
      }
    })
  }
  return seq
}

FilteredSequence 的 getIterator() 和 each() 中都可以看到 filter 的计算模式,就是前面说的,判断上级 Sequence 的值,根据结果决定是返回给下一级 Sequence 还是继续获取。

不再赘述。

(三)take

take 的计算模式是从上级 Sequence 中获取值,达到指定数目就终止计算:

function TakeSequence(parent, count) {
  var seq = new Sequence()
  seq.getIterator = () => {
    var iterator = parent.getIterator()
    var _count = count
    return {
      current() { return iterator.current() },
      moveNext() { return (--_count >= 0) && iterator.moveNext() }
    }
  }
  seq.each = (fn) => {
    var _count = count
    var i = 0
    var result
    parent.each(e => {
      if (i < count) { result = fn(e, i++); }
      if (i >= count) { return false; }
      return result
    })
    return i === count && result !== false
  }
  return seq
}

只看 TakeSequence 类型,与 FilteredSequence 类似,其 getIterator() 和 each() 中都提现了其计算模式。一旦获得了指定数目的值,就终止计算(通过 return false)。

总结

simply-lazy 中虽然只是实现了 Lazy.js 的三个惰性计算方法(map,filter、take),但从中已经可以看出 Lazy.js 的设计模式了。不同的计算方法体现的是不同的计算模式,而这计算模式则是通过不同的 Sequence 类型来实现的。

具体的 Sequence 类型包含了特定的计算模式,这从其类型名称上也能看出来。例如,MappedArrayWrapper、MappedSequence、IndexedMappedSequence 都是对应 map 计算模式。

而求值的过程,或者说求值的模式是统一的,都是借助 Sequence 链,链条上的每个 Sequence 节点只负责:

  • 上传:向上级 Sequence 获取值
  • 下达:向下级 Sequence 传递至
  • 求值:应用类型对应的计算模式对数据进行计算或者过滤等操作

由内嵌了不同的计算模式的 Sequence 构成的链,就实现了惰性求值。

当然,这只是 Lazy.js 中的惰性求值的实现,并不意外这“惰性求值”就等于这里的实现,或者说惰性求值的全部特性就是这里 Lazy.js 的全部特性。更何况,本文主要分析的 simply-lazy 也只是从模式上对 Lazy.js 的惰性求值进行了说明,也并不包含 Lazy.js 的全部特性(例如生成无限长度的列表)。

不管怎样,还是阅读过后能够给你带来一点有价值的东西。哪怕只有一点,我也很高兴。

文中对 Lazy.js 的惰性求值的分析仅是我个人的见解,如果错漏,欢迎指正!

最后,感谢阅读!

回到顶部