2018年7月19日 星期四

C# 建立物件的淺層複製(Shallow Clone/Copy)及深層複製(Deep Clone/Copy)

這幾天在使用 Memory Cache 時,意外發現外部的修改會影響到 Memory Cache 原本的值,進而延伸出其它神奇的問題,追根究柢,主要是因為在存取 Memory Cache 值時,都是使用同一份參考(reference),造成程式上某個地方改動該 Cache 值時,其它地方都會受到影響,所以我的解決方式就是深層複製出一份 Cache 的值供外部使用,防止外部操作去更動到原始 Cache 值 類似的問題其實以前就遇過了,但當時因為手上太多案子所以就只在網路上找ㄧ些快速解法複製貼上而不了了之(菸~~~),這次趁休假空擋整理出ㄧ些覺得還不錯的實作方式及順便比較一下優缺點。


0. 一般使用狀況(無使用任何深層複製)

public class Item
{
    public string Name { get; set; }
    public int Number { get; set; }
    public List<string> Features { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        var source = new Item { Name = "The Lord of the Rings", Number = 10, Features = new List<string> { "Love", "Funny" } };
        var clone = source;

        clone.Name = "Harry Potter";
        clone.Number = 30;
        clone.Features[0] = "Adventure";
        clone.Features[1] = "Magic";

        Console.WriteLine("Source" + Environment.NewLine + "Name: "+ source.Name + Environment.NewLine + "Number: " + source.Number + Environment.NewLine + "Features: " + string.Join(", ", source.Features));
        Console.WriteLine();
        Console.WriteLine("Clone" + Environment.NewLine + "Name: " + clone.Name + Environment.NewLine + "Number: " + clone.Number + Environment.NewLine + "Features: " + string.Join(", ", clone.Features));
    }
}

/* 輸出結果(原始資料被更動了) */
//Source Object
//Name: Harry Potter
//Number: 30
//Features: Adventure, Magic
//
//Clone Object
//Name: Harry Potter
//Number: 30
//Features: Adventure, Magic 

1. 使用 BinaryFormatter 複製
優點: 幾乎可完美處理任何複雜的物件,網路上查到的大多也都是這個方式
缺點: 執行時間較其他方式長,複製對象 Class 必需標記 [Serializable] 標籤有點麻煩

先建立一個擴充方法如下

using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

public static class CommonExtensions
{
    /// <summary>
    /// 深層複製(複製對象必需可序列化)
    /// </summary>
    /// <typeparam name="T">複製對象類別</typeparam>
    /// <param name="source">複製對象
    /// <returns>複製出的物件</returns>
    public static T DeepClone<T>(this T source)
    {
        if (!typeof(T).IsSerializable)
        {
            throw new ArgumentException("The type must be serializable.", nameof(source));
        }

        if (source != null)
        {
            using (MemoryStream stream = new MemoryStream())
            {
                var formatter = new BinaryFormatter();
                formatter.Serialize(stream, source);
                stream.Seek(0, SeekOrigin.Begin);
                return (T)formatter.Deserialize(stream);
            }
        }

        return default(T);
    }
}

使用方式如下

[Serializable]
public class Item
{
    public string Name { get; set; }
    public int Number { get; set; }
    public List<string> Features { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        var source = new Item { Name = "The Lord of the Rings", Number = 10, Features = new List<string> { "Love", "Funny" } };
        var clone = source.DeepClone();

        clone.Name = "Harry Potter";
        clone.Number = 30;
        clone.Features[0] = "Adventure";
        clone.Features[1] = "Magic";

        Console.WriteLine("Source" + Environment.NewLine + "Name: "+ source.Name + Environment.NewLine + "Number: " + source.Number + Environment.NewLine + "Features: " + string.Join(", ", source.Features));
        Console.WriteLine();
        Console.WriteLine("Clone" + Environment.NewLine + "Name: " + clone.Name + Environment.NewLine + "Number: " + clone.Number + Environment.NewLine + "Features: " + string.Join(", ", clone.Features));
    }
}

/* 輸出結果(原始資料不變) */
//Source Object
//Name: The Lord of the Rings
//Number: 10
//Features: Love, Funny
//
//Clone Object
//Name: Harry Potter
//Number: 30
//Features: Adventure, Magic 

2. 使用 Newtonsoft.Json 套件
優點: 輕巧、簡單、速度比 BinaryFormatter 快上不少,基本上不知道要挑哪一個方式又想自己寫時,我會傾向建議使用此方式
缺點: Private 欄位無法被複製到,遇到循環參考會出錯(解決方式可參考這篇)

建立一個擴充方法如下

using System;
using Newtonsoft.Json;

public static class CommonExtensions
{  
    /// <summary>
    /// 深層複製
    /// </summary>
    /// <typeparam name="T">複製對象類別</typeparam>
    /// <param name="source">複製對象
    /// <returns>複製出的物件</returns>
    public static T DeepCloneByJson<T>(this T source)
    {
        if (Object.ReferenceEquals(source, null))
        {
            return default(T);
        }
        var deserializeSettings = new JsonSerializerSettings { ObjectCreationHandling = ObjectCreationHandling.Replace };
        return JsonConvert.DeserializeObject<T>(JsonConvert.SerializeObject(source), deserializeSettings);
    }
}

使用方式如下

public class Item
{
    public string Name { get; set; }
    public int Number { get; set; }
    public List<string> Features { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        var source = new Item { Name = "The Lord of the Rings", Number = 10, Features = new List<string> { "Love", "Funny" } };
        var clone = source.DeepCloneByJson();

        clone.Name = "Harry Potter";
        clone.Number = 30;
        clone.Features[0] = "Adventure";
        clone.Features[1] = "Magic";

        Console.WriteLine("Source" + Environment.NewLine + "Name: "+ source.Name + Environment.NewLine + "Number: " + source.Number + Environment.NewLine + "Features: " + string.Join(", ", source.Features));
        Console.WriteLine();
        Console.WriteLine("Clone" + Environment.NewLine + "Name: " + clone.Name + Environment.NewLine + "Number: " + clone.Number + Environment.NewLine + "Features: " + string.Join(", ", clone.Features));
    }
}

/* 輸出結果(原始資料不變) */
//Source Object
//Name: The Lord of the Rings
//Number: 10
//Features: Love, Funny
//
//Clone Object
//Name: Harry Potter
//Number: 30
//Features: Adventure, Magic 

3. 使用 Expression Trees 方式(參考來源)
優點: 速度又比第二種方式更快,也可處理 Private 欄位、循環參考的問題
缺點: Framework 需 .NET 4 以上,我有遇到當型態是 IEnumerable<T> 時,會跳出錯誤(目前我是先轉 List<T> 型態解決),除此之外,還沒遇到其它問題

由於該擴充方法的程式碼有點冗長,所以這邊直接提供原始碼檔案下載,然後使用方式參考下面

public class Item
{
    public string Name { get; set; }
    public int Number { get; set; }
    public List<string> Features { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        var source = new Item { Name = "The Lord of the Rings", Number = 10, Features = new List<string> { "Love", "Funny" } };
        var clone = source.DeepCopyByExpressionTree();

        clone.Name = "Harry Potter";
        clone.Number = 30;
        clone.Features[0] = "Adventure";
        clone.Features[1] = "Magic";

        Console.WriteLine("Source Object" + Environment.NewLine + "Name: "+ source.Name + Environment.NewLine + "Number: " + source.Number + Environment.NewLine + "Features: " + string.Join(", ", source.Features));
        Console.WriteLine();
        Console.WriteLine("Clone Object" + Environment.NewLine + "Name: " + clone.Name + Environment.NewLine + "Number: " + clone.Number + Environment.NewLine + "Features: " + string.Join(", ", clone.Features));
    }
}

/* 輸出結果(原始資料不變) */
//Source Object
//Name: The Lord of the Rings
//Number: 10
//Features: Love, Funny
//
//Clone Object
//Name: Harry Potter
//Number: 30
//Features: Adventure, Magic 

4. 在 Class 裡手動撰寫 DeepCopy 和 ShallowCopy 方法
優點: 速度最快,假設有其他更快的方式,請告知我,感恩
缺點: 必須撰寫較多的程式碼,後續維護較麻煩且容易有人為上的疏失(假如你夠細心的話可無視)

ShallowCopy 微軟提供了內建方法Object.MemberwiseClone()供呼叫,和 DeepCopy 的差意主要是前者只複製實值(Value)型別的欄位,如果是參考(reference)型別的欄位,則只複製該物件欄位的參考位址,所以該欄位的值還是屬於和大家共用的狀態,至於什麼是參考型別還實值型別,由於不在本篇討論的範圍,有興趣的讀者可以自行參考這兩篇文章:一、Value Type(實值型別) vs Reference Type(參考型別)二、實值型別與參考型別的記憶體配置

這邊分別比較使用這兩種方式的結果

public class Item
{
    public Item ShallowCopy()
    {
        return (Item)this.MemberwiseClone();
    }

    public Item DeepCopy()
    {
        var other = (Item)this.MemberwiseClone();
        other.Name = String.Copy(Name);
        other.Features = new List<string>();
        foreach (var m in Features)
        {
            other.Features.Add(m);
        }

        return other;
    }

    public string Name { get; set; }
    public int Number { get; set; }
    public List<string> Features { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        /* ShallowCopy */
        var source = new Item { Name = "The Lord of the Rings", Number = 10, Features = new List<string> { "Love", "Funny" } };
        var clone = source.ShallowCopy();

        clone.Name = "Harry Potter";
        clone.Name = "Harry Potter";
        clone.Number = 30;
        clone.Features[0] = "Adventure";
        clone.Features[1] = "Magic";

        Console.WriteLine("*** ShallowCopy ***");
        Console.WriteLine("Source Object" + Environment.NewLine + "Name: "+ source.Name + Environment.NewLine + "Number: " + source.Number + Environment.NewLine + "Features: " + string.Join(", ", source.Features));
        Console.WriteLine();
        Console.WriteLine("Clone Object" + Environment.NewLine + "Name: " + clone.Name + Environment.NewLine + "Number: " + clone.Number + Environment.NewLine + "Features: " + string.Join(", ", clone.Features));

        /* DeepCopy */
        source = new Item { Name = "The Lord of the Rings", Number = 10, Features = new List<string> { "Love", "Funny" } };
        clone = source.DeepCopy();

        clone.Name = "Harry Potter";
        clone.Number = 30;
        clone.Features[0] = "Adventure";
        clone.Features[1] = "Magic";

        Console.WriteLine(Environment.NewLine + "*** DeepCopy ***");
        Console.WriteLine("Source Object" + Environment.NewLine + "Name: " + source.Name + Environment.NewLine + "Number: " + source.Number + Environment.NewLine + "Features: " + string.Join(", ", source.Features));
        Console.WriteLine();
        Console.WriteLine("Clone Object" + Environment.NewLine + "Name: " + clone.Name + Environment.NewLine + "Number: " + clone.Number + Environment.NewLine + "Features: " + string.Join(", ", clone.Features));
    }
}

/* 輸出結果 */
//*** ShallowCopy(原物件的實值型別欄位沒有被更動,但欄位Features的值被更動了) ***
//Source Object
//Name: The Lord of the Rings
//Number: 10
//Features: Adventure, Magic
//
//Clone Object
//Name: Harry Potter
//Number: 30
//Features: Adventure, Magic 

//*** DeepCopy(原物件的欄位都沒有被更動) ***
//Source Object
//Name: The Lord of the Rings
//Number: 10
//Features: Love, Funny
//
//Clone Object
//Name: Harry Potter
//Number: 30
//Features: Adventure, Magic 

到這邊我猜有些讀者應該和我一樣,會有一個疑問就是string也是參考型別,但在執行 ShallowCopy 的時候,怎麼 source 物件的 Name 屬性確沒有被修改到,抱著該疑問查找了一下資料後,原因主要在於 CLR string 屬於不可變變數(Immutable),一但宣告建立後就不可再改變或重組,所以當變更 clone 物件的 Name 值時,實際上是重新分配了一塊記憶體給 clone 的 Name 屬性使用,造成兩個物件的 Name 屬性已指向不同的記憶體區塊

5. Nuget 上的 DeepCloner 套件(原始碼)
優點: 速度比使用 Expression Trees 方式還快一些,懶得自己寫程式碼時,是一個還不錯的選擇,官方文件也有提供和各種方式的效能比較
缺點: 需另外安裝額外的套件,且需 .NET Framework 4 以上,不過它有支援 .Net Standard 算是一種優點吧!?

使用前需先從 Nuget 上安裝,使用方式如下

public class Item
{
    public string Name { get; set; }
    public int Number { get; set; }
    public List<string> Features { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        var source = new Item { Name = "The Lord of the Rings", Number = 10, Features = new List<string> { "Love", "Funny" } };
        var clone = source.DeepClone();

        clone.Name = "Harry Potter";
        clone.Number = 30;
        clone.Features[0] = "Adventure";
        clone.Features[1] = "Magic";

        Console.WriteLine("Source Object" + Environment.NewLine + "Name: "+ source.Name + Environment.NewLine + "Number: " + source.Number + Environment.NewLine + "Features: " + string.Join(", ", source.Features));
        Console.WriteLine();
        Console.WriteLine("Clone Object" + Environment.NewLine + "Name: " + clone.Name + Environment.NewLine + "Number: " + clone.Number + Environment.NewLine + "Features: " + string.Join(", ", clone.Features));
    }
}

/* 輸出結果(原始資料不變) */
//Source Object
//Name: The Lord of the Rings
//Number: 10
//Features: Love, Funny
//
//Clone Object
//Name: Harry Potter
//Number: 30
//Features: Adventure, Magic 

總結
經我自己測試後,大概整理出以下的心得

效能(處理時間): 4 > 5 > 3 > 2 > 1

實用性(之後我專案傾向採用的機率來排序): 5 > 2 = 3 > 1 > 4

事實上,真的很難挑出一個十全十美的處理方式,我認為選擇出一種最適合目前手上專案的才是最重要的



參考資料

[搞搞就懂] 深層複製(DeepClone)功能實作及應用

[史丹利好熱] 物件建立之淺層複製vs深層複製

[Stackoverflow] Deep cloning objects

MSDN 的 Object.MemberwiseClone Method

訪客統計