22

自定义值类型一定不要忘了重写Equals,否则性能和空间双双堪忧

 4 years ago
source link: http://www.cnblogs.com/huangxincheng/p/12996361.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

一:背景

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看一下。

NjMrAfM.png!web

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 也需要装箱一次。

VRzq6bZ.png!web

<3> 有两种比较方式,要么采用 FastEqualsCheck 比较,要么采用 反射 比较,我去.... 反射就玩大了。

综合来看确实没毛病, equals 会把比较的两个对象都进行装箱。

2. 改进方案

问题找到了,解决起来就简单了,不走这个通用的 equals 不就行啦,我自定义一个equals方法,然后跑一下代码。

public bool Equals(Point other)
        {
            return this.x == other.x && this.y == other.y;
        }

mInYBfM.png!web

可以看到走了我的自定义的Equals,:cow::nose:。 貌似问题就这样简单粗暴的解决了,真开心,打脸时刻开始。。。

三:真的解决问题了吗?

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;
        }
    }

3mQryqR.png!web

从输出结果看,还是走了通用的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:cow::nose:,貌似我的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> { ... }

然后将程序跑起来,如下图:

b2u6zq7.png!web

:cow::nose:,虽然是成功了,但有一个地方让我不是很舒服,就是上面的第二行代码,在 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

jaER3um.png!web

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> ,:cow::nose:,然后继续追一下代码。

JBfYbiI.png!web

因为这里的 runtimeType 实现了 IEquatable<T> 接口,所以代码返回了一个泛型比较器: GenericEqualityComparer<T> ,然后我们继续查看这个泛型比较器是咋样的。

UF3Uvay.png!web

从图中可以看到最终还是对 T 进行了 IEquatable<T> 约束,不过这里给提取出来了,还是挺厉害的,然后我也学的模仿一下:

FNBzEnR.png!web

可以看到也走了我的自定义实现,两种方式大家都可以用哈:grin::grin::grin:。

最后要注意一点的是,当你重写了 Equals 之后,编译器会告知你最好也把 GetHashCode 重写一下,只是建议,如果看不惯这个提示,尽可能自定义 GetHashCode 方法让 hashcode 分布的均匀一点。

四:总结

一定要实现自定义值类型的 Equals 方法,人家的 Equals 方法是用来兜底的,一次比较两次装箱,对你的程序可是双杀哦:grin::grin::grin:。

如您有更多问题与我互动,扫描下方进来吧~

baaMbyv.png!web

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK