这是我直到今天才注意到的事情。显然,当执行基于相等的操作时,大量使用的元组类(Tuple<T>
,Tuple<T1, T2>
等)的.NET实现会导致值类型的装箱惩罚。
以下是该类在框架中的实现方式(来自ILSpy的源代码):
public class Tuple<T1, T2> : IStructuralEquatable
{
public T1 Item1 { get; private set; }
public T2 Item2 { get; private set; }
public Tuple(T1 item1, T2 item2)
{
this.Item1 = item1;
this.Item2 = item2;
}
public override bool Equals(object obj)
{
return this.Equals(obj, EqualityComparer<object>.Default);
}
public override int GetHashCode()
{
return this.GetHashCode(EqualityComparer<object>.Default);
}
public bool Equals(object obj, IEqualityComparer comparer)
{
if (obj == null)
{
return false;
}
var tuple = obj as Tuple<T1, T2>;
return tuple != null
&& comparer.Equals(this.Item1, tuple.Item1)
&& comparer.Equals(this.Item2, tuple.Item2);
}
public int GetHashCode(IEqualityComparer comparer)
{
int h1 = comparer.GetHashCode(this.Item1);
int h2 = comparer.GetHashCode(this.Item2);
return (h1 << 5) + h1 ^ h2;
}
}
我看到的问题是它会导致两阶段装箱 - 取消装箱,例如Equals
个来电,一个,comparer.Equals
装箱物品,两个,EqualityComparer<object>
来电非通用 Equals
,而内部必须将项目拆分为原始类型。
相反,他们为什么不这样做:
public override bool Equals(object obj)
{
var tuple = obj as Tuple<T1, T2>;
return tuple != null
&& EqualityComparer<T1>.Default.Equals(this.Item1, tuple.Item1)
&& EqualityComparer<T2>.Default.Equals(this.Item2, tuple.Item2);
}
public override int GetHashCode()
{
int h1 = EqualityComparer<T1>.Default.GetHashCode(this.Item1);
int h2 = EqualityComparer<T2>.Default.GetHashCode(this.Item2);
return (h1 << 5) + h1 ^ h2;
}
public bool Equals(object obj, IEqualityComparer comparer)
{
var tuple = obj as Tuple<T1, T2>;
return tuple != null
&& comparer.Equals(this.Item1, tuple.Item1)
&& comparer.Equals(this.Item2, tuple.Item2);
}
public int GetHashCode(IEqualityComparer comparer)
{
int h1 = comparer.GetHashCode(this.Item1);
int h2 = comparer.GetHashCode(this.Item2);
return (h1 << 5) + h1 ^ h2;
}
我很惊讶地看到在.NET元组类中以这种方式实现了相等性。我在其中一个词典中使用元组类型作为键。
是否有任何理由必须如第一段代码所示实现?在这种情况下使用此类有点令人沮丧。
我认为代码重构和非重复数据应该是主要问题。同样的非通用/装箱实现也落后于IStructuralComparable
,但由于IStructuralComparable.CompareTo
使用较少,因此通常不会出现问题。
我使用第三种方法对上述两种方法进行了基准测试,这种方法仍然不那么费力,像这样(只有必需品):
public override bool Equals(object obj)
{
return this.Equals(obj, EqualityComparer<T1>.Default, EqualityComparer<T2>.Default);
}
public bool Equals(object obj, IEqualityComparer comparer)
{
return this.Equals(obj, comparer, comparer);
}
private bool Equals(object obj, IEqualityComparer comparer1, IEqualityComparer comparer2)
{
var tuple = obj as Tuple<T1, T2>;
return tuple != null
&& comparer1.Equals(this.Item1, tuple.Item1)
&& comparer2.Equals(this.Item2, tuple.Item2);
}
对于几个Tuple<DateTime, DateTime>
字段进行1000000次Equals
次呼叫。这是结果:
第一种方法(原始.NET实现) - 310 ms
第二种方法 - 60 ms
第3种方法 - 130 ms
默认实施比最佳解决方案慢约4-5倍。
答案 0 :(得分:10)
你想知道是否必须以这种方式实施。简而言之,我会说不:有许多功能相同的实现。
但是为什么现有的实现明确使用EqualityComparer<object>.Default
?这可能只是一个人写了这个心理优化的'错误'的情况,或者至少不同于你在内循环中的速度情景。根据他们的基准,它可能看起来是“正确的”。
但是什么基准测试场景可能导致他们做出这样的选择呢?他们所针对的优化似乎是针对最小数量的EqualityComparer类模板实例进行优化。他们可能会选择这个,因为模板实例化会带来内存或加载时间成本。如果是这样,我们可以猜测他们的基准测试场景可能是基于app-startup-time或内存使用而不是一些紧凑的循环场景。
这是支持该理论的一个知识点(通过使用确认偏差找到:) - 如果T是结构,则无法共享EqualityComparer实现方法体。摘自http://blogs.microsoft.co.il/sasha/2012/09/18/runtime-representation-of-genericspart-2/
当CLR需要创建一个封闭泛型类型的实例时, 比如List,它创建了一个方法表和基于的EEClass 开放式。与往常一样,方法表包含方法指针,其中 由JIT编译器即时编译。但是,有一个 这里的关键优化:关闭泛型的编译方法体 可以共享具有引用类型参数的类型。 [...] 相同 想法不适用于价值类型。例如,当T很长时, 赋值语句项[size] = item需要不同的 指令,因为必须复制8个字节而不是4个。甚至更大 值类型甚至可能需要多个指令;等等。