ValueType与判等

在C#中类型分为值类型和引用类型,对于引用类型,如果我们有一大堆对象想统一存放,一般会用Object,对于值类型我们则是多了一种选择,那就是用ValueType来存放。

而对于值类型,因为其非常基本(比如int),我们经常遇到值类型判等的操作,比如我们经常用==来判断两个整数是否相等,就是如此。但是当我们手里没有int这种具体的类型,只有valueType对象时,我们要判等就可能遇到性能的问题,因为它出现在系统底层部分的概率比较大。

对于ValueType对象之间的判等,我们一般可以用以下两种方式:

  1. 调用Object.Equals
  2. 强制类型转换之后调用==运算符。

Benchmark测试

首先,我们简单地针对int来进行一个Benchmark,看看到底有没有差别,以int之间的比较作为参照,代码如下:

using System;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

var summary = BenchmarkRunner.Run(typeof(Program).Assembly);
Console.WriteLine(summary);

public class UnitTestCompare
{
    public ValueType ValueA { get; set; }
    public ValueType ValueB { get; set; }
    public Object ObjectA { get; set; }
    public Object ObjectB { get; set; }
    public int NumA { get; set; }
    public int NumB { get; set; }
    [GlobalSetup]
    public void SetUp()
    {
        ValueA = new Random().Next(int.MinValue, int.MaxValue);
        ValueB = new Random().Next(int.MinValue, int.MaxValue);
        ObjectA = new Random().Next(int.MinValue, int.MaxValue);
        ObjectB = new Random().Next(int.MinValue, int.MaxValue);
        NumA = new Random().Next(int.MinValue, int.MaxValue);
        NumB = new Random().Next(int.MinValue, int.MaxValue);
    }
    [Benchmark]
    public void OriginalTypeEqual()
    {
        var res = NumA == NumB;
    }
    [Benchmark]
    public void ObjectEqual()
    {
        var res = ObjectA.Equals(ObjectB);
    }
    [Benchmark]
    public void ValueTypeEqual()
    {
        var res = ValueA.Equals(ValueB);
    }
    [Benchmark]
    public void CastingEqual()
    {
        var res = (int)ValueA == (int)ValueB;
    }
}

得到的结果如下表所示,可以看出,直接用int比较肯定最快,Object Equal和ValueType Equal差不多,但相比强制转换的方式还是有不小的一个差距,强转的方式比调用Object.Equals要快了4倍。

Method Mean Error StdDev
OriginalTypeEqual 0.0085 ns 0.0060 ns 0.0056 ns
ObjectEqual 2.6131 ns 0.0154 ns 0.0137 ns
ValueTypeEqual 2.6160 ns 0.0213 ns 0.0199 ns
CastingEqual 0.5021 ns 0.0049 ns 0.0041 ns

造成这个的原因,姑且放到一边,我们再测一下double看看,代码和上面几乎一样,这里只给出结果:

Method Mean Error StdDev Median
OriginalTypeEqual 0.0009 ns 0.0018 ns 0.0028 ns 0.0000 ns
ObjectEqual 3.2757 ns 0.0146 ns 0.0129 ns 3.2741 ns
ValueTypeEqual 3.2970 ns 0.0298 ns 0.0279 ns 3.2952 ns
CastingEqual 0.4746 ns 0.0020 ns 0.0017 ns 0.4745 ns

Object Equal的方式耗时略多一点,其它基本一致,然后我们再试试自定义结构体的对比,首先,我们使用默认的Object.Equals,结构体定义代码如下,其它部分基本不变,只改变一下类型和初始化:

public struct SomeValue
{
    public double ValueA { get; set; }
    public int ValueB { get; set; }
    public bool ValueC { get; set; }
    public static bool operator ==(SomeValue lhs, SomeValue rhs)
    {
        return lhs.ValueA == rhs.ValueA && lhs.ValueB == rhs.ValueB && lhs.ValueC == rhs.ValueC;
    }
    public static bool operator !=(SomeValue lhs, SomeValue rhs)
    {
        return (lhs == rhs) is false;
    }
    public override bool Equals([NotNullWhen(true)] object? obj)
    {
        return base.Equals(obj);
    }
    public override int GetHashCode()
    {
        return base.GetHashCode();
    }
}

结果让人有点大跌眼镜,因为”Object Equal“和“ValueType Equal”的方式慢了几十倍:

Method Mean Error StdDev Median
OriginalTypeEqual 1.614 ns 0.1385 ns 0.4085 ns 1.399 ns
ObjectEqual 88.717 ns 1.7834 ns 3.4361 ns 88.306 ns
ValueTypeEqual 87.483 ns 1.7687 ns 2.4795 ns 86.061 ns
CastingEqual 1.749 ns 0.0301 ns 0.0281 ns 1.748 ns

分析

产生这种差异的原因是什么呢?首先,对于Object.Equals要明白这是一个属于Object的虚函数,并且提供了一套默认实现,我们从源码中找出这个默认实现:

public virtual bool Equals(object? obj)
{
    return this == obj;
}

里面实际上调用了operator==,但这个没有体现在代码中,这个是编译器提供的。

再看Int32实现的Equals方法(Double也是类似的实现,这里不给出):

public override bool Equals([NotNullWhen(true)] object? obj)
{
    if (!(obj is int))
    {
        return false;
    }
    return m_value == ((int)obj).m_value;
}

可以看出实际上是调用了等于号来进行比较的,而等于号则是编译器给添加的实现。

进行到这里,int等基本类型为什么强转比较快就很显然了,因为Equals方法里面还是调用了强转,那么额外的那一点开销就造成了速度上的差异。

但是,为什么对于自定义结构体+默认的Equals方法会慢那么多,还不甚清楚,再看ValueType重写后的Equals方法:

[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075:UnrecognizedReflectionPattern",
            Justification = "Trimmed fields don't make a difference for equality")]
public override unsafe bool Equals([NotNullWhen(true)] object? obj)
{
    if (null == obj)
    {
        return false;
    }

    if (GetType() != obj.GetType())
    {
        return false;
    }

    // if there are no GC references in this object we can avoid reflection
    // and do a fast memcmp
    if (CanCompareBits(this))
    {
        return SpanHelpers.SequenceEqual(
            ref RuntimeHelpers.GetRawData(this),
            ref RuntimeHelpers.GetRawData(obj),
            RuntimeHelpers.GetMethodTable(this)->GetNumInstanceFieldBytes());
    }

    FieldInfo[] thisFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);

    for (int i = 0; i < thisFields.Length; i++)
    {
        object? thisResult = thisFields[i].GetValue(this);
        object? thatResult = thisFields[i].GetValue(obj);

        if (thisResult == null)
        {
            if (thatResult != null)
                return false;
        }
        else
        if (!thisResult.Equals(thatResult))
        {
            return false;
        }
    }

    return true;
}

大意就是如果是值类型(没有GC引用),那么就直接按照比特进行比较,反之就通过反射拿到fields然后依次比较,可以看出还是比较多的操作的。

如果没有后面的意外,可能就到此为止了……意外就是,我把自定义结构体的struct换成class,一下子就变快了,如下表所示,由于class对象不能转ValueType,所以只有三个。

Method Mean Error StdDev
OriginalTypeEqual 1.411 ns 0.0078 ns 0.0073 ns
ObjectEqual 2.045 ns 0.0250 ns 0.0234 ns
CastingEqual 2.108 ns 0.0274 ns 0.0243 ns

可以看出,速度已经基本一致。

这里究竟为什么会这样,现在还不是很清楚,等仔细学习CLR之后再来更新一下,目前猜测是值类型装箱拆箱的损耗问题。

总结

(待更新)

对于基本数据类型(int等),在性能要求极高的场景应该选用强转再比较的方式。

对于自己定义的struct,应该重写一下Equals函数,改成强转之后按成员比较。

对于自己定义的class,可以直接调用Equals,毕竟都用到class了,应该不至于在乎这么一点点性能,对吧?