В предыдущей публикации мы рассмотрели общие принципы реализации минимально необходимых доработок класса для возможности сравнения объектов класса по значению с помощью стандартной инфраструктуры платформы .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):
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:
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 соответствующим образом:
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);
}
}
}
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):
- вначале проверяются на равенство ссылки, указывающие на текущий и входящий объекты, и, в случае совпадения ссылок, возвращается true;
- затем проверяется на null ссылка на входящий объект, и, в случае положительного результата проверки, возвращается false;
- затем проверяется идентичность типов текущего и входящего объекта, и, в случае отрицательного результата проверки, возвращается false;
- на последнем этапе производятся приведение входящего объекта к типу данного класса и непосредственно сравнение объектов по значению.
Таким образом, мы нашли оптимальный способ реализации ожидаемого поведения метода 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.
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