Странное поведение EqualityComparer с обнуляемыми полями - PullRequest
3 голосов
/ 15 марта 2020

Предположим, есть этот класс:

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. Если некоторые из объектов / оба они не имеют 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) ). Мне кажется, что это происходит из-за Lookup, который создается внутри метода Join.

Может кто-нибудь прояснить, что там происходит? Что я должен изменить, чтобы добиться желаемого поведения?

Ответы [ 2 ]

6 голосов
/ 15 марта 2020

Ваш метод Equals является рефлексивным и симметричным c, но не транзитивным.

Ваша реализация не соответствует требованиям, указанным в документации:

Если ( x.Equals (y) && y.Equals (z)) возвращает true, затем x.Equals (z) возвращает 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)

It также может быть слишком медленным для вас, если у вас есть десятки тысяч предметов с одинаковым Id, потому что он собирает каждую возможную пару с совпадающими Id перед их фильтрацией. Если в каждом списке 10 000 пунктов с Id == 1, то это 100 000 000 пар для выбора.

0 голосов
/ 16 марта 2020

Мой ответ содержит программу, которая, на мой взгляд, лучше, чем предложенная в ответе Уибла, но сначала я хотел бы продемонстрировать, как работает метод Join, и рассказать о проблемах, которые я вижу в вашем подходе.

Как вы можете видеть здесь https://docs.microsoft.com/en-us/dotnet/api/system.linq.enumerable.join?view=netframework-4.8 метод Join

Коррелирует элементы двух последовательностей на основе соответствующих ключей.

Если ключи не совпадают, элементы из обеих коллекций не включаются. Например, удалите методы Equals и GetHashCode и попробуйте этот код:

  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}.

Теперь вернемся к вашему вопросу. Я бы посоветовал вам пересмотреть весь ваш подход в таких случаях, и вот почему. Давайте представим, что ваша реализация методов Equals и GetHashCode сработала так, как вы ожидали, и вы даже не опубликовали свой вопрос. Тогда ваше решение создает следующие результаты, как я вижу:

  • Чтобы понять, как ваш код рассчитывает его вывод, пользователь вашего кода должен иметь доступ к коду Foo напечатайте и проведите время, рассматривая вашу реализацию методов Equals и GetHashCode (или читая документацию).

  • При такой реализации методов Equals и GetHashCode вы пытаетесь изменить ожидаемое поведение метода Join. Пользователь может ожидать, что первый элемент первой коллекции Foo (1, null) не будет считаться равным первому элементу второй коллекции Foo (1, 1).

  • Давайте представим, что у вас есть несколько классов для присоединения, каждый написан каким-то отдельным человеком, и у каждого класса есть свои собственные логики c в методах Equals и GetHashCode , Чтобы выяснить, как на самом деле ваше объединение работает с каждым типом, пользователю вместо того, чтобы изучать реализацию метода объединения только один раз, нужно проверить исходный код всех этих классов, пытаясь понять, как каждый тип обрабатывает свое собственное сравнение, сталкиваясь с различными вариациями таких вещей, как это с магическими числами c (взятыми из вашего кода):

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

    Это может не показаться большой проблемой, но представьте, что вы - новый человек в проекте, у вас много классов с logi c, подобным этому, и ограниченным временем для выполнения вашей задачи, например, у вас есть срочный запрос на изменение, огромный набор данных для ввода и нет юнит-тестов.

  • Если кто-то унаследует от вашего класса 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();
    

    , тогда у вас есть среда выполнения исключение в методе Equals типа Foo1: System.InvalidCastException, Message = Невозможно привести объект типа '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();
    
  • С вашей реализацией методов Equals и GetHashCode, когда кто-то изменяет код присоединения, например это:

     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();
    

    тогда ваши логики c в методах Equals и GetHashCode будут игнорироваться и у вас будет другой результат.

На мой взгляд, этот подход (с переопределением Equals и GetHashCode методов) может быть источником множества ошибок. Я думаю, что лучше, когда ваш код, выполняющий объединение, имеет реализацию, которая может быть понята без какой-либо дополнительной информации, реализация logi c сосредоточена в одном методе, реализация ясна, предсказуема, поддерживаема, и ее легко Понимаю.

Обратите также внимание, что с вашими входными данными:

 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)
...