原文地址:https://zhuanlan.zhihu.com/p/3161634
C#的闭包,是一个语法糖。 它实质上是将匿名函数转换成一个类,函数作为其中的类方法,并调整外部调用代码来实现的。既然是对象,自然就有自己的堆内存分配。 但它并不是无脑地每次创建委托就生成一个新的对象,而是做了编译期间优化,实际程序中生成的对象是少于我们的预期的。 下面就是通过反编译来查看C#是如何编译匿名函数的。 首先是这个经典闭包示例,认为结果是0,1,2,3,4,5,6,7,8,9的统统出去抽自己10个耳光,不解释。 public class Test : MonoBehaviour { Action[] actions = new Action[10]; void Awake() { for (int i = 0;i < 10;i++) { actions[i] = () => { Debug.Log(i); }; } foreach (var action in actions) { action(); } } } 反编译的结果是这样的: public class Test : MonoBehaviour { private Action[] actions = new Action[10]; private void Awake() { <>c__DisplayClass1_0 CS$<>8__locals0 = new <>c__DisplayClass1_0(); CS$<>8__locals0.i = 0; while (CS$<>8__locals0.i < 10) { this.actions[CS$<>8__locals0.i] = new Action(CS$<>8__locals0.<Awake>b__0); int i = CS$<>8__locals0.i; CS$<>8__locals0.i = i + 1; } foreach (Action action in this.actions) { action(); } } } private sealed class <>c__DisplayClass1_0 { public int i; internal void <Awake>b__0() { Debug.Log(this.i); } } 估计你们看得都很费劲,我把里面的名字改一下再看吧(之后的代码都是如此) public class Test : MonoBehaviour { private Action[] actions = new Action[10]; private void Awake() { //生成一个闭包对象(产生额外堆内存分配) AnonymousClass anonymous = new AnonymousClass(); //闭包里使用到的外部变量全部替换成这个闭包对象的属性,这就是一般说的值对象装箱 anonymous.i = 0; while (anonymous.i < 10) { this.actions[anonymous.i] = new Action(anonymous.Action); int i = anonymous.i; anonymous.i = i + 1; } foreach (Action action in this.actions) { action(); } } } private sealed class AnonymousClass { public int i; internal void Action() { Debug.Log(this.i); } } 所以,闭包产生的堆内存有两个,一个是闭包本身的这个对象AnonymousClass,另一个就是它所用到的外部变量i。外部变量越多,堆内存自然也就越多。但外部变量没有,闭包本身还是会占用一个空对象的内存(大概17B?) 光这么看,似乎引用点外部变量也没事?并非如此。 下面是能够正常输出0,1,2,3,4,5,6,7,8,9的经典写法 public class Test : MonoBehaviour { Action[] actions = new Action[10]; void Awake() { for (int i = 0;i < 10;i++) { int j = i;//不同之处就是将i先存到另一个变量中,再在闭包引用这个值 actions[i] = () => { Debug.Log(j); }; } foreach (var action in actions) { action(); } } } 结果是: using System; using UnityEngine; public class Test : MonoBehaviour { private Action[] actions = new Action[10]; private void Awake() { for (int i = 0; i < 10; i++) { AnonymousClass anonymous = new AnonymousClass(); //闭包对象每个循环都创建了一次 anonymous.j = i; this.actions[i] = new Action(anonymous.Action); } foreach (Action action in this.actions) { action(); } } } private sealed class AnonymousClass { public int j; internal void Action() { Debug.Log(this.j); } } 虽然闭包对象还是以前的样子,但是却一共创建了10个,GC也就变成了10倍。当然,也因为这个原因能够打印出正确的结果了。 这是理所当然的,因为你需要让它打印出10个不同的结果,它就得保持10个不同的状态嘛。所以你看,并不是只要用循环就一定会产生循环次数的闭包,关键还要看使用的属性是循环内的还是循环外的。其实只要别引用循环内的数据,循环内多次生成闭包函数是没关系的。 然后下一步自然就是,如果我闭包里什么变量都不引用,会是什么样呢? public class Test : MonoBehaviour { Action[] actions = new Action[10]; void Awake() { for (int i = 0;i < 10;i++) { actions[i] = () => { Debug.Log("");//不再引用i }; } foreach (var action in actions) { action(); } } } 结果是: public class Test : MonoBehaviour { private Action[] actions = new Action[10]; private void Awake() { for (int i = 0; i < 10; i++) { this.actions[i] = (AnonymousClass.action != null) ? AnonymousClass.action : AnonymousClass.action = new Action(AnonymousClass.instance.Funtion); } foreach (Action action in this.actions) { action(); } } private sealed class AnonymousClass { public static readonly Test.AnonymousClass instance = new Test.AnonymousClass(); public static Action action ; internal void Funtion() { Debug.Log(""); } } } 虽然有很多操作的样子,其实就是将这个匿名函数变成了一个静态单例。不过C#生成的这代码并不是很smart,有不少多余的处理,但反正都是个静态类,也无所谓了。 静态类只会实例化一次,所以可以认为没有引用外部变量的闭包函数都没有GC。 那么引用成员属性呢? public class Test : MonoBehaviour { Action[] actions = new Action[10]; void Awake() { for (int i = 0;i < 10;i++) { actions[i] = () => { Debug.Log(actions.Length); }; } foreach (var action in actions) { action(); } } } 结果是: public class Test : MonoBehaviour { private Action[] actions = new Action[10]; private void Awake() { for (int i = 0; i < 10; i++) { this.actions[i] = new Action(this.Function); } foreach (Action action in this.actions) { action(); } } private void Function() { Debug.Log(this.actions.Length); } } 只引用成员属性的闭包函数,等效于成员方法 那,如果是引入的是方法内的临时引用对象呢? public class Test : MonoBehaviour { Action[] actions = new Action[10]; void Awake() { for (int i = 0;i < 10;i++) { Action[] v = actions;//只是简单换个变量保存一下 actions[i] = () => { Debug.Log(v.Length); }; } foreach (var action in actions) { action(); } } } 结果是: public class Test : MonoBehaviour { private Action[] actions = new Action[10]; private void Awake() { for (int i = 0; i < 10; i++) { AnonymousClass anonymous = new AnonymousClass(); anonymous.v = this.actions; this.actions[i] = new Action(anonymous.Action); } foreach (Action action in this.actions) { action(); } } } private sealed class AnonymousClass { public Action[] v; internal void Action() { Debug.Log(this.v.Length); } } 龟龟,这也太蠢了吧?和第二个使用循环内值类型的情况一模一样(10个匿名对象),就不知道优化成上一个的结果么?(0个匿名对象)。也就是说即使是引用对象,在循环里建个临时变量放在闭包里引用也会导致严重的GC问题。 再试试一个方法内多个引用对象的情况 public class Test : MonoBehaviour { Action[] actions = new Action[10]; void Awake() { int x = 1; int y = 1; int z = 1; for (int i = 0;i < 5;i++) { actions[i] = () => { Debug.Log(x + y); }; } for (int i = 5; i < 10; i++) { actions[i] = () => { Debug.Log(y + z); }; } foreach (var action in actions) { action(); } } } 结果是: using System; using UnityEngine; public class Test : MonoBehaviour { private Action[] actions = new Action[10]; private void Awake() { AnonymousClass anonymous = new <>c__DisplayClass1_0(); anonymous.x = 1; anonymous.y = 1; anonymous.z = 1; for (int i = 0; i < 5; i++) { this.actions[i] = anonymous.action1 ?? (anonymous.Action1 = new Action(anonymous.Action1)); } for (int i = 5; i < 10; i++) { this.actions[i] = anonymous.action2 ?? (anonymous.Action2 = new Action(anonymous.Action2)); } foreach (Action action in this.actions) { action(); } } } private sealed class AnonymousClass { public Action action1; public Action action2; public int x; public int y; public int z; internal void Action1() { Debug.Log(this.x + this.y); } internal void Action2() { Debug.Log(this.y + this.z); } } 当你一个方法内用到多个闭包函数,而它们引用的是不同的临时变量的话,匿名对象会保存全部的变量的总和。 但这样做的目的是为了只生成一个匿名对象,实际上是一种优化。 当它没得优化的时候: public class Test : MonoBehaviour { Action[] actions = new Action[10]; void Awake() { int x = 1; int y = 1; for (int i = 0;i < 5;i++) { actions[i] = () => { Debug.Log(x + y); }; } for (int i = 5; i < 10; i++) { int z = 1; actions[i] = () => { Debug.Log(y + z); }; } foreach (var action in actions) { action(); } } } 结果是: public class Test : MonoBehaviour { private Action[] actions = new Action[10]; private void Awake() { AnonymousClass1 anonymous1 = new AnonymousClass1(); anonymous1.x = 1; anonymous1.y = 1; for (int i = 0; i < 5; i++) { this.actions[i] = anonymous1.action1 ?? (anonymous1.action1 = new Action(anonymous1.Action1)); } for (int i = 5; i < 10; i++) { AnonymousClass2 anonymous2 = new AnonymousClass2(); anonymous2.anonymous1 = anonymous1;//把另一个匿名对象存过来取y值 anonymous2.z = 1; this.actions[i] = new Action(anonymous2.Action2); } foreach (Action action in this.actions) { action(); } } } private sealed class AnonymousClass1 { public Action action1; public int x; public int y; internal void Action1() { Debug.Log(this.x + this.y); } } private sealed class AnonymousClass2 { public Test.AnonymousClass1 anonymous1; public int z; internal void Action2() { Debug.Log(this.anonymous1.y + this.z); } }
总之,就是和我们手写这种情况的时候,差不多的处理。
结论就是多闭包函数同时出现没问题。
我知道有很多人都排斥使用闭包函数,认为它容易导致“问题”(好多人说的都是“内存泄露”这种无稽之谈,不过GC问题确实还是存在的)
闭包在不同应用域传递变量的方法是创建额外对象,而手写代码则可以选择利用成员属性来代替,这确实会导致闭包产生更多的GC。但在上面的那些用例里,闭包会造成的大量额外GC,也只有“明明可以把变量放在外侧,却非要转存一份在内部”这一种情况,而多出来的GC其实也就是一个空对象,不在逐帧事件里使用并没有什么问题。
闭包毕竟会让代码变得简洁,而低GC写法往往都很丑陋。这种可读性和效能的矛盾一直都存在,适当的权衡是需要的。