О сравнении объектов по значению — 4, или Inheritance & Equality operators

    В предыдущей публикации мы получили вариант реализации сравнения объектов по значению для платформы .NET, на примере класса Person, включающий:


    • перекрытие методов Object.GetHashCode(), Object.Equals(Object);
    • реализацию интерфейса IEquatable (Of T);
    • реализацию Type-specific статических метода Equals(Person, Person) и операторов ==(Person, Person), !=(Person, Person).

    Каждый из способов сравнения для любой одной и той же пары объектов возвращает один и тот же результат:


    Пример кода
    Person p1 = new Person("John", "Smith", new DateTime(1990, 1, 1));
    Person p2 = new Person("John", "Smith", new DateTime(1990, 1, 1));
    //Person p2 = new Person("Robert", "Smith", new DateTime(1991, 1, 1));
    
    object o1 = p1;
    object o2 = p2;
    
    bool isSamePerson;
    
    isSamePerson = o1.Equals(o2);
    isSamePerson = p1.Equals(p2);
    isSamePerson = object.Equals(o1, o2);
    isSamePerson = Person.Equals(p1, p2);
    isSamePerson = p1 == p2;
    isSamePerson = !(p1 == p2);

    При этом, каждый из способов сравнения является коммутативным:
    x.Equals(y) возвращает тот же результат, что и y.Equals(x), и т.д.


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


    Однако, требует раскрытия вопрос:


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


    Для наглядности приведем класс 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);
        }
    }

    И создадим класс-наследник PersonEx:


    class PersonEx
    using System;
    
    namespace HelloEquatable
    {
        public class PersonEx : Person, IEquatable<PersonEx>
        {
            public string MiddleName { get; }
    
            public PersonEx(
                string firstName, string middleName, string lastName, DateTime? birthDate
            ) : base(firstName, lastName, birthDate)
            {
                this.MiddleName = NormalizeName(middleName);
            }
    
            public override int GetHashCode() => GetHashCodeHelper(
                new int[]
                {
                    base.GetHashCode(),
                    this.MiddleName.GetHashCode()
                }
            );
    
            protected static bool EqualsHelper(PersonEx first, PersonEx second) =>
                EqualsHelper((Person)first, (Person)second) &&
                first.MiddleName == second.MiddleName;
    
            public virtual bool Equals(PersonEx 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(Person other) => this.Equals(other as PersonEx);
    
            // Optional overloadings:
    
            public override bool Equals(object obj) => this.Equals(obj as PersonEx);
    
            public static bool Equals(PersonEx first, PersonEx second) =>
                first?.Equals(second) ?? (object)first == (object)second;
    
            public static bool operator ==(PersonEx first, PersonEx second) => Equals(first, second);
    
            public static bool operator !=(PersonEx first, PersonEx second) => !Equals(first, second);
        }
    }

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


    • Реализовать интерфейс IEquatable(Of PersonEx).
    • Реализовать метод PersonEx.Equals(Person), перекрыв унаследованный метод Person.Equals(Person) (стоит обратить внимание, что последний изначально был объявлен виртуальным для учета возможности наследования) и попытавшись привести объект типа Person к типу PersonEx.

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


    При этом:


    • Реализация метода PersonEx.Equals(PersonEx) аналогична реализации метода Person.Equals(Person).
    • Реализация метода PersonEx.Equals(Person) аналогична реализации метода Person.Equals(Object).
    • Реализация статического protected-метода EqualsHelper(PersonEx, PersonEx) аналогична реализации метода EqualsHelper(Person, Person); для повторного использования кода, последний используется в первом методе.

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


    Стоит отметить, что реализация PersonEx.Equals(Object) не является обязательной, т.к. в случае ее отсутствия и вызова клиентским кодом метода Equals(Object) вызвался бы унаследованный метод Person.Equals(Object), который внутри себя вызывает виртуальный метод PersonEx.Equals(Person), приводящий к вызову PersonEx.Equals(PersonEx).
    Однако, метод PersonEx.Equals(Object) реализован для "полноты" кода и большего быстродействия (за счет минимизации количества приведений типов и промежуточных вызовов методов).


    Другими словами, создавая класс PersonEx и наследуя класс Person, мы поступали таким же образом, как при создании класса Person и наследовании класса Object.


    Теперь, какой бы метод у объекта класса PersonEx мы не вызывали:
    Equals(PersonEx), Equals(Person), Equals(object),
    для любой одной и той же пары объектов будет возвращаться один и тот же результат (при смене операндов местами так же будет возвращаться тот же самый результат).
    Обеспечить такое поведение позволяет полиморфизм.


    Также мы реализовали в классе PersonEx статический метод PersonEx.Equals(PersonEx, PersonEx) и соответствующие ему операторы сравнения PersonEx.==(PersonEx, PersonEx) и PersonEx.!=(PersonEx, PersonEx), также действуя таким же образом, как и при при создании класса Person.


    Использование метода PersonEx.Equals(PersonEx, PersonEx) или операторов PersonEx.==(PersonEx, PersonEx) и PersonEx.!=(PersonEx, PersonEx) для любой одной и той же пары объектов даст тот же результат, что и использование экземплярных методов Equals класса PersonEx.


    А вот дальше становится интереснее.


    Класс PersonEx "унаследовал" от класса Person статический метод Equals(Person, Person) и соответствующие ему операторы сравнения ==(Person, Person) и !=(Person, Person).


    Какой результат будет получен, если выполнить следующий код?


    Код
    bool isSamePerson;
    
    PersonEx pex1 = new PersonEx("John", "Teddy", "Smith", new DateTime(1990, 1, 1));
    PersonEx pex2 = new PersonEx("John", "Bobby", "Smith", new DateTime(1990, 1, 1));
    //PersonEx pex2 = new PersonEx("John", "Teddy", "Smith", new DateTime(1990, 1, 1));
    Person p1 = pex1;
    Person p2 = pex2;
    
    isSamePerson = Person.Equals(pex1, pex2);
    isSamePerson = PersonEx.Equals(p1, p2);
    isSamePerson = pex1 == pex2;
    isSamePerson = p1 == p2;

    Несмотря на то, что метод Equals(Person, Person) и операторы сравнения ==(Person, Person) и !=(Person, Person) — статические, результат всегда будет тем же самым, что и при вызове метода Equals(PersonEx, PersonEx), операторов ==(PersonEx, PersonEx) и !=(PersonEx, PersonEx), или любого из экземплярных виртуальных методов Equals.


    Именно для получения такого полиморфного поведения, статические методы Equals и операторы сравнения "==" и "!=", на каждом из этапов наследования реализуются с помощью экземплярного виртуального метода Equals.


    Более того, реализация в классе PersonEx метода Equals(PersonEx, PersonEx) и операторов ==(PersonEx, PersonEx) и !=(PersonEx, PersonEx), так же, как и для метода PersonEx.Equals(Object), является опциональной.
    Метод Equals(PersonEx, PersonEx) и операторы ==(PersonEx, PersonEx) и !=(PersonEx, PersonEx) реализованы для "полноты" кода и большего быстродействия (за счет минимизации количества приведений типов и промежуточных вызовов методов).


    Единственным нестройным моментом в "полиморфности" статических Equals, "==" и "!=" является то, что если два объекта типа Person или PersonEx привести к типу object, то сравнение объектов с помощью операторов == и != будет произведено по ссылке, а с помощью метода Object.Equals(Object, Object) — по значению. Но это — "by design" платформы.


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

    Share post

    Similar posts

    Comments 7

      +1
      «При этом, каждый из способов сравнения является коммуникативным:
      x.Equals(y) возвращает тот же результат, что и y.Equals(x), и т.д.»

      Коммутативным только, наверное.
        0
        Все очень интересно и замечательно, но хочется понять необходимость сравнения объектов по значению в реальных проектах. IMHO для того чтобы, что-то сравнивать по значению — это что-то должно иметь семантику значения. Как правило, для этого используют value-типы, в .net — это скалярные значения, строка и типы унаследованные от System.ValueType (struct). В этом случае проблем с наследованием не возникает. Reference-типы сравнивать по значению если и приходится, то только по причине плохого дизайна. Но даже если гипотетически предположить, что это необходимо, то логику сравнения «по значению» я бы реализовал в виде отдельного класса.
        Что-то типа:
        internal sealed PersonMembersComparer: IEqualityComparer<Person> {
            public bool Equals(Person p1, Person p2) {
        ...
            }
        }
        

          0
          Верно, решение о необходимости встраивания в класс сравнения объектов по значению для каждой сущности нужно принимать отдельно.
          И в идеале, для типов, отличных от примитивных (или условно примитивных — типа TimeSpan или DateTime), при хорошем дизайне, должно быть достаточно сравнения по ссылке.

          А для особых случаев — использовать компаратор, тем более случаи для объектов одного и того же типа могут быть разные:
          может потребоваться сравнение как по некому ID, так и по ФИО — для того же класса Person.
          Значит, нужны разные компараторы, и, возможно, нет смысла реализовывать в самом классе «предустановленное» сравнение по значению.

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

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

          P.S. И еще один момент.
          Вероятно, вы говорили о слое клиентского кода, где создаются программные сущности для той или иной предметной области, и для которых лучше использовать компараторы.
          Но если речь вести о разработке какого-либо фреймворка, API, то там будут создаваться достаточно универсальные сущности, наподобие примитивных типов или стандартных классов платформы.
          И скорее всего, для этих сущностей потребуется в т.ч. и сравнение по значению.
            +1
            В вашей статье используется class Person, уж очень распространенная бизнес-сущность. А вот Uri — хороший пример или IPAddress и т.п., эти типы как раз имеют семантику значения.
            Я не отрицаю, что это кому-то может понадобиться, но даже для .net framework переопределение Equals является очень уж нетипичной задачей. В mscorlib, System, System.Core и System.Data метод Equals переопределен лишь у каждого 100-го класса ~1%, а всего их более 7000, т.е. где-то 70-80.

            В «прикладных» бизнес-системах я несколько раз встречал классы, которые реализовали сравнение по значению, но это были как раз случаи плохого дизайна, который в конечном итоге «выпиливался» из системы.

            Диссонанс возник именно из-за класса Person, который мне показался слишком «бизнесовым». IMHO реализация сравнения ссылочных типов по значению в бизнес системах — вещь вредная и опасная, т.к. не всем будет понятна логика реализации сравнения таких объектов, кроме этого не так часто можно встретить immutable класс.
              0
              А вот Uri — хороший пример или IPAddress и т.п., эти типы как раз имеют семантику значения.

              Повторюсь, и даже Uri.Equals в стандартной библиотеке реализован, скорее всего, некорректно (разбор случая Uri — двумя статьями ранее).
              Поэтому тема важная.

              Насчет Person и остального в целом вы правы.
                0
                кроме этого не так часто можно встретить immutable класс.

                На мой взгляд, immutable-классы в бизнес-логике могут применяться чаще, что позволило бы обеспечить более предсказуемую работу логики, но это уже другой разговор.
              0
              Reference-типы сравнивать по значению если и приходится, то только по причине плохого дизайна.

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

              Например, когда по надуманным соображениям создается обертка вокруг DateTime (причем не Struct, а Reference-обертка), чтобы приводить размерность к меньшей размерности, используемой в БД.

              (Наверное, не стоит пояснять почему — потом этот велосипед тащится в коде из версии в версию, даже когда в БД уже 10 лет как поддерживаются нормальные DateTime, что изначальная проблема должна была решаться не созданием велосипеда, и т.д.)

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