CodeGym /课程 /C# SELF /变量捕获 ( Closures)

变量捕获 ( Closures)

C# SELF
第 49 级 , 课程 4
可用

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:长期存在的带闭包的委托。
如果带闭包的委托被长期保存(例如放在静态字段),被捕获的变量也会因此长期存在于内存中,可能引发隐蔽的内存泄露和性能问题。

1
调查/小测验
Lambda-表达式第 49 级,课程 4
不可用
Lambda-表达式
Lambda-表达式的语法
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION