C# 中的 List 引用、浅拷贝与闭包捕获问题详解

C# 中的 List 引用、浅拷贝与闭包捕获问题详解

1. List 是引用类型

在 C# 中,List<T>引用类型 。如果多个变量指向同一个 List,那么对其中一个变量进行修改,其他变量看到的也会被修改。

csharp 复制代码
List<int> list = new List<int>() { 3, 1, 2 };
List<int> a = list;
List<int> b = list;

b.Sort();
Console.WriteLine(string.Join(",", a)); // 输出 1,2,3

原因:ab 都只是保存了对同一个 List<int> 对象的引用。修改 b 时,本质上就是修改同一个对象。


1.1 ArrayList 也是引用类型

除了 List<T> 之外,旧版本常用的 ArrayList 也同样是引用类型。

csharp 复制代码
var arr1 = new ArrayList() { 1, 2, 3 };
var arr2 = arr1;

arr2.Add(4);

Console.WriteLine(string.Join(",", arr1.ToArray())); // 输出 1,2,3,4

这里 arr1arr2 依旧指向同一个 ArrayList 实例。


1.2 List 与 ArrayList 的接口关系

  • List<T>:实现了 泛型接口 IList<T>ICollection<T>IEnumerable<T> 等。
  • ArrayList:实现了 非泛型接口 IListICollectionIEnumerable

两者的共同点:

  • 都是 引用类型,赋值时拷贝的是引用,而不是集合本身。
  • 修改任意一个变量操作的集合内容,都会影响到另一个。

2. 浅拷贝与深拷贝

浅拷贝

复制的是 List 容器,但元素本身还是指向原来的引用对象。

csharp 复制代码
class Person { public string Name; }

var listA = new List<Person> {
    new Person { Name = "Alice" },
    new Person { Name = "Bob" }
};

var listB = new List<Person>(listA); // 浅拷贝
listB[0].Name = "Changed";

Console.WriteLine(listA[0].Name); // 输出 Changed

深拷贝

需要为每个元素都创建一份新的对象。

csharp 复制代码
var listC = listA.Select(p => new Person { Name = p.Name }).ToList();
listC[0].Name = "Another";

Console.WriteLine(listA[0].Name); // 仍然是 Changed,不受影响

总结:

  • 浅拷贝:List 本身复制,但元素还是指向同一份对象。
  • 深拷贝:元素对象也复制,互不影响。

3. 闭包与循环变量捕获

来看一个常见的闭包问题:

csharp 复制代码
var actions = new List<Action>();

for (int i = 0; i < 5; i++)
{
    actions.Add(() => Console.WriteLine(i));
}

foreach (var act in actions) act();

输出结果

复制代码
5
5
5
5
5

很多人会以为输出 0~4,为什么会这样?


4. 什么是闭包

闭包(Closure):函数体中引用了外部作用域的变量,函数与这些变量的组合就称为闭包。

在下面这个例子里:

csharp 复制代码
var actions = new List<Action>();

for (int i = 0; i < 5; i++)
{
    actions.Add(() => Console.WriteLine(i));
}

foreach (var act in actions) act();
  • Lambda 表达式 ()=>Console.WriteLine(i) 捕获的是 循环变量 i 的存储位置,而不是它的值。
  • 循环结束时 i == 5,所有 Lambda 执行时都访问到同一个 i,所以打印出 5
  • 实际上,编译器会把 i 提升(hoist)到一个隐藏的类中,所有 Lambda 共享这个字段。

4.1 传递参数 vs 闭包捕获

闭包与向函数传参有本质区别:

  1. 普通参数传递
csharp 复制代码
int a = 10;
void Foo(int x) { Console.WriteLine(x); }

Foo(a);  // 输出 10
a = 20;
Foo(a);  // 输出 20
  • 参数传入时,复制了一份值(副本),函数拿到的是独立拷贝。
  1. 闭包捕获变量
csharp 复制代码
int a = 10;
Func<int> f = () => a;

Console.WriteLine(f()); // 输出 10
a = 20;
Console.WriteLine(f()); // 输出 20
  • 闭包捕获的是 变量本身,不是值快照。
  • 所以闭包里看到的值会随外部变量变化而变化。
  1. 引用类型(如字典、List)
csharp 复制代码
var dict = new Dictionary<string,int>() { ["a"] = 1 };
Action action = () => Console.WriteLine(dict["a"]);

dict["a"] = 100;
action(); // 输出 100
  • 传引用类型参数或闭包捕获引用类型,都是捕获对象本身的引用。
  • 修改对象内容,闭包也会看到变化。

5. 正确做法

如果希望闭包捕获的是"当前值",而不是后续变化的变量,需要在循环内部引入新的局部变量:

csharp 复制代码
var actions = new List<Action>();

for (int i = 0; i < 5; i++)
{
    int copy = i; // 新的局部变量
    actions.Add(() => Console.WriteLine(copy));
}

foreach (var act in actions) act();

输出:

复制代码
0
1
2
3
4

这样每个闭包都捕获独立的变量,不会互相干扰。


✨ 总结:

  • 闭包捕获的是 变量本身,而不是值。
  • 循环变量、全局变量、引用类型对象都可能造成闭包中值变化的坑。
  • 解决办法:手动复制一份局部变量,或者把需要固定的值当参数传进去。

6. 总结

  • List 是引用类型:多个变量引用同一个实例时,修改会互相影响。
  • 浅拷贝 vs 深拷贝:浅拷贝只复制引用,深拷贝复制对象本身。
  • 闭包:捕获变量的存储位置而不是值,循环中容易踩坑。
  • 解决闭包问题:引入新的局部变量。