О сравнении объектов по значению — 1: Beginning
По умолчанию два объекта считаются равными, если соответствующие переменные содержат одну и ту же ссылку. В противном случае объекты считаются неравными.
Однако, может возникнуть ситуация, когда необходимо считать объекты некоторого класса равными, если они определенным образом совпадают по своему содержимому.
Пусть есть класс Person, содержащий персональные данные — имя, фамилию, и дату рождения персоны.На примере этого класса рассмотрим:
- минимально необходимый набор доработок класса для того, чтобы объекты этого класса сравнивались по значению с помощью стандартной инфраструктуры .NET;
- минимально необходимый и достаточный набор доработок, чтобы объекты этого класса всегда сравнивались по значению с помощью стандартной инфраструктуры .NET — если явно не указано, что сравнение должно производиться по ссылке.
Задача является не настолько тривиальной, насколько это может показаться на первый взгляд.
А также рассмотрим, какие улучшения могли бы быть внесены в платформу, чтобы упростить реализацию этой задачи. Класс Person:
- методом Object.ReferenceEquals(Object, Object),
- методом Object.Equals(Object),
- методом Object.Equals(Object, Object),
- операторами == или !=,
При помещении в хеш-наборы (хеш-карты) и словари, объекты так же будут считаться равными только в случае совпадения ссылок.
Для сравнения объектов по значению в клиентском коде потребуется написать строки вида:
- Класс Person реализован таким образом, что строковые свойства FirstName и LastName всегда не равны null. Если FirstName или LastName неизвестны (не заданы), то в качестве признака отсутствия значения подойдет пустая строка. Это позволит избежать исключения NullReferenceException при обращении к свойствам и методам полей FirstName и LastName, а также коллизии при сравнении null и пустой строки (считать ли FirstName у двух объектов равными, если у одного объекта FirstName равен null, а у другого — пустой строке?).
- Свойство BirthDate, напротив, реализовано как Nullable(Of T)-структура, т.к. в случае, если дата рождения неизвестна (не задана), то целесообразно сохранить в свойстве именно неопределенное значение, а не особое значение вида 01/01/1900, 01/01/1970, 01/01/0001 или MinValue.
- При сравнении объектов по значению первым реализовано сравнение дат, т.к. сравнение переменных типа дата-время в общем случае будет производиться быстрее, чем сравнение строк.
- Сравнение дат и строк реализовано с помощью оператора равенства, т.к. оператор равенства сравнивает структуры по значению, а для строк оператор равенства перегружен и так же сравнивает строки по значению.
- методом Object.Equals(Object),
- методом Object.Equals(Object, Object),
- при помещении в хеш-наборы (хеш-карты) и словари,
- Метод Equals(Object) сравнивает те поля класса, сочетание значений которых образует значение объекта.
- Метод GetHashCode() должен возвращать одинаковые значения хеш-кодов для равных объектов (т.е., для объектов, сравнение которых с помощью Equals(Object) возвращает true). Отсюда следует, что если у объектов различные хеш-коды, то объекты не равны; при этом неравные объекты могут иметь одинаковые хеш-коды. (Для получения хеш-кода обычно используется результат операции «исключающее или» значений GetHashCode() полей, которые используются в Equals для сравнения объектов по значению; в случае, если какое-либо поле является 32-битным целым, вместо хеш-кода этого поля может использоваться непосредственно значение поля; также возможны различные оптимизации для минимизации вероятности коллизий, когда два неравных объекта имеют одинаковый хеш-код.)
- 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.
- x.Equals(null) returns false.
- Successive calls to x.Equals(y) return the same value as long as the objects referenced by x and y are not modified.
- И ряд других, в частности, касающихся правил сравнения значений чисел с плавающей точкой.
Класс Person с перекрытыми методами Equals(Object) и GetHashCode():
Примечания к методу GetHashCode():
- Если какое-либо из используемых полей содержит null, то для него вместо значения GetHashCode() обычно используется ноль.
- Класс Person реализован таким образом, что ссылочные поля FirstName и LastName не могут содержать null, а поле BirthDate является Nullable(Of T)-структурой, для которой в случае неопределенного значения GetHashCode() возвращает ноль, и исключения NullReferenceException при вызове GetHashCode() не возникает.
- Если бы поля класса Person могли содержать null, то метод GetHashCode() был бы реализован следующим образом:
- Вначале ссылка на текущий объект (this) сравнивается со ссылкой на входящий объектом, и если ссылки равны, возвращается true (это один и тот же объект, и сравнение по значению не имеет смысла, в т.ч. из соображений производительности).
- Затем выполняется приведение входящего объекта к типу Person с помощью оператора as. Если результат приведения — null, то возвращается false (либо входящая ссылка изначально была равна null, либо входящий объект имеет несовместимый с классом Person тип, и заведомо не равен текущему объекту).
- Затем выполняется сравнение полей двух объектов класса Person по значению, и возвращается соответствующий результат. Для читабельности кода и возможного повторного использования, сравнение объектов непосредственно по значению вынесено во вспомогательный метод EqualsHelper.
Обратим внимание на требование к методу Equals(Object):
Когда-то меня заинтересовало, почему некоторые экземплярные методы в стандартной библиотеке .NET проверяют this на null — например, так реализован метод String.Equals(Object):
Первым делом в методе выполняется проверка this на null и, в случае положительного результата проверки, генерируется исключение NullReferenceException.
В комментарии указано, в каких случаях this может принимать null-значение.
(Кстати, сравнение this на null выполнено с помощью оператора ==, который у класса String перегружен, поэтому с точки зрения производительности проверку лучше сделать, явно приведя this к object: (object)this == null, или же воспользоваться методом Object.ReferenceEquals(Object, Object), как это сделано во втором сравнении в этом же методе.)
А затем появилась статья, где об этом можно прочитать подробнее: Когда this == null: невыдуманная история из мира CLR.
Однако, в таком случае, если вызвать перегруженный метод Person.Equals(Object) без создания экземпляра, передав в качестве входного параметра null, то первая же строчка метода (if ((object)this == obj) return true;) возвратит true, что фактически будет правильно, но формально будет противоречить требованиям к реализации метода.
При этом в документации к методу не указано, что первым делом нужно проверять this на null и генерировать исключение в случае успешной проверки.
Да и в таком случае следовало бы вообще во всех экземплярных методах всех классов первой строчкой проверять this на null, что является абсурдом.
Поэтому представляется, что официальные требования к реализации метода Equals(Object) должны быть уточнены следующим образом:
- (для классов, не структур) если ссылки на текущий и входящий объект равны, то возвращается true;
- и уже вторым требованием — если ссылка на входящий объекта равна null, то возвращается false.
Он касается того, как наиболее корректно реализовать требование: И того, полностью и непротиворечиво ли изложены в документации требования и примеры к реализации метода в этой части, и есть ли альтернативные подходы к реализации этого требования.