JavaScript 数组惰性求值

我们以一个常见的简单例子开始。

当我们需要操作一个大数组 arr (就假设 arr 有1000个元素好了),我们要对这个数组中每个元素进行平方,在过滤出前5个大于 100 的元素,如果我们使用 Underscore (或者Local-Dash)要怎么做呢?

// 辅助函数
function square(n) {
  return n * n;
}

function isGreater(x) {
  return x > 100
}

var result = _.chain(arr).map(square).filter(isGreater).take(5).value();

我们看看上面做了些什么:

  • map(square) 遍历了整个数组,创建了一个新的包含1000个元素的数组
  • filter(isGreater) 遍历了整个数组,创建了一个新的包含1000个元素的数组
  • take(5) 取出前5个元素

Excuse me?为了5个元素我需要遍历并创建两次超大的数组。那我们要怎么办呢?我们只需要一次遍历,在遍历时判断每个元素的平方是否大于 100,得到第5个大于100的元素后跳出这次遍历就行了,这样的话,我们只需要一次遍历就可以完成:

var result = [];
for (var i = 0; i < arr.length; i++) {
  var value = arr[i] * arr[i];
  if (value > 100) {
  	result.push(value);
  	if (result.length == 5) {
  	   break;
  	}
  }
}

好,现在我们不会再做多余的遍历和创建数组了,但是这堆代码不仅不能复用,还又长又丑,我们只差对此进行封装以便复用。

到这里,其实已经用到了惰性求值的思想,简单来说惰性求值是一种将对数据的处理延迟到真正需要结果时再进行计算的通用概念,在函数式编程中被大面积使用 —— Haskell 最显著的特点之一就是惰性求值,Haskell 不会保证语句的顺序执行,它会判断自己需要返回什么,然后根据需要反向执行。

let lt = [100, 230, 123, 345]
filter (> 100) $ map(+ 5) lt

filter 并不会在最开始就被执行,只有当 GHCI 需要显示结果时才会计算 filter。

就如我们前文所举的例子,我们需要获取前5个平方后大于100的元素,我们并不需要依次对所有元素进行平方、过滤、最后取值,我们只用在取值时去平方、过滤即可,延迟平方和过滤操作,这样可以显著提高性能。

最后,我们对前面的例子进行一个简单的封装,让它可以对数组进行惰性求值操作。

var result = Lazy(arr).map(square).filter(isGreater).take(5).value();

要实现上面类似的链式调用,我们首先需要将传入的数组包装成一个序列对象,并提供操作数组的方法,这里,我们将传入的数组保存到 _source 属性中:

function  Sequence (array) {
   this._source = array;
}

Sequence.prototype = {
   constructor: Sequence,
   filter: function () {},
   map: function () {},
   next: function () {},
   value: function () {},
   // more method
};

window.Lazy = function (array) {
   return new Sequence(array);
};

这样,我们就可以通过 Lazy([1,2,3,4]).map(fn1).filter(fn2) 这样的方式操作数组了。

那么我们怎么让这些方法被调用的时候延迟执行呢?我们可以将我们可以将需要执行的函数和需要操作的数组保存到一个可迭代的对象中去。以 map 为例,map 方法返回一个 MapIteratorSequence 对象,这个对象在创建时会保存它的 source

function MapIteratorWrapper (source, callback) {
   return new Sequence(new MapIterator(source, callback))
}

Sequence.prototype.map = function (callback) {
   return new MapIteratorWrapper(this._source, callback);
}

这里,我们将 MapIterator 重新包装了一层,以提供统一的 API 供链式调用。此时,Lazy([1,2,3,4]).map(fn1) 有如下结构:

{
  _source: { //MapIterator
  	_callback: fn1,
  	_index: 0,
  	_source: [1,2,3,4],
  }
  __prot0__: {
  	filter: filter(),
  	map: map(),
  	// more ...
  }
}

其他方法同理,当链式调用时,_source 属性会将调用顺序依次保存:

{
  _source: { // TakeIterator
  	_source: { // FilterIterator
  	  _source: { //MapIterator
  		_source: [1,2,3,4]
  	  }
  	}
  }
}

当我们需要取得结果时,调用 value 方法会调用 each 方法进行迭代,然后取出迭代的值:

Sequence.prototype.each = function () {
  var arr = [],
      res;

  do {
      res = this._source.next();

      if (res !== undefined) {
          arr.push(res.value);
      }
  } while (res !== undefined);

  return new Sequence(arr);
},
Sequence.prototype.value = function () {
  return this.each()._source.slice(0);
}

还记得我们说 MapIterator 是一个可迭代的对象吗?当 each 方法执行到 this._source.next() 时,便会调用 MapIteratornext 方法。

MapIterator.prototype = new Iterator();
MapIterator.prototype.next = function () {
   var item = this._next();
   return item === undefined ? undefined : this._callback(item);
};

这里我们提供了一个通用的 Iterator

function Iterator(source) {
   this._source = source;
   this._index = 0;
}

Iterator.prototype._next = function () {
   var res;
   if (this._source instanceof Iterator) {
       res = this._source.next();
   }
   else {
       if (this._index < this._source.length) {
           res = this._source[this._index++];
       }
       else {
           res = undefined;
       }
   }

   return res;
};

这就是一个简单的惰性求值封装,当然,这是非常粗糙的实践。目前,Lo-Dash 对数组的部分操作已经支持惰性求值,更有意思的是lazy.js,这个库完全支持惰性求值,并且支持无穷数列、DOM 事件封装等功能,性能提升颇为惊人,不过有多少坑,我也不知道了。

完整代码

References