ValueType与判等
在C#中类型分为值类型和引用类型,对于引用类型,如果我们有一大堆对象想统一存放,一般会用Object
,对于值类型我们则是多了一种选择,那就是用ValueType
来存放。
而对于值类型,因为其非常基本(比如int),我们经常遇到值类型判等的操作,比如我们经常用==
来判断两个整数是否相等,就是如此。但是当我们手里没有int
这种具体的类型,只有valueType
对象时,我们要判等就可能遇到性能的问题,因为它出现在系统底层部分的概率比较大。
对于ValueType
对象之间的判等,我们一般可以用以下两种方式:
- 调用
Object.Equals
。 - 强制类型转换之后调用
==
运算符。
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了,应该不至于在乎这么一点点性能,对吧?