1. 介绍
在编程里,闭包(closure)不是用来关门的 JavaScript 技巧,而是一种机制:lambda 表达式或匿名方法会捕获来自外部上下文的变量,并且即使声明它们的代码块已经结束,lambda 仍然「记住」这些变量。 简单来说,闭包就是一个记住它出生时环境的函数,把那些值装进一个小包裹里(变量)。
闭包 —— 是函数和在创建时存在的环境(scope)的组合。
最简单的闭包示例
我们来用实践说明:
Func<int> MakeCounter()
{
int count = 0;
return () =>
{
count++;
return count;
};
}
这样调用:
var counter = MakeCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2
Console.WriteLine(counter()); // 3
这是怎么工作的?
- 变量 count 在方法 MakeCounter 内声明。
- lambda () => { ... } 被返回到外面并现在在方法外部存活。
- 但!它记住了变量 count,尽管方法 MakeCounter 已经结束。
这就是闭包: lambda「闭合/捕获」了来自外部上下文的变量 count。
lambda 到底捕获了什么?
- 来自外部方法(scope)的本地变量,
- 方法的参数,
- 块内的变量(比如 for, foreach 等)。
重要:变量不是按值捕获,而是按引用捕获!如果我们在闭包里修改变量,外部也会看到变化。实际上 C# 编译器会为这些变量生成一个辅助类 —— 从概念上记住这点就够了,能让你自信地使用闭包。
2. 闭包与词法作用域
把「函数工厂」的例子改造一下:
Func<int, int> PowerFactory(int power)
{
return x =>
{
int result = 1;
for (int i = 0; i < power; i++)
result *= x;
return result;
};
}
使用:
var square = PowerFactory(2); // x^2
var cube = PowerFactory(3); // x^3
Console.WriteLine(square(5)); // 25
Console.WriteLine(cube(2)); // 8
函数 square 和 cube 使用了不同的 power 值,每个都记住了自己的值。每次调用 PowerFactory 都会产生一个「专属背包」来保存被捕获的值。
3. 被捕获变量的变异
有时候会问:如果在循环里创建多个捕获循环变量的 lambda,会发生什么?这里很容易踩坑。
示例:循环里的闭包
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (var action in actions)
action(); // ???
你期望看到 0, 1, 2 吗?实际上会是:
3
3
3
为什么?所有 lambda 都「引用」同一个变量 i。到循环结束时 i 的值变成了 3,这正是所有 Action 会看到的值。
修正办法
为了让每个 lambda 捕获自己的值,在循环体内创建新变量:
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int copy = i;
actions.Add(() => Console.WriteLine(copy));
}
foreach (var action in actions)
action(); // 0 1 2
现在 copy 在每次迭代中都是新的变量,闭包捕获的是它的那个副本。
4. 在实际任务中应用闭包
数据处理与回调
当你做异步操作或延迟执行(事件处理、过滤、任务调度)时,闭包允许你把逻辑和参数“打包”在一起。例如:
void ProcessList(List<int> list, int threshold)
{
var filtered = list.Where(x => x > threshold);
foreach (var item in filtered)
Console.WriteLine(item);
}
这里 Where 里的 lambda 捕获了变量 threshold。
创建「函数工厂」
传入参数,得到带着这个参数的函数。这个技巧很适合配置过滤器、排序比较器、UI 响应等场景。
管理状态
有时你想保存一点状态,而不想写一个完整的类:
Func<string, string> CreateGreeting()
{
string prefix = "Hello";
return name =>
{
return $"{prefix}, {name}!";
};
}
5. 有用的细节
底层:C# 中闭包如何工作
lambda 捕获的所有东西都会被编译器变成一个辅助类:变量变成字段,lambda 变成方法。因此变量的状态会在多次调用之间保持。
每个「工厂」都会生成一个小对象。这没关系 —— .NET 对这类对象管理得很好,并且只有在需要时才分配。
提示:不要无必要地捕获「大」对象
如果闭包捕获了一个很大的对象(比如 UI 窗体),只要 lambda 存活,那个对象就无法被释放。常见的内存泄漏原因是通过 lambda 订阅事件却不取消订阅(+=/-=)。
闭包与集合的生命周期 — LINQ 的例子
闭包让 LINQ 很灵活:过滤器记住它们的参数。
List<string> colors = new List<string> { "Red", "Green", "Blue", "Yellow" };
string startsWith = "B";
var filtered = colors.Where(c => c.StartsWith(startsWith));
foreach (var color in filtered)
Console.WriteLine(color); // "Blue"
如果之后改变 startsWith,结果也会改变:
startsWith = "R";
foreach (var color in filtered)
Console.WriteLine(color); // "Red"
这是因为闭包引用的是同一个变量 startsWith,而 StartsWith 每次都会基于当前值去判断。
6. 使用闭包时常见的错误
错误 №1:捕获会在使用前被改变的变量。
循环里的经典情况:闭包内部的 lambda 看到的是同一个循环变量,到调用时它已经变成别的值。解决方案是在循环内引入独立变量。
错误 №2:捕获太大范围的上下文。
闭包可能会把整个对象链带走,而不是你只需要的字段/值。这样会增加不必要的耦合并让代码变复杂。只捕获需要的东西。
错误 №3:保持对重资源的引用导致内存泄漏。
用 lambda 订阅事件并捕获了「重」对象,如果不取消订阅对象就无法被释放。关注订阅的生命周期并显式取消订阅(-=)。
错误 №4:代码可控性下降。
过度使用闭包会让追踪数据来源和变化变得困难,尤其当闭包定义的位置离调用地很远时。保持逻辑集中,别滥用闭包。
GO TO FULL VERSION