平等和多态性

25

有两个不可变的类Base和Derived(派生自Base),我想定义相等性,使得:

  • 相等性始终是多态的 - 也就是说,((Base)derived1).Equals((Base)derived2)将调用Derived.Equals

  • 操作符==!=将调用Equals而不是ReferenceEquals(值相等性)

我的做法:

class Base: IEquatable<Base> {
  public readonly ImmutableType1 X;
  readonly ImmutableType2 Y;

  public Base(ImmutableType1 X, ImmutableType2 Y) { 
    this.X = X; 
    this.Y = Y; 
  }

  public override bool Equals(object obj) {
    if (object.ReferenceEquals(this, obj)) return true;
    if (obj is null || obj.GetType()!=this.GetType()) return false;

    return obj is Base o 
      && X.Equals(o.X) && Y.Equals(o.Y);
  }

  public override int GetHashCode() => HashCode.Combine(X, Y);

  // boilerplate
  public bool Equals(Base o) => object.Equals(this, o);
  public static bool operator ==(Base o1, Base o2) => object.Equals(o1, o2);
  public static bool operator !=(Base o1, Base o2) => !object.Equals(o1, o2);    }

所有内容最终都会进入Equals(object),该方法始终是多态的,因此两个目标都可以实现。

然后我像这样派生:

class Derived : Base, IEquatable<Derived> {
  public readonly ImmutableType3 Z;
  readonly ImmutableType4 K;

  public Derived(ImmutableType1 X, ImmutableType2 Y, ImmutableType3 Z, ImmutableType4 K) : base(X, Y) {
    this.Z = Z; 
    this.K = K; 
  }

  public override bool Equals(object obj) {
    if (object.ReferenceEquals(this, obj)) return true;
    if (obj is null || obj.GetType()!=this.GetType()) return false;

    return obj is Derived o
      && base.Equals(obj) /* ! */
      && Z.Equals(o.Z) && K.Equals(o.K);
  }

  public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), Z, K);

  // boilerplate
  public bool Equals(Derived o) => object.Equals(this, o);
}

基本相同,唯一的不同之处在于一个小技巧 - 调用base.Equals时,我调用了base.Equals(object)而不是base.Equals(Derived)(这会导致无限递归)。

此实现中的Equals(C)也会进行一些装箱/拆箱操作,但对于我来说这是值得的。

我的问题是 -

首先,这样做是否正确?我的测试似乎表明正确,但由于C#在相等性方面非常困难,我已经不确定了...是否存在任何情况下这种方法是错误的?

其次,这样做好吗?是否有更好、更清晰的方法可以实现这一点?


4
我认为这里存在一个根本性问题,就是Base.Equals(Object)可以接受Derived的实例,如果XY相等,那么这两个实例将是相等的,而完全忽略了Derived更多的内容。也就是说,new Base(1, 2).Equals(new Derived(1, 2, 3, 4))会返回true,这让我很难理解为什么要称之为"多态"。如果一个对象只能与同一类型的另一个实例相等,但是可以从一个共同的基类进行比较,这感觉更加正确,并且可以极大地简化问题。 - madreflection
7
小心 - 你的 XYZK 变量是可变的,这对于覆盖 GetHashCode 是不好的。哈希码应该永远不会改变。 - Enigmativity
3
readonly并不等同于"immutable"(不可变)。如果您使用来自readonly变量的哈希码,并且它们反过来又从其可变属性计算出哈希码,那么您仍然处于同样的位置。您需要确保哈希码在整个对象模型中都不会发生变化。 - Enigmativity
8
我没有足够的时间仔细审查代码,但我会指出两件事情:(1)你是对的,C#中这个问题比我们想象中的要难; (2)确保你的测试用例测试所有相等的必要属性:a==bb==a一致,a==a始终为真,且支持传递性;如果a==bb==c为真,则a==c必须为真。很多相等性的实现都无法满足这些标准,然后糟糕的事情就会发生。 - Eric Lippert
5
一个最近的教育例子是 https://stackoverflow.com/questions/54025578/need-help-understanding-unexpected-behavior-using-linq-join-with-hashsett/54028123#54028123 -- 注意发布者的评论,他们说错误在于他们的相等性实现上,尽管事实上它不符合传递性要求,但他们坚称它是正确的。如果你不正确地实现相等性,会发生糟糕的事情。 - Eric Lippert
显示剩余15条评论
4个回答

10

我想你的问题可以分成两个部分:

  1. 在嵌套层次中执行equals操作
  2. 限制相等的类型

这个链接能解决你的问题吗?https://dotnetfiddle.net/eVLiMZ (因为在dotnetfiddle上无法编译,所以我使用了一些较旧的语法)。

using System;


public class Program
{
    public class Base
    {
        public string Name { get; set; }
        public string VarName { get; set; }

        public override bool Equals(object o)
        {
            return object.ReferenceEquals(this, o) 
                || o.GetType()==this.GetType() && ThisEquals(o);
        }

        protected virtual bool ThisEquals(object o)
        {
            Base b = o as Base;
            return b != null
                && (Name == b.Name);
        }

        public override string ToString()
        {
            return string.Format("[{0}@{1} Name:{2}]", GetType(), VarName, Name);
        }

        public override int GetHashCode()
        {
            return Name.GetHashCode();
        }
    }

    public class Derived : Base
    {
        public int Age { get; set; }

        protected override bool ThisEquals(object o)
        {
            var d = o as Derived;
            return base.ThisEquals(o)
                && d != null
                && (d.Age == Age);
        }

        public override string ToString()
        {
            return string.Format("[{0}@{1} Name:{2} Age:{3}]", GetType(), VarName, Name, Age);
        }

        public override int GetHashCode()
        {
            return base.GetHashCode() ^ Age.GetHashCode();
        }
    }

    public static void Main()
    {
        var b1 = new Base { Name = "anna", VarName = "b1" };
        var b2 = new Base { Name = "leo", VarName = "b2" };
        var b3 = new Base { Name = "anna", VarName = "b3" };
        var d1 = new Derived { Name = "anna", Age = 21, VarName = "d1" };
        var d2 = new Derived { Name = "anna", Age = 12, VarName = "d2" };
        var d3 = new Derived { Name = "anna", Age = 21, VarName = "d3" };

        var all = new object [] { b1, b2, b3, d1, d2, d3 };

        foreach(var a in all) 
        {
            foreach(var b in all)
            {
                Console.WriteLine("{0}.Equals({1}) => {2}", a, b, a.Equals(b));
            }
        }
    }
}


谢谢@aiodintsov,我看到你添加了一个ThisEquals方法..它与上面的解决方案有什么不同或更好的地方吗? - kofifus
3
我的方案之所以值得选择,是因为代码更简洁、易读性更高,关注点分离、不会出现混乱的代码结构,使用基本方法而非通用方法,无需支持类,能够自我包含于层次结构中,操作直接明了。但每个人都不同,经常没有一个正确答案,往往取决于目标。 - aiodintsov
1
@kofifus 它基本上是基于模板方法设计模式。 - aiodintsov
1
我个人不确定类型是否必须匹配,通常情况下,由于D是B,从B的角度来看,在额外字段中并没有什么区别。实际上,既然我说了这个,明天我可能不得不重新考虑我的答案。除非您为equals定义了一个导致无限循环的重载,否则我无法看出这会成为问题。Equals本来就是多态的。 - aiodintsov
啊,我明白了...这样可以避免你在派生类中定义Equals方法。 - kofifus
是的,但从一个 ImmutableList<Base> 的角度来看。 - kofifus

8
此比较方法使用反射,相比扩展方法更简单,而且保持了私有成员的私密性。
所有逻辑都在IImmutableExtensions类中。它仅查看哪些字段是只读的,并将其用于比较。
您不需要在基类或派生类中编写对象比较的方法。只需在重载 == != Equals()时调用扩展方法 ImmutableEquals 即可。哈希码同理。
public class Base : IEquatable<Base>, IImmutable
{
    public readonly ImmutableType1 X;
    readonly ImmutableType2 Y;

    public Base(ImmutableType1 X, ImmutableType2 Y) => (this.X, this.Y) = (X, Y);

    // boilerplate
    public override bool Equals(object obj) => this.ImmutableEquals(obj);
    public bool Equals(Base o) => this.ImmutableEquals(o);
    public static bool operator ==(Base o1, Base o2) => o1.ImmutableEquals(o2);
    public static bool operator !=(Base o1, Base o2) => !o1.ImmutableEquals(o2);
    private int? _hashCache;
    public override int GetHashCode() => this.ImmutableHash(ref _hashCache);
}

public class Derived : Base, IEquatable<Derived>, IImmutable
{
    public readonly ImmutableType3 Z;
    readonly ImmutableType4 K;

    public Derived(ImmutableType1 X, ImmutableType2 Y, ImmutableType3 Z, ImmutableType4 K) : base(X, Y) => (this.Z, this.K) = (Z, K);

    public bool Equals(Derived other) => this.ImmutableEquals(other);
}

还有 IImmutableExtensions 类:

public static class IImmutableExtensions
{
    public static bool ImmutableEquals(this IImmutable o1, object o2)
    {
        if (ReferenceEquals(o1, o2)) return true;
        if (o2 is null || o1.GetType() != o2.GetType() || o1.GetHashCode() != o2.GetHashCode()) return false;

        foreach (var tProp in GetImmutableFields(o1))
        {
            var test = tProp.GetValue(o1)?.Equals(tProp.GetValue(o2));
            if (test is null) continue;
            if (!test.Value) return false;
        }
        return true;
    }

    public static int ImmutableHash(this IImmutable o, ref int? hashCache)
    {
        if (hashCache is null)
        {
            hashCache = 0;

            foreach (var tProp in GetImmutableFields(o))
            {
                hashCache = HashCode.Combine(hashCache.Value, tProp.GetValue(o).GetHashCode());
            }
        }
        return hashCache.Value;
    }

    private static IEnumerable<FieldInfo> GetImmutableFields(object o)
    {
        var t = o.GetType();
        do
        {
            var fields = t.GetFields(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Where(field => field.IsInitOnly);

            foreach(var field in fields)
            {
                yield return field;
            }
        }
        while ((t = t.BaseType) != typeof(object));
    }
}

旧回答:(仅供参考)

根据你所说的需要转换为 object,我想到了在从派生类中调用方法 Equals(object)Equals(Base) 时过于模糊。

这让我认为逻辑应该移出这两个类,移到一个更好描述我们意图的方法中。

等式仍将作为基类中的多态性保留,因为 ImmutableEquals 会调用重写的 ValuesEqual。这是您可以在每个派生类中决定如何比较等式的地方。

这是实现此目标重构后的代码。

修订后的答案:

我想到了,如果我们只提供包含我们想要比较的不可变字段的元组,那么我们在 IsEqual()GetHashCode() 中的所有逻辑都能够工作。这避免了在每个类中复制那么多的代码。

创建派生类的开发人员需要重写 GetImmutableTuple()。我认为这是最小的恶中取其一,而无需使用反射(请参见其他答案)。

public class Base : IEquatable<Base>, IImmutable
{
    public readonly ImmutableType1 X;
    readonly ImmutableType2 Y;

    public Base(ImmutableType1 X, ImmutableType2 Y) => 
      (this.X, this.Y) = (X, Y);

    protected virtual IStructuralEquatable GetImmutableTuple() => (X, Y);

    // boilerplate
    public override bool Equals(object o) => IsEqual(o as Base);
    public bool Equals(Base o) => IsEqual(o);
    public static bool operator ==(Base o1, Base o2) => o1.IsEqual(o2);
    public static bool operator !=(Base o1, Base o2) => !o1.IsEqual(o2);
    public override int GetHashCode() => hashCache is null ? (hashCache = GetImmutableTuple().GetHashCode()).Value : hashCache.Value;
    protected bool IsEqual(Base obj) => ReferenceEquals(this, obj) || !(obj is null) && GetType() == obj.GetType() && GetHashCode() == obj.GetHashCode() && GetImmutableTuple() != obj.GetImmutableTuple();
    protected int? hashCache;
}

public class Derived : Base, IEquatable<Derived>, IImmutable
{
    public readonly ImmutableType3 Z;
    readonly ImmutableType4 K;

    public Derived(ImmutableType1 X, ImmutableType2 Y, ImmutableType3 Z, ImmutableType4 K) : base(X, Y) => 
      (this.Z, this.K) = (Z, K);

    protected override IStructuralEquatable GetImmutableTuple() => (base.GetImmutableTuple(), K, Z);

    // boilerplate
    public bool Equals(Derived o) => IsEqual(o);
}

我对你的回答进行了一些编辑。另外,为什么要将IsEqual单独出来?你可以将它的代码放在Equals(object obj)中,然后让Equals(Base o)和Equals(Derived o)调用object.Equals(this, o)。 - kofifus
1
不会的,使用 || 运算符时,如果左侧表达式为真,则右侧表达式不会被计算。https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/conditional-or-operator. - Jerry
1
我看不到进一步简化的方法,它符合您的所有标准。它是多态的,代码相对容易阅读,派生类中没有重复的样板代码,实现了“IEquatable”并覆盖了“Equals(object)”,“==”和“!=”,并基于您的不可变对象的值进行比较。在派生类中,您唯一需要声明的是用于比较的变量。 - Jerry
1
在某个时候,必须从“对象”转换为“基类”,使用此解决方案可以在锅炉代码中执行一次,不必重复执行。 - Jerry
1
它只查看只读字段(不可变)。虽然我同意,但我尝试的所有东西都是简单性和性能之间的权衡。最快的解决方案与您当前拥有的解决方案非常相似。反射比较具有相同不可变值的对象执行速度约为每秒1000次,而元组和显式解决方案则为每秒13000次(6700k i7)。当不可变值不同时,它们都表现出类似的性能,因为它会在哈希码处短路。 - Jerry
显示剩余15条评论

6

通过使用扩展方法和一些模板代码,可以简化代码。这几乎消除了所有的麻烦,让类集中于比较它们的实例,而无需处理所有特殊的边缘情况:

namespace System {
  public static partial class ExtensionMethods {
    public static bool Equals<T>(this T inst, object obj, Func<T, bool> thisEquals) where T : IEquatable<T> =>
      object.ReferenceEquals(inst, obj) // same reference ->  equal
      || !(obj is null) // this is not null but obj is -> not equal
      && obj.GetType() == inst.GetType() // obj is more derived than this -> not equal
      && obj is T o // obj cannot be cast to this type -> not equal
      && thisEquals(o);
  }
}

我现在可以做到:

class Base : IEquatable<Base> {
    public SomeType1 X;
    SomeType2 Y;
    public Base(SomeType1 X, SomeType2 Y) => (this.X, this.Y) = (X, Y);

    public bool ThisEquals(Base o) => (X, Y) == (o.X, o.Y);

    // boilerplate
    public override bool Equals(object obj) => this.Equals(obj, ThisEquals);
    public bool Equals(Base o) => object.Equals(this, o);
    public static bool operator ==(Base o1, Base o2) => object.Equals(o1, o2);
    public static bool operator !=(Base o1, Base o2) => !object.Equals(o1, o2);
}


class Derived : Base, IEquatable<Derived> {
    public SomeType3 Z;
    SomeType4 K;
    public Derived(SomeType1 X, SomeType2 Y, SomeType3 Z, SomeType4 K) : base(X, Y) => (this.Z, this.K) = (Z, K);

    public bool ThisEquals(Derived o) => base.ThisEquals(o) && (Z, K) == (o.Z, o.K);

    // boilerplate
    public override bool Equals(object obj) => this.Equals(obj, ThisEquals);
    public bool Equals(Derived o) => object.Equals(this, o);
}

这很好,没有强制类型转换或空值检查,所有真正的工作都清晰地分离在ThisEquals函数中。
(测试)


对于不可变类,可以通过缓存哈希码并在Equals中使用它来优化,以便在哈希码不同时快速判断相等性:

namespace System.Immutable {
  public interface IImmutableEquatable<T> : IEquatable<T> { };

  public static partial class ExtensionMethods {
    public static bool ImmutableEquals<T>(this T inst, object obj, Func<T, bool> thisEquals) where T : IImmutableEquatable<T> =>
      object.ReferenceEquals(inst, obj) // same reference ->  equal
      || !(obj is null) // this is not null but obj is -> not equal
      && obj.GetType() == inst.GetType() // obj is more derived than this -> not equal
      && inst.GetHashCode() == obj.GetHashCode() // optimization, hash codes are different -> not equal
      && obj is T o // obj cannot be cast to this type -> not equal
      && thisEquals(o);

    public static int GetHashCode<T>(this T inst, ref int? hashCache, Func<int> thisHashCode) where T : IImmutableEquatable<T> {
      if (hashCache is null) hashCache = thisHashCode();
      return hashCache.Value;
    }
  }
}


我现在能够做到:

class Base : IImmutableEquatable<Base> {
    public readonly SomeImmutableType1 X;
    readonly SomeImmutableType2 Y;
    public Base(SomeImmutableType1 X, SomeImmutableType2 Y) => (this.X, this.Y) = (X, Y);

    public bool ThisEquals(Base o) => (X, Y) == (o.X, o.Y);
    public int ThisHashCode() => (X, Y).GetHashCode();


    // boilerplate
    public override bool Equals(object obj) => this.ImmutableEquals(obj, ThisEquals);
    public bool Equals(Base o) => object.Equals(this, o);
    public static bool operator ==(Base o1, Base o2) => object.Equals(o1, o2);
    public static bool operator !=(Base o1, Base o2) => !object.Equals(o1, o2);
    protected int? hashCache;
    public override int GetHashCode() => this.GetHashCode(ref hashCache, ThisHashCode);
}


class Derived : Base, IImmutableEquatable<Derived> {
    public readonly SomeImmutableType3 Z;
    readonly SomeImmutableType4 K;
    public Derived(SomeImmutableType1 X, SomeImmutableType2 Y, SomeImmutableType3 Z, SomeImmutableType4 K) : base(X, Y) => (this.Z, this.K) = (Z, K);

    public bool ThisEquals(Derived o) => base.ThisEquals(o) && (Z, K) == (o.Z, o.K);
    public new int ThisHashCode() => (base.ThisHashCode(), Z, K).GetHashCode();


    // boilerplate
    public override bool Equals(object obj) => this.ImmutableEquals(obj, ThisEquals);
    public bool Equals(Derived o) => object.Equals(this, o);
    public override int GetHashCode() => this.GetHashCode(ref hashCache, ThisHashCode);
}

这还不错 - 有更多的复杂性,但都只是枯燥无味的样板代码,我只需要复制粘贴..逻辑明确地分离在ThisEqualsThisHashCode

(测试)


3
另一种方法是使用 Reflection 自动比较您的所有字段和属性。您只需将它们标记为 Immutable 属性,AutoCompare() 将处理剩余部分。
这也会使用 Reflection 基于带有 Immutable 标记的字段和属性构建哈希码,并将其缓存以优化对象比较。
public class Base : ComparableImmutable, IEquatable<Base>, IImmutable
{
    [Immutable]
    public ImmutableType1 X { get; set; }

    [Immutable]
    readonly ImmutableType2 Y;

    public Base(ImmutableType1 X, ImmutableType2 Y) => (this.X, this.Y) = (X, Y);

    public bool Equals(Base o) => AutoCompare(o);
}

public class Derived : Base, IEquatable<Derived>, IImmutable
{
    [Immutable]
    public readonly ImmutableType3 Z;

    [Immutable]
    readonly ImmutableType4 K;

    public Derived(ImmutableType1 X, ImmutableType2 Y, ImmutableType3 Z, ImmutableType4 K)
        : base(X, Y)
        => (this.Z, this.K) = (Z, K);

    public bool Equals(Derived o) => AutoCompare(o);
}

[AttributeUsage(validOn: AttributeTargets.Field | AttributeTargets.Property)]
public class ImmutableAttribute : Attribute { }

public abstract class ComparableImmutable
{
    static BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly;

    protected int? hashCache;

    public override int GetHashCode()
    {
        if (hashCache is null)
        {
            hashCache = 0;
            var type = GetType();

            do
            {
                foreach (var field in type.GetFields(flags).Where(field => Attribute.IsDefined(field, typeof(ImmutableAttribute))))
                    hashCache = HashCode.Combine(hashCache, field.GetValue(this));

                foreach (var property in type.GetProperties(flags).Where(property => Attribute.IsDefined(property, typeof(ImmutableAttribute))))
                    hashCache = HashCode.Combine(hashCache, property.GetValue(this));

                type = type.BaseType;
            }
            while (type != null);
        }

        return hashCache.Value;
    }

    protected bool AutoCompare(object obj2)
    {
        if (ReferenceEquals(this, obj2)) return true;

        if (obj2 is null
            || GetType() != obj2.GetType()
            || GetHashCode() != obj2.GetHashCode())
            return false;

        var type = GetType();

        do
        {
            foreach (var field in type.GetFields(flags).Where(field => Attribute.IsDefined(field, typeof(ImmutableAttribute))))
            {
                if (field.GetValue(this) != field.GetValue(obj2))
                {
                    return false;
                }
            }

            foreach (var property in type.GetProperties(flags).Where(property => Attribute.IsDefined(property, typeof(ImmutableAttribute))))
            {
                if (property.GetValue(this) != property.GetValue(obj2))
                {
                    return false;
                }
            }

            type = type.BaseType;
        }
        while (type != null);

        return true;
    }

    public override bool Equals(object o) => AutoCompare(o);
    public static bool operator ==(Comparable o1, Comparable o2) => o1.AutoCompare(o2);
    public static bool operator !=(Comparable o1, Comparable o2) => !o1.AutoCompare(o2);
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接