Except в Linq

от автора

Думаю, что каждый программист рано или поздно сталкивается с кодом, который работает «не так, как ты от него ожидаешь». Именно это и подтолкнуло меня к написанию следующей статьи, в которой я пытаюсь понять, почему Except в Linq работает так, как написан, а не так, как я хочу.


Что, по вашему мнению, должен вывести следующий код:

var documentsDir = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));  FileInfo[] FirstDirrectoryFiles = documentsDir.GetFiles();  FileInfo[] SecondDirrectoryFiles = documentsDir.GetFiles();  foreach (FileInfo item in FirstDirrectoryFiles.Except(SecondDirrectoryFiles))  {  Console.WriteLine(item.Name);  }

Я вот предположил, что ничего, потому что Except должен вычитать множество (IEnumerable) правого аргумента из множества (IEnumerable) левого аргумента. Однако, вопреки моим ожиданиям я получил:

Вне всякого сомнения — это не похоже на пустое множество. Давайте попробуем разобраться в том, почему так получается (результат в .NET 5 и в .NET 6 — эквивалентен). Чтобы понять, почему так происходит, и что можно с этим сделать обратимся к документации метода Except. Там действительно написано, что этот метод «Находит разность множеств, представленных двумя последовательностями» и имеет две перегрузки:

Except<TSource>(IEnumerable<TSource>, IEnumerable<TSource>)

Находит разность множеств, представленных двумя последовательностями, используя для сравнения значений компаратор проверки на равенство по умолчанию.

Except<TSource>(IEnumerable<TSource>, IEnumerable<TSource>, IEqualityComparer<TSource>)

Находит разность множеств, представленных двумя последовательностями, используя для сравнения значений указанный компаратор IEqualityComparer<T>.

Обратите внимание на заявление о том, что для сравнения используется компаратор по умолчанию. Чтобы понять, почему наш код сработал именно так, как сработал, нам предстоит разобраться с поведением компаратора по умолчанию. Для этого я предлагаю проследовать на https://github.com/dotnet/runtime/ и проанализировать работу метода Except.

Наша точка входа:

Код на картинке
public static IEnumerable<TSource> Except<TSource>(this IEnumerable<TSource> first, IEnumerable<TSource> second) {             if (first == null)             {                 ThrowHelper.ThrowArgumentNullException(ExceptionArgument.first);             }              if (second == null)             {                 ThrowHelper.ThrowArgumentNullException(ExceptionArgument.second);             }              return ExceptIterator(first, second, null); }

Метод проверяет, что получил два объекта с ненулевым указателем и передает аргументы в ExceptIterator.

Строго говоря, можно было бы использовать и метод перегрузку, так как он работает буквально также:

Код на картинке
public static IEnumerable<TSource> Except<TSource>(this IEnumerable<TSource> first, IEnumerable<TSource> second, IEqualityComparer<TSource>? comparer) {             if (first == null)             {                 ThrowHelper.ThrowArgumentNullException(ExceptionArgument.first);             }              if (second == null)             {                 ThrowHelper.ThrowArgumentNullException(ExceptionArgument.second);             }              return ExceptIterator(first, second, comparer); }

Почему перегрузка, а не параметр по умолчанию? Силами сообщества было вынесено предположение, что дело в этом и этом.

Собственно код ExceptIterator:

Код на картинке
private static IEnumerable<TSource> ExceptIterator<TSource>(IEnumerable<TSource> first, IEnumerable<TSource> second, IEqualityComparer<TSource>? comparer) {             var set = new HashSet<TSource>(second, comparer);              foreach (TSource element in first)             {                 if (set.Add(element))                 {                     yield return element;                 }             } }

Из элементов второго аргумента создается множество. Элементы первого аргумента, которые удалось добавить в множество, возвращаются в качестве итератора.

Конструктор множества принимает интерфейс компаратора, который используется для сравнения элементов множества:  

Конкретно в нашем случае компаратор равен null, поэтому проваливаемся в свойство Default обобщенного класса EqualityComparer.

Код на картинке
public HashSet(IEqualityComparer<T>? comparer) {             if (comparer is not null && comparer != EqualityComparer<T>.Default) // first check for null to avoid forcing default comparer instantiation unnecessarily             {                 _comparer = comparer;             }              // Special-case EqualityComparer<string>.Default, StringComparer.Ordinal, and StringComparer.OrdinalIgnoreCase.             // We use a non-randomized comparer for improved perf, falling back to a randomized comparer if the             // hash buckets become unbalanced.             if (typeof(T) == typeof(string))             {                 IEqualityComparer<string>? stringComparer = NonRandomizedStringEqualityComparer.GetStringComparer(_comparer);                 if (stringComparer is not null)                 {                     _comparer = (IEqualityComparer<T>?)stringComparer;                 }             } }

Тут, на мой скромный взгляд, все очевидно:

Код на картинке
public abstract partial class EqualityComparer<T> : IEqualityComparer, IEqualityComparer<T> {         // To minimize generic instantiation overhead of creating the comparer per type, we keep the generic portion of the code as small         // as possible and define most of the creation logic in a non-generic class.         public static EqualityComparer<T> Default { [Intrinsic] get; } = (EqualityComparer<T>)ComparerHelpers.CreateDefaultEqualityComparer(typeof(T)); }

Комментарий гласит, что с целью минимизации накладных расходов на создание универсального экземпляра для каждого типа код обобщенных классов уменьшают насколько это возможно за счет переноса его логики в необобщенный класс (в нашем случае это ComparerHelpers).

Давайте же посмотрим на то, как происходит процесс создания компаратора по умолчанию:

Код на картинке
internal static object CreateDefaultEqualityComparer(Type type) {             Debug.Assert(type != null && type is RuntimeType);              object? result = null;             var runtimeType = (RuntimeType)type;              if (type == typeof(byte))             {                 // Specialize for byte so Array.IndexOf is faster.                 result = new ByteEqualityComparer();             }             else if (type == typeof(string))             {                 // Specialize for string, as EqualityComparer<string>.Default is on the startup path                 result = new GenericEqualityComparer<string>();             }             else if (type.IsAssignableTo(typeof(IEquatable<>).MakeGenericType(type)))             {                 // If T implements IEquatable<T> return a GenericEqualityComparer<T>                 result = CreateInstanceForAnotherGenericParameter((RuntimeType)typeof(GenericEqualityComparer<string>), runtimeType);             }             else if (type.IsGenericType)             {                 // Nullable does not implement IEquatable<T?> directly because that would add an extra interface call per comparison.                 // Instead, it relies on EqualityComparer<T?>.Default to specialize for nullables and do the lifted comparisons if T implements IEquatable.                 if (type.GetGenericTypeDefinition() == typeof(Nullable<>))                 {                     result = TryCreateNullableEqualityComparer(runtimeType);                 }             }             else if (type.IsEnum)             {                 // The equality comparer for enums is specialized to avoid boxing.                 result = TryCreateEnumEqualityComparer(runtimeType);             }              return result ?? CreateInstanceForAnotherGenericParameter((RuntimeType)typeof(ObjectEqualityComparer<object>), runtimeType); }

Пойдем по порядку:

1.       Если тип аргумента byte, то возвращается компаратор специально для этого типа (ByteEqualityComparer)

2.       Если это строка, то возвращается GenericEqualityComparer<string>();

3.       Если тип реализует IEquatable, то на основе GenericEqualityComparer<string> возвращается GenericEqualityComparer для типа аргумента (даже не спрашивайте);

4.       Если аргумент является универсальным типом (обобщением) и если этот универсальный тип Nullable<>,  то на основе NullableEqualityComparer<int>  создается NullableEqualityComparer для типа аргумента;

5.       Если аргумент – перечисление, то на основе EnumEqualityComparer<> создается EnumEqualityComparer;

6.       Во всех остальных случаях на основе ObjectEqualityComparer<object> создается ObjectEqualityComparer.

С помощью такого нехитрого кода (хотел было переписать через string builder, но, думаю, тут можно забить :D) попробуем понять, какой же из перечисленных случаев – наш:

Код на картинке
var documentsDir = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)); FileInfo[] result1 = documentsDir.GetFiles(); FileInfo[] result2 = documentsDir.GetFiles();  Type type = result1[0].GetType(); Console.Write(     $"type == typeof(byte): {type == typeof(byte)}\n" +     $"type == typeof(string): {type == typeof(string)}\n" +     $"type.IsAssignableTo(typeof(IEquatable<>).MakeGenericType(type)): {type.IsAssignableTo(typeof(IEquatable<>).MakeGenericType(type))}\n" +     $"type.IsGenericType: {type.IsGenericType}\n" +     $"type.IsEnum: {type.IsEnum}\n\n");

Что и следовало ожидать:

 Это значит, что теперь наш путь лежит в ObjectEqualityComparer. Вот, собственно, и он:

Код на картинке
public sealed partial class ObjectEqualityComparer<T> : EqualityComparer<T> {         [MethodImpl(MethodImplOptions.AggressiveInlining)]         public override bool Equals(T? x, T? y)         {             if (x != null)             {                 if (y != null) return x.Equals(y);                 return false;             }             if (y != null) return false;             return true;         }          [MethodImpl(MethodImplOptions.AggressiveInlining)]         public override int GetHashCode([DisallowNull] T obj) => obj?.GetHashCode() ?? 0;          // Equals method for the comparer itself.         public override bool Equals([NotNullWhen(true)] object? obj) =>             obj != null && GetType() == obj.GetType();          public override int GetHashCode() =>             GetType().GetHashCode(); }

ObjectEqualityComparer определяет метод Equals для двух объектов следующим образом:

·         Объекты равны, если они оба null (что логично);

·         Объекты не равны, если только один из них null;

·         Если оба объекта не null, то их эквивалентность определяется методом Equals «левого» аргумента.

Если обратиться к документации, то можно увидеть, что у нашего класса FileInfo действительно есть метод Equals с пометкой «Унаследовано от Object». Что же, туда и лежит наш путь! Там в секции «комментарии» мы можем узнать, что:

Если текущий экземпляр является ссылочным типом, Equals(Object) метод проверяет равенство ссылок, а вызов Equals(Object) метода эквивалентен вызову ReferenceEquals метода. Равенство ссылок означает, что сравниваемые объектные переменные ссылаются на один и тот же объект.

На этом, казалось, можно было бы завершить наше путешествие, но давайте на последок придумаем, как заставить Except перестать показывать файлы в директории с моими документами.

Вариант из категории «пока так, потом пофикшу»:

Код на картинке
var documentsDir = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)); FileInfo[] result1 = documentsDir.GetFiles(); FileInfo[] result2 = documentsDir.GetFiles(); foreach (FileInfo item in result1                                  .Select(i => i.FullName)                                  .Except(result2.Select(i => i.FullName))                                  .Select(i => new FileInfo(i))) {     Console.WriteLine(item.Name); }

Мы, по сути, вызываем Except для двух IEnumerate<string>, а потом из результата снова собираем IEnumerate<FileInfo>.

В свежем .net6 еще можно воспользоваться ExceptBy:

Код на картинке
var documentsDir = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)); FileInfo[] result1 = documentsDir.GetFiles(); FileInfo[] result2 = documentsDir.GetFiles(); foreach (FileInfo item in result1.ExceptBy(result2.Select(i => i.FullName), ks => ks.FullName)) {     Console.WriteLine(item.Name); }

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

Следующей идеей, посетившей мою голову, было унаследовать FileInfo и переопределить Equals, но, к сожалению, FileInfo является запечатанным (sealed) классом, а это значит, что он не может быть унаследован, так что этот путь нам отрезан.

Подойдем к вопросу с другой стороны. Вспомним, что Equals имеет перегрузку, принимающую вторым аргументом IEqualityComparer, что позволяет нам создать что-то вроде такого решения:

var documentsDir = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)); FileInfo[] result1 = documentsDir.GetFiles(); FileInfo[] result2 = documentsDir.GetFiles();  foreach (FileInfo item in result1.Except(result2, new CustomFileInfoComparer())) {     Console.WriteLine(item.Name); }  public class CustomFileInfoComparer : IEqualityComparer<FileInfo> {     bool IEqualityComparer<FileInfo>.Equals(FileInfo? lhv, FileInfo? rhv)        => lhv?.FullName == rhv?.FullName;     int IEqualityComparer<FileInfo>.GetHashCode(FileInfo obj) => obj.FullName.GetHashCode(); }

Это решение хорошо подходит в том случае, если дальше по коду вам предстоит еще хотя бы раз сравнивать IEnumerable<FileInfo>.

На этом у меня все, надеюсь, что читатель нашел любопытным мой скромный труд.

Хочется выразить огромную благодарность моей жене за помощь в подготовке данного поста, а также сообществу .NET Talks?


ссылка на оригинал статьи https://habr.com/ru/articles/590445/