Pull to refresh

О сравнении объектов по значению — 1: Beginning

Reading time7 min
Views27K

Общеизвестно, что в объектной модели .NET, как и во многих других программных платформах, сравнивать объекты можно по ссылке и по значению.


По умолчанию два объекта считаются равными, если соответствующие переменные содержат одну и ту же ссылку. В противном случае объекты считаются неравными.

Однако, может возникнуть ситуация, когда необходимо считать объекты некоторого класса равными, если они определенным образом совпадают по своему содержимому.

Пусть есть класс Person, содержащий персональные данные — имя, фамилию, и дату рождения персоны.


На примере этого класса рассмотрим:

  1. минимально необходимый набор доработок класса для того, чтобы объекты этого класса сравнивались по значению с помощью стандартной инфраструктуры .NET;
  2. минимально необходимый и достаточный набор доработок, чтобы объекты этого класса всегда сравнивались по значению с помощью стандартной инфраструктуры .NET — если явно не указано, что сравнение должно производиться по ссылке.

Для каждого случая рассмотрим, каким именно образом лучше реализовать сравнение объектов по значению, чтобы получился согласованный и, насколько это возможно, компактный, copy-paste free, производительный код.

Задача является не настолько тривиальной, насколько это может показаться на первый взгляд.

А также рассмотрим, какие улучшения могли бы быть внесены в платформу, чтобы упростить реализацию этой задачи.

Класс Person:
class Person
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);
        }
    }
}

Если два объекта класса Person сравнивать любым способом:
то объекты будут считаться равными, только если указывающие на них переменные содержат одну и ту же ссылку.

При помещении в хеш-наборы (хеш-карты) и словари, объекты так же будут считаться равными только в случае совпадения ссылок.

Для сравнения объектов по значению в клиентском коде потребуется написать строки вида:
Код
var p1 = new Person("John", "Smith", new DateTime(1990, 1, 1));
var p2 = new Person("John", "Smith", new DateTime(1990, 1, 1));

bool isSamePerson =
    p1.BirthDate == p2.BirthDate &&
    p1.FirstName == p2.FirstName &&
    p1.LastName == p2.LastName;

Примечания:

  1. Класс Person реализован таким образом, что строковые свойства FirstName и LastName всегда не равны null.
    Если FirstName или LastName неизвестны (не заданы), то в качестве признака отсутствия значения подойдет пустая строка.
    Это позволит избежать исключения NullReferenceException при обращении к свойствам и методам полей FirstName и LastName, а также коллизии при сравнении null и пустой строки (считать ли FirstName у двух объектов равными, если у одного объекта FirstName равен null, а у другого — пустой строке?).
  2. Свойство BirthDate, напротив, реализовано как Nullable(Of T)-структура, т.к. в случае, если дата рождения неизвестна (не задана), то целесообразно сохранить в свойстве именно неопределенное значение, а не особое значение вида 01/01/1900, 01/01/1970, 01/01/0001 или MinValue.
  3. При сравнении объектов по значению первым реализовано сравнение дат, т.к. сравнение переменных типа дата-время в общем случае будет производиться быстрее, чем сравнение строк.
  4. Сравнение дат и строк реализовано с помощью оператора равенства, т.к. оператор равенства сравнивает структуры по значению, а для строк оператор равенства перегружен и так же сравнивает строки по значению.

Для того, чтобы объекты класса Person можно было сравнивать по значению следующими способами:


у класса Person необходимо перекрыть методы Object.Equals(Object) и Object.GetHashCode() следующим образом:

  • Метод Equals(Object) сравнивает те поля класса, сочетание значений которых образует значение объекта.
  • Метод GetHashCode() должен возвращать одинаковые значения хеш-кодов для равных объектов (т.е., для объектов, сравнение которых с помощью Equals(Object) возвращает true).
    Отсюда следует, что если у объектов различные хеш-коды, то объекты не равны; при этом неравные объекты могут иметь одинаковые хеш-коды.
    (Для получения хеш-кода обычно используется результат операции «исключающее или» значений GetHashCode() полей, которые используются в Equals для сравнения объектов по значению;
    в случае, если какое-либо поле является 32-битным целым, вместо хеш-кода этого поля может использоваться непосредственно значение поля;
    также возможны различные оптимизации для минимизации вероятности коллизий, когда два неравных объекта имеют одинаковый хеш-код.)

Стоит обратить особое внимание, что в документации к методу 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.
  • 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.
  • И ряд других, в частности, касающихся правил сравнения значений чисел с плавающей точкой.

Также стоит обратить внимание, что в документации к методу GetHashCode() приведено предупреждение, что значение, возвращаемое методом, не является постоянным значением, и поэтому его не следует сохранять на диск или в базу данных, использовать в качестве ключа, а также что его не следует использовать для сравнения объектов (неравные объекты могут иметь одинаковые хеш-коды), и т.д.

Класс Person с перекрытыми методами Equals(Object) и GetHashCode():
class Person
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;

            var other = obj as Person;

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

            return EqualsHelper(this, other);
        }
    }
}

Примечания к методу GetHashCode():

  • Если какое-либо из используемых полей содержит null, то для него вместо значения GetHashCode() обычно используется ноль.
  • Класс Person реализован таким образом, что ссылочные поля FirstName и LastName не могут содержать null, а поле BirthDate является Nullable(Of T)-структурой, для которой в случае неопределенного значения GetHashCode() возвращает ноль, и исключения NullReferenceException при вызове GetHashCode() не возникает.
  • Если бы поля класса Person могли содержать null, то метод GetHashCode() был бы реализован следующим образом:
    GetHashCode()
    public override int GetHashCode() =>
        this.FirstName?.GetHashCode() ?? 0 ^
        this.LastName?.GetHashCode() ?? 0 ^
        this.BirthDate?.GetHashCode() ?? 0;
    

Рассмотрим детально, как именно реализован метод Equals(Object):

  1. Вначале ссылка на текущий объект (this) сравнивается со ссылкой на входящий объектом, и если ссылки равны, возвращается true (это один и тот же объект, и сравнение по значению не имеет смысла, в т.ч. из соображений производительности).
  2. Затем выполняется приведение входящего объекта к типу Person с помощью оператора as. Если результат приведения — null, то возвращается false (либо входящая ссылка изначально была равна null, либо входящий объект имеет несовместимый с классом Person тип, и заведомо не равен текущему объекту).
  3. Затем выполняется сравнение полей двух объектов класса Person по значению, и возвращается соответствующий результат.
    Для читабельности кода и возможного повторного использования, сравнение объектов непосредственно по значению вынесено во вспомогательный метод EqualsHelper.

Пока мы реализовали только минимально необходимую функциональность для сравнения объектов по значению, но уже появляются вопросы.


Первый вопрос больше теоретический.


Обратим внимание на требование к методу Equals(Object):
x.Equals(null) returns false.

Когда-то меня заинтересовало, почему некоторые экземплярные методы в стандартной библиотеке .NET проверяют this на null — например, так реализован метод String.Equals(Object):
String.Equals(Object)
public override bool Equals(Object obj) {
    //this is necessary to guard against reverse-pinvokes and
    //other callers who do not use the callvirt instruction
    if (this == null)
        throw new NullReferenceException();

    String str = obj as String;
    if (str == null)
        return false;

    if (Object.ReferenceEquals(this, obj))
        return true;

    if (this.Length != str.Length)
        return false;

     return EqualsHelper(this, str);
}

Первым делом в методе выполняется проверка 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.

А вот второй вопрос по реализации метода Equals(Object) интереснее, и имеет прикладное значение.


Он касается того, как наиболее корректно реализовать требование:
x.Equals(y) returns the same value as y.Equals(x).
И того, полностью и непротиворечиво ли изложены в документации требования и примеры к реализации метода в этой части, и есть ли альтернативные подходы к реализации этого требования.

Об этом, а также о вопросах реализации полного набора доработок класса для сравнения его объектов по значению, поговорим в следующих публикациях.

Tags:
Hubs:
Total votes 24: ↑17 and ↓7+10
Comments58

Articles