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

в 13:14, , рубрики: .net, C#, equality operators, Equals, Equals<T>, GetHashCode, object equality, Программирование, Проектирование и рефакторинг, Совершенный код

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

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

Остановимся подробнее на особенностях реализации метода Object.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.

Класс 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 isEqualTypes = (object)obj1.GetType() == (object)obj2.GetType();

или

bool isEqualTypes = 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, представляется, что наиболее корректным способом сравнения объектов будет использование операторов "==" и "!=", и, в зависимости от используемой версии .NET при сборке, исходный код будет собран либо с использованием сравнение по ссылке (идентично первому варианту), либо с использованием перегруженных операторов "==" и "!=".

Реализуем классы 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).

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

P.S. А на десерт проверим корректность реализации 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);

Автор: sand14

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js