为什么 ICollection<>.Contains 会忽略我重写的 Equals 和 IEquatable<> 接口?

Why ICollection<>.Contains ignores my overridden Equals and the IEquatable<> interface?

我在 entity framework 项目中遇到导航 属性 的问题。

这里是 class MobileUser:

[DataContract]
[Table("MobileUser")]
public class MobileUser: IEquatable<MobileUser>
{
    // constructors omitted....

    /// <summary>
    /// The primary-key of MobileUser.
    /// This is not the VwdId which is stored in a separate column
    /// </summary>
    [DataMember, Key, Required, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int UserId { get; set; }

    [DataMember, Required, Index(IsUnique = true), MinLength(VwdIdMinLength), MaxLength(VwdIdMaxLength)]
    public string VwdId { get; set; }

    // other properties omitted ...

    [DataMember]
    public virtual ICollection<MobileDeviceInfo> DeviceInfos { get; private set; }

    public bool Equals(MobileUser other)
    {
        return this.UserId == other?.UserId || this.VwdId == other?.VwdId;
    }

    public override bool Equals(object obj)
    {
        if(object.ReferenceEquals(this, obj))return true;
        MobileUser other = obj as MobileUser;
        if (other == null) return false;
        return this.Equals(other);
    }

    public override int GetHashCode()
    {
        // ReSharper disable once NonReadonlyMemberInGetHashCode
        return VwdId.GetHashCode();
    }

    public override string ToString()
    {
        return "foo"; // omitted actual implementation
    }

    #region constants
    // irrelevant
    #endregion
}

相关部分是这个导航属性:

public virtual ICollection<MobileDeviceInfo> DeviceInfos { get; private set; }

这是class MobileDeviceInfo:

[DataContract]
[Table("MobileDeviceInfo")]
public class MobileDeviceInfo : IEquatable<MobileDeviceInfo>
{
    [DataContract]
    public enum MobilePlatform
    {
        [EnumMember]
        // ReSharper disable once InconsistentNaming because correct spelling is iOS
        iOS = 1,
        [EnumMember] Android = 2,
        [EnumMember] WindowsPhone = 3,
        [EnumMember] Blackberry = 4
    }

    // constructors omitted ...

    [DataMember, Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int DeviceInfoId { get; private set; }

    [DataMember, Required, Index(IsUnique = true), MinLength(DeviceTokenMinLength), MaxLength(DeviceTokenMaxLength)]
    public string DeviceToken { get; set; }

    [DataMember, Required, MinLength(DeviceNameMinLength), MaxLength(DeviceNameMaxLength)]
    public string DeviceName { get; set; }

    [DataMember, Required]
    public MobilePlatform Platform { get; set; }

    // other properties ...

    [DataMember]
    public virtual MobileUser MobileUser { get; private set; }

    /// <summary>
    ///     The foreign-key to the MobileUser.
    ///     This is not the VwdId which is stored in MobileUser
    /// </summary>
    [DataMember, ForeignKey("MobileUser")]
    public int UserId { get; set; }

    public bool Equals(MobileDeviceInfo other)
    {
        if (other == null) return false;
        return DeviceToken == other.DeviceToken;
    }

    public override string ToString()
    {
        return "Bah"; // implementation omitted

    public override bool Equals(object obj)
    {
        if (ReferenceEquals(this, obj)) return true;
        MobileDeviceInfo other = obj as MobileDeviceInfo;
        if (other == null) return false;
        return Equals(other);
    }

    public override int GetHashCode()
    {
        // ReSharper disable once NonReadonlyMemberInGetHashCode
        return DeviceToken.GetHashCode();
    }

    #region constants
    // irrelevant
    #endregion
}

如您所见,它实现了 IEquatable<MobileDeviceInfo> 并且还覆盖了 System.Object 中的 EqualsGetHashCode

我有以下测试,我预计 Contains 会调用我的 Equals 但它不会。它似乎改用 Object.ReferenceEquals,所以找不到我的设备,因为它是不同的参考:

var userRepo = new MobileUserRepository((ILog)null);
var deviceRepo = new MobileDeviceRepository((ILog)null);

IReadOnlyList<MobileUser> allUser = userRepo.GetAllMobileUsersWithDevices();
MobileUser user = allUser.First();

IReadOnlyList<MobileDeviceInfo> allDevices = deviceRepo.GetMobileDeviceInfos(user.VwdId, true);
MobileDeviceInfo device = allDevices.First();
bool contains = user.DeviceInfos.Contains(device);
bool anyEqual = user.DeviceInfos.Any(x => x.DeviceToken == device.DeviceToken);
Assert.IsTrue(contains); // no, it's false

第二种方法 LINQEnumerable.Any returns 预期的 true

如果我不使用 user.DeviceInfos.Contains(device)user.DeviceInfos.ToList().Contains(device) 它也会按预期工作,因为 List<>.Contains 使用我的 Equals.

ICollection<> 的实际类型似乎是 System.Collections.Generic.HashSet<MobileDeviceInfo> 但如果我使用以下代码也使用 HashSet<> 它再次按预期工作:

bool contains = new HashSet<MobileDeviceInfo>(user.DeviceInfos).Contains(device); // true

那么为什么只比较引用而忽略我的自定义 Equals

更新:

更令人困惑的是结果是 false 即使我将它投射到 HashSet<MobileDeviceInfo>:

 // still false
bool contains2 = ((HashSet<MobileDeviceInfo>)user.DeviceInfos).Contains(device);
// but this is true as already mentioned
bool contains3 = new HashSet<MobileDeviceInfo>(user.DeviceInfos).Contains(device); 

更新 2::原因似乎是两个 HashSet 使用不同的比较器。 entity-framework-HashSet 使用:

System.Data.Entity.Infrastructure.ObjectReferenceEqualityComparer

标准 HashSet<> 使用:

GenericEqualityComparer<T>

这就解释了这个问题,尽管我不明白为什么 entity framework 在某些情况下使用忽略自定义 Equals 实现的实现。这是一个令人讨厌的陷阱,不是吗?


结论:如果您不知道将使用什么比较器,请不要使用 Contains 或将 Enumerable.Contains 与采用自定义比较器的重载一起使用:

bool contains = user.DeviceInfos.Contains(device, EqualityComparer<MobileDeviceInfo>.Default);  // true

从 EF 源代码中,您可能会偶然发现 CreateCollectionCreateDelegate,这似乎是连接导航属性的一部分。

这调用 EntityUtil.DetermineCollectionType 和 returns 一个 HashSet<T> 作为与 属性 兼容的类型。

然后,使用 HashSet<T>,它在构造函数中调用 DelegateFactory.GetNewExpressionForCollectionType which, per the code and the description, handles HashSet<T> as a special case and passes it an ObjectReferenceEqualityComparer

因此:HashSet<T> EF 为您创建的不是使用您的相等性实现,而是使用引用相等性。

Why ICollection<>.Contains ignores my overridden Equals and the IEquatable<> interface?

因为接口的实现者没有要求这样做。

ICollection<T>.Contains 方法 MSDN documentation 指出:

Determines whether the ICollection<T> contains a specific value.

然后

Remarks

Implementations can vary in how they determine equality of objects; for example, List<T> uses Comparer<T>.Default, whereas Dictionary<TKey, TValue> allows the user to specify the IComparer<T> implementation to use for comparing keys.

旁注:看起来他们把 IComparer<T>IEqualityComparer<T> 搞砸了,但你明白了:)

Conclusion: never use Contains if you don't know what comparer will be used or use Enumerable.Contains with the overload that takes a custom comparer

根据 Enumerable.Contains<T>(IEnumerable<T>, T) 方法重载(即没有自定义比较器)documentation:

Determines whether a sequence contains a specified element by using the default equality comparer.

听起来您的覆盖将被调用。但随后出现以下内容:

Remarks
If the type of source implements ICollection<T>, the Contains method in that implementation is invoked to obtain the result. Otherwise, this method determines whether source contains the specified element.

与初始语句冲突。

真是乱七八糟。我只能说我完全同意这个结论!