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


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


Теперь рассмотрим 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 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 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, чревата еще и тем, что как только в классе реализовано сравнение объектов по значению, то реализацию сравнения придется "тащить" (и делать это правильным образом) в классы-наследники, что несет дополнительные затраты и потенциал ошибок.


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

-->


К сожалению, не доступен сервер mySQL