一文淺析C#如何優(yōu)雅處理引用類型的深拷貝
前言
幾年前寫過一個 bug,根因很土:該深拷貝的地方?jīng)]深拷貝,副本一改,原件跟著變。排查的時候老板以為動的是庫里的數(shù)據(jù),其實就是一個本地對象被共享了。
先把詞說清楚:
淺拷貝:值類型復制一份;引用類型復制的是引用,兩邊還指著同一個子對象。你改副本里的引用成員,原件也會變。
只復制對象自身的一層:字段/屬性里如果是值類型,會復制一份值;如果是引用類型,復制的是引用(指針),新舊對象仍指向同一塊堆上的子對象。
深拷貝:引用鏈上也建新對象,改副本不該動到原件的嵌套數(shù)據(jù)。
從根對象開始,遞歸地為引用類型也創(chuàng)建新實例,并把內(nèi)容復制過去,直到整棵「對象圖」在邏輯上獨立。改拷貝不應意外改動原對象里的嵌套數(shù)據(jù)。
ICloneable:能深,但接口不保證
ICloneable 只有一個 object Clone(),文檔不會替你承諾淺還是深,看實現(xiàn)。你想做深拷貝,可以,全寫在 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,原數(shù)據(jù)跟著變
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; }
}
}深拷貝就要讓 Address 也 Clone() 一份。引用類型多就一層層寫,啰嗦但清楚。
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 已是新實例,原數(shù)據(jù)不變
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,調(diào)用處總要轉一下類型,有點煩。
序列化 / AutoMapper:省事,但要心里有數(shù)
我們 CRUD 程序員經(jīng)常不想維護一整張克隆圖,就會想走捷徑。
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; }
}
}
好處是加字段一般不用改克隆邏輯(只要還能序列化)。麻煩在循環(huán)引用要單獨配,委托、怪類型、非公開成員也可能過不去。
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 本質(zhì)上都是在「按數(shù)據(jù)重建對象」,只是經(jīng)常能重建出一棵獨立的樹,和手寫 Clone 的語義不是一回事。
record:我眼里的版本答案
前面 ICloneable 要到處 as,JSON 像繞路,AutoMapper 容易配成玄學。record 從語言層面把「數(shù)據(jù)」這件事說清楚了:默認值語義、相等性、ToString、非破壞性修改,編譯器幫你生成一大坨樣板,你只要在業(yè)務代碼里寫 with。
為什么說它像版本答案(不是銀彈,但在「數(shù)據(jù)拷貝 / 派生」這條線上很對味):
- 相等按值比:同類型的兩個實例,成員一樣就相等,寫單元測試、去重、緩存 key 都省心。class 默認比引用,想比內(nèi)容要自己重寫
Equals/GetHashCode,一懶就埋雷。 with是語法級的「從舊副本改幾處」:讀代碼的人一眼知道「基于 rawUser 出了一個新對象」,不用跳進Clone()里猜深還是淺。- 打印友好:自動生成的
ToString把主要字段打出來,日志里好認,排障少猜幾次。
語法上有兩種常見寫法,知道就行:
- 傳統(tǒng)屬性寫法:和 class 差不多,只是類型是
record,白嫖相等性和with。 - 位置參數(shù) record:
public record UserDto(int Id, string Name, AddressDto Address);編譯器幫你生成主構造函數(shù)、解構、with里按位置對應,DTO 里很省字。
默認的 record 是引用類型(相當于 record class)。還有 record struct,那是值類型語義,拷貝整坨 struct 時是按位復制,和「引用圖里拆不拆」又是另一套題,別混在一塊講深拷貝時搞暈自己就行。
with 和深拷貝的關系再強調(diào)一遍,避免面試翻車:with 會復制你沒改到的成員;引用類型的成員如果沒在 with 里換掉,新舊兩邊仍指著同一個子對象。所以要深,就顯式寫 Address = rawUser.Address with { ... } 或給一個新的實例。嵌套深就鏈式 with,丑一點但誠實——至少「哪里拆引用」全攤在調(diào)用點,不靠隱式魔法。
想往「真·快照」靠,可以把屬性收成 init 或只在構造函數(shù)里賦值,外面用 with 派生。{ get; set; } 照樣能改字段,別嘴上說 record 不可變、手還在到處 set。
和 class 分工可以這樣記:class 扛行為、生命周期長、引用身份有時就是業(yè)務含義;record 扛可比較的數(shù)據(jù)快照、適合命令/事件/讀模型里那種「從上一版捏一版」的寫法。
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 / 配置、能序列化、沒環(huán):JSON 往返最省事。
- 項目里 Mapper 已經(jīng)到處都是:可以
CreateMap<T,T>(),把子類型也配全,并記得做配置校驗和用例,別光靠手感。 - 業(yè)務就是「從舊狀態(tài)派生一個新狀態(tài)」、嵌套也愿意寫清楚:
record+with。
結論
深拷貝沒有萬能 API,只有你對「哪些引用該共享、哪些該拆開」有沒有想清楚。工具省的是打字時間,省不了腦子。
挖坑待埋:record class與record struct 詳解
到此這篇關于一文淺析C#如何優(yōu)雅處理引用類型的深拷貝的文章就介紹到這了,更多相關C#處理引用類型的深拷貝內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
C#以太網(wǎng)Sockets客戶端設計實現(xiàn)

