Про порівняння об'єктів за значенням — 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)) повертає true, then x.Equals(z) повертає 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()) повертає 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 транспортний system type of the current object Type is the same as the транспортний system type of the specified Object.

Return Value
Type: System.Boolean
true if the транспортний system type of o is the same as the транспортний 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 object Type.

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 транспортний system type of the current Type is the same as the транспортний system type of the specified Type.

Return Value
Type: System.Boolean
true if the транспортний system type of o is the same as the транспортний 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, second Person) =>
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);

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.