闭包

C# SELF
第 51 级 , 课程 3
可用

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

函数 squarecube 使用了不同的 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:代码可控性下降。
过度使用闭包会让追踪数据来源和变化变得困难,尤其当闭包定义的位置离调用地很远时。保持逻辑集中,别滥用闭包。

2
任务
C# SELF, 第 51 级, 课程 3
已锁定
创建一个带有内置数据(闭包)的简单函数
创建一个带有内置数据(闭包)的简单函数
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION