Lambda 与闭包
本教程共 100 篇 · 第 59 篇 · 更新于 2026-07-31 · 约 9 分钟阅读
59. Lambda 与闭包
本节目标:学完你明白 Lambda 怎么「记住」外部变量,闭包活多久,以及 foreach 捕获陷阱为何被修掉。
上一章的 Lambda 都用参数算结果,不碰外面的世界。但 Lambda 有个厉害本事:它能用外层作用域里的变量。
这种「Lambda 连同它引用的外部变量一起打包」的机制,叫闭包(closure)。
一、捕获外部变量
Lambda 能直接读外层方法的局部变量:
namespace CSharpDemo;
int factor = 3;
Func<int, int> scale = n => n * factor;
Console.WriteLine(scale(10)); // 30
factor = 5;
Console.WriteLine(scale(10)); // 50
factor 不是 Lambda 的参数,却能被它用到。而且改了 factor,再调用 scale 结果也变——说明 Lambda「看见」的是那个变量本身,不是当时的拷贝。
Note这种行为叫「按引用捕获」。Lambda 捕获的是变量,而不是捕获那一刻的值。
二、闭包的生命周期
普通局部变量,方法结束就该没了。可一旦被 Lambda 捕获,它的寿命就被拉长:
namespace CSharpDemo;
Func<int> MakeCounter()
{
int count = 0;
return () => ++count; // count 被 Lambda 带着走
}
var counter = MakeCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2
Console.WriteLine(counter()); // 3
MakeCounter 早就返回了,但 count 没消失——因为它被返回的 Lambda 抱着。只要这个 Lambda 还活着,count 就活着。这就叫闭包延长了变量生命周期。
Tip闭包底层:编译器会悄悄生成一个「内部类」,把被捕获的变量变成它的字段。所以这不是魔法,是对象在撑着寿命。
顺带提醒:被捕获的若是个很大的对象(比如一整张缓存表),它会跟着 Lambda 一起留在内存,直到 Lambda 本身不再被引用才会释放。写闭包时留意「顺手抱住了多大一块数据」,别让小 Lambda 拖着大对象不放。
三、多个 Lambda 共享同一变量
捕获的是「同一个变量」,不是各存一份:
namespace CSharpDemo;
int baseValue = 10;
Func<int> a = () => baseValue + 1;
Func<int> b = () => baseValue + 2;
baseValue = 100;
Console.WriteLine(a()); // 101
Console.WriteLine(b()); // 102
a 和 b 都指向同一个 baseValue,改一处两边都感知。这常是新手意外的源头。
四、经典的 foreach 陷阱(已修复)
这是闭包历史上最著名的坑。早年 C#(5 之前)里,foreach 的循环变量是「同一个」,Lambda 捕获它就会出问题:
// C# 4 及更早的行为(仅作历史说明,现在不会这样)
var actions = new List<Func<int>>();
int[] nums = [1, 2, 3];
foreach (int i in nums)
{
actions.Add(() => i); // 捕获的是「同一个 i」
}
// 旧版本全输出 3;因为每个 Lambda 看的是最后那个 i
好消息:从 C# 5 起,foreach 每次迭代都用独立的循环变量。上面代码现在会正确输出 1、2、3。这个坑已经被语言官方修掉了。
Note注意:修复只针对
foreach。老式for (int i = 0; ...)里的i仍是「同一个变量」,在 Lambda 里捕获它照样踩坑:
namespace CSharpDemo;
var acts = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
acts.Add(() => i); // 三个 Lambda 共享同一个 i
}
foreach (var f in acts) Console.WriteLine(f()); // 全输出 3
想在 for 里正确捕获,得在循环内复制一份:
namespace CSharpDemo;
var acts = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
int copy = i; // 每次循环一个新的变量
acts.Add(() => copy);
}
foreach (var f in acts) Console.WriteLine(f()); // 0 1 2
五、可变捕获的其他陷阱
陷阱一:循环里攒了一堆 Lambda,它们共享一个会变的变量,最后一起看到终值(见上)。
陷阱二:在闭包里改外部变量,多个 Lambda 互相影响,逻辑难追。能不共享可变状态就不共享。
Tip写循环 + Lambda 时,先问自己「这个变量在 Lambda 里会不会变」。会,就在循环内建局部副本。
六、闭包能捕获什么
Lambda 能捕获:外层局部变量、外层方法的参数、甚至 this(实例成员)。
namespace CSharpDemo;
class Counter
{
private int total;
public Func<int, int> Adder() => n => total += n;
}
var c = new Counter();
var add = c.Adder();
Console.WriteLine(add(5)); // 5
Console.WriteLine(add(3)); // 8
这里 Lambda 捕获了 this,于是能改实例字段 total。闭包「记住」的不只是局部变量。
七、延迟读取:调用时才看变量
闭包「按引用捕获」,意味着它不是在定义时取值,而是在调用时去读变量当前的值。下面的例子很说明问题:
namespace CSharpDemo;
var readers = new List<Func<int>>();
int v = 0;
readers.Add(() => v);
v = 99; // 调用前改了 v
Console.WriteLine(readers[0]()); // 99,读到的是最新值
Lambda 存的是「对 v 的引用」,不是「定义那一刻的 0」。所以你在它定义之后改了 v,调用时看到的就是改后的值。这正是闭包好用也易错的根源。
Note记住一句话:闭包捕获的是变量,不是值。定义时、调用时变量长什么样,取决于中间有没有人改过它。
八、两个独立的闭包互不干扰
只要每次捕获的是「不同的变量」,闭包之间就互不影响。看两个计数器各自独立:
namespace CSharpDemo;
Func<int> MakeA()
{
int c = 0;
return () => ++c;
}
var a1 = MakeA();
var a2 = MakeA();
Console.WriteLine(a1()); // 1
Console.WriteLine(a1()); // 2
Console.WriteLine(a2()); // 1,a2 有自己的 c,从 1 起
a1 和 a2 各自抱住一份独立的 c,谁也不碰谁的。这证明:闭包隔离的单位是「变量实例」,不是「变量名字」。
九、闭包的日常正经用法
闭包不只是陷阱,它非常实用。最常见的正经用法是「造出带配置的函数」——把参数封进闭包,返回一个已经帮你记住该参数的新函数:
namespace CSharpDemo;
Func<int, int> MakeMultiplier(int factor)
{
return n => n * factor; // 捕获 factor
}
var doubleIt = MakeMultiplier(2);
var tripleIt = MakeMultiplier(3);
Console.WriteLine(doubleIt(5)); // 10
Console.WriteLine(tripleIt(5)); // 15
MakeMultiplier(2) 返回的函数「记住了」factor=2;MakeMultiplier(3) 记住了 3。两个函数互不影响,却都不用你每次传 factor。这就是闭包让代码既简洁又灵活的典型场面:把「变化的部分」固化进函数,留下「不变的调用形态」。
十、闭包在 LINQ 里的样子
你写 LINQ 时几乎一定在无意中用闭包。比如按阈值过滤:
namespace CSharpDemo;
int min = 60;
List<int> scores = [55, 72, 88];
var passed = scores.Where(s => s >= min); // Lambda 捕获了 min
foreach (var s in passed) Console.WriteLine(s); // 72 88
Where 里的 Lambda 捕获了外层的 min。这里还有个隐藏彩蛋:LINQ 默认是延迟执行的,真正遍历 passed 时才调用 Lambda。所以如果你在遍历之前改了 min,看到的过滤结果会跟着变——这正是本章第七节「延迟读取」在 LINQ 中的体现。理解闭包,能避免这种「改了变量、结果跟着变」的意外。
Tip用 LINQ 时,若 Lambda 捕获了会变的外部变量,尽量在查询定义前就把阈值定死,或先把结果用
ToList()固化,省得后续改动牵连查询结果。
十一、什么时候该小心闭包
闭包强大,但三处最容易翻车:
- 循环里共享可变变量:
for循环的i是同一个,多个 Lambda 一起看到终值。解法:循环内复制一份。 - 延迟读取踩坑:定义后改了外部变量,调用时才发现值变了,和预期不符。
- 意外延长生命周期:被捕获的变量赖着不走,可能让本该释放的大对象停在内存里更久。
Tip写闭包时养成习惯:问自己「这个变量之后还会被改吗?多个 Lambda 会共享它吗?」想清楚,坑就少一大半。
十二、动手练一练
- 写一个
MakeAdder(int baseValue),返回n => n + baseValue;分别用 10 和 100 生成两个加法器,验证它们独立且记忆了各自的基数。 - 用
MakeCounter造两个计数器c1、c2,各调用两次,确认c1和c2的计数互不干扰。 - 写一段
for (int i = 0; i < 3; i++) acts.Add(() => i);,运行观察输出;再把int copy = i;改法加进去,确认输出变成 0、1、2。 - 用
List<int>和Where(s => s >= min)过滤,先在遍历前把min从 60 改成 80,观察结果变化,体会延迟读取。
十三、小结
闭包 = Lambda + 它引用的外部变量,按引用捕获、延长生命周期、调用时才读当前值。它能造出「带配置的函数」,也是 LINQ 延迟执行的幕后功臣。C# 5 起 foreach 的捕获陷阱已修复,但 for 循环里仍要当心,记得在循环内复制变量。少共享可变状态,闭包就可控又安全。下一章看 Lambda 的另一种形态:表达式树。