首页 / C# 入门教程 / Lambda 与闭包

C# 入门教程

Lambda 与闭包

本教程共 100 篇 · 第 59 篇 · 更新于 2026-07-31 · 约 9 分钟阅读

C#C# 入门教程编程语言Lambda闭包closure

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

ab 都指向同一个 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 起

a1a2 各自抱住一份独立的 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() 固化,省得后续改动牵连查询结果。

十一、什么时候该小心闭包

闭包强大,但三处最容易翻车:

  1. 循环里共享可变变量for 循环的 i 是同一个,多个 Lambda 一起看到终值。解法:循环内复制一份。
  2. 延迟读取踩坑:定义后改了外部变量,调用时才发现值变了,和预期不符。
  3. 意外延长生命周期:被捕获的变量赖着不走,可能让本该释放的大对象停在内存里更久。
Tip

写闭包时养成习惯:问自己「这个变量之后还会被改吗?多个 Lambda 会共享它吗?」想清楚,坑就少一大半。

十二、动手练一练

  1. 写一个 MakeAdder(int baseValue),返回 n => n + baseValue;分别用 10 和 100 生成两个加法器,验证它们独立且记忆了各自的基数。
  2. MakeCounter 造两个计数器 c1c2,各调用两次,确认 c1c2 的计数互不干扰。
  3. 写一段 for (int i = 0; i < 3; i++) acts.Add(() => i);,运行观察输出;再把 int copy = i; 改法加进去,确认输出变成 0、1、2。
  4. List<int>Where(s => s >= min) 过滤,先在遍历前把 min 从 60 改成 80,观察结果变化,体会延迟读取。

十三、小结

闭包 = Lambda + 它引用的外部变量,按引用捕获、延长生命周期、调用时才读当前值。它能造出「带配置的函数」,也是 LINQ 延迟执行的幕后功臣。C# 5 起 foreach 的捕获陷阱已修复,但 for 循环里仍要当心,记得在循环内复制变量。少共享可变状态,闭包就可控又安全。下一章看 Lambda 的另一种形态:表达式树。