- PVSM.RU - https://www.pvsm.ru -

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

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

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

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

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, созданный в предыдущей публикации [1], содержит следующую реализацию метода Equals(Object) [2]:

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 для возможности сравнения объектов по значению.

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

Реализуем класс 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 [5], в противном случае метод возвратит false [6].
(При выполнении метода Equals, входящий объект, имеющий во время выполнения (runtime) тип PersonEx, будет успешно приведен к типу Person с помощью оператора as [4], и далее будет произведено сравнение объектов по значениям полей, имеющихся только в классе Person, и будет возвращен соответствующий результат.)

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

Если же, напротив, у объекта класса PersonEx вызвать метод Equals(Object) и передать в него объект класса Person, то метод Equals в любом случае возвратит false [6], независимо от значений свойств объектов.
(При выполнении метода Equals, входящий объект, имеющий во время выполнения (runtime) тип Person, не будет успешно приведен к типу PersonEx с помощью оператора as [4] — результатом приведения будет null [7], и метод возвратит false [6].)
Здесь мы наблюдаем верное с предметной точки зрения поведение, в отличие от предыдущего случая.

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

Код

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) требованиям в документации [2], нежели корректность логики с предметной точки зрения.

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

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

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

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

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

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

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

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

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

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

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

bool isEqualTypes = (object)obj1.GetType() == (object)obj2.GetType();

или

bool isEqualTypes = Object.ReferenceEquals(obj1.GetType(), obj2.GetType());

2. Класс Type [10] имеет методы Equals(Object) [12] и Equals(Type) [13], поведение которых определено следующим образом:

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 [10] в общем случае может отличаться от сравнения объектов по ссылке, т.к. в случае использования методов Equals, сравниваются по ссылке не сами объекты класса Type [10], а их свойства UnderlyingSystemType [14], относящиеся к тому же классу.

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

Примечание:
Для метода Type.Equals(Object) [12] проблема несоответствия требованию (как следствие использования оператора as [4])

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

не возникнет, т.к. класс Type [10] — абстрактный, если только в потомках класса метод не будет перекрыт некорректным образом.
Для предотвращения этой потенциальной проблемы, возможно, стоило объявить метод как sealed [15].

3. Класс Type [10], начиная с .NET Framework 4.0, имеет перегруженные операторы == [16] или != [17], поведение которых описывается простым образом, без описания деталей реализации:

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 [10], представляется, что наиболее корректным способом сравнения объектов будет использование операторов "==" и "!=", и, в зависимости от используемой версии .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) [2] будет соблюдаться:

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 [5];
  2. затем проверяется на null [7] ссылка на входящий объект, и, в случае положительного результата проверки, возвращается false [6];
  3. затем проверяется идентичность типов текущего и входящего объекта, и, в случае отрицательного результата проверки, возвращается false [6];
  4. на последнем этапе производятся приведение входящего объекта к типу данного класса и непосредственно сравнение объектов по значению.

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

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

P.S. А на десерт проверим корректность реализации Equals(Object) [2] в стандартной библиотеке.

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

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) [2] не выполняется:

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

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

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

Код

const string uriString = "https://www.habrahabr.ru";
Uri uri = new Uri(uriString);

bool isSameUri = uri.Equals(uriString);
bool isSameUri2 = uriString.Equals(uri);

Автор: sand14

Источник [24]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/c-2/206851

Ссылки в тексте:

[1] предыдущей публикации: https://habrahabr.ru/post/314328/

[2] Object.Equals(Object): https://msdn.microsoft.com/library/bsc2ak47.aspx

[3] Object.GetHashCode(): https://msdn.microsoft.com/library/system.object.gethashcode.aspx

[4] as: https://msdn.microsoft.com/library/cscsdfbt.aspx

[5] true: https://msdn.microsoft.com/library/6x6y6z4d.aspx

[6] false: https://msdn.microsoft.com/library/67bxt5ee.aspx

[7] null: https://msdn.microsoft.com/library/edakx9da.aspx

[8] хеш-наборы (хеш-карты): https://msdn.microsoft.com/library/bb359438.aspx

[9] словари: https://msdn.microsoft.com/library/xfhwa508.aspx

[10] типы: https://msdn.microsoft.com/library/system.type.aspx

[11] Object.GetType(): https://msdn.microsoft.com/library/system.object.gettype.aspx

[12] Equals(Object): https://msdn.microsoft.com/library/hh32dc75.aspx

[13] Equals(Type): https://msdn.microsoft.com/library/3ahwab82.aspx

[14] UnderlyingSystemType: https://msdn.microsoft.com/library/system.type.underlyingsystemtype.aspx

[15] sealed: https://msdn.microsoft.com/library/88c54tsw.aspx

[16] ==: https://msdn.microsoft.com/library/system.type.op_equality.aspx

[17] !=: https://msdn.microsoft.com/library/system.type.op_inequality.aspx

[18] IEquatable(Of T): https://msdn.microsoft.com/library/ms131187.aspx

[19] IEquatable(Of T).Equals(T): https://msdn.microsoft.com/library/ms131190.aspx

[20] Uri.Equals(Object): https://msdn.microsoft.com/library/f83xtf15.aspx

[21] String: https://msdn.microsoft.com/library/system.string.aspx

[22] String.Equals(Object): https://msdn.microsoft.com/library/fkfd9eh8.aspx

[23] Uri: https://msdn.microsoft.com/library/system.uri.aspx

[24] Источник: https://habrahabr.ru/post/314500/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best