一文浅析C#如何优雅处理引用类型的深拷贝

 更新时间:2026年05月17日 09:51:53   作者:叫我安不理  
本文主要讲述了C#中浅拷贝和深拷贝的概念,实现方式以及适用场景,文内探讨了ICloneable,序列化/AutoMapper,record等方法,并强调了在不同场景下选择合适的方法的重要性,感兴趣的小伙伴可以了解下

前言

几年前写过一个 bug,根因很土:该深拷贝的地方没深拷贝,副本一改,原件跟着变。排查的时候老板以为动的是库里的数据,其实就是一个本地对象被共享了。

先把词说清楚:

浅拷贝:值类型复制一份;引用类型复制的是引用,两边还指着同一个子对象。你改副本里的引用成员,原件也会变。

只复制对象自身的一层:字段/属性里如果是值类型,会复制一份值;如果是引用类型,复制的是引用(指针),新旧对象仍指向同一块堆上的子对象。

深拷贝:引用链上也建新对象,改副本不该动到原件的嵌套数据。

从根对象开始,递归地为引用类型也创建新实例,并把内容复制过去,直到整棵「对象图」在逻辑上独立。改拷贝不应意外改动原对象里的嵌套数据。

ICloneable:能深,但接口不保证

ICloneable 只有一个 object Clone(),文档不会替你承诺浅还是深,看实现。你想做深拷贝,可以,全写在 Clone() 里就行。

浅拷贝场景下,改拷贝里的引用类型字段,往往会影响原对象(反之亦然),除非你再给那个字段赋一个新实例。

// 浅拷贝示例(Address 还是同一个引用)
public class DeepAndShallowCopy
{
    public static void ShallowCopy()
    {
        var rawUser = new UserDto { Id = 1, Name = "name1", Address = new UserDto.AddressDto { City = "CS" } };
        var copyUser = rawUser.Clone() as UserDto;
        copyUser.Id = 2;
        copyUser.Name = "name2";
        copyUser.Address.City = "CS2"; // 浅拷贝:动的是同一块 Address,原数据跟着变
        Console.WriteLine($"rawUser={JsonSerializer.Serialize(rawUser)}");
        Console.WriteLine($"copyUser={JsonSerializer.Serialize(copyUser)}");
    }
}
public class UserDto : ICloneable
{
    public int Id { get; set; }
    public string Name { get; set; }
    public AddressDto Address { get; set; }
    public object Clone()
    {
        return new UserDto { Id = Id, Name = Name, Address = Address };
    }
    public class AddressDto
    {
        public string City { get; set; }
    }
}

深拷贝就要让 AddressClone() 一份。引用类型多就一层层写,啰嗦但清楚。

public class DeepAndShallowCopy
{
    public static void DeepCopy()
    {
        var rawUser = new UserDto { Id = 1, Name = "name1", Address = new UserDto.AddressDto { City = "CS" } };

        var copyUser = rawUser.Clone() as UserDto;
        copyUser.Id = 2;
        copyUser.Name = "name2";
        copyUser.Address.City = "CS2"; // 深拷贝:Address 已是新实例,原数据不变

        Console.WriteLine($"rawUser={JsonSerializer.Serialize(rawUser)}");
        Console.WriteLine($"copyUser={JsonSerializer.Serialize(copyUser)}");
    }
}

public class UserDto : ICloneable
{
    public int Id { get; set; }
    public string Name { get; set; }
    public AddressDto Address { get; set; }

    public object Clone()
    {
        return new UserDto { Id = Id, Name = Name, Address = Address.Clone() as AddressDto };
    }

    public class AddressDto : ICloneable
    {
        public string City { get; set; }

        public object Clone()
        {
            return new AddressDto { City = City };
        }
    }
}

手写这条路:性能好,行为自己说了算。代价是对象图一大就容易漏,漏一处就是浅拷贝;另外 Clone() 返回 object,调用处总要转一下类型,有点烦。

序列化 / AutoMapper:省事,但要心里有数

我们 CRUD 程序员经常不想维护一整张克隆图,就会想走捷径。

System.Text.Json

思路就是序列化再反序列化,得到一棵新对象。代码少,DTO、配置这类能完整序列化的类型用起来很省事。

public class DeepAndShallowCopy
{
    public static void DeepCopyByJsonSerializer()
    {
        var rawUser = new UserDto { Id = 1, Name = "name1", Address = new UserDto.AddressDto { City = "CS" } };

        var copyUser = rawUser.DeepCopy();
        copyUser.Id = 2;
        copyUser.Name = "name2";
        copyUser.Address.City = "CS2";

        Console.WriteLine($"rawUser={JsonSerializer.Serialize(rawUser)}");
        Console.WriteLine($"copyUser={JsonSerializer.Serialize(copyUser)}");
    }
}

public class UserDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public AddressDto Address { get; set; }

    /// <summary>
    /// 序列化 → 反序列化,换一批新实例
    /// </summary>
    public UserDto DeepCopy()
    {
        var rawUserString = JsonSerializer.Serialize(this);
        return JsonSerializer.Deserialize<UserDto>(rawUserString)!;
    }

    public class AddressDto
    {
        public string City { get; set; }
    }
}

好处是加字段一般不用改克隆逻辑(只要还能序列化)。麻烦在循环引用要单独配,委托、怪类型、非公开成员也可能过不去。

AutoMapper

public static void DeepCopyByAutoMapper()
{
    var config = new MapperConfiguration(config =>
    {
        config.CreateMap<UserDto, UserDto>();
        // 子类型也要建同型映射,否则 Address 可能还是同一条引用
        config.CreateMap<UserDto.AddressDto, UserDto.AddressDto>();
    });

    IMapper mapper = config.CreateMapper();

    var rawUser = new UserDto { Id = 1, Name = "name1", Address = new UserDto.AddressDto { City = "CS" } };

    var copyUser = mapper.Map<UserDto>(rawUser);
    copyUser.Id = 2;
    copyUser.Name = "name2";
    copyUser.Address.City = "CS2";

    Console.WriteLine($"rawUser={JsonSerializer.Serialize(rawUser)}");
    Console.WriteLine($"copyUser={JsonSerializer.Serialize(copyUser)}");
}

项目里本来就有 Mapper 的话,顺手 Map 一下也行。它本职是 DTO 映射,不是克隆库:子图没配齐、策略不对,照样可能浅拷贝。别指望「默认就是深拷贝」。

JSON 和 Mapper 本质上都是在「按数据重建对象」,只是经常能重建出一棵独立的树,和手写 Clone 的语义不是一回事。

record:我眼里的版本答案

前面 ICloneable 要到处 as,JSON 像绕路,AutoMapper 容易配成玄学。record 从语言层面把「数据」这件事说清楚了:默认值语义、相等性、ToString、非破坏性修改,编译器帮你生成一大坨样板,你只要在业务代码里写 with

为什么说它像版本答案(不是银弹,但在「数据拷贝 / 派生」这条线上很对味):

  • 相等按值比:同类型的两个实例,成员一样就相等,写单元测试、去重、缓存 key 都省心。class 默认比引用,想比内容要自己重写 Equals/GetHashCode,一懒就埋雷。
  • with 是语法级的「从旧副本改几处」:读代码的人一眼知道「基于 rawUser 出了一个新对象」,不用跳进 Clone() 里猜深还是浅。
  • 打印友好:自动生成的 ToString 把主要字段打出来,日志里好认,排障少猜几次。

语法上有两种常见写法,知道就行:

  1. 传统属性写法:和 class 差不多,只是类型是 record,白嫖相等性和 with
  2. 位置参数 recordpublic record UserDto(int Id, string Name, AddressDto Address); 编译器帮你生成主构造函数、解构、with 里按位置对应,DTO 里很省字。

默认的 record引用类型(相当于 record class)。还有 record struct,那是值类型语义,拷贝整坨 struct 时是按位复制,和「引用图里拆不拆」又是另一套题,别混在一块讲深拷贝时搞晕自己就行。

with 和深拷贝的关系再强调一遍,避免面试翻车:with 会复制你没改到的成员;引用类型的成员如果with 里换掉,新旧两边仍指着同一个子对象。所以要深,就显式写 Address = rawUser.Address with { ... } 或给一个新的实例。嵌套深就链式 with,丑一点但诚实——至少「哪里拆引用」全摊在调用点,不靠隐式魔法。

想往「真·快照」靠,可以把属性收成 init 或只在构造函数里赋值,外面用 with 派生。{ get; set; } 照样能改字段,别嘴上说 record 不可变、手还在到处 set。

class 分工可以这样记:class 扛行为、生命周期长、引用身份有时就是业务含义;record 扛可比较的数据快照、适合命令/事件/读模型里那种「从上一版捏一版」的写法。

public class DeepAndShallowCopy
{
    public static void DeepCopyByRecord()
    {
        var rawUser = new UserDto { Id = 1, Name = "name1", Address = new UserDto.AddressDto { City = "CS" } };
        var copyUser = rawUser with { Id = 2, Name = "name2", Address = rawUser.Address with { City = "CS2" } };

        Console.WriteLine($"rawUser={JsonSerializer.Serialize(rawUser)}");
        Console.WriteLine($"copyUser={JsonSerializer.Serialize(copyUser)}");
    }
}

//注意,这不是标准用法,只是为了演示
public record UserDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public AddressDto Address { get; set; }

    public record AddressDto
    {
        public string City { get; set; }
    }
}

标准写法示意(省样板,相等/ToString/with 照样有):

public record AddressDto(string City);

public record UserDto(int Id, string Name, AddressDto Address);

var rawUser = new UserDto(1, "name1", new AddressDto("CS"));
var copyUser = rawUser with { Name = "name2", Address = rawUser.Address with { City = "CS2" } };

怎么选

按常见情况排个序,够用就行:

  • 核心模型、性能敏感、要一眼能审代码:手写 Clone 或工厂从旧对象构造新的。
  • 普通 DTO / 配置、能序列化、没环:JSON 往返最省事。
  • 项目里 Mapper 已经到处都是:可以 CreateMap<T,T>(),把子类型也配全,并记得做配置校验和用例,别光靠手感。
  • 业务就是「从旧状态派生一个新状态」、嵌套也愿意写清楚:record + with

结论

深拷贝没有万能 API,只有你对「哪些引用该共享、哪些该拆开」有没有想清楚。工具省的是打字时间,省不了脑子。

挖坑待埋:record class与record struct 详解

到此这篇关于一文浅析C#如何优雅处理引用类型的深拷贝的文章就介绍到这了,更多相关C#处理引用类型的深拷贝内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • C#线性渐变画刷LinearGradientBrush用法实例

    C#线性渐变画刷LinearGradientBrush用法实例

    这篇文章主要介绍了C#线性渐变画刷LinearGradientBrush用法,实例分析了线性渐变画刷LinearGradientBrush的相关使用技巧,需要的朋友可以参考下
    2015-06-06
  • C# Onnx实现轻量实时的M-LSD直线检测

    C# Onnx实现轻量实时的M-LSD直线检测

    这篇文章主要为大家详细介绍了C#如何结合Onnx实现轻量实时的M-LSD直线检测,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下
    2023-11-11
  • 详解c# 可空类型(Nullable)

    详解c# 可空类型(Nullable)

    这篇文章主要介绍了c# 可空类型(Nullable)的相关资料,文中示例代码非常详细,帮助大家更好的理解和学习,感兴趣的朋友可以了解下
    2020-07-07
  • C#线程同步的三类情景分析

    C#线程同步的三类情景分析

    这篇文章主要介绍了C#线程同步的三类情景分析,较为详细生动的讲述了C#线程同步的三类情况,让大家对C#多线程程序设计有一个深入的了解,需要的朋友可以参考下
    2014-10-10
  • C#可选参数的相关使用

    C#可选参数的相关使用

    .net framework 4.0新增加了可选参数的支持,其实很简单,只要给参数赋个默认值就可以了
    2013-05-05
  • C#控制图像旋转和翻转的方法

    C#控制图像旋转和翻转的方法

    这篇文章主要介绍了C#控制图像旋转和翻转的方法,涉及C#图像操作中RotateFlip方法的相关使用技巧,需要的朋友可以参考下
    2015-06-06
  • 使用C#高效嵌入文件和注释附件到PDF文档的操作指南

    使用C#高效嵌入文件和注释附件到PDF文档的操作指南

    在现代办公和数据交换中,PDF文档因其跨平台、内容固定等特性,已经成为不可或缺的一部分,本教程将深入探讨如何在C#编程环境中,利用强大的Spire.PDF for .NET库实现PDF附件的插入,需要的朋友可以参考下
    2026-01-01
  • C#如何获取当前路径的父路径

    C#如何获取当前路径的父路径

    这篇文章主要介绍了C#如何获取当前路径的父路径问题,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-07-07
  • C#环形队列的实现方法详解

    C#环形队列的实现方法详解

    这篇文章先是简单的给大家介绍了什么是环形队列和环形队列的优点,然后通过实例代码给大家介绍C#如何实现环形队列,有需要的朋友们可以参考借鉴,下面来一起看看吧。
    2016-09-09
  • C#委托用法详解

    C#委托用法详解

    本文详细讲解了C#中委托的用法,文中通过示例代码介绍的非常详细。对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-02-02

最新评论