C#笔记——闭包

闭包是现代类型编程语言的特性,本文将从委托开始逐步深入闭包的原理。


前置知识

Delegate(委托)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 定义
private delegate void Myfunc(int a);
// 使用delegate关键字定义委托,void表示返回值,int a表示参数;

// 创建委托变量
Myfunc myfunc;

// 使用
// 定义函数
private void func(int a){
...
}
// 赋值,不赋值Myfunc == null
myfunc = func;

// 调用
myfunc(1);
// 判断是否有值
myfunc?.Invoke(1);

所谓委托,可以类似于C++的函数指针。可视为是函数的“容器”。可以看做是创建了一种类型。

Multicasting(多播)

一个委托可以储存多个函数,当委托调用时。储存的函数会按照存储的顺序逐个执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 定义
private delegate int Myfunc(int a, int b);
private Myfunc myfunc;
// 定义函数
private int add(int a, int b){
return a+b;
}

private int multiply(int a, int b){
return a*b;
}

// 添加函数
myfunc += add;
myfunc += multiply;

// 减少函数
myfunc -= multiply;

// 接下

注意,当一个委托内加入多个有返回值的函数时,返回值是最后一个函数的返回值。

1
2
3
4
5
// 接上
myfunc += multiply;

print(myfunc(1,2));
// 输出2

匿名函数和Lambda表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义
private delegate void Myfunc(int a);
private Myfunc myfunc;
// 使用delegate关键字定义委托,void表示返回值,int a表示参数;

// 对于一些简单的函数,我们不想单独定义出来。可以这样使用。
myfunc = delegate(){
...
}
// 使用delegate关键字定义匿名函数

// 还用更简单的方法。
myfunc = () => {
...
}
//省略delegate关键字,使用=>表示Lambda表达式。

库工具Action和Func

Action

用于定义无返回值的委托

1
2
3
4
5
6
7
8
9
// 无参数
private Action _action;

_action = () => pass;

// 有参数
private Action<int a> _action;

_action = (int a) => pass;

Func

用于定义有返回值的委托

1
2
3
4
5
// 定义
private Func<int, int> _func;
// 最后一个泛型参数表示函数的返回值

_func = (int a) => return a;

最后一个泛型参数表示函数的返回值

event(事件)

我们先用普通的delegate做一个事件系统。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 假设做一个游戏的管理器,多个子系统要和总管理器沟通。
// 想要解耦合,就可以使用事件。
public class Manager{
// 系统开始
public Action systemStart();
// 系统结束
public Action systemEnd();

// 对读取子系统的依赖
private Load _load;
// 对储存子系统的依赖
private Save _save;

public void main(){
// 触发系统开始事件
systemStart?.Invoke();
/*
pass
*/
// 触发系统结束事件
systemEnd?.Invoke();
}
}

public class Load{
// 对总管理的依赖
Manager _manager;

Load(Manager manager){
_manager = manager;
// 注册事件
_manager.systemStart += load;
}

~Load(){
// 注销事件
_manager.systemStart -= load;
}

private void load(){
pass;
}
}

public class Save{
// 对总管理的依赖
Manager _manager;

Save(Manager manager){
_manager = manager;
// 注册事件
_manager.systemStart += save;
}

~Save(){
// 注销事件
_manager.systemStart -= save;
}

private void save(){
pass;
}
}

这样实现了解耦合。但是这样的代码有危险性。

为了让外部类注册事件,委托的访问权限是public。但这样就导致,委托可以在外部被触发。这是不安全,我们不希望总系统的事件被外界触发。


可以使用event关键字,定义事件。让事件不能被外界触发。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 假设做一个游戏的管理器,多个子系统要和总管理器沟通。
// 想要解耦合,就可以使用事件。
public class Manager{
// 系统开始
public event Action systemStart();
// 系统结束
public event Action systemEnd();

// 对读取子系统的依赖
private Load _load;
// 对储存子系统的依赖
private Save _save;

public void main(){
// 触发系统开始事件
systemStart?.Invoke();
/*
pass
*/
// 触发系统结束事件
systemEnd?.Invoke();
}
}

public class Load{
// 对总管理的依赖
Manager _manager;

Load(Manager manager){
_manager = manager;
// 注册事件
_manager.systemStart += load;
}

~Load(){
// 注销事件
_manager.systemStart -= load;
}

private void load(){
pass;
}
}

public class Save{
// 对总管理的依赖
Manager _manager;

Save(Manager manager){
_manager = manager;
// 注册事件
_manager.systemStart += save;
}

~Save(){
// 注销事件
_manager.systemStart -= save;
}

private void save(){
pass;
}
}

这样事件就不能再外部触发了。

闭包

经过漫长的前言,终于来到了本文的重点——闭包。

所谓闭包,表现为匿名函数(包括Lambda表达式,以下都简称匿名函数),在使用外界变量时,会延长该变量的生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 定义委托
void Action _func;

// 外部函数
void make(){
// 外部变量
int a = 10;
// 在匿名函数中使用外部变量
_func = () => {
print(a);
}
}

// 触发委托
_func?.Invoke();
// 委托输出10,变量a的生命周期延长了。

这样就是一个闭包,但闭包有个坑。

闭包的循环问题

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义委托
void Action _func;

// 外部函数
void make(){
for(int i = 0; i < 10; i++){
_func += () => print(i);
}
}

// 触发委托
_func?.Invoke();
// 委托输出10次10。

这超出了我们的预期,按理来说我们希望输出0-9。但现在却输出了10次10。这是为什么?

原因是C#编译时,匿名函数会被编译器自动生成一个匿名的类(事实上这个类不是匿名的,只是对于程序原来说类名是不可知的)。而且这个匿名类只会创建一次。

如果匿名函数使用了外部变量,这个外部变量在编译后会在匿名类内被定义。成为匿名类的一个内部的public的变量。

又因为for循环的i是在循环之前定义的,这就导致匿名类也必须在循环外定义——因为i在编译后是匿名类的类内变量。

而我们像委托添加的函数都是匿名类内的同一个函数,使用的类就是这个类内的变量“i”。所以代码最后输出了10次10。

反编译的代码类似(请注意这里只是类似,但本实例仅用于展现反编译代码的“精髓”。):

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
28
29
30
31
32
33
34
35
public class A{
// 定义委托
void Action my_action;

// 外部函数
void make(){
Nclass nclass = new Nclass();
for(nclass.i = 0; nclass.i < 10; nclass.i++){
this.my_action = (Action)Delegate.
Combine(
(Delegate) this._myaction,
(Delegate) new Action(nclass.Nfunc));
// 这里可以看到,委托每一次添加的函数都是nclass这个类实例中的Nfunc函数。
}
}

// 触发委托
my_action?.Invoke();
// 委托输出10次10。
}

public class Nclass{
// 内部变量
int i;

// 构造方法
public Nclass(){
base..ctor();
}

// 方法
internal void Nfunc(){
print(this.i.ToString());
}
}

循环问题的解决方法

解决这个”bug”很简单。既然问题的根本在于直接使用了for循环的i,导致闭包匿名类在循环外部创建。那么只要不使用i,让闭包匿名类在循环内部创建就行了。

实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定义委托
void Action _func;

// 外部函数
void make(){
for(int i = 0; i < 10; i++){
int j = i;
_func += () => print(j);
}
}

// 触发委托
_func?.Invoke();
// 委托输出0 1 2 3 4 5 6 7 8 9

这样我们在循环内部定义临时变量j,这样因为j是在循环内定义的。那么编译后的代码也会在循环内创建匿名类。这样每次添加的函数就是不同的类实例的方法,每个类方法调用自己的内部变量j。

反编译代码类似:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class A{
// 定义委托
void Action my_action;

// 外部函数
void make(){

for(nclass.i = 0; nclass.i < 10; nclass.i++){
Nclass nclass = new Nclass();
nclass.j = i;
this.my_action = (Action)Delegate.
Combine(
(Delegate) this._myaction,
(Delegate) new Action(nclass.Nfunc));
/*
可以看到,这里每次循环都创建了一个匿名类实例。
这样委托每次添加的函数就是不同实例的方法了。
这些实例各自使用自己的j变量。
*/
}
}

// 触发委托
my_action?.Invoke();
// 委托输出10次10。
}

public class Nclass{
// 内部变量
int j;

// 构造方法
public Nclass(){
base..ctor();
}

// 方法
internal void Nfunc(){
print(this.j.ToString());
}
}

这样我们就解决了闭包在循环中导致的问题。但看到这里,很显然闭包每次都要创建对象。这些对象都会储存在堆内存中,由GC负责处理。这就会导致性能损耗,如果可以尽量避免频繁的使用闭包。