C# 深浅拷贝、值类型、引用类型浅析
这是一个非常经典且重要的C#基础面试题,也是实际开发中容易产生Bug的重灾区。要彻底理解深拷贝和浅拷贝,首先必须弄清楚值类型和引用类型在内存中的存储方式。
第一部分:基础概念 —— 值类型 vs 引用类型
在C#中,数据类型分为两大类,它们的存储位置和赋值行为完全不同。
1. 值类型 (Value Types)
- 包含:
int,double,bool,char,struct(结构体),enum(枚举) 等。 - 存储: 变量直接存储具体的数据值。通常存储在栈(Stack)上。
- 赋值: 当你把一个值类型变量赋值给另一个时,是完全复制了一份数据。修改其中一个,不会影响另一个。
2. 引用类型 (Reference Types)
- 包含:
class(类),interface,delegate,object,string,Array(数组) 等。 - 存储: 变量存储的是数据的内存地址(引用/指针),而实际的数据对象存储在堆(Heap)上。
- 赋值: 当你把一个引用类型变量赋值给另一个时,只是复制了地址。两个变量指向堆上的同一个对象。修改其中一个对象的属性,另一个也会变。
比喻:
- 值类型就像是你复印了一份文件给同事,他怎么涂改他的那份,你手里的原件不会变。
- 引用类型就像是你把Google Doc(在线文档)的链接发给同事,任何一个人编辑,大家看到的都是修改后的版本。
第二部分:核心机制 —— 浅拷贝 vs 深拷贝
拷贝(Clone)通常针对的是引用类型(对象)。因为值类型赋值即拷贝,不需要讨论深浅。
1. 浅拷贝 (Shallow Copy)
- 定义: 创建一个新的对象,然后将当前对象的非静态字段复制到新对象中。
- 行为细节:
- 如果字段是值类型,则复制该值。
- 如果字段是引用类型,则只复制引用(地址),不复制引用的对象本身。
- 结果: 新对象和原对象虽然是两个不同的实例,但它们内部引用的子对象(如List, class属性)是指向同一块内存的。修改子对象会相互影响。
- 实现方式: C# 提供了
Object.MemberwiseClone()方法来实现浅拷贝。
2. 深拷贝 (Deep Copy)
- 定义: 创建一个新的对象,并将当前对象的所有字段(包括嵌套的引用类型字段)递归地复制到新对象中。
- 行为细节: 不仅复制对象本身,还复制对象引用的所有层级的子对象。
- 结果: 新对象和原对象在内存中是完全独立的。修改任何一方,绝不会影响另一方。
- 实现方式: C# 没有直接的内置方法,通常需要通过序列化、反射或手动编写代码来实现。
第三部分:代码演示(一图胜千言)
假设我们要复制一个“人”对象,他有一个属性是“住址”(引用类型)。
// 地址类 (引用类型)
public class Address
{
public string City { get; set; }
}
// 人类 (引用类型)
public class Person
{
public int Age { get; set; } // 值类型
public Address Home { get; set; } // 引用类型
// 1. 浅拷贝实现
public Person ShallowCopy()
{
return (Person)this.MemberwiseClone();
}
// 2. 深拷贝实现 (手动方式示例)
public Person DeepCopy()
{
Person other = (Person)this.MemberwiseClone();
// 关键点:手动新建引用类型属性
other.Home = new Address { City = this.Home.City };
return other;
}
}
测试场景:
void Main()
{
// 初始化原对象
Person original = new Person();
original.Age = 20;
original.Home = new Address { City = "北京" };
// --- 测试浅拷贝 ---
Person shallow = original.ShallowCopy();
shallow.Age = 30; // 修改值类型
shallow.Home.City = "上海"; // 修改引用类型属性
Console.WriteLine(original.Age); // 输出 20 (未变,因为int是值类型)
Console.WriteLine(original.Home.City); // 输出 "上海" (变了!因为Address是引用共享)
// --- 重置 ---
original.Home.City = "北京";
// --- 测试深拷贝 ---
Person deep = original.DeepCopy();
deep.Age = 30;
deep.Home.City = "深圳";
Console.WriteLine(original.Age); // 输出 20 (未变)
Console.WriteLine(original.Home.City); // 输出 "北京" (未变!完全独立)
}
第四部分:深拷贝的常见实现方式
实际开发中,手动写DeepCopy太累且容易漏,常用以下方法:
-
JSON 序列化(推荐):
先转成字符串,再转回来。虽然性能不是最快,但最通用、最简单。using System.Text.Json; public static T DeepClone<T>(T obj) { var json = JsonSerializer.Serialize(obj); return JsonSerializer.Deserialize<T>(json); } -
反射 (Reflection):
利用反射递归遍历所有属性进行赋值。性能较差,但有一些库(如 AutoMapper)做了优化。 -
二进制序列化 (BinaryFormatter):
警告: 在 .NET Core / .NET 5+ 中已不推荐使用甚至被标记为过时,因为存在严重的安全漏洞。 -
Data Contract Serialization:
比JSON快一点,但配置较麻烦。
第五部分:实际开发中的注意事项(避坑指南)
在实际项目中,如果不注意这些细节,会导致难以排查的Bug:
1. string 的特殊性
虽然 string 是引用类型,但它是不可变(Immutable)的。
- 在浅拷贝中,复制的是字符串的引用。
- 但是,当你修改字符串(如
s = "new")时,并不会修改堆上的原内容,而是创建了一个新字符串对象并改变引用指向。 - 结论: 在拷贝问题上,你可以把
string当作值类型放心使用,不会出现副作用。
2. 集合(List, Array, Dictionary)
这是最容易出错的地方。
List<T> list2 = list1;这是仅仅复制了引用,完全共享。List<T> list2 = new List<T>(list1);这是浅拷贝!如果 T 是引用类型(如List<Person>),新列表里的元素依然指向旧列表里的那些 Person 对象。- 如果需要复制集合且集合内元素是引用类型,必须遍历集合进行深拷贝。
3. 性能考量
- 浅拷贝速度极快,几乎没有性能损耗。
- 深拷贝成本高昂,特别是对象层级很深或数据量大时(涉及大量的内存分配和垃圾回收)。
- 建议: 除非必须隔离修改,否则优先使用浅拷贝或直接传递引用。
4. ICloneable 接口
C# 官方提供了一个 ICloneable 接口,包含 Clone() 方法。
- 最佳实践:尽量不要用它。
- 原因: 官方没有规定
Clone()到底应该实现深拷贝还是浅拷贝。调用者无法预知行为,容易产生歧义。建议自己定义明确的方法,如DeepClone()。
5. C# 9.0+ 的 record 和 with
C# 9 引入了 record 类型,支持 with 表达式:
Person p2 = p1 with { Age = 30 };
- 注意:
with操作本质上是浅拷贝。它创建新对象并修改指定属性,但未修改的引用类型属性依然共享。
总结
| 特性 | 赋值 (=) |
浅拷贝 (MemberwiseClone) |
深拷贝 (序列化等) |
|---|---|---|---|
| 新对象创建 | 否 (引用类型时) | 是 | 是 |
| 值类型字段 | 复制 | 复制 | 复制 |
| 引用类型字段 | 共享引用 | 共享引用 | 复制新对象 |
| 相互影响 | 是 | 是 (仅限子对象) | 否 (完全隔离) |
| 性能 | 最快 | 快 | 慢 |
一句话总结: 如果你希望修改副本时不影响原对象,对于含有引用类型(类、数组、集合)的对象,必须使用深拷贝;如果对象里全是值类型或字符串,浅拷贝就足够了。

浙公网安备 33010602011771号