Equals и GetHashCode в C# это сложно

Работать в C# с  Equals и  GetHashCode по многим причинам непросто, даже несмотря на наличие разного рода инструментов. Особенно по сравнению с тем, как это могло бы быть в нормально спроектированном ЯП, к которым, к большому сожалению, C#, в данном контексте, не относится.  Если вам интересно, что это за проблемы, для чего этот метод вообще нужен, почему этот механизм реализован именно так, а не как-то иначе, как можно было сделать лучше, то добро пожаловать под кат.

Если есть некоторый инструмент и этим инструментом сложно пользоваться, то обычно на это есть как объективные, так и необъективные причины. Порой мир устроен довольно сложно, и под на первый взгляд «простым» инструментом может скрываться сколь угодно сложная система. Поэтому не всегда удается реализовать инструмент так, чтобы им было легко пользоваться на регулярной основе. Особенно если это делается в первый раз. Но зачастую это не значит, что это невозможно в принципе. И обычно, делая работу над ошибками, инструмент можно довести до ума, разумеется, при наличии желания.

Другой проблемой может быть то, что не всегда очевидно, что данным инструментом трудно пользоваться или то, что его сразу можно было сделать лучше. Никто не обратил внимание, что при использовании могут возникнуть проблемы. Или создатели инструмента не подозревали, как пользователи будут с ним работать, или даже что пользователи вообще будут работать с ним. И самое главное: большинство производителей не слушают своих пользователей и, как следствие, не подозревают, что у последних есть сложности с  их инструментом  (а части производителей и вообще глубоко плевать на все и всех). Это вообще довольно острая проблема, которая относится не только к нашей сегодняшней теме, но и в целом к современному ПО, т.к. зачастую им невозможно пользоваться вообще. Порой нельзя сделать что-то, даже строго следуя инструкции (не говоря уже о чем-то более сложном и при условии, что инструкция вообще есть). Но не будем о грустном.

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

 

Идентичность и эквивалентность

Если начать разбираться со всеми возможными вариантами механизмов для реализации проверок на идентичность и эквивалентность в C#, то невольно можно задаться вопросом: почему все так сложно? Дело в том, что нельзя однозначно ответить (а, соответственно, и реализовать в общем виде) на вопрос, равны ли два объекта или нет, так как под равенством может подразумеваться все, что угодно, и многое зависит от контекста использования.  В одном случае два объекта могут считаться равными, а в другом эти же самые  объекты уже не равны. Таким образом, частично сложность механизмов сравнения на равенство в C# обусловлена сложностью самой концепции сравнения.

Можно выделить два основных вида эквивалентности: значимое равенство (Value Equality) и ссылочное равенство (Referential equality). В первом случае подразумевается эквивалентность в некотором смысле, которая может зависеть как от контекста использования, так и от самих объектов. Во втором случае два объекта являются эквивалентными только в случае, если они ссылаются на один и тот же объект. По умолчанию значимые типы используют первый вид эквивалентности. А ссылочные типы — второй. Например:

В первом случае две структуры равны, т.к. равны все соответствующие поля обеих структур — они имеют одинаковое значение. Такой вид семантики эквивалентности называют структурной (Structural Equality), и именно она используется по умолчанию при сравнении структур. Второе сравнение возвращает  False , несмотря на то, что мы сравниваем объекты с одинаковым содержимым. В третьем случае два объекта равны, т.к. указывают, по факту, на один и тот же объект. В четвертом случае значения объектов равны, несмотря на то, что не все поля этих структур имеют одинаковые значения, т.к. объекты указывают на один и тот же момент времени. Другими словами, оператор сравнения  == был переписан для  DateTimeOffset для того, чтобы сравнение происходило по другому принципу. В последнем случае, несмотря на то, что две строки являются разными объектами, они равны, т.к. равно их строковое представление. Таким образом, при использовании одного и того же оператора сравнения мы можем получить очень разные результаты. Но это еще далеко не все, т.к. есть и другие способы сравнения. Для наглядности перечислим основные:

  • == и  != ;
  • Object.Equals(Object) и Object.Equals(Object, Object) ;
  • ValueType.Equals ;
  • IEquatable<T> ;
  • IStructuralEquatable (начиная с .Net 4);
  • IEqualityComparer  и  IEqualityComparer<T> ;
  • Object.ReferenceEquals .

Внушительный список, не находите? При этом нужно учитывать, что у некоторых вышеописаных способов есть список определенных правил реализации, особенностей и «подводных камней». Это все необходимо учитывать как при реализации, так и при последующем использовании, чтобы получить корректное поведение сравнения объектов. Также нужно учитывать, что поведение должно быть консистентно при реализации разных способов сравнения. Но не всегда. Такое количество способов сравнения обусловлено не только тем, что концепт сравнения сложен сам по себе, но и не совсем удачным проектированием первой версии языка, а так же попытками закостылить не очень удачные решения в последующих версиях. При изначально правильном дизайне языка вполне можно было обойтись без части из вышеперечисленных вариантов без потери функциональности. Но увы…

Equals и GetHashCode

Очевидно, что одного способа для сравнения двух объектов недостаточно, т.к. нельзя однозначно сказать, что считается равным, а что нет (и зависеть это может от контекста). Разные виды сравнения могут возвращать разный результат (а могут и одинаковый) при сравнении одних и тех же объектов. Например:

В первом случае результат сравнения  False , и это ожидаемое поведение согласно стандарту IEEE 754 представления чисел с плавающей точкой. В тоже время, во втором случае мы получаем противоположный ответ:  True . Так происходит потому, что метод  Equals выполняет соответствующий контракт (об этом чуть позже). А раз нельзя реализовать общие методы сравнения на все случаи жизни, то должно быть можно как-то кастомизировать поведение стандартных операций, т.к., возможно, в вашем случае стандартное поведение вам не подходит. Для этого мы можем определить соответствующие операторы сравнения для своих типов, переопределить метод  Equals или реализовать один из подходящих для наших целей интерфейсов. Сегодня мы не будем особо углубляться в переопределение операторов и реализацию интерфейсов, т.к. это не является темой данной статьи. Поэтому давайте сразу перейдем к методу  Equals .

Основных причин для переопределения метода  Equals для своего типа две:

  • измение семантики эквивалентности;
  • ускориние процесса сравнения структур.

Метод  Equals определен в базовом для всех классов классе  Object и имеет следующую сигнатуру:  bool Object.Equals(Object value); . Его реализация по умолчанию производит сравнение объектов на соответствие ссылок. Т.е. два объекта будут считаться равными только в том случае, если оба указывают на один и тот же объект. Для наследников  ValueType этот метод переопределен (и является реализацией по умолчанию для всех структур) . В таком случае результатом сравнения двух структур будет результат сравнения каждого из полей структуры (на самом деле все несколько сложней, но об этом чуть позже).

Скорее всего, вы слышали, что если переопределяется метод  Equals , то также необходимо переопределить и метод  GetHashCode . Дело в том, что эти методы связаны контрактом, и при переопределении одного метода нужно так же переопределить и другой. При этом, если этого не сделать, то компилятор любезно выдаст предупреждение (что хорошо), наподобие следующего:

Дело в том, что методы  Equals и  GetHashCode необходимы для корректной работы разных коллекций, в частности,  System.Collections.Hashtable и  System.Collections.Generic.Dictionary<TKey,TValue> и других. Давайте кратко рассмотрим, как устроены хэштаблицы. Хэштаблица — это коллекция, в которой каждый элемент представляет из себя пару «ключ-значение». Таким образом, с помощью хештаблиц мы можем создавать ассоциации между объектами- ключами и объектами значений. Для того, чтобы реализовать такую коллекцию, нам необходима операция сравнения. А для того, чтобы такая коллекция работала быстро, нам также необходима возможность получить хэшкод объекта ключа. Хэшкод представляет из себя просто целое знаковое 32 битное число. Простейший алгоритм работы выглядит так:

Получаем хешкод ключа. С его помощью вычисляем, где именно может лежать искомый объект. И, далее, сравнивая ключи, мы либо находим искомое значение (если оно есть), либо нет (если его нет). Алгоритм чертовски прост (естественно, в наипростейшей его реализации; на практике все несколько сложнее, но для нас это абсолютно не важно).

Если присмотреться к алгоритму внимательно, то становится очевидно, что его сложность  имеет прямую зависимость от распределения зачений, возввращаемых методом  GetHashCode . Идеальные условия — равномерное распределение. Таким образом, можно выделить следующие правила переопределения метода  GetHashCode :

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

Самое время рассмотреть и правила переопределения метода  Equals :

  • объект не может быть равен  null (за исключением  Nullable типов);
  • реализация должна быть возвратной ( a.Equals(a) == true );
  • реализация должна быть коммутативной (если a.Equals(b) , то  b.Equals(a) );
  • реализация должна быть последовательной (если  a.Equals(b) и  b.Equals(c) , то  a.Equals(c) );
  • реализация должна быть повторяема (должна возвращать одно и то же значение, до тех пор пока один из объектов не изменится) и надежна (не должна выкидывать исключений).

Правил, даже для переопределения одних только  Equals и  GetHashCode , немало. Учесть все правила, несмотря на то, что на первый взгляд они не настолько сложны, на практике не так-то просто. Рассмотрим пример, как может выглядеть переопределение   Equals и  GetHashCode :

Вроде бы, не так все и сложно.

Давайте более подробно рассмотрим, как работают методы   Equals и  GetHashCode по умолчанию. Начнем с  Object.Equals и  Object.GetHashCode . Первый метод можно представить в виде следующего псевдокода:

Ничего неожиданного. По умолчанию  Object.Equals возвращает  True  в случае, если ссылки на сравниваемых объекты равны. А как же работает  Object.GetHashCode ? Каждый поток в CLR имеет собственный генератор для хэшкодов. Хэшкод вычисляется один раз и сохраняется в служебной памяти, ассоциированной с объектом. Значение хэшкода не меняется на протяжении всей жизни объекта. Вероятность того, что два потока будут последовательно генерировать одинаковые хэш-коды, маловероятна.  На псевдокоде это может выглядеть примерно так:

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

Начнем, опять-таки, с первого метода. Его исходный код выглядит примерно так:

Довольно объемный метод для, казалось бы, такой простой операции. Быстро оценив код, мы можем сделать несколько простых выводов. Существует два варианта реализации метода  ValueType.Equals . В случае, если это возможно, происходит быстрая побитовая операция сравнения двух структур. Во втором случае, если условия для выполнения первой операции неподходящие, то выполняется очень медленное сравнение (возможно, даже с боксингом) с использованием рефлексии. И, прежде, чем переходить к рассмотрению реализации  ValueType.GetHashCode , давайте взглянем на следующий пример:

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

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

Сложности

К данному моменту должно быть очевидно, что корректно реализовать  Equals и  GetHashCode может быть не так-то и просто. Нужно учитывать кучу нюансов, помнить о правилах; и это все при практически нулевой поддержке со стороны компилятора. Что, конечно, не может не радовать (особенно в сравнении с другими языками программирования). Наличие проблем в реализации этих методов подтверждается практикой. Нет-нет, да косяки в реализации одного (или обоих) методов находятся. Давайте рассмотрим несколько подобных случаев.

Имеем две одинаково реализованные структуры (возможно, одна из них была реализована посредством знаменитой методики Copy&Paste?). Конечно же, беглого взгляда достаточно, чтобы понять, что не так, не так ли? А ведь в реальности струтуры могут быть намного более сложными, и понять, что не так, и есть ли вообще ошибка, еще сложнее. 

Ответ:

Для флоат структуры не соблюдается правило a.Equals(b) == true, например: var a = new FloatPoint(float.NaN, 5); var b = new FloatPoint(float.NaN, 5); Console.WriteLine(a.Equals(b)); // False

[свернуть]


В этом примере, также невооруженным взглядом видно, где проблема, не так ли? Обычно подобные проблемы происходят по мере развития кодовой базы. И они должны быть знакомы большинству. Так что же не так? 
Ответ:

1. Реализация GetHashCode не учитывает userData. 2. Реализация GetHashCode не учитывает, что notificationProfile может быть null.

[свернуть]


А в этом примере что  реализовано не так? Почему мы получили результат, который не ожидали? Конечно, ошибка просто элементарная и сказать, что пошло не так, можно, даже несмотря на реализацию методов  Equals и  GetHashCode , не так ли? 
Ответ:

Неправильно реализован метод ExcellentStudentComparer.GetHashCode. Должно быть что-то типа такого: public override int GetHashCode(Student obj) { var hash = 17; hash = hash * 31 + obj.name.GetHashCode(); hash = hash * 31 + obj.isExcellent.GetHashCode(); return hash; }

[свернуть]

Другим косвенным примером наличия проблем может служить следующий доклад, в котором, кроме всего прочего, Bill Wagner, так же косвенно затрагивает проблему реализации методов  Equals и  GetHashCode в современных версиях C#. Вот как это может выглядеть:

На самом деле, нечто подобное было доступно и раньше (с использованием анонимных классов). Но это — тоже не панацея, не всегда получится использовать подобный код. К тому же, скорее всего, это несколько медленнее, чем описание идентичного кода вручную.

Заключение

На первый взгляд, идея о возможности использования любого объекта в качестве ключа в хэштаблице кажется удобной. И, когда начинаешь работать с ЯП, в котором нельзя просто так взять и использовать любой объект в качестве ключа, приходят мысли о том, как это такое; вроде бы, элементарная возможность, как же без нее жить? Но проходит время, и ты осознаешь: а ведь действительно, может быть это и правильно. Так ли нужна такая возможность, как любой объект в качестве ключа хэштаблицы? Конечно, если эта возможность достается бесплатно и нет необходимости постоянно что-то  править ручками, то  это удобно. Но вот с C# совсем другой случай.  Equals и  GetHashCode — еще пара мест, в которых легко можно выстрелить в ногу, и на которые необходимо обращать внимание, тратить время (которого обычно не хватает). И, если бы дизайнеры языка выбрали иной путь, то сейчас этот ЯП мог бы быть совсем другим, и, возможно, много лучше,чем он есть сейчас. Трудно точно ответить на вопрос, почему именно такое решение приняли разработчики, но, скорее всего, это было сделано под копирку (слизано из Java). Вот как бы мог быть реализован метод  Object.Equals :

Подобный код упрощал бы реализацию метода  Equals в наследниках.

Возможно, также стоило бы полностью отказаться от объявления  Equals и  GetHashCode  в базовом классе, что позволило бы реализовывать соответствующие методы только в том случае, когда они действительно нужны, а неиспользуемый код не висел бы мертвым грузом; время, потраченное на реализацию и поддержку работоспособности этого мертвого код, было бы использовано более полезно.

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

Казалось бы, не самый сложный функционал превратился в громадное нечто, и  даже в статьях (возможно, и в этой статье тоже есть?) и книгах порой можно встретить не только неточности, но даже и ошибки. Что, конечно, не может не радовать. И, хотя существуют средства, помогающие несколько упростить жизнь (позволяющие генерировать необходимые реализации полуавтоматически, например, ReSharper), это все равно не спасает от механических, Copy&Paste и других подобных ошибок. И, опять-таки, наличие подобных средств открытым текстом говорит, что не все так гладко в датском королевстве.

Ну, а на сегодня все, спасибо за внимание. Если есть какие-то вопросы или нашли ошибку, то, милости просим в комментарии. Увидимся в следующей статье.

 

Добавить комментарий