О сравнении объектов по значению — 3, или Type-specific Equals & Equality operators

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


    Теперь рассмотрим Type-specific реализацию сравнения объектов по значению, включающую реализацию Generic-интерфейса IEquatable(Of T) и перегрузку операторов "==" и "!=".


    Type-specific сравнение объектов по значению позволяет достичь:


    • Более стабильного, масштабируемого и мнемонического (читаемого) кода (последнее за счет перегруженных операторов).
    • Более высокой производительности.

    Кроме того, реализация Type-specific сравнения по значению необходима по причинам:


    • Стандартные Generic-коллекции (List(Ot T), Dictionary(Of TKey, TValue) и др.) рекомендуют наличие реализации IEquatable(Of T) для всех объектов, помещаемых в коллекции.
    • Стандартный компаратор EqualityComparer(Of T).Default использует (по умолчанию — при наличии) реализацию IEquatable(Of T) у операндов.

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


    • Соответствие результатов сравнения у различных способов (включая сохранение соответствия при наследовании).
    • Минимизацию copy-paste и общего объема кода.
    • Учет того, что операторы сравнения технически являются статическими методами и, соответственно, у них отсутствует полиморфность (а также, что не все CLS-совместимые языки поддерживают операторы или их перегрузку).

    Рассмотрим Type-specific реализацию сравнения объектов по значению с учетом вышеизложенных условий, на примере класса Person.


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


    Класс Person с реализацией полного набора способов сравнения объектов по значению:


    class Person
    using System;
    
    namespace HelloEquatable
    {
        public class Person : IEquatable<Person>
        {
            protected static int GetHashCodeHelper(int[] subCodes)
            {
                if ((object)subCodes == null || subCodes.Length == 0)
                    return 0;
    
                int result = subCodes[0];
    
                for (int i = 1; i < subCodes.Length; i++)
                    result = unchecked(result * 397) ^ subCodes[i];
    
                return result;
            }
    
            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() => GetHashCodeHelper(
                new int[]
                {
                    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 virtual bool Equals(Person other)
            {
                //if ((object)this == null)
                //    throw new InvalidOperationException("This is null.");
    
                if ((object)this == (object)other)
                    return true;
    
                if ((object)other == null)
                    return false;
    
                if (this.GetType() != other.GetType())
                    return false;
    
                return EqualsHelper(this, other);
            }
    
            public override bool Equals(object obj) => this.Equals(obj as Person);
    
            public static bool Equals(Person first, Person second) =>
                first?.Equals(second) ?? (object)first == (object)second;
    
            public static bool operator ==(Person first, Person second) => Equals(first, second);
    
            public static bool operator !=(Person first, Person second) => !Equals(first, second);
        }
    }

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


    2. Статический protected метод-хелпер EqualsHelper(Person, Person) сравнивает два объекта по полям, сочетание значений которых образует уникальность значения конкретного объекта.


    3. Виртуальный метод Person.Equals(Person) реализует интерфейс IEquatable(Of Person).
      (Метод объявлен виртуальным, т.к. его перекрытие понадобится при наследовании — будет рассмотрено ниже.)


      • На "нулевом" шаге закомментирован код, проверяющий на null ссылку на текущий объект.
        Если ссылка равна null, то генерируется исключение InvalidOperationException, говорящее о том, что объект находится в недопустимом состоянии. Зачем это может быть нужно — чуть ниже.
      • На первом шаге проверяется равенство по ссылке текущего и входящего объекта. Если да — то объекты равны (это один и тот же объект).
      • На втором шаге проверяется на null ссылка на входящий объект. Если да — то объекты не равны (это разные объекты).
        (Равенство по ссылке проверяется с помощью операторов == и !=, с предварительным приведением операндов к object для вызова неперегруженного оператора, либо с помощью метода Object.ReferenceEquals(Object, Object). Если используются операторы == и !=, то в данном случае приведение операндов к object обязательно, т.к. в данном классе эти операторы будут перегружены и сами будут использовать метод Person.Equals(Person).)
      • Далее проверяется идентичность типов текущего и входящего объектов. Если типы не идентичны — то объекты не равны.
        (Проверка идентичности типов объектов, вместо проверки совместимости, используется для учета реализации сравнения по значению при наследовании типа. Подробнее об этом в предыдущей публикации.)
      • Затем, если предыдущие проверки не позволили дать быстрый ответ, равны объекты или нет, то текущий и входящий объекты проверяются непосредственно по значению с помощью метода-хелпера EqualsHelper(Person, Person).

    4. Метод Person.Equals(Object), реализован как вызов метода Person.Equals(Person) с приведением входящего объекта к типу Person с помощью оператора as.


      • Примечание. Если типы объектов не совместимы, то результатом приведения будет null, что приведет к получению результата сравнения объектов в методе Person.Equals(Person) на втором шаге (объекты не равны).
        Однако, в общем случае, результат сравнения в методе Person.Equals(Person) может быть получен и на первом шаге (объекты равны), т.к. теоретически в .NET возможен вызов экземплярного метода без создания экземпляра (подробнее об этом в первой публикации).
        И тогда, если ссылка на текущий объект будет равна null, ссылка на входящий объект будет не равна null, а типы текущего и входящего объектов будут несовместимы, то такой вызов Person.Equals(Object) с последующим вызовом Person.Equals(Person) даст неверный результат на первом шаге — "объекты равны", в то время на самом деле объекты не равны.
        Представляется, что такой редкий случай не требует специальной обработки, т.к. вызов экземплярного метода и использование его результата не имеет смысла без создания самого экземпляра.
        Если же потребуется его учесть, то достаточно раскомментировать код "нулевого шага" в методе Person.Equals(Person), что не только предотвратит получение теоретически возможного неверного результата при вызове метода Person.Equals(Object), но и, при непосредственном вызове метода Person.Equals(Person) у null-объекта, сгенерирует на "нулевом" шаге более информативное исключение, вместо NullReferenceException на третьем шаге.

    5. Для поддержки статического сравнения объектов по значению для CLS-совместимых языков, не поддерживающих операторы или их перегрузку, реализован статический метод Person.Equals(Person, Person).
      (В качестве Type-specific, и более быстродействующей, альтернативы методу Object.Equals(Object, Object).)
      (О необходимости реализации методов, соответствующих операторам, и рекомендации по соответствию операторов и имен методов, можно прочесть в книге Джеффри Рихтера (Jeffrey Richter) CLR via C# (Part II "Designing Types", Chapter 8 "Methods", Subchapter "Operator Overload Methods").)


      • Метод Person.Equals(Person, Person) реализован через вызов экземплярного виртуального метода Person.Equals(Person), т.к. это необходимо для обеспечения того, чтобы "вызов x == y давал давал тот же результат, что и вызов "y == x", что соответствует требованию "вызов x.Equals(y) должен давать тот же результат, что и вызов y.Equals(x)" (подробнее о последнем требовании, включая его обеспечение при наследовании — в предыдущей публикации).
      • Т.к. статические методы при наследовании типа не могут быть перекрыты (речь именно о перекрытии — override, а не о переопределении — new), т.е. не имеют полиморфного поведения, то причина именно такой реализации — вызов статического метода Person.Equals(Person, Person) через вызов виртуального экземплярного Person.Equals(Person) — именно в необходимости обеспечить полиморфизм при статических вызовах, и, тем самым, обеспечения соответствия результатов "статического" и "экземплярного" сравнения при наследовании.
      • В методе Person.Equals(Person, Person) вызове экземплярного метода Person.Equals(Person) реализован с проверкой на null ссылки на тот объект, у которого вызывается метод Equals(Person).
        Если этот объект — null, то выполняется сравнение объектов по ссылке.

    6. Перегруженные операторы Person.==(Person, Person) и Person.!=(Person, Person) реализованы с помощью вызова "как есть" статического метода Person.Equals(Person, Person) (для оператора "!=" — в паре с оператором !).

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


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


    Пусть есть класс PersonEx, наследующий класс Person, и имеющий дополнительное свойство MiddleName. В этом случае сравнение двух объектов класса PersonEx:


    John Teddy Smith 1990-01-01
    John Bobby Smith 1990-01-01
    

    Любым реализованным способом даст результат "объекты равны", что неверно с предметной точки зрения.


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


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

    Share post

    Similar posts

    Comments 13

      0
      Type-specific сравнение объектов по значению позволяет достичь [...] Более высокой производительности.

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


      Кроме того, реализация Type-specific сравнения по значению необходима по причинам:

      Стандартные Generic-коллекции (List(Ot T), LinkedList(Of T), Dictionary(Of TKey, TValue) и др.) требуют реализацию IEquatable(Of T) для всех объектов, хранимых в коллекциях.

      Нет.



      Стандартный компаратор-по-значению EqualityComparer(Of T) по умолчанию использует реализацию IEquatable(Of T) у операндов.

      Вы, наверное, имеете в виду EqualityComparer<T>.Default. В таком случае, ваше утверждение верно только частично. Действительно, если T в EqualityComparer<T>.Default реализует IEquatable<T>, то компаратор будет использовать эту реализацию (через GenericEqualityComparer<T>). Однако, T не обязан реализовывать IEquatable<T> — в этом случае будет использован ObjectEqualityComparer<T>.


      Таким образом, хотя реализация IEquatable<T> и имеет некоторые преимущества, для указанных вами сценариев она не обязательна.

        +1
        List(Of T): T неограничен
        LinkedList(Of T): T неограничен
        Dictionary(Of TKey, TValue): ни TKey, ни TValue не ограничены

        Верно, не требуют обязательного IEquatable(Of T), т.к. «It should be implemented for any object that might be stored in a generic collection.»,
        а не «It must be implemented».

        Стоит уточнить формулировку в статье.
          0
          It should be implemented for any object that might be stored in a generic collection.

          … зачем мне это, если я использую List<T>, на котором никогда не вызываю ни одного метода, работающего со сравнениями объектов?

          +1
          Вы, наверное, имеете в виду EqualityComparer(Of T).Default. В таком случае, ваше утверждение верно только частично. Действительно, если T в EqualityComparer(Of T).Default реализует IEquatable(Of T), то компаратор будет использовать эту реализацию (через GenericEqualityComparer(Of T)). Однако, T не обязан реализовывать IEquatable(Of T) — в этом случае будет использован ObjectEqualityComparer(Of T).

          «по умолчанию» относится к «по умолчанию использует реализацию», т.е. когда есть Generic-реализация.
          а не «компаратор по умолчанию».
          Требуется уточнение формулировки в статье.
            +1
            Я надеюсь, у вас есть конкретные бенчмарки в пользу этого утверждения?
            Давайте определимся — ранее, сейчас и далее, мы говорим о контрактах/спецификациях, а не конкретных реализациях, которые могут быть оптимизированы, в т.ч. на уровне JIT и т.д.

            Из двух методов Equals, где заведомо все строки совпадают, но в одном есть дополнительное приведение через as, один будет заведомо быстрее другого. Насколько — другой вопрос.

            То же касается и случая, когда хеш-контейнер для входящего объекта проверяет вначале наличие Generic-метода Equals, и только затем Object-метода.
            (Хотя последний пример — зависит от реализации контейнера, но можно предполагать, что стандартные контейнеры вначале будут проверять Generic-версию.)
              +1
              мы говорим о контрактах/спецификациях

              В контракте и/или спецификации есть какое-либо конкретное утверждение, подтверждающее ваш тезис?


              Из двух методов Equals, где заведомо все строки совпадают, но в одном есть дополнительное приведение через as, один будет заведомо быстрее другого.

              … если не учитывать накладные расходы на остальные операции. Именно поэтому важны бенчмарки, а не теоретические рассуждения о "будет заведомо быстрее".


              хеш-контейнер для входящего объекта проверяет вначале наличие Generic-метода Equals, и только затем Object-метода. [...] можно предполагать, что стандартные контейнеры вначале будут проверять Generic-версию.

              Нет. Стандартные контейнеры достают все тот же EqualityComparer<T>.Default, который и используют впоследствии. Так что там есть однократная потенциальная потеря в момент создания компарера, но в операциях работы с элементами разницы нет.

            0
            Статический protected метод-хелпер EqualsHelper(Person, Person) сравнивает два объекта по полям, сочетание значений которых образует уникальность значения конкретного объекта.

            Совершенно не понятно, зачем он такой нужен, когда можно просто использовать Equals(Person).


            Я бы понял, если бы он проверки на null делал, но он же ожидает не-null объекты с обеих стороны. И вызываете вы его ровно в одном месте.


            Аналогично, я бы понял, если бы вы его вызывали отовсюду, откуда можно, вместо a.Equals(b), чтобы получить call вместо callvirt (но тут возникают сложности с полиморфизмом, но они у вас и так возникают пока).

              +1
              Совершенно не понятно, зачем он такой нужен, когда можно просто использовать Equals(Person).
              И вызываете вы его ровно в одном месте.

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

                То есть вы опять дали нам неполный пример. Спасибо, но нет.

              +1
              реализован статический метод Person.Equals(Person, Person).

              Отдельный задорный вопрос. Как должен себя вести этот метод, когда у нас есть PersonEx: Person, имеющий свой собственный PersonEx.Equals(PersonEx, PersonEx)? Должна ли для пользователя быть разница между PersonEx.Equals(pex1, pex2) и Person.Equals(pex1, pex2)?

                –2
                Ответ на этот вопрос уже есть в статье/коде.
                Подробный разбор этого вопроса с классом-наследником и примером клиентского кода будет отдельно.
                  +1

                  Неа, нету. Если вы считаете, что есть, то вам не составит труда его процитировать.

                –1

                Почему-то возникает стойкое ощущение спама статьями качества ниже среднего ради высокого рейтинга здесь и сейчас.

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