1. 什么是闭包?
在编程里,闭包 (closure) 是一个捕获了外部上下文变量的函数。简单来说,如果一个 lambda 表达式或匿名方法使用了在其体外声明的变量,这个函数就成了闭包。它“记得”那些变量在创建时的状态。
生活类比:
想象你把一个秘方写在纸上并放进信封。即便那张纸后来丢了或直接访问不到了(变量不能直接访问),拥有信封的人(lambda)仍然能拿到那个秘方。
简单示例:
int x = 42;
Func<int> getX = () => x;
Console.WriteLine(getX()); // 42
这里 getX 是一个闭包,因为它使用了在外部声明的变量 x。
2. 为什么变量捕获很重要?
在 C# 里闭包用得非常广泛:
- 在集合和 LINQ 查询中
- 用于向事件或异步方法传递参数
- 在循环里创建事件处理器时
- 用于在不同调用间保存“上下文”
没有闭包,很多常见的 C# 用法要么不可能,要么非常麻烦。
现实示例
假设我们在做一个提醒应用:用户设置了一系列提醒,过一会儿(分钟、小时、周……)它要显示相应的信息。把一个 lambda 传给处理器,让它“记住”要提醒的内容就很方便。这就是变量捕获的经典用法。
3. C# 如何实现变量捕获
在背后 C# 做了个巧妙的事:当你有一个使用外部变量的 lambda 表达式,编译器会自动生成一个辅助类 —— display class。所有被“捕获”的变量都变成这个类的字段。
示意图看起来像这样:
外部变量 ──► DisplayClass
▲
│
闭包 (lambda)
代码示意
下面是“引擎盖下”的发生:
int x = 5;
Func<int> f = () => x;
// 这里编译器大概会做成这样:
class DisplayClass
{
public int x;
public int Lambda() => x;
}
DisplayClass display = new DisplayClass();
display.x = 5;
Func<int> f = display.Lambda;
这解释了为什么闭包在外层作用域结束后仍能看到变量的当前值。
4. 变量的值是“冻结”的还是会变化?
在 C# 里变量是按引用捕获的,而不是按值捕获。也就是说,如果 lambda 使用某个变量,而这个变量在别处被修改,lambda 会看到新的值。
示例:
int x = 10;
Func<int> getX = () => x;
x = 20;
Console.WriteLine(getX()); // 20, 不是 10!
学生常以为 getX() 会一直返回 10,因为“捕获”了变量。但实际上 lambda 读的是仍然存在且可修改的变量。
什么时候值会被固定?
如果变量在循环中每次都有新的作用域创建,比如用 foreach,那么每次迭代会有新的变量实例 —— lambda 会“记住”那个特定的值。
5. 示例:循环中的闭包 —— 常见陷阱
常见错误
想创建一组委托,每个委托打印循环中的自己的编号:
Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
actions[i] = () => Console.WriteLine(i);
}
foreach (var action in actions)
action();
程序会输出什么?
5
5
5
5
5
哎?为什么不是 0,1,2,3,4?
原因:
lambda 捕获的是同一个变量 i,这个变量在循环过程中不断变化。当你后来调用这些委托时,i 已经等于 5。
正确做法:
需要在循环体内创建一个新的局部变量:
Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
int index = i; // 每次迭代的新变量!
actions[i] = () => Console.WriteLine(index);
}
foreach (var action in actions)
action();
现在程序会输出:
0
1
2
3
4
这和 display class 有什么关系?
在第一个版本里,所有委托都“挂”在同一个字段上 —— 所以结果一样。在第二种写法中,每次迭代都会有新的局部变量,因此每个委托对应自己的 DisplayClass,有独立的值。
6. 捕获变量的实际使用场景
示例 1:带“上下文”的事件处理
比如在我们的任务列表应用里,每个任务有一个“完成”按钮,我们希望处理器记住该处理哪个任务:
foreach (var task in tasks)
{
button.Click += (sender, e) => CompleteTask(task);
}
这里变量 task 在每次迭代中被捕获。要注意确保它在循环内正确声明,以免掉入上面提到的陷阱。
示例 2:异步操作
闭包常用于向异步逻辑传递参数 —— 比如在启动异步任务时把变量存到本地“槽”里:
for (int i = 0; i < 3; i++)
{
int index = i; // 必须!
Task.Run(() => Console.WriteLine($"Task #{index}"));
}
没有本地变量的话,所有任务可能都会打印相同的编号,这通常不是我们想要的。
示例 3:LINQ 查询
LINQ 在集合上经常使用闭包,根据外部变量来过滤或变换元素。例如:
string prefix = "Task";
var filtered = tasks.Where(t => t.Name.StartsWith(prefix));
这里 Where 里的 lambda 记住了 prefix 的值,并调用 StartsWith。
7. 使用闭包时的注意事项、限制和常见错误
错误 №1:在循环中所有委托共用同一个变量。
如果循环里所有委托都引用同一个变量,结果会很意外。应在循环内为每个委托创建新的局部变量,以避免共享引用。
错误 №2:闭包捕获方法外的变量。
如果闭包捕获了类字段或在当前方法外声明的变量,它会保持对该变量的引用。这可能造成内存泄露 —— 只要闭包存在,垃圾回收器就不能释放被引用的对象。
错误 №3:长期存在的带闭包的委托。
如果带闭包的委托被长期保存(例如放在静态字段),被捕获的变量也会因此长期存在于内存中,可能引发隐蔽的内存泄露和性能问题。
GO TO FULL VERSION