Про порівняння об'єктів за значенням — 3, або Type-specific Equals & Equality operators

ми розглянули коректну реалізацію мінімально необхідного набору доробок класу для порівняння об'єктів класу за значенням.
Тепер розглянемо Type-specific реалізацію порівняння об'єктів за значенням, що включає реалізацію Generic-інтерфейсу IEquatable(Of T) і перевантаження операторів "==" і "!=".
Type-specific порівняння об'єктів за значенням дозволяє досягти:
  • Більш стабільного, масштабованого і мнемонического (читається) коду (останнє за рахунок перевантажених операторів).
  • Більш високої продуктивності.
Крім того, реалізація Type-specific порівняння за значенням необхідна з таких причин:
  • Стандартні Generic-колекції (List(Ot T), Dictionary(Of TKey, TValue) та ін.) рекомендують наявність реалізації IEquatable(Of T) для всіх об'єктів, які розміщені в колекції.
  • Стандартний компаратор EqualityComparer(Of T).Default використовує (за замовчуванням — за наявності) реалізацію IEquatable(Of T) у операндів.
Реалізація одночасно всіх способів порівняння пов'язана з певними труднощами, оскільки для коректної роботи потрібно забезпечити:
  • Відповідність результатів порівняння у різних способів.
  • Збереження поведінки при спадкуванні.
  • Мінімізацію copy-paste і загального обсягу коду.
  • Облік того, що оператори порівняння технічно є статичними методами і, відповідно, у них відсутня поліморфність (а також, що не всі CLS-сумісні мови підтримують оператори або їх перевантаження).
Розглянемо реалізацію порівняння об'єктів за значенням з урахуванням вищевикладених умов, на прикладі класу Person.
Відразу наведемо остаточний варіант коду з поясненнями, чому це зроблено саме так, і як саме це працює.
(Демонстрація виведення рішення з урахуванням кожного нюансу містить занадто багато ітерацій.)
Отже, клас Person з реалізацією повного набору способів порівняння об'єктів за значенням:
class Person
using System;

namespace HelloEquatable
{
public class Person : IEquatable<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 virtual bool Equals(other Person)
{
//if ((object)this == null)
// throw new InvalidOperationException("This is null.");

if ((object)this == (object)other)
return true;

if ((object)other == null)
return false;

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

return EqualsHelper(this, other);
}

public override bool Equals(object obj) => this.Equals(obj as Person);

public static bool Equals(Person first, second Person) =>
first?.Equals(second) ?? (object)first == (object)second;

public static bool operator ==(Person first, second Person) => Equals(first, second);

public static bool operator !=(Person first, second Person) => !Equals(first, second);
}
}

  1. Метод Person.GetHashCode() обчислює хеш-код об'єкта, грунтуючись на полях, поєднання яких утворює унікальність значення конкретного об'єкта.
    Особливості обчислення хеш-кодів і вимоги до перекриття методу Object.GetHashCode() наведені в документації, а також першої публікації.
  2. Статичний protected метод-хелпер EqualsHelper(Person, Person) порівнює два об'єкта по полях, комбінація значень яких утворює унікальність значення конкретного об'єкта.
  3. Віртуальний метод Person.Equals(Person) реалізує інтерфейс IEquatable(Of Person).
    (Метод оголошено віртуальним, оскільки його перекриття знадобиться при спадкуванні — буде розглянуто нижче).
    • На "нульовому" крок закомментирован код, перевіряючий на null посилання на поточний об'єкт.
      Якщо посилання дорівнює null, то генерується виняток InvalidOperationException, говорить про те, що об'єкт знаходиться в недоступному стані.
      Навіщо це може бути потрібно — трохи нижче.
    • На першому кроці перевіряється рівність за посиланням поточного і вихідного об'єкта.
      Якщо так — то об'єкти рівні (це один і той же об'єкт).
    • На другому кроці перевіряється на null посилання на вхідний об'єкт.
      Якщо так — то об'єкти не рівні (це різні об'єкти).
      (Рівність за посиланням перевіряється з допомогою операторів == і !=, за попередніми приведенням операндів object для виклику неперегруженного оператора, або з допомогою методу Object.ReferenceEquals(Object, Object).
      Якщо використовуються оператори == і !=, то в даному випадку приведення операндів object обов'язково, оскільки в даному класі ці оператори будуть перевантажені і самі будуть використовувати метод Person.Equals(Person).)
    • Далі перевіряється ідентичність типів поточного та вхідного об'єктів
      Якщо типи не ідентичні — то об'єкти не рівні.
      (Перевірка ідентичності типів об'єктів, замість перевірки сумісності, використовується для обліку реалізації порівняння за значенням при спадкуванні типу. Детальніше про це попередній публікації.)
    • Потім, якщо попередні перевірки не дозволили дати швидку відповідь, дорівнюють об'єкти або немає, то поточний і входить об'єкти перевіряються безпосередньо за значенням з допомогою методу-хелперу EqualsHelper(Person, Person).
  4. Метод Person.Equals(Object), реалізований як виклик методу Person.Equals(Person) з приведенням вхідного об'єкта до типу Person з допомогою оператора as.
    Примітка. Якщо типи об'єктів не сумісні, то результатом приведення буде null, що призведе до отримання результату порівняння об'єктів у методі Person.Equals(Person) на другому кроці (об'єкти не рівні).
    • Проте, в загальному випадку, результат порівняння в методі Person.Equals(Person) може бути отриманий і на першому кроці (об'єкти рівні), т. к. теоретично .NET можливий виклик экземплярного методу без створення примірника (докладніше про це в першої публікації).
    • І тоді, коли посилання на даний об'єкт буде дорівнює null, посилання на вхідний об'єкт буде не дорівнює null, а типи поточного та вхідного об'єктів будуть несумісні, то такий виклик Person.Equals(Object) з подальшим викликом Person.Equals(Person) дасть неправильний результат на першому кроці — "об'єкти рівні", в той час насправді об'єкти не рівні.
    • Представляється, що такий рідкісний випадок не вимагає спеціальної обробки, т. к. виклик экземплярного методу і використання його результату не має сенсу без створення самого екземпляра.
      Якщо буде потрібно його врахувати, то досить розкоментувати код "нульового кроку" у методі Person.Equals(Person), що не тільки запобіжить отримання теоретично можливого невірного результату при виклику методу Person.Equals(Object), але і, при безпосередньому виклику методу Person.Equals(Person) у null-об'єкта, згенерує на "нульовому" крок більш інформативне виняток, замість NullReferenceException на третьому кроці.
  5. Для підтримки статичного порівняння об'єктів за значенням для CLS-сумісних мов, не підтримують оператори або їх перевантаження, реалізований статичний метод Person.Equals(Person, Person).
    (В якості Type-specific, і більш швидкодіючою, альтернативи методу Object.Equals(Object, Object).)
    (Про необхідність реалізації методів, що відповідають операторам, та рекомендації щодо відповідності операторів та імен методів, можна прочитати в книзі Джеффрі Ріхтера (Jeffrey Richter) CLR via C# (Part II "Designing Types", Chapter 8 "Methods", Subchapter "Operator Overload Methods").)
    • Метод Person.Equals(Person, Person) реалізований через виклик экземплярного віртуального методу Person.Equals(Person), оскільки це необхідно для забезпечення того, щоб "виклик x == y давав давав той же результат, що і виклик "y == x", що відповідає вимозі "виклик x.Equals(y) повинен давати той самий результат, що і виклик y.Equals(x)" (детальніше про останній вимозі, включаючи його забезпечення при спадкуванні — в попередній публікації).
    • Т. к. статичні методи при спадкуванні типу не можуть бути перекриті (мова саме про перекриття — override, а не про перевизначенні — new), тобто не мають поліморфного поведінки, то причина саме такої реалізації — виклик статичного методу Person.Equals(Person, Person) через виклик віртуального экземплярного Person.Equals(Person) — саме в необхідності забезпечити поліморфізм при статичних виклики, і, тим самим, забезпечення відповідності результатів "статичного" і "экземплярного" порівняння при спадкуванні.
    • У методі Person.Equals(Person, Person) виклик экземплярного методу Person.Equals(Person) реалізований з перевіркою на null посилання на той об'єкт, у якого викликається метод Equals(Person).
      Якщо цей об'єкт — null, то виконується порівняння об'єктів за посиланням.
  6. Перевантажені оператори Person.==(Person, Person) та Person.!=(Person, Person) реалізовані за допомогою виклику "є" статичного методу Person.Equals(Person, Person) (для оператора "!=" — в парі з оператором !).
Отже, ми знайшли коректний і досить компактний спосіб реалізації в одному класі всіх способів порівняння об'єктів класу за значенням, і навіть врахували коректність поведінки на випадок спадкування, заклали в коді можливості, які зможемо використати при спадкуванні.
При цьому необхідно окремо розглянути, як для даного варіанта реалізації порівняння об'єктів за значенням коректно виконати спадкування, якщо в клас спадкоємець вноситься поле, що входить в безліч полів об'єкта, що утворюють унікальне значення об'єкта:
Нехай є клас PersonEx, успадковує клас Person, і має додаткове властивість MiddleName.
У цьому випадку порівняння двох об'єктів класу PersonEx:
John Teddy Smith 1990-01-01
John Bobby Smith 1990-01-01

будь реалізованим способом дасть результат "об'єкти рівні", що невірно з предметної точки зору.
Таким чином, при уявній тривіальності завдання, крім досить великих витрат і ризиків, реалізація порівняння об'єктів за значенням у поточній інфраструктурі .NET, небезпечна ще й тим, що як тільки в класі реалізовано порівняння об'єктів за значенням, то реалізацію порівняння доведеться "тягти" (і робити це правильним чином) класи-спадкоємці, що несе додаткові витрати і потенціал помилок.
Як рішення цієї задачі зробити, наскільки можливо, легким і компактним, поговоримо в продовженні.
Джерело: Хабрахабр

0 коментарів

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