Better

Ethan的博客,欢迎访问交流

JavaScript之惰性函数、函数组合、函数记忆、函数递归

主要理解惰性函数、函数组合、函数记忆、函数递归的思想和性能优化!

惰性函数

这应该是四者最好理解的了,惰性函数就是解决每次都要进行判断的这个问题,解决原理很简单,重写函数。

比如有这样一个需求:我们现在需要写一个 foo 函数,这个函数返回首次调用时的 Date 对象,注意是首次。

var foo = function() {
    var t = new Date();
    foo = function() {
        return t;
    };
    return foo();
};

更多应用,判断浏览器环境,在一次浏览器访问期间,环境是不会变化的,因此我们只需要判断一次就好!

var addEvent = (function(){
    if (window.addEventListener) {
        return function (type, el, fn) {
            el.addEventListener(type, fn, false);
        }
    }
    else if(window.attachEvent){
        return function (type, el, fn) {
            el.attachEvent('on' + type, fn);
        }
    }
})();

当我们每次都需要进行条件判断,其实只需要判断一次,接下来的使用方式都不会发生改变的时候,想想是否可以考虑使用惰性函数。

函数组合

这个概念就比较高端了。针对函数的逻辑处理部分,可以抽象出多个子方法组合使用,同时牵扯出pointfree编程风格。

优化函数嵌套

如果代码中存在fn3(fn2(fn1(fn0(x))))这样一种函数的写法,其实可读性很差,那么有没有办法优化成这样一种写法呢?compose(fn3,fn2,fn1,fn0),compose函数内部将方法参数从右往左执行,后者将前者的输出或者自己的输入!答案是可以的,构建方法compose如下:

function compose() {
    var args = arguments;
    var start = args.length - 1;
    return function() {
        var i = start;
        var result = args[start].apply(this, arguments);
        while (i--) result = args[i].call(this, result);
        return result;
    };
};

Pointfree 的概念

我们完全可以把数据处理的过程,定义成一种与参数无关的合成运算。不需要用到代表数据的那个参数,只要把一些简单的运算步骤合成在一起即可。 这就叫做 Pointfree:不使用所要处理的值,只合成运算过程。中文可以译作"无值"风格。

Pointfree 的本质就是使用一些通用的函数,组合出各种复杂运算。上层运算不要直接操作数据,而是通过底层函数去处理。这就要求,将一些常用的操作封装成函数。

Pointfree 就是运算过程抽象化,处理一个值,但是不提到这个值。这样做有很多好处,它能够让代码更清晰和简练,更符合语义,更容易复用,测试也变得轻而易举。

举个复杂例子如下:

// pointfree
// 先定义基本运算
var split = curry(function(separator, str) { return str.split(separator) })
var head = function(str) { return str.slice(0, 1) }
var toUpperCase = function(str) { return str.toUpperCase() }
var join = curry(function(separator, arr) { return arr.join(separator) })
var map = curry(function(fn, arr) { return arr.map(fn) })

var initials = compose(join('.'), map(compose(toUpperCase, head)), split(' '));

initials("kevin daisy kelly");

根据需求,对于分隔符和连接符我们一开始是确定的,同时确保函数通用,是不建议在代码中写死的,这里就需要参数分开传递,就想到了之前的柯里化,在上面的例子中就有描述,同时知道最后一步调用组合函数时我们才知道要操作的输入数据是什么。这也是pointfree的体现。利用柯里化(curry)和函数组合 (compose) 非常有助于实现 pointfree

如果觉得这种写法好麻烦呐,我们还需要定义那么多的基础函数……可是如果有工具库已经帮你写好了呢?比如 ramda.js。

函数记忆

函数记忆是指将上次的计算结果缓存起来,当下次调用时,如果遇到相同的参数,就直接返回缓存中的数据。

实现这样一个 memorize 函数很简单,原理上只用把参数和对应的结果数据存到一个对象中,调用时,判断参数对应的数据是否存在,存在就返回对应的结果数据。

注意:函数记忆也并不是万能的,函数记忆只是一种编程技巧,本质上是牺牲算法的空间复杂度以换取更优的时间复杂度,在客户端 JavaScript 中代码的执行时间复杂度往往成为瓶颈,因此在大多数场景下,这种牺牲空间换取时间的做法以提升程序执行效率的做法是非常可取的。

如下使用函数记忆的例子,却更加耗时;

function memorize(f) {
    var cache = {};
    return function(){
        var key = arguments.length + Array.prototype.join.call(arguments, ",");
        if (key in cache) {
            return cache[key]
        }
        else {
            return cache[key] = f.apply(this, arguments)
        }
    }
}
var add = function(a, b, c) {
  return a + b + c
}

var memoizedAdd = memorize(add)

console.time('use memorize')
for(var i = 0; i < 100000; i++) {
    memoizedAdd(1, 2, 3)
}
console.timeEnd('use memorize')

console.time('not use memorize')
for(var i = 0; i < 100000; i++) {
    add(1, 2, 3)
}
console.timeEnd('not use memorize')

问题与优化

上面例子我们是使用join方法连接参数作为对样的唯一key,但应对基本类型参数时是可取的,可是当参数是对象时,也就有问题了,因为对象自动调用 toString 方法均转换成 [Object object],参考underscore的实现:

var memorize = function(func, hasher) {
    var memoize = function(key) {
        var cache = memoize.cache;
        var address = '' + (hasher ? hasher.apply(this, arguments) : key);
        if (!cache[address]) {
            cache[address] = func.apply(this, arguments);
        }
        return cache[address];
    };
    memoize.cache = {};
    return memoize;
};

从这个实现可以看出,underscore 默认使用 function 的第一个参数作为 key,这显然是不对的,因此我们必须传入hasher函数。自定义存储的 key 值。所以我们考虑使用 JSON.stringify:

var memoizedAdd = memorize(add, function(){
    var args = Array.prototype.slice.call(arguments)
    return JSON.stringify(args)
})

具体适用场景

实现斐波那契数列。通用公式为F(0)=0,F(1)=1, F(n)=F(n-1)+F(n-2)。

不适用函数记忆:

var count = 0;
var fibonacci = function(n){
    count++;
    return n < 2? n : fibonacci(n-1) + fibonacci(n-2);
};
for (var i = 0; i <= 10; i++){
    fibonacci(i)
}

console.log(count)// 453

使用函数记忆:

var count = 0;
var fibonacci = function(n) {
    count++;
    return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
};

fibonacci = memorize(fibonacci)

for (var i = 0; i <= 10; i++) {
    fibonacci(i)
}

console.log(count) // 12

在浏览器中依旧比较两者的时间,使用了函数记忆的还是慢一点点(use 0.22ms,not use 0.18),看来数据量还是不够大,因此我将循坏改到100,没有使用函数记忆的直接卡死了,而使用了函数记忆的用时0.5ms。可见在循环次数剧增的情况下,函数记忆还是很有用的。

递归

程序调用自身的编程技巧称为递归(recursion)。

递归条件

构成递归需具备边界条件、递归前进段和递归返回段,当边界条件不满足时,递归前进,当边界条件满足时,递归返回。阶乘中的 n == 1 和 斐波那契数列中的 n < 2 都是边界条件。

递归的特点:

  • 子问题须与原始问题为同样的事,且更为简单;
  • 不能无限制地调用本身,须有个出口,化简为非递归状况处理。

性能优化

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。

试着对阶乘函数分析执行的过程,我们会发现,JavaScript 会不停的创建执行上下文压入执行上下文栈,对于内存而言,维护这么多的执行上下文也是一笔不小的开销呐!那么,我们该如何优化呢?

答案就是尾调用

尾调用

什么是尾调用?尾调用,是指函数内部的最后一个动作是函数调用。该调用的返回值,直接返回给函数。

举个例子:

// 尾调用
function f(x){
    return g(x);
}
// 非尾调用
function f(x){
    return g(x) + 1;
}

为什么尾调用能提高性能呢?需要从执行上下文栈来考虑!模拟上述的栈过程:

尾调用的结果为:

// 伪代码
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.push(<g> functionContext);
ECStack.pop();

非尾调用函数执行时的执行上下文栈变化:

ECStack.push(<f> functionContext);
ECStack.push(<g> functionContext);
ECStack.pop();
ECStack.pop();

也就说尾调用函数执行时,虽然也调用了一个函数,但是因为原来的的函数执行完毕,执行上下文会被弹出,执行上下文栈中相当于只多压入了一个执行上下文。然而非尾调用函数,就会创建多个执行上下文压入执行上下文栈。

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。

所以我们只用把阶乘函数改造成一个尾递归形式,就可以避免创建那么多的执行上下文。那么该如何实现呢?

我们需要做的就是把所有用到的内部变量改写成函数的参数,以阶乘函数为例:

function factorial(n, res) {
    if (n == 1) return res;
    return factorial2(n - 1, n * res)
}

console.log(factorial(4, 1)) // 24

然而这个很奇怪呐……我们计算 4 的阶乘,结果函数要传入 4 和 1,我就不能只传入一个 4 吗?

这个时候就要用到curry 函数了,_表示参数占位符:

var newFactorial = curry(factorial, _, 1)
newFactorial(5) // 24

来源



留言