27

这是我直到今天才注意到的。显然,当执行基于相等的操作时Tuple<T>,经常使用的元组类(等)的 .NET 实现会导致值类型Tuple<T1, T2>的装箱惩罚。

以下是该类在框架中的实现方式(来自 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 毫秒

第二种方法 - 60 毫秒

第三种方法 - 130 毫秒

默认实现比最优解决方案慢大约 4-5 倍。

4

1 回答 1

13

您想知道它是否“必须”以这种方式实施。简而言之,我会说不:有许多功能等效的实现。

但是为什么现有的实现会如此明确地使用EqualityComparer<object>.Default? 这可能只是写这篇文章的人在心理上针对“错误”进行优化的人的情况,或者至少与您在内部循环中的速度场景不同。根据他们的基准,它可能看起来是“正确”的事情。

但是,什么样的基准情景会导致他们做出这样的选择呢?好吧,他们所针对的优化似乎是针对最少数量的 EqualityComparer 类模板实例化进行优化。他们可能会选择这个,因为模板实例化会带来内存或加载时间成本。如果是这样,我们可以猜测他们的基准场景可能是基于应用程序启动时间或内存使用情况,而不是一些紧密循环的场景。

这是支持该理论的一个知识点(通过使用确认偏差发现:) -如果 T 是 struct ,则无法共享 EqualityComparer 实现方法体。摘自http://blogs.microsoft.co.il/sasha/2012/09/18/runtime-representation-of-genericspart-2/

当 CLR 需要创建一个封闭的泛型类型的实例时,例如 List,它会基于开放的类型创建一个方法表和 EEClass。与往常一样,方法表包含由 JIT 编译器动态编译的方法指针。但是,这里有一个关键的优化:可以共享在具有引用类型参数的封闭泛型类型上编译的方法体。[...]同样的想法不适用于值类型。例如,当 T 很长时,赋值语句 items[size] = item 需要不同的指令,因为必须复制 8 个字节而不是 4 个字节。更大的值类型甚至可能需要多条指令;等等。

于 2014-01-22T17:18:39.060 回答