О сравнении объектов по значению — 6: Structure Equality Implementation

В предыдущей публикации мы рассмотрели особенности устройства и работы структур платформы .NET, являющихся «типами по значению» (Value Types) в разрезе сравнения по значению объектов — экземпляров структур.


Теперь рассмотрим готовый пример реализации сравнения по значению объектов — экземпляров структур.


Поможет ли пример для структур более точно определить с предметной (доменной) точки зрения область применимости сравнения объектов по значению в целом, и тем самым упростить образец сравнения по значению объектов — экземпляров классов, являющихся ссылочными типами (Reference Types), выведенный в одной из предыдущих публикаций?


struct PersonStruct
using System;

namespace HelloEquatable
{
    public struct PersonStruct : IEquatable, IEquatable
    {
        private static string NormalizeName(string name) => name?.Trim() ?? string.Empty;

        private static DateTime? NormalizeDate(DateTime? date) => date?.Date;

        public string FirstName { get; }

        public string LastName { get; }

        public DateTime? BirthDate { get; }

        public PersonStruct(string firstName, string lastName, DateTime? birthDate)
        {
            this.FirstName = NormalizeName(firstName);
            this.LastName = NormalizeName(lastName);
            this.BirthDate = NormalizeDate(birthDate);
        }

        public override int GetHashCode() =>
            this.FirstName.GetHashCode() ^
            this.LastName.GetHashCode() ^
            this.BirthDate.GetHashCode();

        public static bool Equals(PersonStruct first, PersonStruct second) =>
            first.BirthDate == second.BirthDate &&
            first.FirstName == second.FirstName &&
            first.LastName == second.LastName;

        public static bool operator ==(PersonStruct first, PersonStruct second) =>
            Equals(first, second);

        public static bool operator !=(PersonStruct first, PersonStruct second) =>
            !Equals(first, second);

        public bool Equals(PersonStruct other) =>
            Equals(this, other);

        public static bool Equals(PersonStruct? first, PersonStruct? second) =>
            first == second;
        // Alternate version:
        //public static bool Equals(PersonStruct? first, PersonStruct? second) =>
        //    first.HasValue == second.HasValue &&
        //    (
        //        !first.HasValue || Equals(first.Value, second.Value)
        //    );

        public bool Equals(PersonStruct? other) => this == other;
        // Alternate version:
        //public bool Equals(PersonStruct? other) =>
        //    other.HasValue && Equals(this, other.Value);

        public override bool Equals(object obj) =>
            (obj is PersonStruct) && Equals(this, (PersonStruct)obj);
        // Alternate version:
        //public override bool Equals(object obj) =>
        //    obj != null &&
        //    this.GetType() == obj.GetType() &&
        //    Equals(this, (PersonStruct)obj);
    }
}

Пример с реализацией сравнения объектов по значению для структур меньше по объему и проще по структуре благодаря тому, что экземпляры структур не могут принимать null-значения и тому, что от структур, определенных пользователем (User defined structs), нельзя унаследоваться (особенности реализации сравнения по значению объектов — экземпляров классов, с учетом наследования, рассмотрены в четвертой публикации данного цикла).


Аналогично предыдущим примерам, определены поля для сравнения и реализован метод GetHashCode ().


Методы и операторы сравнения реализованы последовательно следующим образом:


  1. Реализован статический метод PersonStruct.Equals (PersonStruct, PersonStruct) для сравнения двух экземпляров структур.
    Этот метод будет использован как эталонный способ сравнения при реализации других методов и операторов.
    Также этот метод может использоваться для сравнения экземпляров структур в языках, не поддерживающих операторы.


  2. Реализованы операторы PersonStruct.==(PersonStruct, PersonStruct) и PersonStruct.!=(PersonStruct, PersonStruct).
    Следует отметить, что компилятор C# имеет интересную особенность:


    • При наличии у структуры T перегруженных операторов T.==(T, T) и T.!=(T, T), для структур Nullable (Of T) также появляется возможность сравнения с помощью операторов T.==(T, T) и T.!=(T, T).
    • Вероятно, это «магия» компилятора, проверяющая наличие значения у экземпляров структуры, перед проверкой равенства непосредственно значений, и не приводящая к упаковке экземпляров структур в объекты.
    • Что характерно, в этом случае сравнение экземпляра структуры Nullable (Of T) с нетипизированным null также приводит к вызову оператора T.==(T, T) или T.!=(T, T), в то время как аналогичное сравнение экземпляра структуры Nullable (Of T), не имеющей перегруженных операторов T.==(T, T) и T.!=(T, T), приводит к вызову оператора Object.==(Object, Object) или Object.!=(Object, Object) и, как следствие, к упаковке экземпляра структуры объект.

  3. Реализован метод PersonStruct.Equals (PersonStruct) (реализация IEquatable (Of PersonStruct)), путем вызова метода PersonStruct.Equals (PersonStruct, PersonStruct).


  4. Для предотвращения упаковки экземпляров структур в объект, если в сравнении участвует один или два экземпляра Nullable (Of PersonStruct), реализованы:

  • Метод PersonStruct.Equals (PersonStruct?, PersonStruct?) — для предотвращения упаковки экземпляров структуробоих аргументов в объекты и вызова метода Object.Equals (Object, Object), если хотя бы один из аргументов является экземпляром Nullable (Of PersonStruct). Также этот метод может быть использован при сравнении экземпляров Nullable (Of PersonStruct) в языках, не поддерживающих операторы. Метод реализован как вызов оператора PersonStruct.==(PersonStruct, PersonStruct). Рядом с методом приведен закомментированный код, показывающий, каким образом нужно было бы реализовать этот метод, если бы компилятор C# не поддерживал вышеупомянутую «магию» использования операторов T.==(T, T) и T.!=(T, T) для Nullable (Of T)-аргументов.


  • Метод PersonStruct.Equals (PersonStruct?) (реализация интерфейса IEquatable (Of PersonStruct?)) — для предотвращения упаковки Nullable (Of PersonStruct)-аргумента в объект и вызова метода PersonStruct.Equals (Object). Метод также реализован как вызов оператора PersonStruct.==(PersonStruct, PersonStruct), с закомментированным кодом реализации при отсутствии «магии» компилятора.


  • И наконец, реализован метод PersonStruct.Equals (Object), перекрывающий метод Object.Equals (Object).
    Метод реализован путем проверки совместимости типа аргумента с типом текущего объекта с помощью оператора is, с последующими приведением аргумента к PersonStruct и вызовом PersonStruct.Equals (PersonStruct, PersonStruct).

Примечание:


  • Реализация интерфейса IEquatable (Of PersonStruct?) — IEquatable (Of Nullable (Of PersonStruct)) приведена для демонстрации определенных проблем в платформе при работе со структурами в той части, что упаковка их экземпляров в объект происходит чаще, чем этого хотелось бы и можно ожидать.
  • В реальных проектах, только если вам не нужно специально оптимизировать производительность, реализовывать IEquatable (Of Nullable (Of T)) не следует по архитектурным причинам — не следует реализовывать в типе T типизированный IEquatable для какого-то другого типа.
  • Да и в целом, не стоит загромождать код различными преждевременными оптимизациями, даже если в самой платформе оптимизация не будет произведена. В этой публикации дополнительно посмотреть, как часто выполняется упаковка при работе со структурами.

Для структур исчерпывающая реализация сравнения экземпляров по значению получилась существенно проще и компактнее благодаря отсутствию наследования у User defined structs, а также благодаря отсутствию необходимости проверок на null.
(Однако, по сравнению с реализацией для классов, появилась и новая логика, поддерживающая Nullable (Of T)-аргументы).


В следующей публикации мы подведем итоги цикла на тему «Object Equality», в т.ч. рассмотрим:


  • в каких случаях, с предметной и технической точек зрения, действительно целесообразно реализовывать сравнение значений объектов по значению;
  • каким образом в этих случаях возможно упростить реализацию сравнения по значению для объектов — экземпляров классов, являющихся ссылочными типами (Reference Types), с учетом опыта упрощенной реализации для структур.

Комментарии (4)

  • 14 января 2017 в 20:11 (комментарий был изменён)

    0

    Имплементировать два IEquitable это неправильное использование интерфейса. С чего это класс T должен реализовывать интерфейс для какого-то другого IFoo? Пусть даже для него есть удобный синтаксис в языке.

    Нехорошо модифицировать параметры, которые приходят в конструктор. Если я создаю new Person("blabla", null, null) я рассчитываю, что LastName будет null, а не какой-нибудь «unknown». А потом словить баг, где я проверяю наличие фамилии (ну вдруг речь про африканца, у которого есть только имя), а у меня никогда это условие не прокает. Придется лезть в конструктор и видеть, что он подменяет то, что я хочу видеть. Так что у конструктора тут два варианта: либо сеттить все, что угодно, либо явно кидать исключение «Должна быть фамилия».


    Хэшкод будет давать одинаковый хэш для «A», «B», null и «B», «A», null. В таком случае обычно делается умножение на константу.


    Непонятно, зачем статический метод? Почему человек просто не может вызывать ==? Вот честно, я скорее вызову if (a == b) чем if (PersonStruct.Equals(a,b)). Это и короче (взгляд сразу цепляется за ==, а имя метода нужно парсить), и понятнее (я не припомню сходу структуры со статическими методами подобного рода во фреймворке). Короче, бесполезный метод.


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


    Так же неясно, о какой магии речь. Там простой как валенок код «проверить наличие значения в обеих Nullable, если их нет, то тут все понятно, если есть, то вызвать их собственный Equals»:


            public override bool Equals(object other) {
                if (!hasValue) return other == null;
                if (other == null) return false;
                return value.Equals(other);
            }

    На мой взгляд корректная реализация должна выглядеть как-то так:


    public struct PersonStruct : IEquatable
    {
        public string FirstName { get; }
    
        public string LastName { get; }
    
        public DateTime? BirthDate { get; }
    
        public PersonStruct(string firstName, string lastName, DateTime? birthDate)
        {
            FirstName = firstName;
            LastName = lastName;
            BirthDate = birthDate;
        }
    
        public bool Equals(PersonStruct other) => FirstName == other.FirstName && LastName == other.LastName && BirthDate.Equals(other.BirthDate);
    
        public override bool Equals(object obj)
        {
            if (ReferenceEquals(null, obj)) return false;
            return obj is PersonStruct && Equals((PersonStruct) obj);
        }
    
        public override int GetHashCode()
        {
            unchecked
            {
                var hashCode = (FirstName?.GetHashCode()).GetValueOrDefault();
                hashCode = (hashCode * 397) ^ (LastName?.GetHashCode()).GetValueOrDefault();
                hashCode = (hashCode * 397) ^ BirthDate.GetHashCode();
                return hashCode;
            }
        }
    }

    Меньше кода — меньше ошибок. Всё, что нужно для структуры и сравнения тут есть. До Nullable нам не должно быть никакого дела, это у майкрософта должна голова болеть, чтобы правильно сравнить два упакованных объекта (и поверьте, они делают это хорошо). У вас много ссылок на документацию, но при этом я ни разу не видел в требованиях к структурам реализовывать дополнительные статические метод с nullable или имплементировать IEquitable>

    • 14 января 2017 в 23:38

      0

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

      Однако, «До Nullable нам не должно быть никакого дела, это у майкрософта должна голова болеть, чтобы правильно сравнить два упакованных объекта (и поверьте, они делают это хорошо).» — очень спорно.


      Достаточно примеров, когда заботы о предотвращении упаковки в платформе нет — недавно только на хабре была статья, где это было хорошо разобрано, например:


      • при вызове GetHashCode у enum без приведения к базовому типу происходит упаковка;
      • Enum.HasFlag — упаковка;
      • передача структур (как есть, без предварительного ToString) при форматирование строк — упаковка.

      Проверьте — при наличии методов Equals (T) и Equals (Object), при вызове Equals с аргументом «T?» будет вызван именно Equals (Object), и нет никаких причин считать, что здесь будет вызвана «магия», предотвращающая упаковку.


      В вашем примере кода нет операторов ==/!=. Экземпляры ваших структур нельзя будет сравнить через ==, хотя вы сами пишите «я скорее вызову if (a == b)»
      Если есть ==(T, T), то нужен и Equals (T, T) — для использования в языках без поддержки кастомных операторов, и для предотвращения упаковки и более быстрой работы (чтобы не был вызван Equals (Object, Object)).


      Разумеется, в каждом конкретном случае нужно смотреть, насколько полно нужно реализовывать Object Equality.


      Модификация параметров, передаваемых через конструктор — зависит от степени анемичности используемой модели.
      А то ведь разные best practices требования — и такое, что строковые свойства (как и массивы) не должны быть null (нужны пустые строки/массивы), если только это не чистая DTO.
      Вопрос в выборе модели.
      Лезть в конструктор не надо. Надо описывать контракт класса.


      GetHashCode — верно. Это уже обсудили в предыдущих публикациях.

      • 15 января 2017 в 03:46 (комментарий был изменён)

        0

        Достаточно примеров, когда заботы о предотвращении упаковки в платформе нет — недавно только на хабре была статья, где это было хорошо разобрано, например:

        А поподробнее? при вызове StringSplitOptions.RemoveEmptyEntries.GetHashCode() будет боксинг?


        Проверьте — при наличии методов Equals (T) и Equals (Object), при вызове Equals с аргументом «T?» будет вызван именно Equals (Object), и нет никаких причин считать, что здесь будет вызвана «магия», предотвращающая упаковку.

        Магии в дотнете вообще мало. Тем более, что в фреймворке уже есть статический метод Nullable.Equals, который делает все то же самое, только обобщенно.


        Модификация параметров, передаваемых через конструктор — зависит от степени анемичности используемой модели.

        Не согласен в корне. Конструктор может или отвергнуть параметры, или принять, модифицировать их он не имеет права. Это корень такого количества багов, что даже страшно подумать.

        • 15 января 2017 в 09:01

          0

          при вызове StringSplitOptions.RemoveEmptyEntries.GetHashCode () будет боксинг?

          Да, вот здесь можно посмотреть IL-код боксинга.


          Конструктор может или отвергнуть параметры, или принять, модифицировать их он не имеет права. Это корень такого количества багов, что даже страшно подумать.

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


          Однако, в таком случае получается «разработка по контракту» (Design by Contract), и если вы работаете в команде, то увидите, что никто не будет проверять данные перед передачей в ваш конструктор или метод.


          И кстати, лучше делать конструктор lightweight, без исключений, только с инициализацией полей.
          А если требуется проверка параметров, то нужно делать фабричный метод Create, выполняющий проверки, и если ок, то вызывающий конструктор, который объявлен как private.


          Ведь сам конструктор не создает объект, а лишь инициализирует поля в уже созданном объекте.
          И порождение исключения в конструкторе приводит к объекту, на который вы не получили ссылки, и который должен быть убран сборщиком мусора.

© Habrahabr.ru