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

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


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


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


    Структура PersonStruct:


    struct PersonStruct
    using System;
    
    namespace HelloEquatable
    {
        public struct PersonStruct : IEquatable<PersonStruct>, IEquatable<PersonStruct?>
        {
            private static int GetHashCodeHelper(int[] subCodes)
            {
                int result = subCodes[0];
    
                for (int i = 1; i < subCodes.Length; i++)
                    result = unchecked(result * 397) ^ subCodes[i];
    
                return result;
            }
    
            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() => GetHashCodeHelper(
                new int[]
                {
                    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), с учетом опыта упрощенной реализации для структур.
    Share post

    Comments 15

      +2

      Имплементировать два IEquitable<T> это неправильное использование интерфейса. С чего это класс 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<T>, если их нет, то тут все понятно, если есть, то вызвать их собственный 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<PersonStruct>
      {
          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<Nullable<T>>

        +2

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

        Однако, "До 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 — верно. Это уже обсудили в предыдущих публикациях.

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

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


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

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


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

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

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

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


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

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


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


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


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

              +1
              Спасибо за ссылку, действительно, интересно. Впрочем, в реальном коде проблем с таким кодом никогда не было. Если же у кого-то бутылочное горлышко — вызов GetHashCode на энумах, то можно просто кастовать в int — это и будет намного короче:

              --return options.GetHashCode();
              027704AA  in          al,dx  
              027704AB  push        eax  
              027704AC  mov         dword ptr [ebp-4],ecx  
              027704AF  mov         ecx,7282A268h  
              027704B4  call        026430F4  
              027704B9  mov         edx,eax  
              027704BB  mov         eax,dword ptr [ebp-4]  
              027704BE  mov         dword ptr [edx+4],eax  
              027704C1  mov         ecx,edx  
              027704C3  mov         eax,dword ptr [ecx]  
              027704C5  mov         eax,dword ptr [eax+28h]  
              027704C8  call        dword ptr [eax+8]  
              027704CB  mov         esp,ebp  
              027704CD  pop         ebp  
              027704CE  ret  
              

              --return ((int)options).GetHashCode();
              02770482  in          al,dx  
              02770483  push        eax  
              02770484  xor         eax,eax  
              02770486  mov         dword ptr [ebp-4],eax  
              02770489  mov         dword ptr [ebp-4],ecx  
              0277048C  mov         eax,dword ptr [ebp-4]  
              0277048F  mov         esp,ebp  
              02770491  pop         ebp  
              02770492  ret  
              

              и победитель
              --return ((int) options);
              02BA0602  in          al,dx  
              02BA0603  mov         eax,ecx  
              02BA0605  pop         ebp  
              02BA0606  ret 
              


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

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

              Что касается паттерна Create, то на самом деле я в нем особого смысла не вижу. Какая разница, как назвать, если от этого ничего не меняется? То того, что вы конструктору дали alias плохая практика проставлять все депенденси не ушла. Тут скорее нужно больше выносить во всякие менеджеры, которые можно мокнуть.

              С другой стороны, такой паттерн естественно появляется при необходимости асинхронно инстанцировать что-то.
                0
                Видимо у нас разные представления о том, что такое команда. У нас например код, который падает с ArgumentNullException залить в основную ветку не получится у человека.

                Как раз у нас то с вами, видимо, все-таки одинаковые представления о команде..

                  0
                  Тогда не знаю, с чем вы не согласны. Человек 1 пишет код, который бросает исключение на аргументы. Заметьте, не падает, а бросает. Т.к. это валидация контракта, то ревью код проходит. Затем другой человек пишет ответную часть. И либо он валидирует аргументы, код компилится и все счастливы, либо он не проверяет, на одном из тестов код падает с ошибкой и код в апстрим не заливается. Всё просто.
        0

        дубль.

          –1
          Одно из ключевых требований к реализации GetHashCode — максимальная эффективность, и об этом было сказано ранее. Однако код в Вашей реализации эффективностью похвастаться не может, поскольку создаёт довольно нагрузку на диспетчер памяти.
                  private static int GetHashCodeHelper(int[] subCodes)
                  {
                      int result = subCodes[0];
          
                      for (int i = 1; i < subCodes.Length; i++)
                          result = unchecked(result * 397) ^ subCodes[i];
          
                      return result;
                  }
          
                  public override int GetHashCode() => GetHashCodeHelper(
                      new int[]
                      {
                          this.FirstName.GetHashCode(),
                          this.LastName.GetHashCode(),
                          this.BirthDate.GetHashCode()
                      }
                  );
          


          Раз уж Вы сами говорите, что структуры проще классов в том числе потому, что структура не может быть изменена наследованием, то почему Вы создаёте столь тяжеловесное решение для GetHashCode?
            0

            Что именно вы понимаете под эффективностью?


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


            Вопрос баланса эффективности и компактности/повторного использования кода:


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


            result = firstField.GetHashCode();
            result = unchecked(result * 397) ^ secondField.GetHashCode();
            result = unchecked(result * 397) ^ thirdField.GetHashCode();

            Самый простой вариант:


            result = firstField.GetHashCode() ^ secondField.GetHashCode() ^ thirdField.GetHashCode();

            является самым эффективным, но, как уже обсудили, может приводить к частым коллизиям с последующим вызовом Equals, нивелирующим эффективность GetHashCode().


            Предложите свой вариант эффективного GetHashCode() без copy-paste.

              –1
              Я утверждаю, что создание промежуточного массива влияет на быстродействие негативным образом.
              Создание промежуточного объекта требует времени, пускай и немного, и создаёт нагрузку на сборщик мусора в долгосрочной перспективе.

              Guideline: the implementation of GetHashCode must be extremely fast

              The whole point of GetHashCode is to optimize a lookup operation; if the call to GetHashCode is slower than looking through those ten thousand items one at a time, then you haven’t made a performance gain.

              I classify this as a “guideline” and not a “rule” because it is so vague. How slow is too slow? That’s up to you to decide.

              Пруф: https://blogs.msdn.microsoft.com/ericlippert/2011/02/28/guidelines-and-rules-for-gethashcode/
                –1
                Из первого же комментария к статье от PsyHaSTe
                    public override int GetHashCode()
                    {
                        unchecked
                        {
                            var hashCode = (FirstName?.GetHashCode()).GetValueOrDefault();
                            hashCode = (hashCode * 397) ^ (LastName?.GetHashCode()).GetValueOrDefault();
                            hashCode = (hashCode * 397) ^ BirthDate.GetHashCode();
                            return hashCode;
                        }
                    }
                
                  +1

                  Эффективный GetHashCode можно генерировать + опционально кэшировать результат прозрачно для пользователя (с помощью того же ConditionalWeakTable). Правда, я не вижу в этом большого смысла.


                  Насчет генерации: можно посмотреть на пример генерации IComparer<T> в моей тестовой библиотечке. В таком случае никаких массивов выделять не придется, просто динамически создаем нужный делегат и вызываем. Оверхед на вызов делегата как уже считали ~3ns, что приемлемо.

                    +1

                    Но если честно, я не вижу большой проблемы сгенерировать equality members каким-нибудь решарпером. Печально, конечно, что тулчейн не умеет сам после сборки добавлять подобный бойлерплейт-код, но тут пока ничего не попишешь: фича пилится, и я её очень жду.

                    –1
                    Минусуйте дальше, подкину ещё повод.
                    Реализация GetHashCode является критичной к производительности, если объект будет использоваться как элемент словаря и таких объектов создаётся много. Если это так, то реализация GetHashCode должна быть настолько эффективной, насколько это возможно.
                    Я лишь утвержаю, что реализация с выделением промежуточного массива менее эффективна чем та, которую можно было бы сделать. В данном случае кэширование и эффективность ортогональны и друг другу никак не мешают.
                    Здесь имеет место лишь преждевременная пессимизация кода исходя из предпосылок, которые по архитектуре приложения явно не должны случиться.

                Only users with full accounts can post comments. Log in, please.