寫 LINQ 再遇上 Value Type vs Reference Type 比對問題,雖已是老掉牙的觀念,卻還是失神絆了一下,特筆記備忘兼加深印象。

LINQ Except() 可以快速列出兩個 IEnumerable<T> 集合的差異項目,Intersect() 則可找出交集項目,比對資料超級好用。(延伸閱讀:善用 LINQ Except 比對產生資料庫增刪指令好 LINQ,不用嗎?)

以往 Except() 比對對象多半為 string、int 等數值型別,這次處理到自訂類別,資料來自資料庫 JSON 欄位及 WebAPI Request 以 JSON 格式傳入的參數,程式出錯沒將重複項目排除。原因出在 Except() 以 Equals()/== 結果做為比對依據,自訂類別為 Reference Type,預設要兩個變數指向同一個 Instance (執行個體)才算相等。當資料經過序列化還原,即使屬性值完全一致,也不會視為相同。(延伸閱讀:TIPS - 比對 .NET Reference Type 物件是否相等C# 隨堂考 - object 超級比一比)

用個實例展示:

public class User
{
    public string Id { get; set; }
    public string Name { get; set; }
}

static void Test1()
{
    var original = new List<User>()
    {
        new User{ Id = "A01", Name = "Jeffrey" },
        new User{ Id = "A02", Name = "Darkthread" }
    };

    var merged = new List<User>();
    merged.AddRange(original);
    merged.Add(new User
    {
        Id = "B01",
        Name = "Ironman"
    });


    var diff = merged.Except(original);
    Console.WriteLine(JsonConvert.SerializeObject(diff));
}

我自訂了一個 User 型別,包含 Id 及 Name 兩個字串屬性。我建立 List<User> orginal 放入兩個物件,建立另一個 List<User> merged,用 AddRange() 加入 orignal,再加入第三個 User "Ironman"。接著用 merged.Except(original),如預期得到二者差異的項目 - Ironman。

再來,加入先前說的「資料來自 JSON 還原」因素:

static void Test2()
{
    var original = new List<User>()
    {
        new User{ Id = "A01", Name = "Jeffrey" },
        new User{ Id = "A02", Name = "Darkthread" }
    };
    var originalJson = JsonConvert.SerializeObject(original);

    var merged = new List<User>();
    merged.AddRange(original);
    merged.Add(new User
    {
        Id = "B01",
        Name = "Ironman"
    });
    var mergedJson = JsonConvert.SerializeObject(merged);

    //模擬情境
    //original 由資料庫欄位 JSON 還原
    //merged 由 WebAPI Request 傳入 JSON 還原
    var fromDb = JsonConvert.DeserializeObject<List<User>>(originalJson);
    var fromReq = JsonConvert.DeserializeObject<List<User>>(mergedJson);

    var diff = fromReq.Except(fromDb);
    Console.WriteLine(JsonConvert.SerializeObject(diff, Formatting.Indented));
}

fromDb 與 fromReq 的元素項目看似與 original、merged 相同,但裡面的 User 為不同的 Instance,故 fromReq.Except(fromDb) 時,即便 Id/Name 值相同,也不會被視為相等,故 fromDb 的三筆都沒被排除。

針對這個問題,微軟在 Except() 說明文件就已貼心附上解答(不禁再次讚嘆微軟對開發人員真好,啾甘心A)。解法有二,一是實做 IEqualityComparer<User> 比較器,另一個則是覆寫 User Equals() 及 GetHashCode()。

先看第一種解法:(此處假設 Id 相同 Name 就相同,忽略二者不一致的可能,故比對 Id 即可)

class UserComparer : IEqualityComparer<User>
{
    public bool Equals(User x, User y)
    {
        if (Object.ReferenceEquals(x, y)) return true;
        if (Object.ReferenceEquals(x, null) || Object.ReferenceEquals(y, null))
            return false;
        return x.Id == y.Id;
    }

    public int GetHashCode(User obj)
    {
        if (object.ReferenceEquals(obj, null)) return 0;
        return obj.Id == null ? 0 : obj.Id.GetHashCode();
    }
}


static void Test3()
{
    var original = new List<User>()
    {
        new User{ Id = "A01", Name = "Jeffrey" },
        new User{ Id = "A02", Name = "Darkthread" }
    };
    var originalJson = JsonConvert.SerializeObject(original);

    var merged = new List<User>();
    merged.AddRange(original);
    merged.Add(new User
    {
        Id = "B01",
        Name = "Ironman"
    });
    var mergedJson = JsonConvert.SerializeObject(merged);

    //模擬情境
    //original 由資料庫欄位 JSON 還原
    //merged 由 WebAPI Request 傳入 JSON 還原
    var fromDb = JsonConvert.DeserializeObject<List<User>>(originalJson);
    var fromReq = JsonConvert.DeserializeObject<List<User>>(mergedJson);

    var diff = fromReq.Except(fromDb, new UserComparer());
    Console.WriteLine(JsonConvert.SerializeObject(diff));
}

為了物件比對實作一顆專屬類別有點費事,故有人想出用公用函式傳入 Lambda 快速建立 IEqualityComparer<T> 的做法可省點工。(參考:快速创建 IEqualityComparer<T> 和 IComparer<T> 的实例)

第二種解法是讓 User 類別實作 IEquatable<T> 與覆寫 Equals()、GetHashCode(),以比對 User.Id 取代比對同一 Instance:

 public class User : IEquatable<User>
{
    public string Id { get; set; }
    public string Name { get; set; }

    public bool Equals(User other)
    {
        if (other == null) return false;
        return this.Id == other.Id;
    }

    public override bool Equals(object obj) => Equals(obj as User);
    public override int GetHashCode() => Id.GetHashCode();
}
	
static void Test4()
{
    var original = new List<User>()
    {
        new User{ Id = "A01", Name = "Jeffrey" },
        new User{ Id = "A02", Name = "Darkthread" }
    };
    var originalJson = JsonConvert.SerializeObject(original);

    var merged = new List<User>();
    merged.AddRange(original);
    merged.Add(new User
    {
        Id = "B01",
        Name = "Ironman"
    });
    var mergedJson = JsonConvert.SerializeObject(merged);

    //模擬情境
    //original 由資料庫欄位 JSON 還原
    //merged 由 WebAPI Request 傳入 JSON 還原
    var fromDb = JsonConvert.DeserializeObject<List<User>>(originalJson);
    var fromReq = JsonConvert.DeserializeObject<List<User>>(mergedJson);

    var diff = fromReq.Except(fromDb);
    Console.WriteLine(JsonConvert.SerializeObject(diff));
}

提醒自己,下回處理自訂類別的 LINQ Distinct()、Except()、Intersect() 時,別再忘記處理比對邏輯。

Tips of using LINQ Except() on user defined classes.


Comments

# by Switch_Squirrel

黑大,「快速创建 IEqualityComparer<T> 和 IComparer<T> 的实例」這個hyper link連到這篇文章了 另外想請問黑大,Except有個多載「Except<TSource>(IEnumerable<TSource>, IEnumerable<TSource>, IEqualityComparer<TSource>)」,這邊的IEqualityComparer是否能像Sort一樣直接自訂Comparer的Lambda函數傳入並實作比較?

# by ChrisTorng

在 Visual Studio 中產生 Equals 與 GetHashCode 方法覆寫 https://docs.microsoft.com/zh-tw/visualstudio/ide/reference/generate-equals-gethashcode-methods?view=vs-2019 中有快速產生 Equals/GetHashCode 之方法。產出的程式為: public override bool Equals(object obj) { return Equals(obj as User); } public bool Equals(User other) { return other != null && Id == other.Id && Name == other.Name; } public override int GetHashCode() { return HashCode.Combine(Id, Name); } 再使用 為 Lambda 運算式使用運算式主體或區塊主體 https://docs.microsoft.com/zh-tw/visualstudio/ide/reference/use-block-body-lambda?view=vs-2019 可得: public override bool Equals(object obj) => Equals(obj as User); public bool Equals(User other) => other != null && Id == other.Id && Name == other.Name; public override int GetHashCode() => HashCode.Combine(Id, Name); 可以學起來的一招是 HashCode 結構 https://docs.microsoft.com/en-us/dotnet/api/system.hashcode 。

# by ChrisTorng

自動將網址轉為超連結功能不會將 ? 之後字串加入?

# by Jeffrey

to Switch_Squirrel,快速创建 IEqualityComparer<T>... 文章連結已修正。我沒有找到直接傳 Lambda 的做法,套用該文章所提的技巧可簡化成 Except(fromDB, Equality<User>.CreateComparer(p => p.Id)),還算簡便。

# by Jeffrey

to ChrisTorng, 這招好,感謝分享。(已筆記) 留言判斷連結不未包含 ? 後方參數為已知問題,已交代本站亞太地區技術中心執行長帶領 RD 團隊盡速解決。(繼續裝死 XD)

# by ChrisTorng

改為 Markdown 如何? 技術人應該要開始練習/熟悉這東西了...

# by Switch_Squirrel

謝黑大,雖然沒辦法避免自己實作的部份,不過還是省了許多工XD

# by ShaoYu

寫了一個土炮版本Extension,思路應該一樣,bug請自行處理 https://gist.github.com/s1495k043/9e192daa00c752e1d77b4e0bd5153062 var list1 = new[] { "A", "B", "C" }; var list2 = new[] { "b", "c", "d" }; var except = list1.Except(list2, x => x.ToUpper()); //結果為["A"]

# by Jeffrey

to ShaoYu, 感謝分享,這樣又更簡潔了~

Post a comment