跳到主要内容

到底什么是闭包

· 阅读需 12 分钟
古时的风筝

闭包属于前端必须理解,后端可选理解。但是理解 JavaScript 中的闭包,有助于理解 Java 中 Lambda 实现的类似于闭包的实现。

回想起来,我初次接触闭包这个概念应该是刚上班不久,当初自诩为全栈开发,跟同事说:“我前后端都能写,虽然职位是后端开发,但是前端压根儿没什么难度”。同事看了我一眼,然后笑着说:“这么厉害的话,我考你一个问题呀,你要知道,那就承认你是全栈了,要不然就只能是 50%的全栈”。

一直到今天,写了不少前端代码了,但是我只敢说自己是50%的全栈。可想而知,我当时是没答出那个问题的,而那个问题是「知道什么是闭包吗?」

我不仅不知道什么是闭包,我连「闭包」这个词儿根本就没听过。当然这也不能全怪我,无论是C#还是Java,都很少提到闭包这个概念。而且当时本着「程序能跑就行」的心态,对于非主职的JavaScript来说,更是如此。

闭包这个概念在 JavaScript 中被提到的最多,也用到的最多。根本原因就是函数在 JavaScript 中的地位,函数既可以被声明、被调用,也可以像其他的类型那样当做参数、当做返回值,而且被当做参数或返回值时可以被直接调用。

除了JavaScript 外,其他像 Python、Ruby、PHP 等语言也有闭包的概念,这些语言都有一个共通的特性,那就是都支持匿名函数,有函数式编程的影子。而像 Java 语言,虽然Java 8 开始也支持 Lambda ,能够实现和闭包类似的功能。如果你有幸读过例如 Java Lambda Stream 的实现源码,你会惊奇的发现,这部分的实现和我们平时用的Java仿佛是两个世界的代码。

什么是闭包

闭包,又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。

上述是官方对于闭包的解释,官方的解释未免过于官方了。

理解闭包最快的方法,我猜就是下面这个例子了,当年我理解闭包全靠这个例子,一个计数器的例子。

 <script>
function makeCounter() {
let count = 0

return function () {
return count++
}
}

let counter = makeCounter()
</script>

<button onclick="alert(counter())">加一</button>

上面的代码执行下来就是这样的,当点击页面上的按钮时,就会记住点击的次数,可以一直累加下去。

来解释一下这段代码

奥秘全都藏着这个方法中:

function makeCounter() {
let count = 0

return function () {
return count++
}
}

这段代码分两层(每个function看作一层),外层 makeCounter,内层也是一个 function ,但是没有命名,稍微改一下, 就更好理解了。

function makeCounter() {
let count = 0

let increment = function increment() {
return count++
}
return increment;
}

在函数 makeCounter中声明了一个内部的函数 incrementincrement的逻辑很简单,就是完成加一操作,最后将 increment函数返回,注意喽,返回的是一个函数。

这个increment有两个特点,可以说是闭包的精髓:

  1. increment可以访问外部的变量,比如count;
  2. increment不能被外部直接访问,必须通过 makeCounter才行。

当我们想要调用 increment函数时,要先用下面的方法调用 makeCounter函数。

let counter = makeCounter()

这时候,makeCounter返回的是 increment这个函数本身,而不是increment执行后的结果,并且increment也不会立即执行,除非你像下面这样开始调用它了。

counter()

每调用一次 counter(),count 的值就被加1,并且返回来,所以最终实现了累加的效果。

这理解起来似乎也顺理成章,我们在写面向对象的语言时,可以对应到上面理解一下,当然原理是不一样的。makeCounter()就像是实例化了一个类,然后counter()就像调用了类中的一个方法。

每次用 makeCounter()创建一个计数器,其内部的环境就被固定下来了,就像是被装在了一个盒子里,盒子里的变量和函数都是自身独有的。你可以在一段代码中多次调用makeCounter()方法,这就能创建多个计数器,并且每个计数器都是一个盒子,盒子与盒子之间是相互独立的。

理解了这个以后,恭喜你,你掌握了目前最流行的前端框架之一的 React 框架的精髓。React 主张组件化编程,一个组件最理想的方式就是一个纯函数,对于相同的输入,一定会有相同的输出。React 中到处都是闭包的影子,可谓将闭包利用到了极致。

再看另外一个例子来加深一下理解,这是一个修改名称的例子,当用户修改globalName之后,仍然可以通过闭包函数 getSource()获取之前的旧名称。

<script>
let globalName = '风筝'
function getSource() {
let sourceName = globalName
return function () {
return sourceName
}
}
function changeName(name) {
let sourceNameContainer = getSource()
globalName = name
console.log('名称被改为:' + globalName)
console.log('原始名称为:' + sourceNameContainer())
}
</script>

<button onclick="changeName('古时的风筝')">改名</button>

点击上面的改名 按钮后,会打印下面的结果。

名称被改为:古时的风筝
原始名称为:原始名称为:风筝

Python 中的闭包

Python 作为一门解释型语言,函数同样是一等公民,和 JavaScript 一样,能作为参数和返回值。所以在 Python 中,闭包的写法和 JavaScript 几乎是一模一样的。

def make_counter():
count = 0

def counter():
nonlocal count
count += 1
return count

return counter

counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3

make_counter方法包着 counter方法,使用 counter = make_counter()创建计数器,最后实现累加的效果。

Java 中的闭包

接下来是Java,Java 8 之后 引入了 lambda 表达式和函数式接口,使用这些可以让 Java 支持类似闭包的功能。

还是拿计数器的例子来说,在 Java 中的实现是这样的,这个看起来就很简单了。

private static void test() {
Supplier<Integer> counter1 = createCounter();
Supplier<Integer> counter2 = createCounter();
// 调用闭包,每次调用都会递增计数器并返回当前值
System.out.println("计数器1:" + counter1.get()); // 计数器1:1
System.out.println("计数器1:" + counter1.get()); // 计数器1:2
System.out.println("计数器1:" + counter1.get()); // 计数器1:3

System.out.println("计数器2:" + counter2.get()); // 计数器2:1
System.out.println("计数器2:" + counter2.get()); // 计数器2:2
System.out.println("计数器2:" + counter2.get()); // 计数器2:3
}
private static Supplier<Integer> createCounter() {
// 使用数组存储计数器的值,以便在 Lambda 表达式内部修改(不用数组没法修改值)
int[] count = {0};

// 使用 Lambda 表达式创建一个闭包
return () -> {
count[0]++; // 修改计数器的值
return count[0]; // 返回修改后的值
};
}

Java 中 Lambda Stream 的很多方法都是用这种原理实现的,比如 Stream.map的内部实现。

List<Integer> nameLengths = names.stream()
.map(String::length)
.collect(Collectors.toList());

以下是 map方法的源码实现。说实话,我看了好几次了,每次看起来都感觉很陌生。总之,可以这样理解,就是在调用 map 的时候,对列表中的每一项包装一下。例如,上述代码是求每一项的长度,包装后的结果类似于 一连串的求长度(name)这样的 Stream ,但不是立即执行的,而是等到调用 collect方法时才执行。

public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
Objects.requireNonNull(mapper);
return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
return new Sink.ChainedReference<P_OUT, R>(sink) {
@Override
public void accept(P_OUT u) {
downstream.accept(mapper.apply(u));
}
};
}
};
}

闭包的好处和应用场景

闭包的好处不在于解决一时一地的问题,而是它们提供了一种操作和存取函数外部变量的方法,打造了一个独立的小环境。

数据封装和隐私想要隐藏数据

不希望被外界随意访问的场景,可以用闭包实现,而且不需要实例化对象那么麻烦。

模块化

用闭包可以创建一个个独立模块,保证私有的方法和变量。

在异步操作中保持状态

前端涉及到界面与后台数据的交互,有很多异步操作的场景,这也是为什么React中有大量闭包的应用了。

风筝

作者

风筝

古时的风筝,一个平庸的程序员,主语言 Java,第二语言 Python,其实学 Python 的时间比 Java 还要早。喜欢写博客,写博客的过程能加深自己对一个知识点的理解,同时还可以分享给他人。喜欢做一些小东西,所以也会一些前端的东西,React、JavaScript、CSS 都会一些,做一些小工具还够用。