О сравнении объектов по значению — 2, или Особенности реализации метода Equals

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

    Эти доработки включают перекрытие методов Object.Equals(Object) и Object.GetHashCode().

    Остановимся подробнее на особенностях реализации метода Object.Equals(Object) для соответствия следующему требованию в документации:

    x.Equals(y) returns the same value as y.Equals(x).

    Класс Person, созданный в предыдущей публикации, содержит следующую реализацию метода Equals(Object):

    Person.Equals(Object)
    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);
    }

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

    В соответствии с примером, приведенным в документации, приведение производится с помощью оператора as. Проверим, дает ли это корректный результат.

    Реализуем класс PersonEx, унаследовав класс Person, добавив в персональные данные свойство Middle Name, и перекрыв соответствующим образом методы Person.Equals(Object) и Person.GetHashCode().

    Класс PersonEx:

    class PersonEx
    using System;
    
    namespace HelloEquatable
    {
        public class PersonEx : Person
        {
            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() =>
                base.GetHashCode() ^
                this.MiddleName.GetHashCode();
    
            protected static bool EqualsHelper(PersonEx first, PersonEx second) =>
                EqualsHelper((Person)first, (Person)second) &&
                first.MiddleName == second.MiddleName;
    
            public override bool Equals(object obj)
            {
                if ((object)this == obj)
                    return true;
    
                var other = obj as PersonEx;
    
                if ((object)other == null)
                    return false;
    
                return EqualsHelper(this, other);
            }
        }
    }

    Легко заметить, что если у объекта класса Person вызвать метод Equals(Object) и передать в него объект класса PersonEx, то, если у этих объектов (персон) совпадают имя, фамилия и дата рождения, метод Equals возвратит true, в противном случае метод возвратит false.

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

    Очевидно, что с предметной точки зрения это неверное поведение:

    Совпадение имени, фамилии и даты рождения не означает, что это одна и та же персона, т.к. у одной персоны отсутствует атрибут middle name (речь не о неопределенном значении атрибута, а об отсутствии самого атрибута), а у другой имеется атрибут middle name.
    (Это разные типы сущностей.)

    Если же, напротив, у объекта класса PersonEx вызвать метод Equals(Object) и передать в него объект класса Person, то метод Equals в любом случае возвратит false, независимо от значений свойств объектов.

    (При выполнении метода Equals, входящий объект, имеющий во время выполнения (runtime) тип Person, не будет успешно приведен к типу PersonEx с помощью оператора as — результатом приведения будет null, и метод возвратит false.)

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

    Эти виды поведения можно легко проверить, выполнив следующий код:

    Код
    var person = new Person("John", "Smith", new DateTime(1990, 1, 1));
    var personEx = new PersonEx("John", "Teddy", "Smith", new DateTime(1990, 1, 1));
    
    bool isSamePerson = person.Equals(personEx);
    bool isSamePerson2 = personEx.Equals(person);

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

    А именно соответствие требованию:

    x.Equals(y) returns the same value as y.Equals(x).

    Это требование не выполняется.

    (А с точки зрения здравого смысла, какие могут быть проблемы при текущей реализации Equals(Object)?
    У разработчика типа данных нет информации, каким именно способом будут сравниваться объекты — x.Equals(y) или y.Equals(x) — как в клиентском коде (при явном вызове Equals), так и при помещении объектов в хеш-наборы (хеш-карты) и словари (внутри самих наборов/словарей).

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

    Рассмотрим, каким именно образом можно реализовать метод Equals(Object), обеспечив ожидаемое поведение.


    На текущий момент представляется корректным способ, предложенный Джеффри Рихтером (Jeffrey Richter) в книге CLR via C# (Part II «Designing Types», Chapter 5 «Primitive, Reference, and Value Types», Subchapter «Object Equality and Identity»), когда перед сравнением объектов непосредственно по значению, типы объектов во время выполнения (runtime), полученные с помощью метода Object.GetType() проверяются на равенство (вместо односторонних проверки на совместимость и приведения типов объектов с помощью оператора as):

    if (this.GetType() != obj.GetType())
        return false;
    

    Следует отметить, что использование данного способа не является однозначным, т.к. существует три различных способа проверки на равенство экземпляров класса Type, с теоретически различными результатами для одних и тех же операндов:

    1. Согласно документации к методу Object.GetType():

    For two objects x and y that have identical runtime types, Object.ReferenceEquals(x.GetType(),y.GetType()) returns true. 

    Таким образом, объекты класса Type можно проверить на равенство с помощью сравнения по ссылке:

    bool isSameType = (object)obj1.GetType() == (object)obj2.GetType();
    или
    bool isSameType = Object.ReferenceEquals(obj1.GetType(), obj2.GetType());

    2. Класс Type имеет методы Equals(Object) и Equals(Type), поведение которых определено следующим образом:
    Determines if the underlying system type of the current Type object is the same as the underlying system type of the specified Object.

    Return Value
    Type: System.Boolean
    true if the underlying system type of o is the same as the underlying system type of the current Type; otherwise, false. This method also returns false if:
    o is null.
    o cannot be cast or converted to a Type object.

    Remarks
    This method overrides Object.Equals. It casts o to an object of type Type and calls the Type.Equals(Type) method.
    и
    Determines if the underlying system type of the current Type is the same as the underlying system type of the specified Type.

    Return Value
    Type: System.Boolean
    true if the underlying system type of o is the same as the underlying system type of the current Type; otherwise, false.

    Внутри эти методы реализованы следующим образом:

    public override bool Equals(Object o)
    {
        if (o == null)
            return false;
    
        return Equals(o as Type);
    }
    

    и

    public virtual bool Equals(Type o)
    {
        if ((object)o == null)
            return false;
    
        return (Object.ReferenceEquals(this.UnderlyingSystemType, o.UnderlyingSystemType));
    }
    

    Как видим, результат выполнения обоих методов Equals для объектов класса Type в общем случае может отличаться от сравнения объектов по ссылке, т.к. в случае использования методов Equals, сравниваются по ссылке не сами объекты класса Type, а их свойства UnderlyingSystemType, относящиеся к тому же классу.

    Однако, из описания методов Equals класса Type.Equals(Object) представляется, что они не предназначены для сравнения непосредственно объектов класса Type.

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

    3. Класс Type, начиная с .NET Framework 4.0, имеет перегруженные операторы == или !=, поведение которых описывается простым образом, без описания деталей реализации:
    Indicates whether two Type objects are equal.

    Return Value
    Type: System.Boolean
    true if left is equal to right; otherwise, false.
    и
    Indicates whether two Type objects are not equal.

    Return Value
    Type: System.Boolean
    true if left is not equal to right; otherwise, false.
    Изучение исходных кодов тоже не дает информации по деталям реализации, для выяснения внутренней логики операторов:

    public static extern bool operator ==(Type left, Type right);
    public static extern bool operator !=(Type left, Type right);

    Исходя из анализа трех документированных способов сравнения объектов класса Type, представляется, что наиболее корректным способом сравнения объектов будет использование операторов "==" и "!=".
    В зависимости от целевой платформы (Target Platform) проекта, исходный код будет собран либо с использованием сравнения по ссылке (идентично первому варианту), либо с использованием перегруженных операторов == и !=.

    Реализуем классы Person и PersonEx соответствующим образом:

    class Person (with new Equals method)
    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;
    
                if (obj == null)
                    return false;
    
                if (this.GetType() != obj.GetType())
                    return false;
    
                return EqualsHelper(this, (Person)obj);
            }
        }
    }
    

    class PersonEx (with new Equals method)
    using System;
    
    namespace HelloEquatable
    {
        public class PersonEx : Person
        {
            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() =>
                base.GetHashCode() ^
                this.MiddleName.GetHashCode();
    
            protected static bool EqualsHelper(PersonEx first, PersonEx second) =>
                EqualsHelper((Person)first, (Person)second) &&
                first.MiddleName == second.MiddleName;
    
            public override bool Equals(object obj)
            {
                if ((object)this == obj)
                    return true;
    
                if (obj == null)
                    return false;
    
                if (this.GetType() != obj.GetType())
                    return false;
    
                return EqualsHelper(this, (PersonEx)obj);
            }
        }
    }
    

    Теперь следующее требование к реализации метода Equals(Object) будет соблюдаться:

    x.Equals(y) returns the same value as y.Equals(x).

    что легко проверяется выполнением кода:

    Код
    var person = new Person("John", "Smith", new DateTime(1990, 1, 1));
    var personEx = new PersonEx("John", "Teddy", "Smith", new DateTime(1990, 1, 1));
    
    bool isSamePerson = person.Equals(personEx);
    bool isSamePerson2 = personEx.Equals(person);

    Примечания к реализации метода Equals(Object):

    1. вначале проверяются на равенство ссылки, указывающие на текущий и входящий объекты, и, в случае совпадения ссылок, возвращается true;
    2. затем проверяется на null ссылка на входящий объект, и, в случае положительного результата проверки, возвращается false;
    3. затем проверяется идентичность типов текущего и входящего объекта, и, в случае отрицательного результата проверки, возвращается false;
    4. на последнем этапе производятся приведение входящего объекта к типу данного класса и непосредственно сравнение объектов по значению.

    Таким образом, мы нашли оптимальный способ реализации ожидаемого поведения метода Equals(Object).


    На десерт проверим корректность реализации Equals(Object) в стандартной библиотеке.

    Метод Uri.Equals(Object):

    Compares two Uri instances for equality.

    Syntax
    public override bool Equals(object comparand)

    Parameters
    comparand
    Type: System.Object
    The Uri instance or a URI identifier to compare with the current instance.

    Return Value
    Type: System.Boolean
    A Boolean value that is true if the two instances represent the same URI; otherwise, false.

    Uri.Equals(Object)
    public override bool Equals(object comparand)
    {
        if ((object)comparand == null)
        {
            return false;
        }
    
        if ((object)this == (object)comparand)
        {
            return true;
        }
    
        Uri obj = comparand as Uri;
    
        //
        // we allow comparisons of Uri and String objects only. If a string
        // is passed, convert to Uri. This is inefficient, but allows us to
        // canonicalize the comparand, making comparison possible
        //
        if ((object)obj == null)
        {
            string s = comparand as string;
    
            if ((object)s == null)
                return false;
    
            if (!TryCreate(s, UriKind.RelativeOrAbsolute, out obj))
                return false;
        }
    
        // method code ...
    }
    

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

    x.Equals(y) returns the same value as y.Equals(x).

    Т.к. класс String и метод String.Equals(Object), в свою очередь, не «знают» о существовании класса Uri.

    Это легко проверить на практике, выполнив код:

    Код
    const string uriString = "https://www.habrahabr.ru";
    Uri uri = new Uri(uriString);
    
    bool isSameUri = uri.Equals(uriString);
    bool isSameUri2 = uriString.Equals(uri);

    В продолжении мы рассмотрим реализацию интерфейса IEquatable(Of T) и type-specific метода IEquatable(Of T).Equals(T), перегрузку операторов равенства и неравенства для сравнения объектов по значению, и найдем способ наиболее компактно, согласованно и производительно реализовать в одном классе все виды проверок по значению.

    Поделиться публикацией

    Комментарии 13

      0
      if (this.GetType() != obj.GetType())
      return false;


      Следует отметить, что данный способ не является однозначным

      Почему же? Он полностью однозначен: .net дает ожидаемое поведение в отношении оператора !=, примененного к операндам типа Type (даже если там на самом деле RuntimeType, как обычно бывает во время выполнения). Если вам удалось добиться от него других результатов — покажите, как.


      Однако, из описания методов Equals класса Type.Equals(Object) представляется, что они не предназначены для сравнения непосредственно объектов класса Type.

      Но почему? Они как раз для этого и предназначены, просто они учитывают специфику работы с Type и RuntimeType.


      В частности, у RuntimeType первый метод как раз перекрыт, и имеет вид obj == (object)this (второй не перекрыт, потому что мы не знаем, чем является второй операнд).


      Исходя из анализа трех документированных способов сравнения объектов класса Type, представляется, что наиболее корректным способом сравнения объектов будет использование операторов "==" и "!=", и, в зависимости от целевой платформы (Target Platform) при сборке, исходный код будет собран либо с использованием сравнения по ссылке (идентично первому варианту), либо с использованием перегруженных операторов "==" и "!=".

      В реальной жизни слева и справа у вас будет RuntimeType чуть реже, чем всегда. Что означает, что будет вызван перегруженный оператор ==, который, внезапно, выглядит вот так:


      public static bool operator ==(RuntimeType left, RuntimeType right) => object.ReferenceEquals(left, right);

      Так что в итоге, в реальной жизни, в которой вы имеете дело с "обычным" типом, у вас оба операнда будут иметь тип RuntimeType, и что бы вы ни делали — left == right, left.Equals(right), Object.ReferenceEquals(left, right), у вас все равно будет одно и то же поведение. Что, в общем-то, позволяет нам выбирать по читаемости (где, конечно, == выигрывает).


      Для метода Type.Equals(Object) проблема несоответствия требованию (как следствие использования оператора as) "x.Equals(y) returns the same value as y.Equals(x)." не возникнет, т.к. класс Type — абстрактный, если только в потомках класса метод не будет перекрыт некорректным образом. Для предотвращения этой потенциальной проблемы, возможно, стоило объявить метод как sealed.

      Нет, не стоило. Как можно видеть, RuntimeType совершенно разумно перекрывает этот метод, выкидывая приличное количество лишних операций.

        0
        В реальной жизни слева и справа у вас будет RuntimeType чуть реже, чем всегда. Что означает, что будет вызван перегруженный оператор ==

        Я не прав, конечно, перегрузка операторов так не сработает, и для результатов двух GetType() будет вызван ==(Type, Type). Но наблюдаемого поведения это не изменит.

        0

        (del)

          0
          > // и, как следствие, следующему: 

          Не как следствие. Коммутативность и транзитивность – независимые свойства.
            0
            а почему мы должны следовать логике, что в случае если объекты не равны по типу, то они не могут быть равны между собой? Объекты различной природы? Но мы же всегда можем сказать, что PersonEx as Person (филисофский вопрос в том, знаем ли мы что это на самом деле PersonEx когда работаем с Person)

            Мне интересно порассуждать: а что если мы моделируем предметную область, и понимаем, что есть где то метод, который принимает Person и внутри вызывает Equals(), например легаси код. Тут мы создаем наследника PersonEx и начинаем теперь экземпляры PersonEx передавать в метод. В легаси коде мы не знаем что у нас может быть PersonEx и что мы теперь добавили сравнение по типу. При дебаге в рантайме мы то конечно увидим, что объекты уже разного типа, но с точки зрения метода мы не сможем отличить PersonEx и Person, если, например у них равны все поля кроме MiddleName. Получается ли нарушение LSP в таком случае?
              +1

              Легаси код будет принимать объекты типа Person не зная, Person это или PersonEx
              В каким порядке при сравнении легаси код будет вызывать Equals — person.Equals(personEx) или personEx.Equals(person) — недетерминировано.


              В этом случае, чтобы обеспечить требование "x.Equals(y) == y.Equals(x)" и получить в легаси коде на выходе всегда верный результат сравнения (т.к. для легаси кода x и y — всегда Person), вам нужно в PersonEx.Equals при сравнении проверять/приводить тип входящего дважды — к PersonEx, и, если нет, то к Person.


              Тогда вы получаете немасштабируемый код — любое новое наследование от Person или PersonEx потребует копипасты одинаковых проверок во всех типах иерархии, порожденных от Person.


              И в любом случае, это будет неверно с предметной точки зрения, т.к. сравнивать записи "Иван Иванов" и "Иван Иванович Иванов" не имеет смысла — вы не сможете сказать, равны эти записи или нет.
              Если же вы по каким то причинам используете запись "Иван Иванович Иванов" (PersonEx) именно как "Иван Иванов" (Person), т.е. везде при работе с данной записью не используете отчество, то встает вопрос, почему используется PersonEx, а не Person.


              А вот реальный кейс, когда вы можете использовать записи разного типа:
              Предположим, вам нужно выбрать всех "Иванов Ивановых" независимо от отчества.
              В этом случае нужно использовать внешний компаратор, а не вшивать логику выборки под частные требования в сам тип.
              И лучше, если компаратор вообще будет принимать не корневой тип, а интерфейс.

                +1
                И в любом случае, это будет неверно с предметной точки зрения, т.к. сравнивать записи «Иван Иванов» и «Иван Иванович Иванов» не имеет смысла — вы не сможете сказать, равны эти записи или нет.


                ну как раз таки смысл в том, что с точки зрения легаси кода «Иван Иванов» и «Иван Иванович Иванов» для меня одно и то же, так как при проекторовании кода я вообще не знал что может быть Иванович или какой-то PersonEx и мне в принципе не важно это в моем легаси коде. Оба объекта типа Person — OK, оба объекта имеют одинаковые значения в поля Name и Surname — OK, значит они равны. Именно это закладывалось при проектировании старого метода.

                Тогда вы получаете немасштабируемый код — любое новое наследование от Person или PersonEx потребует копипасты одинаковых проверок во всех типах иерархии, порожденных от Person.


                Согласен. А что если просто при несовпадении типов в PersonEx делегировать сравнение в базовый класс? Тогда конечно получается, что объекты будут равны если их типы не совпадают по базовому сравнению. В этом, ВОЗМОЖНО, есть смысл, так как тогда мы LSP не нарушим и будем всегда уверены, что наш протестированный код работает как надо.

                т.е. везде при работе с данной записью не используете отчество, то встает вопрос, почему используется PersonEx, а не Person.


                ну вот был у меня написан метод, когда еще не предполагалось наличие PersonEx, и я ожидаю что при добавлении наследника мой код продолжит работать как и раньше (опять тот же LSP)
                  0
                  ну как раз таки смысл в том, что с точки зрения легаси кода «Иван Иванов» и «Иван Иванович Иванов» для меня одно и то же, так как при проекторовании кода я вообще не знал что может быть Иванович или какой-то PersonEx и мне в принципе не важно это в моем легаси коде.

                  Тогда получается, что для нового кода и новой схемы данных, оперирующими PersonEx и передающих PersonEx в легаси-код, два Иванова Ивана с разными отчествами — два разных человека, а легаси-код выведет в какой-нибудь отчет или файл обмена запись только об одном человеке.


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


                  И спасибо за вопросы и дискуссию по существу. Этим циклом статей я и хотел предложить задуматься о, казалось бы на первый взгляд, такой тривиальной теме, как Object Equality.

                    0
                    Тогда получается, что для нового кода и новой схемы данных, оперирующими PersonEx и передающих PersonEx в легаси-код, два Иванова Ивана с разными отчествами — два разных человека, а легаси-код выведет в какой-нибудь отчет или файл обмена запись только об одном человеке.


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

                    Поэтому думаю, что реализация Equality в самом типе данных имеет очень малую степень применимости — делать сравнение только для объектов с идентичным рантайм-типом, и только для объектов с семантикой «значение» (и сущность Person тут не подходит, кстати).
                    Во всех остальных случаях нужен кастомный компаратор в зависимости от задачи, и вопрос в том, что, легаси код должен уметь принимать такой компаратор для сравнения.


                    здесь абсолютно согласен, даже можно переформулировать — при проектировании бизнес-логики, имеет смысл внедрять зависимость от компаратора, так как это поможет и в тестировании и в поддержке в будущем. В моем варианте получлось бы так, что когда внесли новый тип данных и использовали старый компаратор — нашли бы ошибку в нем, и принцип LSP не был бы нарушен
                    0
                    ну вот был у меня написан метод, когда еще не предполагалось наличие PersonEx, и я ожидаю что при добавлении наследника мой код продолжит работать как и раньше (опять тот же LSP)

                    PS. В том то и дело, что одно дело — LSP, и когда я начинал писать примеры для статьи, вначале рассуждал, как вы.
                    Но потом оказалось, что случае одновременных наследования (с расширением списка полей при сравнении) и реализации Object Equality мы можем обеспечить принцип LSP, но нарушаются другие базовые принципы.

                      0
                      А что если просто при несовпадении типов в PersonEx делегировать сравнение в базовый класс? Тогда конечно получается, что объекты будут равны если их типы не совпадают по базовому сравнению.

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

                  0

                  del.

                    0
                    Тогда получается, что для нового кода и новой схемы данных, оперирующими PersonEx и передающих PersonEx в легаси-код, два Иванова Ивана с разными отчествами — два разных человека, а легаси-код выведет в какой-нибудь отчет или файл обмена запись только об одном человеке.


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

                    Поэтому думаю, что реализация Equality в самом типе данных имеет очень малую степень применимости — делать сравнение только для объектов с идентичным рантайм-типом, и только для объектов с семантикой «значение» (и сущность Person тут не подходит, кстати).
                    Во всех остальных случаях нужен кастомный компаратор в зависимости от задачи, и вопрос в том, что, легаси код должен уметь принимать такой компаратор для сравнения.


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

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

                    Самое читаемое