C#闭包陷阱2

在 C# 开发(尤其是 Unity 开发)中,闭包(Closure)是一个非常强大的特性。但如果你在 for 循环中不小心使用了闭包,很可能会掉进一个经典的"内存陷阱"。

一、经典的"闭包陷阱"

我们先来看一段看似正常的测试代码。我们的目标是:保存 5 个委托,每个委托打印它被创建时的索引值。

csharp 复制代码
using System;
using System.Collections.Generic;
using UnityEngine;

namespace ClosureTest
{
    public class TestCode : MonoBehaviour
    {
        private static readonly List<Action> List = new List<Action>();

        private void Start()
        {
            Debug.Log("=== 开始测试 ===");
            TestMain();
            
            Debug.Log("=== 验证独立性 ===");
            // 我们期望 list[0] 打印 0,list[1] 打印 1
            Debug.Log("再次调用 list[0] (期望是 0):");
            List[0](); 
            
            Debug.Log("再次调用 list[1] (期望是 1):");
            List[1]();
        }
        
        private void TestMain()
        {
            List.Clear();

            // ❌ 陷阱代码:直接捕获循环变量 index
            for (int index = 0; index < 5; index++)
            {
                 List.Add(() => Debug.Log(index));
            }

            Debug.Log("--- 第一轮遍历调用 ---");
            foreach (var t in List)
            {
                t();
            }
        }
    }
}

运行结果

为什么? 为什么所有的委托都指向了同一个值?是时候看一眼底层的 IL 代码了。


深入 IL:寻找"罪魁祸首"

通过反编译工具(如 Rider 自带的 IL Viewer),我们发现了编译器背后的操作。

铁证 1:只有一个闭包实例(共享的盒子)

生成了一个闭包类

csharp 复制代码
.class nested private sealed auto ansi beforefieldinit
    '<>c__DisplayClass2_0'
      extends [netstandard]System.Object
  {
    //...
    .field public int32 index
     //...
  }

TestMain 方法的开头,循环开始之前,我们发现了这样一行代码:

il 复制代码
 .method private hidebysig instance void
    TestMain() cil managed
 {
    .maxstack 3
    .locals init (
      [0] class ClosureTest.TestCode/'<>c__DisplayClass2_0' 'CS$<>8__locals0',//闭包类实例名
      [1] int32 V_1,
      [2] valuetype [netstandard]System.Collections.Generic.List`1/Enumerator<class [netstandard]System.Action> V_2
    )
    
	//...
	
    // 🛑 核心证据 1:newobj 在循环外面!
    // 注意:这行代码在 loop 开始跳转 (IL_0019) 之前。
    // 这意味着整个方法只执行一次 newobj,只创建了一个闭包实例。
    IL_000a: newobj       instance void ClosureTest.TestCode/'<>c__DisplayClass2_0'::.ctor()
    IL_0011: stloc.0      // 🛑 把这个唯一的实例存到变量 'CS$<>8__locals0'

    // [30 18 - 30 31] 初始化 index = 0
    IL_0010: ldloc.0      
    IL_0011: ldc.i4.0
    IL_0012: stfld        int32 ClosureTest.TestCode/'<>c__DisplayClass2_0'::index

    // 跳转进循环检查
    IL_0017: br.s         IL_003f
    //...
}

解析: 编译器把 index 变量提升到了一个自动生成的类(闭包类)中。关键在于,它只实例化了一次 。你可以把它想象成编译器在桌子上放了唯一的一个盒子

铁证 2:循环体内在"共用"

进入循环体内部,我们看看委托是如何被创建的:

il 复制代码
      // start of loop, entry point: IL_003f
      //加载委托列表
      IL_0019: ldsfld       class [netstandard]System.Collections.Generic.List`1<class [netstandard]System.Action> ClosureTest.TestCode::List
      IL_001e: ldloc.0      // 🛑 核心证据 2:加载前面的CS$<>8__locals0实例!
      
      // 创建委托
      IL_001f: ldftn        instance void ClosureTest.TestCode/'<>c__DisplayClass2_0'::'<TestMain>b__0'()
      //这里面的object就是CS$<>8__locals0的地址,指向同一个对象
      IL_0025: newobj       instance void [netstandard]System.Action::.ctor(object, native int)
      IL_002a: callvirt     instance void class [netstandard]System.Collections.Generic.List`1<class [netstandard]System.Action>::Add(!0/*class [netstandard]System.Action*/)

解析: 每次循环添加委托时,所有委托的 object 都指向了那个唯一的盒子。这就好比你拍了 5 张照片,但 5 张照片拍的都是同一个时钟。

铁证 3:修改的是同一个字段

再看 index++ 是怎么执行的:

il 复制代码
      IL_003b: ldloc.0      
      IL_003f: stfld        int32 ...::index // 修改它的 index
IL_0017: br.s         IL_003f
    // start of loop, entry point: IL_003f

      // [32 17 - 32 50]
      IL_0019: ldsfld       class [netstandard]System.Collections.Generic.List`1<class [netstandard]System.Action> ClosureTest.TestCode::List
      IL_001e: ldloc.0      // 'CS$<>8__locals0'
      IL_001f: ldftn        instance void ClosureTest.TestCode/'<>c__DisplayClass2_0'::'<TestMain>b__0'()
      IL_0025: newobj       instance void [netstandard]System.Action::.ctor(object, native int)
      IL_002a: callvirt     instance void class [netstandard]System.Collections.Generic.List`1<class [netstandard]System.Action>::Add(!0/*class [netstandard]System.Action*/)

      // [30 44 - 30 51] index++操作
      IL_002f: ldloc.0      // 'CS$<>8__locals0'
      IL_0030: ldfld        int32 ClosureTest.TestCode/'<>c__DisplayClass2_0'::index
      IL_0035: stloc.1      // V_1
      IL_0036: ldloc.0      // 'CS$<>8__locals0'
      IL_0037: ldloc.1      // V_1
      IL_0038: ldc.i4.1
      IL_0039: add
       // 🛑 核心证据 3:写回同一个实例!
      IL_003a: stfld        int32 ClosureTest.TestCode/'<>c__DisplayClass2_0'::index

      // [30 33 - 30 42]
      IL_003f: ldloc.0      // 'CS$<>8__locals0'
      IL_0040: ldfld        int32 ClosureTest.TestCode/'<>c__DisplayClass2_0'::index
      IL_0045: ldc.i4.5
      IL_0046: blt.s        IL_0019
    // end of loop

结论: 循环每次执行 index++,实际上是在修改堆上那个唯一对象 里的字段。当循环结束时,盒子里的 index 变成了 5。当你后来调用委托时,大家打开同一个盒子,看到的自然都是 5。


二、解决方案:引入局部变量

要解决这个问题,我们需要打破"共享",实现"独享"。方法非常简单:在循环内部引入一个临时变量

csharp 复制代码
private void TestMain()
{
    List.Clear();
    for (int index = 0; index < 5; index++)
    {
         // ✅ 关键修改:在循环体内创建局部变量
         int localI = index; 
         
         // 闭包捕获 localI,而不是 index
         List.Add(() => Debug.Log(localI));
    }
    // ...
}

修正后的运行结果

输出结果为 0, 1, 2, 3, 4,完美!但这在底层到底发生了什么变化?


IL 复盘:为何这样做就"独立"了?

让我们再次查看修正后的 IL 代码,真相一目了然。

证据 A:newobj 跑到了循环体内

il 复制代码
    IL_000e: br.s         IL_003a  // 跳转到循环判断
    // start of loop, entry point: IL_003a

      // 🛑 核心变化在此!
      // 每次循环开始,都会执行一次 newobj
      // 这意味着:第1次循环创建对象A,第2次循环创建对象B...
      IL_0010: newobj       instance void ClosureTest.TestCode/'<>c__DisplayClass2_0'::.ctor()
      IL_0015: stloc.1      // 把新对象存入局部变量 1

解析: 因为变量 localI 的作用域限制在循环体内,编译器被强制要求每次循环都创建一个新的闭包实例 。这就好比给每个委托发了一个全新的玩具,而不是大家抢一个。

证据 B:数据的"快照"复制

il 复制代码
      // 加载刚刚创建的新对象
      IL_0017: ldloc.1      
      // 加载当前的循环索引 index (比如 0, 1, 2...)
      IL_0018: ldloc.0      
      // 把 index 的值【复制】到闭包对象的 localI 字段中
      IL_0019: stfld        int32 ClosureTest.TestCode/'<>c__DisplayClass2_0'::localI

解析: 这里发生了值拷贝。

  • 第 1 次循环:创建对象 A,把 index(0) 拷给 A.localI
  • 第 2 次循环:创建对象 B,把 index(1) 拷给 B.localI
  • 对象 A 和 B 互不干扰。

三、终极总结

通过 IL 代码的实锤验证,我们可以得出一个确定的结论:

在循环中捕获变量时:

  • 如果捕获的是循环变量 (如 for 头部的 index),所有委托都会共享同一个变量实例
  • 如果捕获的是循环内声明的局部变量 (如 localI),每个委托会捕获一个独立的实例
比较项 ❌ 陷阱做法 (捕获 index) ✅ 正确做法 (捕获 localI)
C# 代码 () => Debug.Log(index) int localI = index; () => Debug.Log(localI)
IL newobj 位置 循环体外部 (只创建一次) 循环体内部 (每次循环都创建)
闭包对象数量 全程只有 1 个 循环 N 次创建 N 个
数据关系 所有委托共享同一个引用 每个委托持有独立的数据副本
结果 全是 5 0, 1, 2, 3, 4

四、进阶测试:当"共享"遇上"独享"

如果我们在同一个闭包里,既捕获了循环外的变量(共享),又捕获了循环内的变量(独享),会发生什么?

下面把TestMain改成如下

csharp 复制代码
private void TestMain()
{
    // 清空列表以防万一
    List.Clear();
    int outside = 0; // ← 共享变量,只声明一次
    for (int index = 0; index < 5; index++)
    {
        int inside = 0; // ← 独立变量,每次循环都创建新实例
        List.Add(() =>
        {
            Debug.Log($"({outside},{inside})");
            outside++; // 修改共享变量
            inside++;  // 修改独立变量);
        });
    }

    // 调用所有委托
    Debug.Log("--- 第一轮遍历调用 ---");
    foreach (var t in List)
    {
        t();
    }
}

运行结果分析

观察到的现象:

  • outside(左边数字):0, 1, 2, 3, 4 -> 持续递增!说明大家共用同一个变量。
  • inside(右边数字):0, 0, 0, 0, 0 -> 每次都是从 0 开始!说明每个人都有自己的变量。

这完全符合我们的预期:outside 是共享的,inside 是独立的。


IL 深度解密:嵌套闭包结构

这背后的 IL 实现比之前的例子更复杂,也更精妙。编译器实际上生成了 两个 闭包类!

1. 结构分析:大盒子套小盒子

通过 IL 代码,我们发现了两个编译器生成的类:

csharp 复制代码
.class nested private sealed auto ansi beforefieldinit
    '<>c__DisplayClass2_0'
      extends [netstandard]System.Object
  {
    ...

    .field public int32 outside
    ...
 }
  • <>c__DisplayClass2_0(外层类)
    • 存放 outside 变量。
    • 只创建 1 次(在循环外)。
csharp 复制代码
.class nested private sealed auto ansi beforefieldinit
    '<>c__DisplayClass2_1'
      extends [netstandard]System.Object
  {
  ...
    .field public int32 inside  // 自己的数据
    
    //指向外层闭包实例的引用!
   .field public class ClosureTest.TestCode/'<>c__DisplayClass2_0' 'CS$<>8__locals1'
    ...
  }
  • <>c__DisplayClass2_1(内层类)
    • 存放 inside 变量。
    • 循环 5 次创建 5 个
    • 关键点 :它里面有一个字段 CS$<>8__locals1,指向外层类实例!

2. 实例化过程:连连看

TestMain 的 IL 代码中,我们可以清晰地看到这层关系的构建过程:

il 复制代码
   // --- 1. 循环外:创建外层大盒子 ---
   IL_0000: newobj       instance void ClosureTest.TestCode/'<>c__DisplayClass2_0'::.ctor()
   IL_0005: stloc.0      // 存起来 (locals0)
   
   // 初始化 outside = 0
   IL_0014: stfld        int32 ClosureTest.TestCode/'<>c__DisplayClass2_0'::outside  

    // --- 2. 循环内:创建内层小盒子 ---
    // start of loop
      IL_001d: newobj       instance void ClosureTest.TestCode/'<>c__DisplayClass2_1'::.ctor()
      IL_0022: stloc.2      // 存起来 (locals1)

      // 🛑关键连接:把大盒子塞进小盒子里!
      IL_0023: ldloc.2      // 加载小盒子
      IL_0024: ldloc.0      // 加载大盒子
      // 建立引用关系
      IL_0025: stfld        class ClosureTest.TestCode/'<>c__DisplayClass2_0' ClosureTest.TestCode/'<>c__DisplayClass2_1'::'CS$<>8__locals1' 

      // 初始化 inside = 0
      IL_002d: stfld        int32 ClosureTest.TestCode/'<>c__DisplayClass2_1'::inside

      // 创建委托:Target 指向【小盒子】
      IL_003e: newobj       instance void [netstandard]System.Action::.ctor(object, native int)

3. 图解内存引用

此时内存里的对象关系是这样的:

text 复制代码
[Delegate 0] -> [小盒子 0 (inside=0)] -> [唯一的大盒子 (outside=0)]
[Delegate 1] -> [小盒子 1 (inside=0)] -> ↗ (同一个引用)
[Delegate 2] -> [小盒子 2 (inside=0)] -> ↗
...
  • 当你修改 inside 时,改的是各自的小盒子。
  • 当你修改 outside 时,大家通过小盒子里的引用,顺藤摸瓜找到同一个大盒子进行修改。

五、终极结论

当一个委托同时捕获多个变量时,编译器会根据变量的作用域精准地决定其存储位置:

  1. 作用域在循环外的变量 :被提升到外层闭包类,全程只有一个实例,所有委托共享(引用传递)。
  2. 作用域在循环内的变量 :被提升到内层闭包类,每次循环创建新实例,每个委托独享。
  3. 引用关系:内层闭包会持有外层闭包的引用,从而实现"既能独享内部,又能共享外部"的混合效果。

⚠️ 最佳实践建议:

虽然这种机制非常智能,但在实际开发中,混合使用共享和独立变量容易导致代码逻辑难以理解。为了代码的可维护性,建议尽量明确变量的作用域,避免在复杂的循环闭包中修改外部状态。

相关推荐
jiayong232 小时前
Word 使用指南:标题间距调整与核心功能详解
开发语言·c#·word
MyBFuture3 小时前
C# 二进制数据读写与BufferStream实战
开发语言·c#·visual studio
wuguan_3 小时前
C#种更高级的文件处理
算法·c#
你不是我我3 小时前
【Java 开发日记】我们来说一下 synchronized 与 ReentrantLock 的区别
开发语言·c#
阿蒙Amon12 小时前
C#每日面试题-重写和重载的区别
开发语言·c#
阿蒙Amon12 小时前
C#每日面试题-委托和事件的区别
java·开发语言·c#
bjzhang7514 小时前
C#操作SQLite数据库
数据库·sqlite·c#
烛阴15 小时前
C# 正则表达式(3):分组与捕获——从子串提取到命名分组
前端·正则表达式·c#
时光呀时光慢慢走18 小时前
C# WinForms 实战:MQTTS 客户端开发(与 STM32 设备通信)
开发语言·c#