具有可为空字段的 EqualityComparer 的奇怪行为

Strange behavior of EqualityComparer with nullable fields

假设有这个 class:

public class Foo
{
    public int Id { get; set; }
    public int? NullableId { get; set; }

    public Foo(int id, int? nullableId)
    {
        Id = id;
        NullableId = nullableId;
    }
}

我需要按照以下规则比较这些对象:

  1. 如果两个对象都具有 NullableId 值,那么我们比较两个 Id 和 NullableId
  2. 如果其中一些 objects/both 没有 NullableId 那么 忽略它,只比较 Id。

为了实现它,我像这样重写了 Equals 和 GetHashCode:

public override bool Equals(object obj)
{
    var otherFoo = (Foo)obj;

    var equalityCondition = Id == otherFoo.Id;

    if (NullableId.HasValue && otherFoo.NullableId.HasValue)
        equalityCondition &= (NullableId== otherFoo.NullableId);

    return equalityCondition;
}

public override int GetHashCode()
{
    var hashCode = 806340729;
    hashCode = hashCode * -1521134295 + Id.GetHashCode();
    return hashCode;
}

再往下我有两个 Foo 列表:

var first = new List<Foo> { new Foo(1, null) };
var second = new List<Foo> { new Foo(1, 1), new Foo(1, 2), new Foo(1, 3) };

接下来,我要加入这些列表。如果我这样做:

var result = second.Join(first, s => s, f => f, (f, s) => new {f, s}).ToList();

那么结果和我预料的一样,我会得到3件物品。 但是,如果我更改顺序并首先加入第二个:

var result = first.Join(second, f => f, s => s, (f, s) => new {f, s}).ToList();

那么结果将只有 1 个项目 - new Foo(1, null)new Foo(1 ,3)

我不明白我做错了什么。如果尝试在 Equals 方法中放置一个断点,那么我可以看到它试图比较来自同一列表的项目(例如比较 new Foo(1, 1)new Foo(1 ,2)).对我来说,这似乎是因为在 Join 方法中创建了 Lookup。

有人可以澄清那里发生了什么吗?我应该更改什么以实现所需的行为?

您的 Equals 方法是自反和对称的,但它不是可传递的。

您的实施不符合文档中指定的要求:

If (x.Equals(y) && y.Equals(z)) returns true, then x.Equals(z) returns true.

来自 https://docs.microsoft.com/en-us/dotnet/api/system.object.equals?view=netframework-4.8

例如,假设您有:

var x = new Foo(1, 100);
var y = new Foo(1, null);
var z = new Foo(1, 200);

您有 x.Equals(y)y.Equals(z),这意味着您还应该有 x.Equals(z),但您的实现并没有这样做。由于您不符合规范,因此您不能指望任何依赖于您的 Equals 方法的算法都能正确运行。


你问你能做什么。这完全取决于您需要做什么。部分问题在于,如果它们确实可以出现,那么在极端案例中的意图并不十分清楚。如果一个 Id 在一个或两个列表中多次出现相同的 NullableId 会怎样?举个简单的例子,如果 new Foo(1, 1) 在第一个列表中出现三次,在第二个列表中出现三次,那么输出中应该是什么?九项,每对一项?

这是解决您问题的幼稚尝试。这仅加入 Id,然后过滤掉任何具有不兼容 NullableId 的配对。但是当 Id 在每个列表中多次出现时,您可能不会期望重复,如示例输出中所示。

using System;
using System.Linq;
using System.Collections.Generic;

public class Foo
{
    public int Id { get; set; }
    public int? NullableId { get; set; }

    public Foo(int id, int? nullableId)
    {
        Id = id;
        NullableId = nullableId;
    }

    public override string ToString() => $"Foo({Id}, {NullableId?.ToString()??"null"})";
}

class MainClass {
  public static IEnumerable<Foo> JoinFoos(IEnumerable<Foo> first, IEnumerable<Foo> second) {
    return first
        .Join(second, f=>f.Id, s=>s.Id, (f,s) => new {f,s})
        .Where(fs =>
            fs.f.NullableId == null ||
            fs.s.NullableId == null ||
            fs.f.NullableId == fs.s.NullableId)
        .Select(fs => new Foo(fs.f.Id, fs.f.NullableId ?? fs.s.NullableId));    
  }
  public static void Main (string[] args) {
    var first = new List<Foo> { new Foo(1, null), new Foo(1, null), new Foo(1, 3) };
    var second = new List<Foo> { new Foo(1, 1), new Foo(1, 2), new Foo(1, 3), new Foo(1, null) };
    foreach (var f in JoinFoos(first, second)) {
      Console.WriteLine(f);
    }
  }
}

输出:

Foo(1, 1)
Foo(1, 2)
Foo(1, 3)
Foo(1, null)
Foo(1, 1)
Foo(1, 2)
Foo(1, 3)
Foo(1, null)
Foo(1, 3)
Foo(1, 3)

如果您有数万个具有相同 Id 的项目,它对您来说也可能太慢了,因为它会在过滤掉它们之前建立每个可能的匹配 Id 对。如果每个列表有 10,000 个带有 Id == 1 的项目,那么就有 100,000,000 对可供选择。

我的回答包含一个我认为比 Weeble 的回答中提出的程序更好的程序,但首先我想演示 Join 方法的工作原理并谈谈我在您的方法中看到的问题。

如您所见https://docs.microsoft.com/en-us/dotnet/api/system.linq.enumerable.join?view=netframework-4.8 Join 方法

Correlates the elements of two sequences based on matching keys.

如果键不匹配,则不包括来自两个 集合的元素。例如,删除您的 EqualsGetHashCode 方法并尝试此代码:

  var first = new List<Foo> { new Foo(1, 1) };
  var second = new List<Foo> { new Foo(1, 1), new Foo(1, 2), new Foo(1, 3) };

  //This is your original code that returns no results
  var result = second.Join(first, s => s, f => f, (f, s) => new { f, s }).ToList();
  result = first.Join(second, s => s, f => f, (f, s) => new { f, s }).ToList();

  //This code is mine and it returns in both calls of the Join method one element in the resulting collection; the element contains two instances of Foo (1,1) - f and s 
  result = second.Join(first, s => new { s.Id, s.NullableId }, f => new { f.Id, f.NullableId }, (f, s) => new { f, s }).ToList();
  result = first.Join(second, s => new { s.Id, s.NullableId }, f => new { f.Id, f.NullableId }, (f, s) => new { f, s }).ToList();

但是,如果您使用我的代码设置包含 null 的原始数据输入:

  var first = new List<Foo> { new Foo(1, null) };
  var second = new List<Foo> { new Foo(1, 1), new Foo(1, 2), new Foo(1, 3) };

  var result = second.Join(first, s => new { s.Id, s.NullableId }, f => new { f.Id, f.NullableId }, (f, s) => new { f, s }).ToList();
  result = first.Join(second, s => new { s.Id, s.NullableId }, f => new { f.Id, f.NullableId }, (f, s) => new { f, s }).ToList();

结果变量在这两种情况下都是空的,因为键 { 1, null } 不匹配任何其他键,即 { 1, 1 }, { 1, 2 }, { 1, 3}.

现在回到你的问题。我建议您在这种情况下重新考虑您的整个方法,这就是原因。让我们想象一下,您对 EqualsGetHashCode 方法的实现如您预期的那样工作,您甚至没有 post 您的问题。然后你的解决方案会产生以下结果,正如我所看到的:

  • 要了解您的代码如何计算其输出,您的代码用户必须能够访问 Foo 类型的代码并花时间查看您对 EqualsGetHashCode 方法(或阅读文档)。

  • 通过 EqualsGetHashCode 方法的这种实现,您正在尝试更改 Join 方法的预期行为。用户可能期望第一个集合的第一个元素 Foo(1, null) 不会被认为等于第二个集合的第一个元素 Foo(1, 1)。

  • 假设你有多个class要加入,每个都是由某个人编写的,每个class在[=23=中都有自己的逻辑] 和 GetHashCode 方法。要弄清楚你的连接实际上是如何与每种类型一起工作的,用户而不是只查看一次连接方法实现将需要检查所有那些 class 试图了解每种类型如何处理自己的比较面临的源代码像这样的东西的不同变体与幻数(取自你的代码):

     public override int GetHashCode()
     {
         var hashCode = 806340729;
         hashCode = hashCode * -1521134295 + Id.GetHashCode();
         return hashCode;
     }
    

    这似乎不是什么大问题,但想象一下你是一个新人 这个项目,你有很多 classes 有这样的逻辑和有限的时间 完成你的任务,例如你有一个紧急的变更请求,巨大的集合 数据输入,没有单元测试。

  • 如果有人继承了您的 class Foo 并将 Foo1 的实例放入包含 Foo 实例的集合中:

    public class Foo1 : Foo
    {
        public Foo1(int id, int? nullableId) : base (id, nullableId)
        {
            Id = id;
            NullableId = nullableId;
        }
        public override bool Equals(object obj)
        {
            var otherFoo1 = (Foo1)obj;
            return Id == otherFoo1.Id;
        }
        public override int GetHashCode()
        {
            var hashCode = 806340729;
            hashCode = hashCode * -1521134295 + Id.GetHashCode();
            return hashCode;
        }
    }
    
    var first = new List<Foo> { new Foo1(1, 1) };
    var second = new List<Foo> { new Foo(1, 1), new Foo(1, 2), new Foo(1, 3)};
    
    var result = second.Join(first, s => s, f => f, (f, s) => new { f, s }).ToList();
    result = first.Join(second, s => s, f => f, (f, s) => new { f, s }).ToList();
    

    那么在 Foo1 类型的 Equals 方法中出现 运行 次异常: System.InvalidCastException,消息=无法转换类型的对象 'ConsoleApp1.Foo' 键入 'ConsoleApp1.Foo1'。使用相同的输入数据,我的代码 在这种情况下可以正常工作:

    var result = second.Join(first, s => s.Id, f => f.Id, (f, s) => new { f, s }).ToList();
    result = first.Join(second, s => s.Id, f => f.Id, (f, s) => new { f, s }).ToList();
    
  • 当有人像这样修改加入代码时,您实现了 EqualsGetHashCode 方法:

     var result = second.Join(first, s => new { s.Id, s.NullableId }, f => new { f.Id, f.NullableId }, (f, s) => new { f, s }).ToList();
     result = first.Join(second, s => new { s.Id, s.NullableId }, f => new { f.Id, f.NullableId }, (f, s) => new { f, s }).ToList();
    

    那么你在 EqualsGetHashCode 方法中的逻辑将被忽略并且 你会有不同的结果。

在我看来,这种方法(覆盖 EqualsGetHashCode 方法)可能是多个错误的来源。我认为当你执行连接的代码有一个不需要任何额外信息就可以理解的实现时会更好,逻辑的实现集中在一个方法中,实现清晰、可预测、可维护且易于理解。

另请注意,您输入的数据:

 var first = new List<Foo> { new Foo(1, null) };
 var second = new List<Foo> { new Foo(1, 1), new Foo(1, 2), new Foo(1, 3) };

Weeble 回答中的代码生成以下输出:

 Foo(1, 1)
 Foo(1, 2)
 Foo(1, 3)

而据我了解,您要求的实现是通过输入产生如下所示的输出:

 Foo(1, null), Foo(1, 1)
 Foo(1, null), Foo(1, 2)
 Foo(1, null), Foo(1, 3)

请考虑使用我的代码更新您的解决方案,因为它会以您要求的格式生成结果,我的代码更易于理解,并且如您所见,它还有其他优点:

using System;
using System.Collections.Generic;
using System.Linq;

namespace ConsoleApp40
{
    public class Foo
    {
        public int Id { get; set; }
        public int? NullableId { get; set; }

        public Foo(int id, int? nullableId)
        {
            Id = id;
            NullableId = nullableId;
        }

        public override string ToString() => $"Foo({Id}, {NullableId?.ToString() ?? "null"})";
    }

    class Program
    {

        static void Main(string[] args)
        {
            var first = new List<Foo> { new Foo(1, null), new Foo(1, 5), new Foo(2, 3), new Foo(6, 2) };
            var second = new List<Foo> { new Foo(1, 1), new Foo(1, 2), new Foo(1, 3), new Foo(2, null) };

            var result = second.Join(first, s=>s.Id, f=>f.Id, (f, s) => new { f, s })
                .Where(o => !((o.f.NullableId != null && o.s.NullableId != null) &&
                     (o.f.NullableId != o.s.NullableId)));

            foreach (var o in result) {  
                Console.WriteLine(o.f + ", " + o.s);
            }

            Console.ReadLine();
        }
    }
}

输出:

Foo(1, 1), Foo(1, null)
Foo(1, 2), Foo(1, null)
Foo(1, 3), Foo(1, null)
Foo(2, null), Foo(2, 3)