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
原因:a
和 b
都只是保存了对同一个 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
这里 arr1
和 arr2
依旧指向同一个 ArrayList
实例。
1.2 List 与 ArrayList 的接口关系
List<T>
:实现了 泛型接口IList<T>
、ICollection<T>
、IEnumerable<T>
等。ArrayList
:实现了 非泛型接口IList
、ICollection
、IEnumerable
。
两者的共同点:
- 都是 引用类型,赋值时拷贝的是引用,而不是集合本身。
- 修改任意一个变量操作的集合内容,都会影响到另一个。
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 闭包捕获
闭包与向函数传参有本质区别:
- 普通参数传递
csharp
int a = 10;
void Foo(int x) { Console.WriteLine(x); }
Foo(a); // 输出 10
a = 20;
Foo(a); // 输出 20
- 参数传入时,复制了一份值(副本),函数拿到的是独立拷贝。
- 闭包捕获变量
csharp
int a = 10;
Func<int> f = () => a;
Console.WriteLine(f()); // 输出 10
a = 20;
Console.WriteLine(f()); // 输出 20
- 闭包捕获的是 变量本身,不是值快照。
- 所以闭包里看到的值会随外部变量变化而变化。
- 引用类型(如字典、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 深拷贝:浅拷贝只复制引用,深拷贝复制对象本身。
- 闭包:捕获变量的存储位置而不是值,循环中容易踩坑。
- 解决闭包问题:引入新的局部变量。