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

В предыдущей публикации мы рассмотрели общие принципы реализации минимально необходимых доработок класса для возможности сравнения объектов класса по значению с помощью стандартной инфраструктуры платформы .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 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).


В продолжении мы рассмотрим реализацию интерфейса 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);

Комментарии (3)

  • 6 ноября 2016 в 20:52

    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 совершенно разумно перекрывает этот метод, выкидывая приличное количество лишних операций.

    • 6 ноября 2016 в 21:46

      0

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

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

  • 6 ноября 2016 в 21:45 (комментарий был изменён)

    0

    (del)

© Habrahabr.ru