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>本身的,和这个也不发生关系。

相关文章
相关标签/搜索