何为闭包?

这里将通过C语言中的一些案例来介绍闭包。本想默认读者都具备一定的指针知识,但考虑到我自己也忘记了许多指针的东西(到底是什么人在非必要情况下用C),不如在这里简单写一下,顺便当作我自己的复习。

C 中的指针

YouTube上有一名叫Cherno的程序员(bilibili有转载)说过一句我铭记至今的话:指针就是一个值,只不过这个值是地址而已,但我个人认为这句话并不完整,还应该加上“单位”。

(IMPORTANT)何为指针的“单位”?众所周知,假设有一个长度为3的一维整形数组arr = {1, 2, 3},数值上arr = &arr(这里假设都为0x9F36FAE0)。但你并不能认为对arr&arr做相同操作得到的结果是相同的,因为它们所处的“地位”是不同的。你可以认为arr是指向于数组起始元素的指针,因此arr的指针类型为int [3],其元素的sizeofsizeof(int) = 4arr + 1自然为第二个元素的地址0x9F36FAE4;而&arr从字面意思上即可知道,它代表整个数组的地址,指向的是整个数组,也就是说&arr站在更高一层,其指针类型为int(*) [3],其元素为数组,sizeof为12,这时对&arr加一,得到的结果便是0x9F36FAEC.

由此可见,指针的“单位”取决于其“定位”,可以简单的理解为它究竟指向于什么。

1
2
3
4
5
int test_arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
int *point_arr[2];
for (int i = 0; i < 2; i++) {
point_arr[i] = test_arr[i];
};

你可以花上几秒钟思考,point_arr的元素是什么?首先point_arr被定义为指针数组,其内的元素分别被赋值为两个一维数组,它们的指针类型都是int [3],即point_arr[i]的指针类型为int [3]。我们可以试着解析一下*(*(point_arr + i) + j):由之前所述,point_arr + i代表了&point_arr[i],因此*(point_arr + i)代表了point_arr[i]这个int [3],故*(point_arr + i) + j代表了&test_arr[i][j],因此*(*(point_arr + i) + j)代表test_arr[i][j]

这和二维数组行指针有异曲同工之妙,行指针定义形式为int (*row_ptr)[3] = test_arrrow_ptr的指针类型为int [2][3],与一维数组同理,row_ptr + i为第i个元素的地址(在这里第i个元素为数组),即为第i个数组的地址,也就是说row_ptr + i的指针类型为int(*) [3],那么*(row_ptr + i) + j即为&test_arr[i][j],进而*(*(row_ptr + i) + j)test_arr[i][j]

说都说到这了不如再提一下列指针(),其实只用理解不管是几维数组,其元素排列都是线性的就可以了,因此int *p = &test_arr[0][0]即可定义列指针,至于怎样取元素就只是一个简单的数学问题了,比如要取test_arr[i][j],要先跨i行,再跨j列,即*(p + i * 3 + j).

(我个人认为把上面这几行读懂,指针也就理解的差不多了)

C 中的 callback function

函数指针

顾名思义,函数指针即指向函数的指针,如

1
int (*func_ptr)(double, char) = func;

定义了一个名为func_ptr的函数指针,其指向了参数列表为(double, char)、返回intfunc。与数组指针不同的是,函数指针赋值时,赋值符右边可以为func,也可以是&func,对于函数而言,它们是等价的。函数指针的调用可以是下面这两种等价的形式:

1
2
(*func_ptr)(1.0, 'a');
func_ptr(1.0, 'a');

可以来看看下面这行代码:

1
(* (void(*)()) 0)(); 

0被强制转换为了void(*)()类型,于是这行代码意味着调用地址为0的函数,而事实上,C语言中指向0地址的指针是一个空指针。

又如

1
2
3
void(* signal_func(int, void(*)(int)) )(int){
// do something
};

首先要明确,void(*)(int)是一个函数指针类型,故这个函数返回此类型的函数指针,因此函数名为signal_func,参数列表为intvoid(*)(int),返回值为void(*)(int)

当然还有函数指针数组,如

1
2
3
4
5
6
7
// 假设已经定义了四个运算函数Add, Sub, Mul, Div,接收两个double并返回double
typedef double (* operator)(double, double);
int main(){
operator ops[4] = {Add, Sub, Mul, Div};
// ops[0](1.5, 2.5); => Add(1.5, 2.5);
return 0;
};

callback function

所谓callback function,即回调函数,是指将一个函数作为参数传递给另一个函数,并在特定事件或条件发生时由后者调用,其核心思想是 “你定义,我调用”,即由调用方定义函数的具体行为,而被调用方在适当的时机执行该函数。这一点在JavaScript中极其常见,在JS中,以函数为参数的函数或以函数为返回值的函数为高阶函数,一个典型就是forEach, addEventListener,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const navContainer = document.querySelector('.nav');
const navLinks = document.querySelectorAll('.nav__link');

const navLinksGrayFunc = function () {
const handleHover = (event, opacity) => {
const grayFunc = (opacity, targetLink) => {
logo.style.opacity = opacity;
navLinks.forEach(link => {
if (link !== targetLink) link.style.opacity = opacity;
});
};
event.target.classList.contains('nav__link') && (function (opacity, targetLink) {
grayFunc(opacity, targetLink);
})(opacity, event.target);
};
navContainer.addEventListener('mouseover', event => handleHover(event, 0.5));
navContainer.addEventListener('mouseout', event => handleHover(event, 1));
};

这里即向navLinks.forEach内传递了一个函数定义,addEventListener的第二个参数也是一个函数定义,它们都实现了callback function,即在特定时机调用(假如你看不懂JavaScript,不对,那你为什么会点进来?)。

我们同样可以用函数指针来实现callback function,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void (* signal_func(int signum, void(* handler)(int)))(int){
static void pre_handler = NULL;
void (*temp)(int) = pre_handler;
pre_handler = handler;
return temp;
};

void handler1(int intnum){
printf("%d ,handler1\n", intnum);
};

void handler2(int intnum){
printf("%d ,handler2\n", intnum);
};

int main(){
void (* pre_handler)(int) = signal_func(1, handler1);// 得到NULL
pre_handler = signal_func(1, handler2);// 得到handler1这个函数指针
pre_handler(2);// 2, handler1
return 0;
};

JavaScript 中的 closure(闭包)

C 语言的函数指针获取的是函数环境,但并没有办法获取到函数“诞生”的环境,但JavaScript可以做到,即闭包 = 函数 + 该函数创建时的词法环境。也就是说,当一个函数记住并访问其创建时的作用域(即使在该作用域外执行),就形成了闭包。这是JavaScript的固定行为,比如

1
2
3
4
5
6
7
8
9
function createCounter() {
let count = 0; // Private variable
return function () {
return ++count;
};
};
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2

(C语言不能在函数内部定义函数,至少C89是这样的)执行counter()时,不在其函数作用域中的count居然被神奇的获取到了,因为它被createCounter函数创建时,count被保存在createCounter函数的词法环境,调用counter函数可以访问到createCounter函数的词法环境,从而访问到count

我们当然可以在C语言中使用结构体与函数指针模拟这一行为,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
typedef struct {
int count;
} Counter;

typedef int (*CounterFunction)(Counter*);

int incrementCounter(Counter* counter) {
return ++(counter->count);
};

CounterFunction createCounter(Counter* counter) {
counter->count = 0;
return incrementCounter;
};

int main() {
Counter counter1, counter2;

CounterFunction counterFunc1 = createCounter(&counter1);
CounterFunction counterFunc2 = createCounter(&counter2);

printf("%d\n", counterFunc1(&counter1)); // 1
printf("%d\n", counterFunc1(&counter1)); // 2
printf("%d\n", counterFunc2(&counter2)); // 1

return 0;
};

但是注意:JS中,闭包 > 全局作用域,这是闭包比较强大的地方。

两个闭包的好例子

模块模式:封装实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const calculator = (function () {
// Private state
let memory = 0;
// Private method
function logOperation(op) {
console.log(`Performed operation: ${op}`);
};
// Expose public API
return {
add: function (x) {
logOperation('Addition');
memory += x;
return this;
},
getValue: function () {
return memory;
}
};
})();
console.log(calculator.add(10), calculator.getValue());

这个例子主要是想要强调在对象中创建的函数不是在非对象的环境中创建的,例如,addgetValue函数在IIFE中创建,因此它们可以访问IIFE的私有变量memory,并且可以访问IIFE的私有函数logOperation

事件处理程序:保留上下文

1
2
3
4
5
6
7
8
9
10
function setupButtons() {
for (var i = 1; i <= 3; i++) {
// IIFE to capture current i value
(function (index) {
document.getElementById(`btn-${index}`).addEventListener('click', function () {
console.log(`Clicked button ${index}`);
});
})(i);
};
};

一些看似简单的东西往往会酿成错误:关于for循环和变量声明的认知刷新【在循环中使用IIFE或let声明来捕获迭代值的重要性】

错误写法示例:

1
2
3
4
5
6
7
function problemDemo() {
for (var i = 1; i <= 3; i++) {
document.getElementById(`btn-${i}`).addEventListener('click', function() {
console.log(`Clicked button ${i}`);
});
}
}

使用var声明的i是函数级作用域,整个循环共享同一个变量。实际等价于(看似很简单的for循环实际是这样的):

1
2
3
4
const i = 4;
document.getElementById(`btn-${i}`).addEventListener('click', ...);
document.getElementById(`btn-${i}`).addEventListener('click', ...);
document.getElementById(`btn-${i}`).addEventListener('click', ...);

使用IIFE的解决方案:

1
2
3
4
5
6
7
8
9
function iifeSolution() {
for (var i = 1; i <= 3; i++) {
(function(index) {
document.getElementById(`btn-${index}`).addEventListener('click', function() {
console.log(`Clicked button ${index}`);
});
})(i);
}
}

通过IIFE创建新的函数作用域。当使用var声明的i作为参数传入时,事件回调函数会形成闭包,捕获当前作用域的index值。这种闭包通常发生在回调函数中。

使用let的现代解决方案:

1
2
3
4
5
6
7
function modernSolution() {
for (let i = 1; i <= 3; i++) {
document.getElementById(`btn-${i}`).addEventListener('click', function() {
console.log(`Clicked button ${i}`);
});
}
}

每次循环都会创建新的块级作用域。当使用let声明的i作为参数传入时,每个回调闭包都会捕获当前作用域的i值。