1. 程式人生 > >C#中HashSet的重複性與判等運算過載

C#中HashSet的重複性與判等運算過載

目錄

  • 一個故事……
    • 一個繁榮的遙遠國度:泛型容器
      • 但是我也不確定容器裡能放些什麼東西啊
    • 一個英勇的皇家騎士:HashSet
      • 值型別的HashSet
      • 引用型別的HashSet
  • 另外一個……故……事??
    • 一對家喻戶曉的雙刀:==和Equals
      • 然而家喻戶曉終究成了舊日傳說
    • 一把開天闢地的神壇巨錘:ReferenceEquals
      • 然而開天闢地需要使出洪荒之力
  • 最後的故事
    • 一支量身打造的騎士聖劍:IEqualityComparer
      • 可是,聖劍似乎尚未開鋒……
      • 沒事,只需稍加淬火打磨……
    • 終於騎士可以攜聖劍踏向討伐惡魔的征途
  • 故事之後的尾聲

本文地址:https://www.cnblogs.com/oberon-zjt0806/p/12355028.html
本文遵循CC BY-NC-SA 4.0協議,轉載請註明出處。

一個故事……

在C#中,HashSet是一種叫做雜湊集的泛型的資料容器(Generic Collection,巨硬的官方術語稱Collection為集合,但區別於Set的數學集合概念,我稱之為資料容器(簡稱容器),泛型資料容器一下簡稱泛型容器)。

C#中泛型容器是通過系統標準庫的名稱空間System.Collections.Generic提供的,例如ArrayListDictionary……
HashSet是在.NET Framework 3.5引入的。

一個繁榮的遙遠國度:泛型容器

資料容器其實就是用於管理資料的資料結構(Data Structure,DS),用於儲存一組資料,而泛型指的是這些容器是針對所有的物件型別的泛型類,因而在使用時必須給出容器所容納的資料型別,以List為例:

List myList = new List();                 // 錯誤,List是泛型容器,必須給定List的容納型別。
List<string> myList = new List<string>(); // 正確,這是一個儲存若干字串的列表容器。

但是我也不確定容器裡能放些什麼東西啊

儘管不推薦非純型別的資料容器的存在,泛型約束統一型別的好處在於方便編寫通用方法進行統一處理,但實際情況是迫於客觀條件這種混合型別的容器是存在並且是大量存在的。
一般來說,我們允許存在共同繼承關係的類以多型的形式存在於一個容器中:

Pigeon pigeon = new Pigeon("咕咕咕"); // class Pigeon : Bird
Cuckoo cuckoo = new Cuckoo("子規");   // class Cuckoo : Bird
List<Bird> flock = new List<Bird>() { pigeon,cuckoo }; // 正確,pigeon和cuckoo可以被視為Bird的多型
                                                       // 換句話說,pigeon和cuckoo都可被看作Bird型別

但如果沒有共同繼承關係呢??比如同時儲存整數和字串??

不管怎麼說,C#裡所有類都隱性繼承System.Objectobject,因此所有類都可以被裝箱為object型別,那麼這種情況下可以使用裝箱的容器,也就是泛型提供為object的容器:

List<string> texts = new List<string>() { "Hello", "World", "C#" }; //這個列表裡只能放入字串
List<object> stuffs = new List<object>() { 1, "a", 2.0f}; //這個列表什麼都能往裡放

一個英勇的皇家騎士:HashSet

當然了,HashSet也是一個泛型容器,也就是說在使用的時候也得是HashSet<T>才行。

不過,前面所說的List是一個典型的順序列表,也就是說List是線性容器,其內部元素有序排列且可重複出現,而HashSet是集合容器,具有與數學上的集合類似的性質:

  1. 元素是唯一的
  2. 元素是無序的

HashSet就是保證這兩點的容器,在HashSet中每種元素有且僅有一個(唯一性),以及其中的元素不具備嚴格的順序性(無序性),此外
注意,這裡說的無序,並不是指這些資料是毫無位置關係的,因為無論如何記憶體管理資料的機制依然是順序的儲存,也就是說即使是HashSet聲稱其元素無序,但實際上內部的元素是存在一個固有順序的,只是這個順序不被外界所關心且在發生修改時很容易打破這種順序關係,因此HashSet對外體現出一種“順序無關”的狀態,這才是無序性的體現,不管怎麼說HashSet也實現了IEnumerable<T>,實現了IEnumerable<T>介面的容器都是有固有的儲存位序的,否則迭代是不可能的。

值型別的HashSet

HashSet<int> integers = new HashSet<int>(){ 1,2,3 }; // 一個整數集合,包含1,2,3
integers.Add(4); // 現在包含1,2,3,4了
integers.Add(2); // integers沒有變化,因為已經包含2了
var a = integers[1]; // 錯誤,HashSet是無序容器,不能使用索引器進行位序訪問

這裡很明顯,對於值型別的元素,只要HashSet中有相等者存在,那麼他就不會被重複加入到其中,這樣保證了唯一性,而HashSet對外不暴露順序或隨機訪問的入口,體現了HashSet的無序性。

引用型別的HashSet

// 為了簡單這裡不封裝了,直接上欄位
class Student 
{
    public int id; 
    public string name;
    public Student(int id, string name)
    {
        this.id = id;
        this.name = name;
    }
}

class Program
{
    public static void Main(string[] args)
    {
        Student s1 = new Student(1, "Tom");
        Student s2 = new Student(2, "Jerry");        
        Student s3 = s1;
        HashSet<Student> students = new HashSet<Student>();
        students.Add(s1); // s1被加入students中
        students.Add(s2); // s2被加入students中
        students.Add(s3); // 沒有變化,s1已存在
    }
}

可以看到,相同的元素也並沒有被加進去,但是,如果我改一下……

        //前面不寫了,直接寫Main裡的東西
        Student s1 = new Student(1, "Tom");
        Student s2 = new Student(2, "Jerry");  
        Student s3 = new Student(1, "Tom");
        HashSet<Student> students = new HashSet<Student>();
        students.Add(s1); // s1被加入students中
        students.Add(s2); // s2被加入students中
        students.Add(s3); // s3被加入students中

明明s1s3長得也一毛一樣,為什麼這次加進去了呢??

當然,如果知道什麼是引用型別的朋友肯定看出了問題關鍵,前者的s1s3是同一個引用,也就是同一個物件,因為Student s3 = s1;的時候並不將s1拷貝給s3,而是讓兩者為同一個東西,而後者的s3只是屬性值和s1一致,但實際上s3是新建出來的物件。

由此可以看出,HashSet對於引用型別的唯一性保障採取的是引用一致性判斷,這也是我為什麼在前者中對students.Add(s3)操作給的註釋是// 沒有變化,s1已存在而不是// 沒有變化,s3已存在

另外一個……故……事??

當然,一般情況下我們認為只要idname相等的兩個Student其實就是同一個人。即使是s1s3都被定義為new Student(1,"Tom"),我們也不希望會被重複新增進來。
我們瞭解了HashSet的唯一性,因此我們要想方設法讓HashSet認為s1s3是相同的。

一對家喻戶曉的雙刀:==和Equals

我們當然會很容易的想到:

不就是讓你們看起來被認為相等嘛,那我就重寫你們的相等判定的不就好了麼??

巧合的是,任何一個(繼承自object的)類都提供了兩個東西:Equals方法和==運算子。
而且,我們瞭解,對於引用型別來說(string被魔改過除外,我個人理解是string已經算是值型別化了),==和Equals都是可過載的,即使不過載,在引用型別的視角下==Equals從功能上是一致的。

Student s4 = new Student(1,"Tom");
Student s5 = new Student(1,"Tom");
Student s6 = s4;

Console.WriteLine($"s4==s5:{s4==s5} s4.Equals(s5):{s4.Equals(s5)}");
Console.WriteLine($"s4==s6:{s4==s6} s4.Equals(s6):{s4.Equals(s6)}");

輸出結果為:

s4==s5:False s4.Equals(s5):False
s4==s6:True s4.Equals(s6):True

注意:
在引用視角下,==和Equals在預設條件下完全相同的,都是判別引用一致性,只是可以被過載或改寫為不同的功能函式。但==和Equals確實有不同之處,主要是體現在值型別和裝箱的情況下,但我們目前不打算考慮這個區別。

然而家喻戶曉終究成了舊日傳說

因此我們很容易的會考慮改寫這兩個函式中的任意一個,又或者兩個一起做,類似於:

class Student 
{
    public int id; 
    public string name;
    public Student(int id, string name)
    {
        this.id = id;
        this.name = name;
    }
    
    public override bool Equals(object o)
    {
        if(o is Student s)
            return id == s.id && name = s.name;
        else
            return false;
    }
    public static bool operator==(Student s1,Student s2)
    {
        return (s1 is null ^ s2 is null) && (s1.Equals(s2));
    }
}

當然這樣做了一溜十三招之後,帶回去重新試你會發現:

毛用都沒有!!!

是的,這給了我們一個結論:和C++裡的set不一樣,HashSet的相等性判定並不依賴於這兩個函式。

一把開天闢地的神壇巨錘:ReferenceEquals

萬念俱灰的我們查閱了msdn,發現引用的一致性判斷工作最終落到了object的另外一個方法上:object.ReferenceEquals,當其他==或者Equals被改寫掉而喪失引用一致性判斷的時候這個方法做最後的兜底工作,那麼從上面的結論來看的話,既然HashSet使用引用一致性判定相等的話,那麼我們如果能過載這個函式使之認為兩者相等,目的就達成了……

然而開天闢地需要使出洪荒之力

過載ReferenceEquals……說的輕鬆,輕鬆得我們都迫不及待要做了,然後我們意外的發現:

object.ReferenceEquals是靜態方法,無法改寫……

無法改寫的話就沒有意義了,看來這個方法也行不通,是啊,反過來仔細想想的話,如果最底層的引用一致性判斷被能被改寫的話那才是真正的災難,所以這玩意怎麼可能隨便讓你亂改。

最後的故事

繞了這麼一大圈,我們不妨回到HashSet自身看看。
HashSet提供瞭如下幾個建構函式:

HashSet<T>(); // 這是預設建構函式,沒什麼好期待的
HashSet<T>(IEnumerable<T>); // 這是把其他的容器轉成HashSet的,也不是我們想要的
HashSet<T>(Int32); // 這個引數是為了定容的,pass
HashSet<T>(SerializationInfo, StreamingContext); // 我們並不拿他來序列化,這個也不用
HashSet<T>(IEqualityComparer<T>); //……咦??

一支量身打造的騎士聖劍:IEqualityComparer

Equality……相等性……看來沒錯了,就是這個東西在直接控制HashSet的相等性判斷了。
IEqualityComparerSystem.Collections.Generic名稱空間提供的一個介面……

居然和HashSet的出處都是一樣的!!看來找對了。IEqualityComparer是用於相等判斷的比較器。提供了兩個方法:EqualsGetHashCode

可是,聖劍似乎尚未開鋒……

IEqualityComparer是一個介面,用於容器內元素的相等性判定,但是介面並不能被例項化,而對於建構函式的引數而言必須提供一個能夠使用的例項,因為不管怎麼說,我們也不能

var comparer = new IEqualityComparer<Student>(); //錯誤,IEqualityComparer<Student>是介面。

沒事,只需稍加淬火打磨……

儘管不能例項化介面,我們可以實現這個介面,而且,因為介面只是提供方法約定而不提供,實現介面的類和介面之間也存在類似父子類之間的多型關係。

class StudentComparer : IEqualityComparer<Student>
{
    public bool Equals([AllowNull] Student x, [AllowNull] Student y)
    {
    return x.id == y.id && x.name == y.name;
    }

    public int GetHashCode([DisallowNull] Student obj)
    {
    return obj.id.GetHashCode();
    }
}

當然,這個StudentComparer也可以被多型性視為一個IEqualityComparer<T>,因此我們的建構函式中就可以寫:

HashSet<Student> students = new HashSet<Student>(new StudentComparer());

這樣的HashSet<Student>採取了StudentComparer作為相等比較器,如果滿足這一比較器的相等條件,那就會被認為是一致的元素而被加進來,也就是說問題的關鍵並不是對等號算符的過載,而是選擇適合於HashSet容器的比較裝置。

終於騎士可以攜聖劍踏向討伐惡魔的征途

我們找到了一個可行的解決方案,於是我們再次嘗試一下:

public static void Main(string[] args)
{
    HashSet<Student> students = new HashSet<Student>(new StudentComparer()); // 空的HashSet
    Student s1 = new Student(1,"Tom");
    Student s2 = s1;
    Student s3 = new Student(1,"Tom");
    students.Add(s1); // students現在包含了s1
    students.Add(s2); // 沒有變化,s1已存在
    students.Add(s3); // 沒有變化,s3和s1相等
    
    Console.WriteLine($"There's {students.Count} student(s).")
    // 迭代輸出看結果
    foreach(var s in students)
    {
        Console.WriteLine($"{s.id}.{s.name}");
    }
}

輸出結果:

There's 1 student(s).
1.Tom

故事之後的尾聲

這次探索得到的結論就是……

我曾經對C#的泛型容器的瞭解……不,對整個資料容器體系的瞭解還是NAIVE as we are!

C#的泛型容器中其實提供了比想象中更多的東西,尤其是System.Collections.Generic提供了一些很重要的介面,如列舉器和比較器等等,甚至還有.NET為泛型容器提供了強大的CRUD工具——LINQ表示式和Lambda表示式等等。

此外,當嘗試外力去解決問題無果時,不妨將視野跳回起點,可能會有不一樣的收穫。