自定义值类型一定不要忘了重写Equals,否则性能和空间双双堪忧 电脑版发表于:2024/9/20 16:05 ![.netcore](https://img.tnblog.net/arcimg/hb/c857299a86d84ee7b26d181a31e58234.jpg ".netcore") >#自定义值类型一定不要忘了重写Equals,否则性能和空间双双堪忧 [TOC] 一:背景 ------------ ### 1. 讲故事 tn2>曾今在项目中发现有同事自定义结构体的时候,居然没有重写Equals方法,比如下面这段代码: ```csharp static void Main(string[] args) { var list = Enumerable.Range(0, 1000).Select(m => new Point(m, m)).ToList(); var item = list.FirstOrDefault(m => m.Equals(new Point(int.MaxValue, int.MaxValue))); Console.ReadLine(); } public struct Point { public int x; public int y; public Point(int x, int y) { this.x = x; this.y = y; } } ``` tn2>这代码貌似也没啥什么问题,好像大家平时也是这么写,没关系,有没有问题,跑一下再用windbg看一下。 ![](https://img.tnblog.net/arcimg/hb/45b41dc69f2b46c2b6377d50ccc83506.png) ```bash 0:000> !dumpheap -stat Statistics: MT Count TotalSize Class Name 00007ff8826fba20 10 16592 ConsoleApp6.Point[] 00007ff8e0055e70 6 35448 System.Object[] 00007ff8826f5b50 2000 48000 ConsoleApp6.Point 0:000> !dumpheap -mt 00007ff8826f5b50 Address MT Size 0000020d00006fe0 00007ff8826f5b50 24 0:000> !do 0000020d00006fe0 Name: ConsoleApp6.Point Fields: MT Field Offset Type VT Attr Value Name 00007ff8e00585a0 4000001 8 System.Int32 1 instance 0 x 00007ff8e00585a0 4000002 c System.Int32 1 instance 0 y ``` tn2>从上面的输出不知道你看出问题了没有? 托管堆上居然有2000个Point,而且还可以用 `!do` 打出来,说明这些都是引用类型。。。这些引用类型哪里来的? 看代码应该是 `equals` 比较时产生的,一次比较就有2个point被装箱放到托管堆上,这下惨了,,,而且大家应该知道引用对象本身还有`(8+8) byte` 自带开销,这在时间和空间上都是巨大的浪费呀。。。 二: 探究默认的Equals实现 ------------ ### 1. 寻找ValueType的Equals实现 tn2>为什么会这样呢? 我们知道`equals`是继承自`ValueType`的,所以把`ValueType`翻出来看看便知: ```csharp public abstract class ValueType { public override bool Equals(object obj) { if (CanCompareBits(this)) {return FastEqualsCheck(this, obj);} FieldInfo[] fields = runtimeType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); for (int i = 0; i < fields.Length; i++) { object obj2 = ((RtFieldInfo)fields[i]).UnsafeGetValue(this); object obj3 = ((RtFieldInfo)fields[i]).UnsafeGetValue(obj); ... } return true; } } ``` tn2>从上面代码中可以看出有如下三点信息: 1.通用的 equals 方法接收object类型,参数装箱一次。 2.`CanCompareBits,FastEqualsCheck` 都是采用object类型,`this`也需要装箱一次。 ![](https://img.tnblog.net/arcimg/hb/4431689589c6425abf14d8b55720bfa4.png) tn2>有两种比较方式,要么采用`FastEqualsCheck`比较,要么采用`反射`比较,我去.... 反射就玩大了。 综合来看确实没毛病, `equals` 会把比较的两个对象都进行装箱。 ### 2. 改进方案 tn2>问题找到了,解决起来就简单了,不走这个通用的 `equals` 不就行啦,我自定义一个`equals`方法,然后跑一下代码。 ```csharp public bool Equals(Point other) { return this.x == other.x && this.y == other.y; } ``` ![](https://img.tnblog.net/arcimg/hb/e898f2c2dd2c42c8b6a5f41ed9a6ea01.png) tn2>可以看到走了我的自定义的Equals,????。 貌似问题就这样简单粗暴的解决了,真开心,打脸时刻开始。。。 三:真的解决问题了吗? ------------ ### 1. 遇到问题 tn2>很多时候我们会定义各种泛型类,在泛型操作中通常会涉及到T之间的 equals, 比如下面我设计的一段代码,为了方便,我把`Point`的默认Equals也重写一下。 ```csharp class Program { static void Main(string[] args) { var p1 = new Point(1, 1); var p2 = new Point(1, 1); TProxy<Point> proxy = new TProxy<Point>() { Instance = p1 }; Console.WriteLine($"p1==p2 {proxy.IsEquals(p2)}"); Console.ReadLine(); } } public struct Point { public int x; public int y; public Point(int x, int y) { this.x = x; this.y = y; } public override bool Equals(object obj) { Console.WriteLine("我是通用的Equals"); return base.Equals(obj); } public bool Equals(Point other) { Console.WriteLine("我是自定义的Equals"); return this.x == other.x && this.y == other.y; } } public class TProxy<T> { public T Instance { get; set; } public bool IsEquals(T obj) { var b = Instance.Equals(obj); return b; } } ``` ![](https://img.tnblog.net/arcimg/hb/505b67ccebd443c88817ca231b87446c.png) tn2>从输出结果看,还是走了通用的equals方法,这就尴尬了,为什么会这样呢? ### 2. 从FCL的值类型实现上寻找问题 tn2>时候苦思冥想找不出问题,突然灵光一现,FCL中不也有一些自定义值类型吗? 比如 int,long,decimal,何不看它们是怎么实现的,寻找寻找灵感, 对吧。。。说干就干,把 int32 源码翻出来。 ```csharp public struct Int32 : IComparable, IFormattable, IConvertible, IComparable<int>, IEquatable<int> { public override bool Equals(object obj) { if (!(obj is int)) { return false; } return this == (int)obj; } public bool Equals(int obj) { return this == obj; } } ``` tn2>我去,还是int????,貌似我的Point就比int少了接口实现,问题应该就出在这里,而且最后一个泛型接口IEquatable<int>特别显眼,看下定义: ```csharp public interface IEquatable<T> { bool Equals(T other); } ``` tn2>这个泛型接口也仅仅只有一个`equals`方法,不过灵感告诉我,貌似。。。也许。。。应该。。。就是这个泛型的`equals`是用来解决泛型情况下的equals比较。 ### 3. 补上 IEquatable 接口 tn2>有了这个思路,我也跟FCL学,让Point实现 `IEquatable<T>`接口,然后在`TProxy<T>`代理类中约束下必须实现`IEquatable<T>`,修改代码如下: ```csharp public struct Point : IEquatable<Point> { ... } public class TProxy<T> where T: IEquatable<T> { ... } ``` tn2>然后将程序跑起来,如下图: ![](https://img.tnblog.net/arcimg/hb/da0127403831417684a02c90fd6402e4.png) tn2>????,虽然是成功了,但有一个地方让我不是很舒服,就是上面的第二行代码,在 `TProxy<T>` 处约束了`T`,因为我翻看`List`的实现也没做这样的泛型约束呀,可能有点强迫症吧,贴一下代码给大家看看。 ```csharp public class List<T> : IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable, IList, ICollection, IReadOnlyList<T>, IReadOnlyCollection<T> {} ``` tn2>然后我继续模仿List,把 `TProxy<T>` 上的T约束去掉,结果就出问题了,又回到了 通用`Equals`。 ![](https://img.tnblog.net/arcimg/hb/0ed1ce01c7ad4058abf98f83d2912feb.png) ### 4. 从List的Contains源码中寻找答案 tn2>好奇心再次驱使我寻找List中是如何做到的,为了能看到List中原生方法,修改代码如下,从`Contains`方法入手。 ```csharp var list = Enumerable.Range(0, 1000).Select(m => new Point(m, m)).ToList(); var item = list.Contains(new Point(int.MaxValue, int.MaxValue)); ---------- outout --------------- 我是自定义的Equals 我是自定义的Equals 我是自定义的Equals ... ``` tn2>我也是太好奇了,翻看下 `Contains` 的源码,简化后实现如下。 ```csharp public bool Contains(T item) { ... EqualityComparer<T> @default = EqualityComparer<T>.Default; for (int j = 0; j < _size; j++) { if (@default.Equals(_items[j], item)) {return true;} } return false; } ``` tn2>原来List是在进行`equals`比较之前,自己构建了一个泛型比较器`EqualityComparer<T>`,????,然后继续追一下代码。 ![](https://img.tnblog.net/arcimg/hb/89b5ef1a282248208b3d5cf1cdad0081.png) tn2>因为这里的`runtimeType`实现了`IEquatable<T>`接口,所以代码返回了一个泛型比较器:`GenericEqualityComparer<T>`,然后我们继续查看这个泛型比较器是咋样的。 ![](https://img.tnblog.net/arcimg/hb/6ae83085288d45ae85c3a4581dcc15ae.png) tn2>从图中可以看到最终还是对T进行了`IEquatable<T>`约束,不过这里给提取出来了,还是挺厉害的,然后我也学的模仿一下: ![](https://img.tnblog.net/arcimg/hb/9b9df11ee929442eb6f3ed42f40a5bdb.png) tn2>可以看到也走了我的自定义实现,两种方式大家都可以用哈。 最后要注意一点的是,当你重写了`Equals`之后,编译器会告知你最好也把`GetHashCode`重写一下,只是建议,如果看不惯这个提示,尽可能自定义`GetHashCode`方法让`hashcode`分布的均匀一点。 四:总结 ------------ tn2>一定要实现自定义值类型的 Equals方法,人家的 Equals方法是用来兜底的,一次比较两次装箱,对你的程序可是双杀哦??????。 tn>转载,一线码农:https://www.cnblogs.com/huangxincheng/p/12996361.html