lazy.js 惰性求值实现分析

背景:惰性求值?

来看一个 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 theory, lazy 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 的惰性求值的分析仅是我个人的见解,如果错漏,欢迎指正!

最后,感谢阅读!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容