Общеизвестно, что в объектной модели .NET, как и во многих других программных платформах, сравнивать объекты можно по ссылке и по значению.


По умолчанию два объекта считаются равными, если соответствующие переменные содержат одну и ту же ссылку. В противном случае объекты считаются неравными.

Однако, может возникнуть ситуация, когда необходимо считать объекты некоторого класса равными, если они определенным образом совпадают по своему содержимому.

Пусть есть класс Person, содержащий персональные данные — имя, фамилию, и дату рождения персоны.


На примере этого класса рассмотрим:

  1. минимально необходимый набор доработок класса для того, чтобы объекты этого класса сравнивались по значению с помощью стандартной инфраструктуры .NET;
  2. минимально необходимый и достаточный набор доработок, чтобы объекты этого класса всегда сравнивались по значению с помощью стандартной инфраструктуры .NET — если явно не указано, что сравнение должно производиться по ссылке.

Для каждого случая рассмотрим, каким именно образом лучше реализовать сравнение объектов по значению, чтобы получился согласованный и, насколько это возможно, компактный, copy-paste free, производительный код.

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

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

Класс Person:
class Person
using System;

namespace HelloEquatable
{
    public class Person
    {
        protected static string NormalizeName(string name) => name?.Trim() ?? string.Empty;

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

        public string FirstName { get; }

        public string LastName { get; }

        public DateTime? BirthDate { get; }

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

Если два объекта класса Person сравнивать любым способом:
то объекты будут считаться равными, только если указывающие на них переменные содержат одну и ту же ссылку.

При помещении в хеш-наборы (хеш-карты) и словари, объекты так же будут считаться равными только в случае совпадения ссылок.

Для сравнения объектов по значению в клиентском коде потребуется написать строки вида:
Код
var p1 = new Person("John", "Smith", new DateTime(1990, 1, 1));
var p2 = new Person("John", "Smith", new DateTime(1990, 1, 1));

bool isSamePerson =
    p1.BirthDate == p2.BirthDate &&
    p1.FirstName == p2.FirstName &&
    p1.LastName == p2.LastName;

Примечания:

  1. Класс Person реализован таким образом, что строковые свойства FirstName и LastName всегда не равны null.
    Если FirstName или LastName неизвестны (не заданы), то в качестве признака отсутствия значения подойдет пустая строка.
    Это позволит избежать исключения NullReferenceException при обращении к свойствам и методам полей FirstName и LastName, а также коллизии при сравнении null и пустой строки (считать ли FirstName у двух объектов равными, если у одного объекта FirstName равен null, а у другого — пустой строке?).
  2. Свойство BirthDate, напротив, реализовано как Nullable(Of T)-структура, т.к. в случае, если дата рождения неизвестна (не задана), то целесообразно сохранить в свойстве именно неопределенное значение, а не особое значение вида 01/01/1900, 01/01/1970, 01/01/0001 или MinValue.
  3. При сравнении объектов по значению первым реализовано сравнение дат, т.к. сравнение переменных типа дата-время в общем случае будет производиться быстрее, чем сравнение строк.
  4. Сравнение дат и строк реализовано с помощью оператора равенства, т.к. оператор равенства сравнивает структуры по значению, а для строк оператор равенства перегружен и так же сравнивает строки по значению.

Для того, чтобы объекты класса Person можно было сравнивать по значению следующими способами:


у класса Person необходимо перекрыть методы Object.Equals(Object) и Object.GetHashCode() следующим образом:

  • Метод Equals(Object) сравнивает те поля класса, сочетание значений которых образует значение объекта.
  • Метод GetHashCode() должен возвращать одинаковые значения хеш-кодов для равных объектов (т.е., для объектов, сравнение которых с помощью Equals(Object) возвращает true).
    Отсюда следует, что если у объектов различные хеш-коды, то объекты не равны; при этом неравные объекты могут иметь одинаковые хеш-коды.
    (Для получения хеш-кода обычно используется результат операции «исключающее или» значений GetHashCode() полей, которые используются в Equals для сравнения объектов по значению;
    в случае, если какое-либо поле является 32-битным целым, вместо хеш-кода этого поля может использоваться непосредственно значение поля;
    также возможны различные оптимизации для минимизации вероятности коллизий, когда два неравных объекта имеют одинаковый хеш-код.)

Стоит обратить особое внимание, что в документации к методу Equals(Object) приведены специальные требования:

  • x.Equals(y) returns the same value as y.Equals(x).
  • If (x.Equals(y) && y.Equals(z)) returns true, then x.Equals(z) returns true.
  • x.Equals(null) returns false.
  • Successive calls to x.Equals(y) return the same value as long as the objects referenced by x and y are not modified.
  • И ряд других, в частности, касающихся правил сравнения значений чисел с плавающей точкой.

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

Класс Person с перекрытыми методами Equals(Object) и GetHashCode():
class Person
using System;

namespace HelloEquatable
{
    public class Person
    {
        protected static string NormalizeName(string name) => name?.Trim() ?? string.Empty;

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

        public string FirstName { get; }

        public string LastName { get; }

        public DateTime? BirthDate { get; }

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

        protected static bool EqualsHelper(Person first, Person second) =>
            first.BirthDate == second.BirthDate &&
            first.FirstName == second.FirstName &&
            first.LastName == second.LastName;

        public override bool Equals(object obj)
        {
            if ((object)this == obj)
                return true;

            var other = obj as Person;

            if ((object)other == null)
                return false;

            return EqualsHelper(this, other);
        }
    }
}

Примечания к методу GetHashCode():

  • Если какое-либо из используемых полей содержит null, то для него вместо значения GetHashCode() обычно используется ноль.
  • Класс Person реализован таким образом, что ссылочные поля FirstName и LastName не могут содержать null, а поле BirthDate является Nullable(Of T)-структурой, для которой в случае неопределенного значения GetHashCode() возвращает ноль, и исключения NullReferenceException при вызове GetHashCode() не возникает.
  • Если бы поля класса Person могли содержать null, то метод GetHashCode() был бы реализован следующим образом:
    GetHashCode()
    public override int GetHashCode() =>
        this.FirstName?.GetHashCode() ?? 0 ^
        this.LastName?.GetHashCode() ?? 0 ^
        this.BirthDate?.GetHashCode() ?? 0;
    

Рассмотрим детально, как именно реализован метод Equals(Object):

  1. Вначале ссылка на текущий объект (this) сравнивается со ссылкой на входящий объектом, и если ссылки равны, возвращается true (это один и тот же объект, и сравнение по значению не имеет смысла, в т.ч. из соображений производительности).
  2. Затем выполняется приведение входящего объекта к типу Person с помощью оператора as. Если результат приведения — null, то возвращается false (либо входящая ссылка изначально была равна null, либо входящий объект имеет несовместимый с классом Person тип, и заведомо не равен текущему объекту).
  3. Затем выполняется сравнение полей двух объектов класса Person по значению, и возвращается соответствующий результат.
    Для читабельности кода и возможного повторного использования, сравнение объектов непосредственно по значению вынесено во вспомогательный метод EqualsHelper.

Пока мы реализовали только минимально необходимую функциональность для сравнения объектов по значению, но уже появляются вопросы.


Первый вопрос больше теоретический.


Обратим внимание на требование к методу Equals(Object):
x.Equals(null) returns false.

Когда-то меня заинтересовало, почему некоторые экземплярные методы в стандартной библиотеке .NET проверяют this на null — например, так реализован метод String.Equals(Object):
String.Equals(Object)
public override bool Equals(Object obj) {
    //this is necessary to guard against reverse-pinvokes and
    //other callers who do not use the callvirt instruction
    if (this == null)
        throw new NullReferenceException();

    String str = obj as String;
    if (str == null)
        return false;

    if (Object.ReferenceEquals(this, obj))
        return true;

    if (this.Length != str.Length)
        return false;

     return EqualsHelper(this, str);
}

Первым делом в методе выполняется проверка this на null и, в случае положительного результата проверки, генерируется исключение NullReferenceException.

В комментарии указано, в каких случаях this может принимать null-значение.

(Кстати, сравнение this на null выполнено с помощью оператора ==, который у класса String перегружен, поэтому с точки зрения производительности проверку лучше сделать, явно приведя this к object: (object)this == null, или же воспользоваться методом Object.ReferenceEquals(Object, Object), как это сделано во втором сравнении в этом же методе.)

А затем появилась статья, где об этом можно прочитать подробнее: Когда this == null: невыдуманная история из мира CLR.

Однако, в таком случае, если вызвать перегруженный метод Person.Equals(Object) без создания экземпляра, передав в качестве входного параметра null, то первая же строчка метода (if ((object)this == obj) return true;) возвратит true, что фактически будет правильно, но формально будет противоречить требованиям к реализации метода.

При этом в документации к методу не указано, что первым делом нужно проверять this на null и генерировать исключение в случае успешной проверки.

Да и в таком случае следовало бы вообще во всех экземплярных методах всех классов первой строчкой проверять this на null, что является абсурдом.

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

  • (для классов, не структур) если ссылки на текущий и входящий объект равны, то возвращается true;
  • и уже вторым требованием — если ссылка на входящий объекта равна null, то возвращается false.

А вот второй вопрос по реализации метода Equals(Object) интереснее, и имеет прикладное значение.


Он касается того, как наиболее корректно реализовать требование:
x.Equals(y) returns the same value as y.Equals(x).
И того, полностью и непротиворечиво ли изложены в документации требования и примеры к реализации метода в этой части, и есть ли альтернативные подходы к реализации этого требования.

Об этом, так же как и о реализации полного набора доработок класса для сравнения его объектов по значению, поговорим в следующих публикациях.
Поделиться с друзьями
-->

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


  1. lair
    03.11.2016 23:38
    +3

    … а вы точно статью закончили?


  1. lair
    04.11.2016 13:08
    +3

    if ((object)this == obj) return true;

    Почему вы не используете ReferenceEquals, который намного читаемее?


    var other = obj as Person;

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


    1. sand14
      04.11.2016 13:17

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

      Это как раз тема следующей статьи. Там поговорим об этом подробно.


      1. lair
        04.11.2016 14:24
        +1

        А эта-то статья нам зачем нужна в таком объеме?


        1. sand14
          04.11.2016 15:29
          +1

          А вам это кому?

          Без шуток, лично вам объективно не нужна — про хеш-коды (включая их особенности) вы знаете, так же как про Equals и проблему оператора as и потомков класса (в разрезе равенства по значению).
          И даже возможное недопонимание вопроса приведения к object и использования оператора == вам не помешает — т.к. с помощью Object.ReferenceEquals вы без ошибок и побочных эффектов можете проверять ссылку на null.

          А вот сообществу, уверен, нужна — я довольно часто в legacy-проектах наблюдаю ошибки (существующие и вновь добавляемые) в реализациях инфраструктуры, используемой для сравнения и сортировки объектов.

          Если хотя бы десяток человек прочтут и заинтересуются, почитают первоисточники, и станут использовать в работе подходы, описанные у Рихтера, в MSDN и прочих best practices, уже хорошо.


          1. lair
            04.11.2016 15:42

            Если хотя бы десяток человек прочтут и заинтересуются, почитают первоисточники, и станут использовать в работе подходы, описанные у Рихтера, в MSDN и прочих best practices, уже хорошо.

            Для того, чтобы это случилось, нужно (а) объяснять все спорные места и (б) использовать максимально читаемые best practices. Вы же для первого случая говорите "в следующей статье", а второй просто оспариваете.


            А бездумное применение "потому что у Рихтера так написано" опасно.


    1. sand14
      04.11.2016 13:36

      Почему вы не используете ReferenceEquals, который намного читаемее?


      Что читаемее, вопрос предпочтений — ни один из способов не считаю хорошим.

      Но если мы хотим сравнить на null, то нужно сравнивать именно на null, а не вызывать перегруженный оператор (который, кстати, может быть внезапно перегружен и в будущем).

      Возможно, хорошим способом будет pattern matching в C# 7.0: obj is null.

      (В своей практике встречал даже такое, когда проверка переменной на null (if (a == null)) приводила к NullReferenceException, т.к. замечательные программисты, реализовав перегруженный оператор, обращались в нем к свойствам операндов для проверки на равенство по значению, не проверив операнды на null.)


      1. lair
        04.11.2016 14:27
        +1

        Что читаемее, вопрос предпочтений — ни один из способов не считаю хорошим.

        В данном случае это как раз весьма очевидно.


        (object)this == obj

        Здесь два (!) оператора, из которых оба могут быть перегружены, и даже дефолтную семантику второго надо помнить.


        Object.ReferenceEquals(this, obj)

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


        Так что здесь вопрос читаемости весьма прост.


        Но если мы хотим сравнить на null, то нужно сравнивать именно на null, а не вызывать перегруженный оператор

        Чего вы тоже не делаете. О чем и речь.


        1. sand14
          04.11.2016 15:06

          Не очевидно:

          https://msdn.microsoft.com/library/53k8ybth.aspx

          For predefined value types, the equality operator (==) returns true if the values of its operands are equal, false otherwise. For reference types other than string, == returns true if its two operands refer to the same object.

          The () operator cannot be overloaded.

          Ну и до кучи:
          public static bool ReferenceEquals (Object objA, Object objB) {
              return objA == objB;
          }


          А если все-таки использовать ReferenceEquals, то с указанием класса: Object.ReferenceEquals(a, b),
          т.к. в текущем классе (или одном из предков) ReferenceEquals может быть переопределен (new).

          Получается, что приведение к object и использование оператора "==" ("!=") — первично, а метод ReferenceEquals — лишь обертка.

          Так что вопросы по-прежнему к дизайну языка/объектной модели, если все так некрасиво.


          1. lair
            04.11.2016 15:39
            +1

            The () operator cannot be overloaded.

            Зато explicit cast вполне себе переопределяем.


            Получается, что приведение к object и использование оператора "==" ("!=") — первично, а метод ReferenceEquals — лишь обертка.

            Тем не менее, когда написано Object.ReferenceEquals — намерение явно читаемо. А когда приведение и сравнение — нет (нужно именно что помнить дизайн языка).


            1. sand14
              04.11.2016 17:36
              +3

              Зато explicit cast вполне себе переопределяем.

              https://msdn.microsoft.com/library/ms173105.aspx
              User-defined conversions: User-defined conversions are performed by special methods that you can define to enable explicit and implicit conversions between custom types that do not have a base class–derived class relationship. For more information, see Conversion Operators.

              Соответственно, переопределение приведения к типу object невозможно ни для одного типа (это легко проверить в коде).

              Кроме того, explicit cast к object происходит статически, на этапе компиляции.

              Поэтому проверка вида "(object)value == null" является первичным способом проверки на null.

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

              Однако, в пользу варианта Object.ReferenceEquals говорит скорее, не большая очевидность, которая на мой взгляд, сомнительна, а то, что CLS-спецификация не обязывает язык поддерживать операторы.
              А потому любой оператор должен иметь соответствующий ему метод (и во всех классах стандартной библиотеки так и есть).
              Поэтому если задать вопрос «а как проверить в .NET (не в C# или VB, в целом .NET) ссылки на равенство, или ссылку на null», то тут верный ответ будет Object.ReferenceEquals.

              Но если мы пишем на C#, то удобно пользоваться всеми его возможностями — а для C# первичным является приведение ссылок к object и использование оператора равенства (иначе ведь придется отказаться от всех операторов, для которых есть методы-дублеры).

              Повторюсь, не считаю оба способа удобными и очевидными, и полагаю, что оптимальнее будет pattern matching «value is null» при условии, что конструкция будет внутри реализована именно через проверку ссылки, а не вызов перегруженного оператора.

              Кстати, как именно реализованы уже имеющиеся операторы "??" и "?."?


              1. lair
                04.11.2016 17:48

                Соответственно, переопределение приведение к типу object невозможно ни для одного типа. Кроме того, explicit cast к object происходит статически, на этапе компиляции.

                Да, но об этом всем надо помнить. В Object.ReferenceEquals все явно написано.


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

                Возможно. Именно поэтому Object.ReferenceEquals — более читаемое решение.


                а для C# первичным является приведение ссылок к object и использование оператора равенства

                Вот этот тезис ничем (кроме вашего представления о "первичности") не обоснован.


                Кстати, как именно реализованы уже имеющиеся операторы "?? и "?."?

                Подозреваю, что через ldnull; ceq; в IL, который всегда сравнивает "по ссылке".


                1. sand14
                  04.11.2016 17:58

                  Про первичность:

                  Есть основания подозревать, что сравнение с null переменной, приведенной к object, тоже происходит через ldnull; ceq;

                  А ReferenceEquals — это в любом случае метод (в данном случае обертка вокруг оператора).
                  Другое дело, что, возможно (не проверял), один из этих атрибутов:
                  [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
                  [System.Runtime.Versioning.NonVersionable]
                  может приводить к тому, что содержимое метода инлайнится при вызове?

                  В любом случае, оба способа корректны, детерминированы, и не имеют побочных эффектов.
                  Меня бы устроило, если бы программисты проверяли ссылки на null любым из этих способов.


                  1. lair
                    04.11.2016 19:34

                    Есть основания подозревать, что сравнение с null переменной, приведенной к object, тоже происходит через ldnull; ceq;

                    … и что?


                    А ReferenceEquals — это в любом случае метод

                    … и что?


                    содержимое метода инлайнится при вызове?

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


                    Меня бы устроило, если бы программисты проверяли ссылки на null любым из этих способов.

                    Вы так и не поняли, что я с самого начала говорю не о сравнении с null, а о сравнении с другим объектом. Первая строчка вашего Equals. И я говорил не о функциональной корректности (я подозреваю, что IL-код в релизе будет просто идентичным), а о читаемости.


                    1. sand14
                      04.11.2016 19:47

                      неважно, о сравнении с null или другим объектом
                      речь, конечно, о сравнении ссылок
                      в строчке, которая вам так не по душе, первый операнд — не object, приведенный статически к object, а второй объявлен object, и его тип не будет меняться — поэтому там приведения нет
                      таким же образом происходит и сравнение с null — его нет нужды приводить к object (он по умолчанию object, если не приведен к другому типу)

                      … и что?

                      Все то же — вопрос первичности.

                      Если мы говорим о C#, то при сравнении ссылок первично сравнение object'ов через оператор сравнения, а метод ReferenceEquals — обертка, т.е., вторичен, и насчет инлайнинга мы можем только предполагать.

                      Если говорим о CLS, то вопрос первичности отпадает, единственный кандидат — ReferenceEquals.


                      1. lair
                        04.11.2016 19:50
                        +1

                        поэтому там приведения нет

                        Там написано приведение.


                        Если мы говорим о C#, то при сравнении ссылок первично сравнение object'ов через оператор сравнения, а метод ReferenceEquals — обертка, т.е., вторичен

                        Это только ваше определение "первичности".


                        Если говорим о CLS, то вопрос первичности отпадает, единственный кандидат — ReferenceEquals.

                        Меня вообще не волнует "первичность". Меня волнует читаемость.


                        1. sand14
                          04.11.2016 19:57

                          поэтому там приведения нет

                          вы же об этой строчке
                          if ((object)this == obj) return true;
                          у первого операнда приведение к object есть, у второго нет, т.к. он объявлен как object

                          Меня вообще не волнует «первичность». Меня волнует читаемость.
                          Но это ваше предпочтение, что важнее — первичность (производительность) или читаемость, и ваше видение, что более читаемо.

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


                          1. lair
                            04.11.2016 20:01
                            +2

                            у первого операнда приведение к object есть, у второго нет,

                            Я вроде ничего и не говорил про второй.


                            Но это ваше предпочтение, что важнее — первичность (производительность)

                            "Первичность" не имеет никакого отношения к производительности. Единственный корректный способ говорить о производительности — это измерения.


                            Но это ваше предпочтение, что важнее — первичность (производительность) или читаемость

                            Да, и я считаю, что читаемость обычно важнее, особенно в коде, где легко допустить ошибку.


  1. vovkaKIT
    04.11.2016 13:17
    +1

    Для двух объектов класса Person a и b, для которых a.FirstName == b.LastName, a.LastName == b.FirstName и a.BirthDate == b.BirthDate, метод GetHashCode() вернет одинаковые значения; правильно ли это?


    1. sand14
      04.11.2016 13:20

      GetHashCode() вернет для a и b одинаковые значения, если следующее выражение вернет true:
      a.FirstName == b.FirstName &&
      a.LastName == b.LastName &&
      a.BirthDate == b.BirthDate;


      1. dymanoid
        04.11.2016 13:44

        Замечание, кстати, справедливое. XOR — ассоциативная операция, поэтому для указанного vovkaKIT примера получатся одинаковые хэши при разных «значениях» в объектах. Однако это не запрещается — хэши разных объектов могут совпадать, хэши одинаковых объектов должны быть равны.


        1. sand14
          04.11.2016 13:54
          +3

          хэш-коды по определению допускают коллизию
          (и в статье об этом упомянуто)

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

          как их использовать конкретно в .NET — можно почитать и в MSDN, и у Рихтера
          можно почитать, как происходит работа с хеш-кодами в Java

          если в целом про природу хэш-кодов, хэш-сумм в криптографии, контрольных сумм, и т.д. — полно литературы и источников


    1. sand14
      04.11.2016 13:58
      +3

      Для случая

      a.FirstName == b.LastName, a.LastName == b.FirstName и a.BirthDate == b.BirthDate

      GetHashCode() действительно вернет одинаковые значения, это коллизия — следствие природы хеш-кода, но не ошибка.

      Главное, на основании равенства хеш-кодов не делать вывод о равенстве объектов по значению.
      Равенство хеш-кодов — повод проверить равенство объектов с помощью Equals.

      А вот различие хеш-кодов однозначно говорит о неравенстве объектов.


      1. vovkaKIT
        04.11.2016 14:22

        к чему вел, может стоит перемножать на какое-либо число каждый раз перед xor, дабы если случается такая ситуация, возвращались разные хэши и нам не приходилось лишний раз вызывать Equals? что-то вроде такого:

        public override int GetHashCode() {
            unchecked {
                return ((this.FirstName.GetHashCode()*397 ^
                    this.LastName.GetHashCode())*397 ^
                    this.BirthDate.GetHashCode();
            }
        }
        


        1. sand14
          04.11.2016 14:36
          +3

          Коллега, хэш вследствие своей природы (и тем более небольшого размера — всего 32 бита) в любом случае будет давать коллизии, особенно на большом количестве объектов.

          Одно из требований к функции получение хеш-кода — скорость.

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

          Например, пусть объект имеет 3 булевых поля.
          Определенно, XOR трех таких полей будет давать коллизии, и нужно продумать функцию хеширования — например, превращать булево поле в бит, для каждого поля делать сдвиг с определенным шагом, и формировать хеш-код с помощью OR.

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

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

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


          1. vovkaKIT
            04.11.2016 14:48
            +1

            коллизия из-за того, что у одного пользователя переставлены местами имя и фамилия, представляется редкой

            Бесспорно, но сам факт, на мой взгляд, упомянуть все-таки стоило (ну и в идеале подобрать соответствующий пример).


            1. sand14
              04.11.2016 15:15
              +1

              Спасибо. Пожалуй, стоило чуть подробнее остановиться на вопросе коллизий хеш-кодов.
              Внесу это в статью.


    1. lair
      04.11.2016 14:29

      правильно ли это?

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


      Так что хотя это и нежелательно, это допустимо.


  1. 3dm
    04.11.2016 14:27

    Все ждал когда же пойдет речь про IEquatable


    1. sand14
      04.11.2016 14:37

      Спасибо. Об этом в продолжении.


      1. questor
        06.11.2016 01:27
        +1

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


  1. dmitry_dvm
    04.11.2016 16:49

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

    DateTime разве nullable? Насколько я знаю она не может быть null.


    1. sand14
      04.11.2016 17:07
      +2

      1. dmitry_dvm
        04.11.2016 19:09

        Да, спасибо, перепутал со случаем, когда просто DateTime сравнивается с null.


  1. gandjustas
    05.11.2016 21:42

    Однако, может возникнуть ситуация, когда необходимо считать объекты некоторого класса равными, если они определенным образом совпадают по своему содержимому (тождественны).

    А можете реальный пример привести? Кроме случаев типа tuple.


    1. lair
      05.11.2016 22:35

      Tuple — это просто частный случай объекта-значения. Скажем, у меня есть некие метаданные, где ключами выступают сложно-составные значения (которые можно представлять кортежами, но представлять записями намного проще), и вот эти ключи-то и надо сравнивать по значению (и при этом сохранять нормальный ОО-доступ к каждой составной части, а иногда и методы поддерживать).


      1. gandjustas
        06.11.2016 05:12

        Прочему в этом случае не использовать tuple и не городить свои классы?


        1. lair
          06.11.2016 18:21

          Во-первых, потому, что Upn и Email читаемее, чем Item1 и Item2. Во-вторых, потому, что если вам внезапно нужно недефолтное сравнение строк, вам придется не только городить свои классы, но еще и передавать их в каждую операцию.


        1. sand14
          06.11.2016 18:35
          +1

          в C# 7.0 есть встроенные туплы, где можно задавать нужные имена свойств


        1. 0xd34df00d
          07.11.2016 04:17

          Потому что все туплы с одинаковыми типами полей эквивалентны с точки зрения системы типов, а классы — нет.


    1. Nipheris
      06.11.2016 02:16

      Любой случай, когда семантически сущность ведёт себя как значение (запись, record), но технически является объектом. За примером далеко ходить не надо, можно взять стандартный System.String.

      Лично я считаю, что отличную идею разведения на два лагеря сущностей-значений/записей (которые тождественны, если тождественна их структура/содержимое) и сущностей-объектов (которые имеют identity и для которых объект тождественнен только самому себе) в дотнете не довели до ума. На struct-ы, которые как раз и должны использоваться для «записей», накладывается слишком много технических ограничений. В результате, выбирать между struct и class приходится чисто их технических соображений, и приходится брать class для реализации типов-записей. В результате чего и имеем типы со смешанным поведением, которые как бы объекты, передаются по ссылке, но сравниваются по значению. Из реальных примеров могу привести Data Transfer Object, которые не более чем записи и должны сравниваться по содержимому.


      1. gandjustas
        06.11.2016 05:11

        Еще раз — я конкретный пример хочу увидеть. Для string никто не будет писать такой код, он уже написан. Для tuple тоже.


        Я уже более 10 лет программирую на .NET и еще ни разу не написал в реальном проекте свою реализацию Equals.


        Из реальных примеров могу привести Data Transfer Object, которые не более чем записи и должны сравниваться по содержимому.
        Кому должны? В каком случае DTO вообще сравнивать надо?


        1. sand14
          06.11.2016 08:50
          +1

          Еще раз — я конкретный пример хочу увидеть. Для string никто не будет писать такой код, он уже написан. Для tuple тоже.

          Это интересный вопрос.

          Да, есть, как уже отметили, сущности-записи, которые нужно сравнивать по значению, но какие конкретно могут быть примеры, кроме стандартных типов вроде целых чисел и строк.

          Уже вещественные числа (несмотря на то, что они — struct) в силу своей природы, выбиваются из ряда:
          x.Equals(x) returns true, except in cases that involve floating-point types.

          Реализация сравнения по значению стандартного Uri неоднозначна — сравнивается только часть свойств, и в зависимости от версии FW, набор сравниваемых свойств менялся.
          А что, если нам нужно сравнить Uri иным образом? Или поместить в словарь все имеющиеся варианты записи Uri, а GetHashCode/Equals позволят поместить только часть?

          В конкретных проектах еще сложнее — в зависимости от предметной области, нужно выбирать способ сравнения сущностей по значению, и вообще решать нужно ли это делать.
          Для той же сущности Person есть куча вариантов:

          — Сравнивать в Equals все поля. А как именно, кстати, сравнивать? — для строк-имен — с учетом регистра или без, с учетом какой культуры, учитывать ли ведущие и ведомые пробельные символы, учитывать ликоличество пробельных символов и их внутренние коды внутри имен/фамилий/отчеств, учитывать ли разницу между составными именами с дефисом, если где-то дефис, а где-то тире, и т.д.

          — Использовать внутри Equals/GetHashCode только идентификатор типа СНИЛС или SSN. А точно подойдет вариант? — что если нам нужно в словарь поместить все возможные записи, и потом уже разбираться, где дубли с различающимися неключевыми полями и почему?

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

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

          Вообще, спасибо за вопрос. Об этом стоит поговорить в продолжении.


          1. Nipheris
            06.11.2016 15:12

            Ответить на поставленный вопрос не так уж сложно. Всё определяется тем, является ли ваш тип объектом.
            К объектам в используемой объектной среде всегда добавляется уникальный идентификатор. В C++ это указатель, в C#/Java это ссылка, в объектных БД это OID, и даже когда в реляционной БД вы используете суррогатный ключ — это тоже в каком-то смысле искуственный уникальный идентификатор. Собственно, необходимость в таком идентификаторе и есть ответ на вопрос, чем является тип с точки зрения семантики. Возьмём ваш Person: скорее всего у вас это будет тип-объект, т.к. даже если у разных Person совпадают все данные, то всегда есть вероятность, что это разные люди. Иными словами, вам нужен искуственный идентификатор, чтобы их различать. Если же в рамках вашей задачи вас не интересуют люди без российского паспорта, и вы условились различать всех по номеру паспорта, то вы вполне можете рассматривать Person как тип-значение.

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

            Это не нужно рассматривать в рамках задачи сравнения Person. Если ФИО нужно сравнивать иначе, например без учёта регистра, нужно тогда для полей Name использовать не стандартный string, а некий NameString, который, к примеру, будет сравниваться регистронезависимо. При этом он всё равно будет оставаться типом-значением. Вопрос сравнения нельзя рассматривать независимо от типизации.
            По возможности, строить архитектуру классов таким образом, чтобы эта задача не возникала.

            Не могу согласиться с этим, также как не могу согласиться с тем, что в реляционной БД все таблицы должны иметь целочисленный суррогатный ID.
            что если нам нужно в словарь поместить все возможные записи, и потом уже разбираться, где дубли с различающимися неключевыми полями и почему?

            И здесь опять следует разобраться, как вы понимаете ваши «записи». Если они могут иметь дубли, но вы таки хотите их различать, вы автоматически наделяете их каким-то отличительным признаком, даже если вы пока не понимаете, каким конкретно. Допустим, вы считываете записи из файла, и их содержимое действительно совпадает. Но вы тем не менее считаете эти записи разными. Как вы их различаете? Ну допустим вы можете говорить о порядковом номере записи. В таком случае именно он является идентификатором и его нужно добавить в ваши дублирующиеся «записи», чтобы перестать считать их дублирующимися. Тогда их можно спокойно добавлять в один список. Либо, вы можете объявить эти «записи» объектами, и тогда такой идентификатор даст вам ваша языковая среда. В случае C# это будет ссылка на объект. Вы также сможете положить несколько ваших дублирующихся по содержимому «записей» в один список.
            Позже, когда вы решите задачу с дублирующимися записями, вы скорее всего всё-таки захотите отказаться от номера записи в качестве идентификатора и будете использовать содержимое СНИЛС для сравнения — иначе зачем вообще этот СНИЛС, если он не идентифицирует?

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


        1. Nipheris
          06.11.2016 14:45

          Кому должны? В каком случае DTO вообще сравнивать надо?

          Ну, к примеру, определить наличие изменений в ответе сервера, если нет более удобных механизмов вроде ETag. Хочется же один раз такое сравнение написать, и пользоваться им. Логично, что в этом случае DTO должен сравниваться как запись. У него нет своего identity, это лишь слепок состояния объекта.
          Кому должны?

          У типа всегда есть семантика. Я думаю вы сильно удивитесь, если некая языковая среда скажет что 5 != 5 только потому, что 5 и 5 это разные «объекты». У вас есть определённые ожидания от поведения числовых литералов. Аналогичные ожидания есть и в моём примере с DTO.
          Я уже более 10 лет программирую на .NET и еще ни разу не написал в реальном проекте свою реализацию Equals.

          Видимо вы не пользовались NHibernate :). Он Equals любит)


        1. lair
          06.11.2016 18:22

          Я уже более 10 лет программирую на .NET и еще ни разу не написал в реальном проекте свою реализацию Equals.

          И ни одного equality comparer тоже?


          1. gandjustas
            06.11.2016 18:25

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


            1. lair
              06.11.2016 18:27

              Ну то есть вам действительно ни разу не приходилось использовать недефолтный компарер, скажем, для HashSet или Dictionary?


              1. gandjustas
                06.11.2016 19:06

                насколько я помню — да


                1. lair
                  06.11.2016 19:07

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


                  1. gandjustas
                    06.11.2016 19:16

                    Я в этом случае tuple использую.


                    1. lair
                      06.11.2016 19:18

                      Для тупла придется написать собственный компарер (и не забыть передать его в каждую операцию). Написать собственный рекорд в каком-то смысле дешевле.


                      1. 0xd34df00d
                        07.11.2016 04:19

                        И типобезопаснее.


      1. sand14
        06.11.2016 10:54
        +1

        Структуры (struct) изначально были созданы для взаимодействия с неуправлямым API и для производительности работы с примитивными типами вроде чисел.
        Если бы в .NET были бы одни только классы, то все равно пришлось бы решать проблемы различения сущностей-значений и сущностей-объектов.
        Очевидно, что предлагаемый в .NET механизм, когда для этого приходится каждый раз вручную с рядом нюансов реализовывать GetHashCode/Equals и опционально Equals и операторы сравнения, неудобен и имеет большой потенциал ошибок.


        1. lair
          06.11.2016 18:26

          Очевидно, что предлагаемый в .NET механизм, когда для этого приходится каждый раз вручную с рядом нюансов реализовывать GetHashCode/Equals и опционально Equals и операторы сравнения, неудобен и имеет большой потенциал ошибок.

          Не в .net, а в C#. В том же F# берете record type, и ничего не надо реализовывать вручную. Кстати, в C# их тоже обещали, но, кажется, в 7-ом опять не дадут.


          1. sand14
            06.11.2016 19:22
            +1

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

            Или, может, F# задействует некие механизмы .NET, а C# — нет
            — как это было с фильтрами исключений, когда они были на уровне IL и Execution Engine, VB поддерживал их много лет, а C# стал поддерживать только в 6.0

            В .NET вообще нет четкой границы, где кончается платформа, а где начинается язык, и наоборот
            Поэтому вряд ли это сделают в C# только на уровне компилятора, скорее внесут что-то в рантайм, а в FCL еще атрибуты внесут, что-то вроде EquatableProperty.


            1. lair
              06.11.2016 19:27

              Синтаксический сахар. Так что не вижу причин, почему в C# не сделать точно так же.


              1. Nipheris
                06.11.2016 21:04

                Согласен, компилятор F# генерит отличные компараторы. В своё время пару классах для задач на графах решил написать на F# в отдельной сборке, и не пожалел. Впрочем, там существует понятие структурной эквивалентности, которым в C# не пахнет, и которое не нужно было бы, если б записи изначально лучше поддерживались в C# или самой платформе.