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

本文地址:http://www.javashuo.com/article/p-ustgpgyg-ge.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爲例:ide

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的容器:this

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集合容器,具備與數學上的集合相似的性質:code

  1. 元素是惟一
  2. 元素是無序

HashSet就是保證這兩點的容器,在HashSet中每種元素有且僅有一個(惟一性),以及其中的元素不具有嚴格的順序性(無序性),此外
注意,這裏說的無序,並非指這些數據是毫無位置關係的,由於不管如何內存管理數據的機制依然是順序的存儲,也就是說即便是HashSet聲稱其元素無序,但實際上內部的元素是存在一個固有順序的,只是這個順序不被外界所關心且在發生修改時很容易打破這種順序關係,所以HashSet對外體現出一種「順序無關」的狀態,這纔是無序性的體現,無論怎麼說HashSet也實現了IEnumerable<T>,實現了IEnumerable<T>接口的容器都是有固有的存儲位序的,不然迭代是不可能的。htm

值類型的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靜態方法,因此子類沒法改寫……
又由於object.ReferenceEquals(object,object),兩個參數都是object,因此沒法重載成一樣的兩個其餘類型參數的副本。

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

最後的故事(真實印記)

繞了這麼一大圈,咱們不妨回到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表達式等等。

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

附錄 - 番外的故事

一把名聲赫赫的「除魔」之杖(虛假傳說番外):GetHashCode

感謝@coredx評論中對個人提醒,這裏對他的這條評論做以說明,評論內容以下:

#8樓 2020-02-24 19:15 coredx
還有,hash set 其實是先調用 gethashcode,若是相等就直接認爲是同一個對象。不然再調用相等判斷。因此微軟在這兩個函數沒有同時重寫的時候會警告你要同時重寫兩個函數,避免邏輯錯亂,和 dictionary 同樣。

這裏面提到了object提供的另一個函數:object.GetHashCode()。此函數用於得到當前實例的哈希值,也就是一個哈希函數,返回一個Int32類型的哈希碼。

多是由於HashSet這個名字的緣由,想到這個函數是很正常的,畢竟那個東西叫作「哈希集」,會不會可能用到這個哈希函數呢??

然而名聲赫赫也不過是信口開河

咱們修改了嘗試修改一下這個函數:

class Student
{
    //...別的不寫了
    public override int GetHashCode() => id;
}

而後再帶回去嘗試,咱們會發現……

As FUTILE as it has ever been.

原來此魔非彼魔

其實,就別說HashSet了,連object.Equals==都不用HashCode來判斷是否相等,若是隻重寫GetHashCode的話,咱們甚至會發現==Equals徹底不受影響。

可是看這位朋友的語氣並不像子虛烏有,因而我特意去msdn上又查閱了一下object.GetHashCode(),結果獲得了一個有趣的說法:

重寫 GetHashCode() 的派生類還必須重寫 Equals(Object),以保證視爲相等的兩個對象具備相同的哈希代碼;不然,Hashtable 類型可能沒法正常工做。

看來這位朋友把HashSetHashtable弄混了。二者雖然都有個Hash,但其實除了都位於System.Collections這一大的命名空間下以外,幾乎一點關係都沒有

HashSet<T>是位於System.Collections.Generic下的泛型容器,而Hashtable是位於System.Collections下的非泛型容器(Non-generic Collection)。並且,前者也並不是後者的泛型版本,事實上,Hashtable對應的泛型版本是Dictionary<TK,TV>

也就是說,Hashtable其實相似於Dictionary<object,object>(儘管實際上不是),這也就意味着,Hashtable的元素也是KV對(Key-Value Pair,鍵值對)。

Hashtable既然相似於Dictionary,那麼Hashtable也要保證一種惟一性——Key的惟一性,爲了保證鍵值的惟一性,Hashtable使用GetHashCode函數的結果是否相等做爲判斷依據,當有特別的判等需求時,可改寫GetHashCode作適配。

不過實際上msdn還提到了GetHashCode的重載和Equals函數之間的關聯規範。上面引用的部分也提到了,本文不對此作過多闡述。

聖劍背後的故事(真實印記番外)

其實,儘管Hashtable使用GetHashCode(),但泛型版本的Dictionary<TK,TV>卻依然使用IEqualityComparer<TK>進行TK類型的相等性判斷。

爲何會這樣??

其實,這兩個泛型容器實際上都有一個屬性叫作Comparer,類型爲IEqualityComparer<T>
不過,這並非由於他們共同繼承了什麼類,也不是由於他們共同實現了什麼接口,這只是這兩種容器相仿的惟一性所帶來的一個巧合。
HashSet<T>但願其中的T類型元素具備惟一性,而Dictionary<TK,TV>則但願TK類型的鍵值具備惟一性,而後很巧合的都使用了泛型版本的相等比較器IEqualityComparer<T>(實際上這個接口有個非泛型版本,但這裏不作介紹)。

HashSet<T>的其中一個構造函數:

HashSet<T>(IEqualityComparer<T>);

這個函數的參數實際上就是給了Comparer屬性。

固然,您可能要問:

若是我使用了沒有此類型參數版本的構造函數,那這個屬性會是null麼??

答案是否認的,實際上用調試器觀察會發現,當什麼也不給Comparer的時候,Comparer被描述爲一個類型爲System.Collections.Generic.ObjectEqualityComparer<T>的類型,可是頗有趣的是,不管是在msdn上仍是在對象瀏覽器中,都找不到名爲ObjectEqualityComparer<T>的類型,雖然緣由不明,但推測是被巨硬寫成了private class

原來聖劍與生俱來

事實上,關於這一點,msdn上也是有所提示的:

HashSet ()

  • Initializes a new instance of the HashSet class that is empty and uses the default equality comparer for the set type.

就是說,即便用無參的構造函數,HashSet實例仍是會被分配一個默認的IEqualityComparer<T>,這也就得出了這樣的結論:不管那種狀況下,object.GetHashCode()HashSet都是沒有什麼關係的。
而實際上,IEqualityComparer<T>接口要求咱們必須實現兩個函數:EqualsGetHashCode,但這兩個函數是IEqualityComparer<T>本身的,和這個也不發生關係。

相關文章
相關標籤/搜索