Ранее мы рассмотрели корректную реализацию минимально необходимого набора доработок класса для сравнения объектов класса по значению.
Теперь рассмотрим 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 с реализацией полного набора способов сравнения объектов по значению:
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, Person second) =>
first.BirthDate == second.BirthDate &&
first.FirstName == second.FirstName &&
first.LastName == second.LastName;
public virtual bool Equals(Person other)
{
//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, Person second) =>
first?.Equals(second) ?? (object)first == (object)second;
public static bool operator ==(Person first, Person second) => Equals(first, second);
public static bool operator !=(Person first, Person second) => !Equals(first, second);
}
}
-
Метод Person.GetHashCode() вычисляет хеш-код объекта, основываясь на полях, сочетание которых образует уникальность значения конкретного объекта.
Особенности вычисления хеш-кодов и требования к перекрытию метода Object.GetHashCode() приведены в документации, а также в первой публикации. -
Статический protected метод-хелпер EqualsHelper(Person, Person) сравнивает два объекта по полям, сочетание значений которых образует уникальность значения конкретного объекта.
-
Виртуальный метод Person.Equals(Person) реализует интерфейс IEquatable(Of Person).
(Метод объявлен виртуальным, т.к. его перекрытие понадобится при наследовании — будет рассмотрено ниже.)- На "нулевом" шаге закомментирован код, проверяющий на null ссылку на текущий объект.
Если ссылка равна null, то генерируется исключение InvalidOperationException, говорящее о том, что объект находится в недопустимом состоянии.
Зачем это может быть нужно — чуть ниже. - На первом шаге проверяется равенство по ссылке текущего и входящего объекта.
Если да — то объекты равны (это один и тот же объект). - На втором шаге проверяется на null ссылка на входящий объект.
Если да — то объекты не равны (это разные объекты).
(Равенство по ссылке проверяется с помощью операторов == и !=, с предварительным приведением операндов к object для вызова неперегруженного оператора, либо с помощью метода Object.ReferenceEquals(Object, Object).
Если используются операторы == и !=, то в данном случае приведение операндов к object обязательно, т.к. в данном классе эти операторы будут перегружены и сами будут использовать метод Person.Equals(Person).) - Далее проверяется идентичность типов текущего и входящего объектов
Если типы не идентичны — то объекты не равны.
(Проверка идентичности типов объектов, вместо проверки совместимости, используется для учета реализации сравнения по значению при наследовании типа. Подробнее об этом в предыдущей публикации.) - Затем, если предыдущие проверки не позволили дать быстрый ответ, равны объекты или нет, то текущий и входящий объекты проверяются непосредственно по значению с помощью метода-хелпера EqualsHelper(Person, Person).
- На "нулевом" шаге закомментирован код, проверяющий на null ссылку на текущий объект.
-
Метод 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 на третьем шаге.
-
Для поддержки статического сравнения объектов по значению для 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, то выполняется сравнение объектов по ссылке.
- Перегруженные операторы 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, чревата еще и тем, что как только в классе реализовано сравнение объектов по значению, то реализацию сравнения придется "тащить" (и делать это правильным образом) в классы-наследники, что несет дополнительные затраты и потенциал ошибок.
Как решение этой задачи сделать, насколько возможно, легким и компактным, поговорим в продолжении.
Автор: sand14