函数式初识(4) - 闭包

by Teobler on 29 / 08 / 2019

views

闭包是一个在JS中怎么都绕不过的问题,不论是新手入门还是面试,你总会在各种地方看到它。而闭包在函数式编程中也是极其重要的,在前面的几篇文章中,几乎每个例子我们都用到了闭包,所以如果你对闭包的理解不到位的话,可以说你用不好函数式编程。可是一部分同学依然对闭包有一定的误解,这篇文章就是想要清除这些误解。

闭包与函数式

那么什么是闭包呢?这里有一个通俗的解释:

Closure is when a function remembers and accesses variables from outside of its own scope, even when that function is executed in a different scope.

下面是一个简单的例子:

const makeCounter = () => {
    let counter = 0;
    return function increment() {
        return ++counter;
    }
}
 
let c = makeCounter();
 
c(); // 1
c(); // 2
c(); // 3

这里的makeCounter函数并没有任何输入值,然后它定义了一个变量counter然后返回了一个函数,每次我们调用这个函数,这个函数将counter加一后返回。可以看到其实在调用函数c的时候,我们并没有传入任何参数,但是它依旧可以返回一个累加过后的根本不存在于自己作用域内的counter

可以看出,这里的c()并不是一个纯的函数调用,我们每一次传入相同的参数(undefined),都会得到一个不同的返回值。由此可见:

闭包不一定是一个纯函数,但是闭包可以用在函数式理论中

所以关键点其实在于:我可以在闭包中封装一个变量,但是我需要保证这个变量是一个常量,它不能被重新赋值,如果它被重新赋值了,那么这个函数就不再是一个纯函数,下面就是两个封装了一个常量的例子:

const unary = (fn) => {
    return function one(arg) {
        return fn(arg);
    }
}
 
const addAnother = (z) => {
    return function addTwo(x, y) {
        return x + y + z;
    }
}

这个unary方法先接受了一个函数fn并"记住"了它,在传入一个参数的时候进行调用,所以在这个例子中,这个参数变量是一定不会被改变的。第二个例子同理。

lazy VS eager

这两是啥意思呢?别着急,我们先来看几个例子,你马上就懂了:

const repeater = (count) => {
    return function allTheAs() {
        return "".padStart(count, "A");
    }
}
 
const A = repeater(10);
 
A(); // AAAAAAAAAA
A(); // AAAAAAAAAA

可以看到这个函数的最终目的是返回N个相同的"A",但是真正做“返回”这件事的地方是在第10行开始,或者说是闭包里面的函数真正被调用的时候,在生成A这个函数的时候,我们什么都没做,仅仅是记住了count这个入参,在之后进行了真正的计算。但是这也就意味着,你调用了几次A函数,你就需要做几次相同的生成"A"的工作。

那么我们就说这个闭包就是Lazy的。那么问题来了,为什么我们要把一些工作推迟到里面的闭包真正执行的时候才执行呢?其中的一个原因是,我们可能并不确定闭包里面的函数什么时候会被调用。我们假设这样一个情景 — 闭包里是一个比较复杂的函数,而不是像例子中一样仅仅返回N个重复的字符,而且我们可能只有10%的可能性执行这个函数,那么是不是这种推迟执行的方式就能节省许多不必要的计算呢?

接下来我们来聊聊eager,假如上面的例子是这样的:

const repeater = (count) => {
    const str = "".padStart(count, "A");
    return function allTheAs() {
        return str;
    }
}
 
const A = repeater(10);
 
A(); // AAAAAAAAAA
A(); // AAAAAAAAAA

可以看到虽然它们的最终输出结果是一致的,但是其实计算在第8行调用repeater这个函数的时候就已经计算好了,在后续调用函数A的时候仅仅只是返回了一个已经计算好的结果。如果我们可以确定这个闭包一定会被调用多次,那么这样的定义方式可以节省很大的开销。

说到底其实可以说,在闭包中,你封装常量的位置决定了这个闭包是lazy的还是eager的。

小孩子才做选择,我全都要

这个时候可能有人会问,有没有一种中庸的解决办法呢?当然:

const repeater = (count) => {
    let str;
    return function allTheAs() {
        if (str === undefined) {
            str = "".padStart(count, "A");
        }
        return str;
    }
}
 
const A = repeater(10);
 
A(); // AAAAAAAAAA
A(); // AAAAAAAAAA

可以看到这种方法也是delay的,计算真正生效在第10行A函数调用的时候,然后我们将结果"cache"起来了。可是熟悉函数式的小伙伴可能会立马拉起警报,这样的函数调用还是纯的吗?

在函数定义的时候很显然我们将str的值从undefined重新赋值成了AAAAAAAAAA,而且这还是一个外部变量,其实这是打破了纯函数的原则的。虽然在函数定义的时候破坏了纯函数原则,但是在真正调用A函数的时候,其实A函数的调用是一个纯的函数调用,它完美符合纯函数调用的所有特点。

不过在"函数式洁癖"们看来,这样的做法依旧是不可接受的,毕竟已经产生了副作用,不能算作是纯函数了,也违背了函数式的原则,所以能不能解决呢?我们需要借助一个工具函数:

const repeater(count) => {
    return memoize(function allTheAs() {
        return "".padStart(count, "A");
    })
}
 
const A = repeater(10);
 
A(); // AAAAAAAAAA
A(); // AAAAAAAAAA

memoize函数会对传入的参数做一个cache,如果传入的是一个函数,那么在调用这个函数时会将返回值cache起来,下次调用时直接返回结果。

所以这段代码不论是从定义还是从调用来看都是纯的,并且要更偏向于声明式的。这里有另外一个重点是,不要cache你的每一个函数,可以想象,这样的方式的确能够降低很多计算,但是有一个前提是,你的这个函数会被用相同的入参调用很多次,假如你的方法只会被调用一两次或者是用不同的入参调用多次,那么你在cache的时候实际上是浪费了更多的资源的,毕竟这不仅仅是在你的函数外面再包一层函数那么简单,memoize底层用内存帮你存储了结果。这时你实际需要的可能是lazy的闭包。

这种代码风格被称作Memoization