C#中HashSet的重复性与判等运算重载
本文地址: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
提供的,例如ArrayList
、Dictionary
……
而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.Object
即object
,因此所有类都可以被装箱为object
类型,那么这种情况下可以使用装箱的容器,也就是泛型提供为object
的容器:
List<string> texts = new List<string>() { "Hello", "World", "C#" }; //这个列表里只能放入字符串
List<object> stuffs = new List<object>() { 1, "a", 2.0f}; //这个列表什么都能往里放
或者的话,既然类型约束是泛型模板造成的,那么你也可以使用非泛型版本(位于System.Collections
命名空间)。
ArrayList stuffs = new ArrayList(){1, "a", texts}; // ArrayList是List<T>的非泛型版本。
不过,值得注意的是,目前msdn并不倡用(Deprecated)非泛型容器,而提倡使用泛型容器,而且事实上混合类型的容器都使用了装箱处理,所以对元素还原的时候还必须依照底层类型进行拆箱。
当然,自.NET 4.0又出现了dynamic
关键字,因此也可以使用泛型为弱类型的容器(尽管dynamic
关键字从一开始就不怎么被msdn提倡):
List<dynamic> stuffs = new List<dynamic>(){ 1, "a", texts }; // 泛型指定为弱类型
dynamic
允许不做拆箱操作直接访问属性(当然前提是属性对于其底层类型确实存在),不过元素都被混成dynamic
之后,每个元素还有什么属性简直就跟猜谜语一样,所以这个方法也不建议使用(如果情非得已那就无所谓了)。
而且,别忘了,本文不打算具体讨论怎么让泛型容器成为大众情人,差点就跑题了……
一个英勇的皇家骑士:HashSet
当然了,HashSet
也是一个泛型容器,也就是说在使用的时候也得是HashSet<T>
才行。
不过,前面所说的List是一个典型的顺序列表,也就是说List是线性容器,其内部元素有序排列且可重复出现,而HashSet
是集合容器,具有与数学上的集合类似的性质:
- 元素是唯一的
- 元素是无序的
而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中
明明s1
和s3
长得也一毛一样,为什么这次加进去了呢??
当然,如果知道什么是引用类型的朋友肯定看出了问题关键,前者的s1
和s3
是同一个引用,也就是同一个对象,因为Student s3 = s1;
的时候并不将s1
拷贝给s3
,而是让两者为同一个东西,而后者的s3
只是属性值和s1
一致,但实际上s3
是新建出来的对象。
由此可以看出,HashSet对于引用类型的唯一性保障采取的是引用一致性判断,这也是我为什么在前者中对students.Add(s3)
操作给的注释是// 没有变化,s1已存在
而不是// 没有变化,s3已存在
。
另外一个……故……事??(虚假传说)
虚假传说-序言
这并不是真正的故事,也就是说这个大标题下不是真正的解决方案(毕竟都说了嘛,虚假传说)。
尽管这里不是真正的解决方案,我们希望各位勇者也能够读读看,这一部分反映了一种想当然的思维模式。
如果需要真正解决方案,请见下一个大标题:真实印记。
当然,一般情况下我们认为只要id
和name
相等的两个Student其实就是同一个人。即使是s1
和s3
都被定义为new Student(1,"Tom")
,我们也不希望会被重复添加进来。
我们了解了HashSet的唯一性,因此我们要想方设法让HashSet认为s1
和s3
是相同的。
一对家喻户晓的双刀:==和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));
}
public static bool operator!=(Student s1,Student s2) //重载==必须同时重载!=
{
return ! s1==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的相等性判断了。
IEqualityComparer
是System.Collections.Generic
命名空间提供的一个接口……
居然和HashSet
的出处都是一样的!!看来找对了。IEqualityComparer
是用于相等判断的比较器。提供了两个方法:Equals
和GetHashCode
。
可是,圣剑似乎尚未开锋……
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 ever been.
原来此魔非彼魔
其实,就别说HashSet
了,连object.Equals
和==
都不用HashCode来判断是否相等,如果只重写GetHashCode
的话,我们甚至会发现==
和Equals
完全不受影响。
但是看这位朋友的语气并不像子虚乌有,于是我特地去msdn上又查阅了一下object.GetHashCode()
,结果得到了一个有趣的说法:
重写 GetHashCode() 的派生类还必须重写 Equals(Object),以保证视为相等的两个对象具有相同的哈希代码;否则,Hashtable 类型可能无法正常工作。
看来这位朋友把HashSet
和Hashtable
弄混了。两者虽然都有个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
类型的相等性判断。
为什么会这样??
其实,无论是HashSet<T>
还是Dictionary<TK,TV>
,这两个泛型容器实际上都有一个属性叫做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>
接口要求我们必须实现两个函数:Equals
和GetHashCode
,但这两个函数是IEqualityComparer<T>
自己的,和这个也不发生关系。
C#中HashSet的重复性与判等运算重载的更多相关文章
- Java中HashSet的重复性与判等运算重载
目录 还有一个故事--(平行世界篇) 还有一个美丽的梦幻家园:java.util 并且还有一个善战的达拉崩巴:HashSet 还有另外一个故事(不是虚假传说) 还有一对涂满毒药的夺命双匕:equals ...
- Java中的二进制及基本的位运算
Java中的二进制及基本的位运算 二进制是计算技术中广泛采用的一种数制.二进制数据是用0和1两个数码来表示的数.它的基数为2,进位规则是"逢二进一",借位规则是"借一当二 ...
- 《挑战30天C++入门极限》C++中利用构造函数与无名对象简化运算符重载函数
C++中利用构造函数与无名对象简化运算符重载函数 在完整描述思想之前,我们先看一下如下的例子,这个例子中的加运算符重载是以非成员函数的方式出现的: //程序作者:管宁 //站点:www.cn ...
- Java中HashSet,HashMap和HashTable的区别
HashMap.HashSet.HashTable之间的区别是Java程序员的一个常见面试题目,在此仅以此博客记录,并深入源代码进行分析: 在分析之前,先将其区别列于下面 1:HashSet底层采用的 ...
- java中HashSet详解(转)
HashSet 的实现 对于 HashSet 而言,它是基于 HashMap 实现的,HashSet 底层采用 HashMap 来保存所有元素,因此 HashSet 的实现比较简单,查看 HashSe ...
- java中HashSet实现(转)
hashset底层的数据结构是hash表,hash表实现方式采用数组+链表,数组类型为HashNode,每个数组元素为链表的头指针,链表中存储经过hash函数散列后冲突的元素,数组的长度为26 has ...
- java中HashSet详解
HashSet 的实现 对于 HashSet 而言,它是基于 HashMap 实现的,HashSet 底层采用 HashMap 来保存所有元素,因此 HashSet 的实现比较简单,查看 HashSe ...
- java集合(4)- java中HashSet详解
HashSet 的实现 对于 HashSet 而言,它是基于 HashMap 实现的,HashSet 底层采用 HashMap 来保存所有元素,因此 HashSet 的实现比较简单,查看 HashSe ...
- 使用spark对hive表中的多列数据判重
本文处理的场景如下,hive表中的数据,对其中的多列进行判重deduplicate. 1.先解决依赖,spark相关的所有包,pom.xml spark-hive是我们进行hive表spark处理的关 ...
随机推荐
- 创建django报错使用miniconda
sqlite文件缺失 下载地址 https://sqlite.org/download.html https://blog.csdn.net/xuzhexing/article/details/905 ...
- 4.JavaSE之标识符
标识符:Java所有的组成部分都需要名字.类名.变量名以及方法名都被称为标识符. 关键字:abstract.assert.boolean.breake.public.static.class...
- 图解 Kubernetes
容器 在了解 Kubernetes 之前,让我们先了解一个容器. 因为如果不了解容器就没法聊容器编排. 容器就是...一个你塞入所有材料的容器. "材料"是指你的应用代码.依赖库, ...
- spring boot配置spring-data-jpa的时候报错CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is java.lang.NoSuchMethodError
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager f ...
- 网络通信-基本概念:网络、IP地址、端口、socket
目录 网络通信 1 网络 1.1 网络定义 1.2 使用网络的目的 1.3 总结 2 IP地址 2.1 ip地址的作用 2.2 ip地址的分类 3 端口 3.1 什么是端口 3.2 端口号 3.3 端 ...
- 美食家App开发日记3
由于个人原因,感觉Android的学习特别复杂,初次接触,实在难以完成最初设想,所以将最初的设想做减法. 今天学习了ListView控件,将图片和美食名字使用ListView界面显示出来,并学习提升L ...
- selenium等待方式之显示等待
有时候,页面元素并未及时加载出来导致后面的步骤无法执行 这里就需要在加载前添加等待时间,让目标元素有足够的时间加载出来 第一种方法:使用time.sleep() 这种方法过于强制,无论元素是否加载出来 ...
- 2、初始ES6及Vue
今日内容 es6的语法 let 特点: 1.局部作用域 2.不会存在变量提升 3.变量不能重复声明 const 特点: 1.局部作用域 2.不会存在变量提升 3.不能重复声明,只声明常量 不可变的量 ...
- Python3-ORM-Sqlalchemy
目录: ORM介绍 sqlalchemy安装 sqlalchemy基本使用 多外键关联 多对多关系 表结构设计作业 1. ORM介绍 orm英文全称object relational mapping, ...
- 长连接 Websocket
import json from flask import Flask,request,render_template from geventwebsocket.handler import WebS ...