一:背景
1. 講故事
曾今在專案中發現有同事自定義結構體的時候,居然沒有重寫Equals方法,比如下面這段程式碼:
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;
}
}
這程式碼貌似也沒啥什麼問題,好像大家平時也是這麼寫,沒關係,有沒有問題,跑一下再用windbg看一下。
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
從上面的輸出不知道你看出問題了沒有? 託管堆上居然有2000個Point,而且還可以用 !do
打出來,說明這些都是引用型別。。。這些引用型別哪裡來的? 看程式碼應該是 equals
比較時產生的,一次比較就有2個point被裝箱放到託管堆上,這下慘了,,,而且大家應該知道引用物件本身還有(8+8) byte
自帶開銷,這在時間和空間上都是巨大的浪費呀。。。
二: 探究預設的Equals實現
1. 尋找ValueType的Equals實現
為什麼會這樣呢? 我們知道equals
是繼承自ValueType
的,所以把 ValueType
翻出來看看便知:
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;
}
}
從上面程式碼中可以看出有如下三點資訊:
<1> 通用的 equals
方法接收object型別,引數裝箱一次。
<2> CanCompareBits,FastEqualsCheck
都是採用object型別,this
也需要裝箱一次。
<3> 有兩種比較方式,要麼採用 FastEqualsCheck
比較,要麼採用反射
比較,我去.... 反射就玩大了。
綜合來看確實沒毛病, equals
會把比較的兩個物件都進行裝箱。
2. 改進方案
問題找到了,解決起來就簡單了,不走這個通用的 equals 不就行啦,我自定義一個equals方法,然後跑一下程式碼。
public bool Equals(Point other)
{
return this.x == other.x && this.y == other.y;
}
可以看到走了我的自定義的Equals,??。 貌似問題就這樣簡單粗暴的解決了,真開心,打臉時刻開始。。。
三:真的解決問題了嗎?
1. 遇到問題
很多時候我們會定義各種泛型類,在泛型操作中通常會涉及到T之間的 equals, 比如下面我設計的一段程式碼,為了方便,我把Point
的預設Equals也重寫一下。
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;
}
}
從輸出結果看,還是走了通用的equals方法,這就尷尬了,為什麼會這樣呢?
2. 從FCL的值型別實現上尋找問題
有時候苦思冥想找不出問題,突然靈光一現,FCL中不也有一些自定義值型別嗎? 比如 int,long,decimal
,何不看它們是怎麼實現的,尋找尋找靈感, 對吧。。。說幹就幹,把 int32
原始碼翻出來。
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;
}
}
我去,還是int??,貌似我的Point就比int少了介面實現,問題應該就出在這裡,而且最後一個泛型介面IEquatable<int>
特別顯眼,看下定義:
public interface IEquatable<T>
{
bool Equals(T other);
}
這個泛型介面也僅僅只有一個equals
方法,不過靈感告訴我,貌似。。。也許。。。應該。。。就是這個泛型的equals
是用來解決泛型情況下的equals
比較。
3. 補上 IEquatable 介面
有了這個思路,我也跟FCL學,讓Point實現 IEquatable<T>
介面,然後在TProxy<T>
代理類中約束下必須實現IEquatable<T>
,修改程式碼如下:
public struct Point : IEquatable<Point> { ... }
public class TProxy<T> where T: IEquatable<T> { ... }
然後將程式跑起來,如下圖:
??,雖然是成功了,但有一個地方讓我不是很舒服,就是上面的第二行程式碼,在 TProxy<T>
處約束了T
,因為我翻看List
的實現也沒做這樣的泛型約束呀,可能有點強迫症吧,貼一下程式碼給大家看看。
public class List<T> : IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable, IList, ICollection, IReadOnlyList<T>, IReadOnlyCollection<T>
{}
然後我繼續模仿List,把 TProxy<T>
上的T約束去掉,結果就出問題了,又回到了 通用Equals
。
4. 從List的Contains原始碼中尋找答案
好奇心再次驅使我尋找List中是如何做到的,為了能看到List中原生方法,修改程式碼如下,從Contains
方法入手。
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
...
我也是太好奇了,翻看下 Contains
的原始碼,簡化後實現如下。
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;
}
原來List是在進行 equals
比較之前,自己構建了一個泛型比較器EqualityComparer<T>
,??,然後繼續追一下程式碼。
因為這裡的runtimeType
實現了IEquatable<T>
介面,所以程式碼返回了一個泛型比較器:GenericEqualityComparer<T>
,然後我們繼續檢視這個泛型比較器是咋樣的。
從圖中可以看到最終還是對T
進行了IEquatable<T>
約束,不過這裡給提取出來了,還是挺厲害的,然後我也學的模仿一下:
可以看到也走了我的自定義實現,兩種方式大家都可以用哈???。
最後要注意一點的是,當你重寫了Equals
之後,編譯器會告知你最好也把 GetHashCode
重寫一下,只是建議,如果看不慣這個提示,儘可能自定義GetHashCode
方法讓hashcode
分佈的均勻一點。
四:總結
一定要實現自定義值型別的 Equals
方法,人家的 Equals
方法是用來兜底的,一次比較兩次裝箱,對你的程式可是雙殺哦???。