Работать в C# с Equals и GetHashCode по многим причинам непросто, даже несмотря на наличие разного рода инструментов. Особенно по сравнению с тем, как это могло бы быть в нормально спроектированном ЯП, к которым, к большому сожалению, C#, в данном контексте, не относится. Если вам интересно, что это за проблемы, для чего этот метод вообще нужен, почему этот механизм реализован именно так, а не как-то иначе, как можно было сделать лучше, то добро пожаловать под кат.
Если есть некоторый инструмент и этим инструментом сложно пользоваться, то обычно на это есть как объективные, так и необъективные причины. Порой мир устроен довольно сложно, и под на первый взгляд «простым» инструментом может скрываться сколь угодно сложная система. Поэтому не всегда удается реализовать инструмент так, чтобы им было легко пользоваться на регулярной основе. Особенно если это делается в первый раз. Но зачастую это не значит, что это невозможно в принципе. И обычно, делая работу над ошибками, инструмент можно довести до ума, разумеется, при наличии желания.
Другой проблемой может быть то, что не всегда очевидно, что данным инструментом трудно пользоваться или то, что его сразу можно было сделать лучше. Никто не обратил внимание, что при использовании могут возникнуть проблемы. Или создатели инструмента не подозревали, как пользователи будут с ним работать, или даже что пользователи вообще будут работать с ним. И самое главное: большинство производителей не слушают своих пользователей и, как следствие, не подозревают, что у последних есть сложности с их инструментом (а части производителей и вообще глубоко плевать на все и всех). Это вообще довольно острая проблема, которая относится не только к нашей сегодняшней теме, но и в целом к современному ПО, т.к. зачастую им невозможно пользоваться вообще. Порой нельзя сделать что-то, даже строго следуя инструкции (не говоря уже о чем-то более сложном и при условии, что инструкция вообще есть). Но не будем о грустном.
Не всегда одного желания достаточно, чтобы что-то исправить. Например, если разрабатываемый инструмент используется для создания других инструментов, то исправить ранее допущенную при проектировании ошибку (проблему, неудобство и т.д.) может быть невозможно из-за необходимости поддержки обратной совместимости. Поэтому к проектированию подобных инструментов необходимо подходить намного более тщательно, ибо назад пути может и не быть, и исправить не получится. Но одно дело, когда вы первопроходец, и, так сказать, идете по тонкому льду, и совсем другое, когда перед вами уже реализованы десятки подобных инструментов, и все, что вам нужно, — это сделать еще более удобный инструмент. И довольно странно допускать подобные ошибки в таких случаях.
Идентичность и эквивалентность
Если начать разбираться со всеми возможными вариантами механизмов для реализации проверок на идентичность и эквивалентность в C#, то невольно можно задаться вопросом: почему все так сложно? Дело в том, что нельзя однозначно ответить (а, соответственно, и реализовать в общем виде) на вопрос, равны ли два объекта или нет, так как под равенством может подразумеваться все, что угодно, и многое зависит от контекста использования. В одном случае два объекта могут считаться равными, а в другом эти же самые объекты уже не равны. Таким образом, частично сложность механизмов сравнения на равенство в C# обусловлена сложностью самой концепции сравнения.
Можно выделить два основных вида эквивалентности: значимое равенство (Value Equality) и ссылочное равенство (Referential equality). В первом случае подразумевается эквивалентность в некотором смысле, которая может зависеть как от контекста использования, так и от самих объектов. Во втором случае два объекта являются эквивалентными только в случае, если они ссылаются на один и тот же объект. По умолчанию значимые типы используют первый вид эквивалентности. А ссылочные типы — второй. Например:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var p1 = (5, 6); var p2 = (5, 6); Console.WriteLine(p1 == p2); // True object o1 = p1; object o2 = p2; Console.WriteLine(o1 == o2); // False var ar1 = new int[10]; var ar2 = ar1; Console.WriteLine (ar1 == ar2); // True var dt1 = new DateTimeOffset(2020, 5, 2, 15, 1, 1, TimeSpan.FromHours(8)); var dt2 = new DateTimeOffset(2020, 5, 2, 16, 1, 1, TimeSpan.FromHours(9)); Console.WriteLine (dt1 == dt2); // True var str1 = new String("Hello World"); var str2 = new String("Hello " + "World"); Console.WriteLine(str1 == str2); // True Console.WriteLine(ReferenceEquals(str1, str2)); // False |
В первом случае две структуры равны, т.к. равны все соответствующие поля обеих структур — они имеют одинаковое значение. Такой вид семантики эквивалентности называют структурной (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
Очевидно, что одного способа для сравнения двух объектов недостаточно, т.к. нельзя однозначно сказать, что считается равным, а что нет (и зависеть это может от контекста). Разные виды сравнения могут возвращать разный результат (а могут и одинаковый) при сравнении одних и тех же объектов. Например:
1 2 3 |
var a = double.NaN; Console.WriteLine(a == a); // False Console.WriteLine(a.Equals(a)); // True |
В первом случае результат сравнения False , и это ожидаемое поведение согласно стандарту IEEE 754 представления чисел с плавающей точкой. В тоже время, во втором случае мы получаем противоположный ответ: True . Так происходит потому, что метод Equals выполняет соответствующий контракт (об этом чуть позже). А раз нельзя реализовать общие методы сравнения на все случаи жизни, то должно быть можно как-то кастомизировать поведение стандартных операций, т.к., возможно, в вашем случае стандартное поведение вам не подходит. Для этого мы можем определить соответствующие операторы сравнения для своих типов, переопределить метод Equals или реализовать один из подходящих для наших целей интерфейсов. Сегодня мы не будем особо углубляться в переопределение операторов и реализацию интерфейсов, т.к. это не является темой данной статьи. Поэтому давайте сразу перейдем к методу Equals .
Основных причин для переопределения метода Equals для своего типа две:
- измение семантики эквивалентности;
- ускориние процесса сравнения структур.
Метод Equals определен в базовом для всех классов классе Object и имеет следующую сигнатуру: bool Object.Equals(Object value); . Его реализация по умолчанию производит сравнение объектов на соответствие ссылок. Т.е. два объекта будут считаться равными только в том случае, если оба указывают на один и тот же объект. Для наследников ValueType этот метод переопределен (и является реализацией по умолчанию для всех структур) . В таком случае результатом сравнения двух структур будет результат сравнения каждого из полей структуры (на самом деле все несколько сложней, но об этом чуть позже).
Скорее всего, вы слышали, что если переопределяется метод Equals , то также необходимо переопределить и метод GetHashCode . Дело в том, что эти методы связаны контрактом, и при переопределении одного метода нужно так же переопределить и другой. При этом, если этого не сделать, то компилятор любезно выдаст предупреждение (что хорошо), наподобие следующего:
1 |
'Program.MyClass' overrides Object.Equals(object o) but does not override Object.GetHashCode() |
Дело в том, что методы Equals и GetHashCode необходимы для корректной работы разных коллекций, в частности, System.Collections.Hashtable и System.Collections.Generic.Dictionary<TKey,TValue> и других. Давайте кратко рассмотрим, как устроены хэштаблицы. Хэштаблица — это коллекция, в которой каждый элемент представляет из себя пару «ключ-значение». Таким образом, с помощью хештаблиц мы можем создавать ассоциации между объектами- ключами и объектами значений. Для того, чтобы реализовать такую коллекцию, нам необходима операция сравнения. А для того, чтобы такая коллекция работала быстро, нам также необходима возможность получить хэшкод объекта ключа. Хэшкод представляет из себя просто целое знаковое 32 битное число. Простейший алгоритм работы выглядит так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public (bool, TValue) Get(TKey key) { var hashCode = key.GetHashCode(); var index = Math.Abs(hashCode % _storageSize); var values = _storage[index]; for (var i = 0; i < values.Length; ++i) { var (storedKey, value) = values[i]; if (storedKey.Equals(key)) { return (true, value); } } return (false, default); } |
Получаем хешкод ключа. С его помощью вычисляем, где именно может лежать искомый объект. И, далее, сравнивая ключи, мы либо находим искомое значение (если оно есть), либо нет (если его нет). Алгоритм чертовски прост (естественно, в наипростейшей его реализации; на практике все несколько сложнее, но для нас это абсолютно не важно).
Если присмотреться к алгоритму внимательно, то становится очевидно, что его сложность имеет прямую зависимость от распределения зачений, возввращаемых методом 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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
public struct Rect { public int x; public int y; public int width; public int height; public Rect(int x, int y, int width, int height) { this.x = x; this.y = y; this.width = width; this.height = height; } public override bool Equals(object other) { if (other is Rect rect) return x == rect.x && y == rect.y && width == rect.width && height == rect.height; return false; } public override int GetHashCode() { var hash = 17; hash = hash * 31 + x.GetHashCode(); hash = hash * 31 + y.GetHashCode(); hash = hash * 31 + width.GetHashCode(); hash = hash * 31 + height.GetHashCode(); return hash; } } |
Вроде бы, не так все и сложно.
Давайте более подробно рассмотрим, как работают методы Equals и GetHashCode по умолчанию. Начнем с Object.Equals и Object.GetHashCode . Первый метод можно представить в виде следующего псевдокода:
1 2 3 4 5 6 7 8 9 |
public virtual bool Equals(object other) { var thisRef = GetReference(this); var otherRef = GetReference(other); if (thisRef == otherRef) return true; return false; } |
Ничего неожиданного. По умолчанию Object.Equals возвращает True в случае, если ссылки на сравниваемых объекты равны. А как же работает Object.GetHashCode ? Каждый поток в CLR имеет собственный генератор для хэшкодов. Хэшкод вычисляется один раз и сохраняется в служебной памяти, ассоциированной с объектом. Значение хэшкода не меняется на протяжении всей жизни объекта. Вероятность того, что два потока будут последовательно генерировать одинаковые хэш-коды, маловероятна. На псевдокоде это может выглядеть примерно так:
1 2 3 4 5 6 7 8 |
public virtual int GetHashCode() { if (_hashCode) return _hashCode; _hashCode = GenerateNewHashCode(_threadId); return _hashCode; } |
Реализации Object.Equals и Object.GetHashCode по умолчанию говорят о том, что необходимости переписывать эти методы при определении своих классов нет, при условии, что нас устраивает семантика эквивалентности. Все работать будет на высоком уровне производительности. То есть здесь все очень хорошо (до тех пор, пока не придется изменить реализацию по умолчанию, но об этом позже). Теперь рассмотрим следующую пару методов: ValueType.Equals и ValueType.GetHashCode .
Начнем, опять-таки, с первого метода. Его исходный код выглядит примерно так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
public override bool Equals(object obj) { if (null == obj) return false; var thisType = (RuntimeType) this.GetType(); var thatType = (RuntimeType) obj.GetType(); if (thatType != thisType) return false; object thisObj = (Object) this; object thisResult, thatResult; // if there are no GC references in this object we can avoid reflection // and do a fast memcmp if (CanCompareBits(this)) return FastEqualsCheck(thisObj, obj); FieldInfo[] thisFields = thisType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); for (int i = 0; i < thisFields.Length; i++) { thisResult = ((RtFieldInfo) thisFields[i]).UnsafeGetValue(thisObj); thatResult = ((RtFieldInfo) thisFields[i]).UnsafeGetValue(obj); if (thisResult == null) { if (thatResult != null) return false; } else if (!thisResult.Equals(thatResult)) { return false; } } return true; } |
Довольно объемный метод для, казалось бы, такой простой операции. Быстро оценив код, мы можем сделать несколько простых выводов. Существует два варианта реализации метода ValueType.Equals . В случае, если это возможно, происходит быстрая побитовая операция сравнения двух структур. Во втором случае, если условия для выполнения первой операции неподходящие, то выполняется очень медленное сравнение (возможно, даже с боксингом) с использованием рефлексии. И, прежде, чем переходить к рассмотрению реализации ValueType.GetHashCode , давайте взглянем на следующий пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
public struct Pair<TKey, TValue> { public TKey key; public TValue value; public Pair(TKey key, TValue value) { this.key = key; this.value = value; } } ... var p1 = new Pair<int, int>(10, 29); var p2 = new Pair<int, int>(10, 31); Console.WriteLine($"p1 {p1.GetHashCode()}"); Console.WriteLine($"p2 {p2.GetHashCode()}"); var p3 = new Pair<int, byte>(10, 29); var p4 = new Pair<int, byte>(10, 31); Console.WriteLine($"p3 {p3.GetHashCode()}"); Console.WriteLine($"p4 {p4.GetHashCode()}"); // p1 -1101417521 // p2 -1101417523 // p3 1745183326 // p4 1745183326 |
Интересный момент во второй паре: несмотря на то, что структура содержит разные значения полей, метод GetHashCode возвращает одинаковый хэшкод. И, несмотря на то, что это не противоречит контракту (коллизии неизбежны), результаты нам явно дают понять, что реализация ValueType.GetHashCode однозначно негативно повлияет на производительность. Ну, а для того, чтобы понять, почему так происходит, давайте взглянем на псевдокод реализации этого метода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public virtual int GetHashCode() { var hashCode = 0; var type = this.GetType(); var typeId = type.LookupTypeId(); hashCode = typeId * 711650207 + 2506965631U; var canUseFastGetHashCodeHelper = false; if (type.HasCheckedCanCompareBitsOrUseFastGetHashCode()) { canUseFastGetHashCodeHelper = type.CanCompareBitsOrUseFastGetHashCode(); } else { canUseFastGetHashCodeHelper = CanCompareBitsOrUseFastGetHashCode(type); } if (canUseFastGetHashCodeHelper) { hashCode ^= FastGetValueTypeHashCodeHelper(type, this.unBox()); } else { hashCode ^= RegularGetValueTypeHashCode(type, this.unBox()); } return hashCode; } |
По аналогии с методом ValueType.Equals , хэшкод может вычисляться (что и поясняет поведение из примера) по-разному, в зависимости от того, что из себя представляет структура. При этом в худшем случае (с точки зрения производительности) расчет хэшкода осуществляется по первому нестатическому полю структуры. Таким образом, при реализации этого метода упор был сделан на скорость расчета хэшкода (поэтому и не учитываются, в некоторых случаях, все поля при его расчете), что, в свою очередь, также может повлиять на производительность работы некоторых коллекций, в частности, хэштаблиц. Отсюда и рекомендация: при объявлении своих структур необходимо переопределять как ValueType.Equals , так и ValueType.GetHashCode
Сложности
К данному моменту должно быть очевидно, что корректно реализовать Equals и GetHashCode может быть не так-то и просто. Нужно учитывать кучу нюансов, помнить о правилах; и это все при практически нулевой поддержке со стороны компилятора. Что, конечно, не может не радовать (особенно в сравнении с другими языками программирования). Наличие проблем в реализации этих методов подтверждается практикой. Нет-нет, да косяки в реализации одного (или обоих) методов находятся. Давайте рассмотрим несколько подобных случаев.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
public struct IntPoint { public int x; public int y; public IntPoint(int x, int y) { this.x = x; this.y = y; } public bool Equals(IntPoint other) { return x == other.x && y == other.y; } public override bool Equals(object obj) { return obj is IntPoint other && Equals(other); } public override int GetHashCode() { return x.GetHashCode() * 31 + y.GetHashCode(); } } public struct FloatPoint { public float x; public float y; public FloatPoint(float x, float y) { this.x = x; this.y = y; } public bool Equals(FloatPoint other) { return x == other.x && y == other.y; } public override bool Equals(object obj) { return obj is FloatPoint other && Equals(other); } public override int GetHashCode() { return x.GetHashCode() * 31 + y.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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
public class PushNotification : Model, IPushNotification { private int _id; private string _title; private string _text; private DateTime _timeShow; private Dictionary<string, string> _userData; private int _badgeNumber; private string _notificationProfile; public PushNotification( int id, [NotNull][NotEmpty] string title, [NotNull][NotEmpty] string text, DateTime timeShow, [CanBeNull] Dictionary<string, string> userData, int badgeNumber = -1, string notificationProfile = null) { if (string.IsNullOrEmpty(title)) throw new ArgumentNullOrEmptyException(nameof(title), title); if (string.IsNullOrEmpty(text)) throw new ArgumentNullOrEmptyException(nameof(text), text); if (id <= 0) { throw new ArgumentOutOfRangeException(nameof(id), "Notification id should be greater than 0."); } _id = id; _title = title; _text = text; _timeShow = timeShow; _userData = userData; _badgeNumber = badgeNumber; _notificationProfile = notificationProfile; } /// <inheritdoc/> public int id => _id; /// <inheritdoc/> public string title => _title; /// <inheritdoc/> public string text => _text; /// <inheritdoc/> public string notificationProfile => _notificationProfile; /// <inheritdoc/> public DateTime timeShow => _timeShow; /// <inheritdoc/> public Dictionary<string, string> userData => _userData; /// <inheritdoc /> public int badgeNumber { get => _badgeNumber; set => _badgeNumber = value; } /// <inheritdoc cref="IModel.ToString()"/> public override string ToString() { return nameof(PushNotification) + "{" + nameof(id) + "=" + _id + ", " + nameof(title) + "=" + _title + ", " + nameof(text) + "=" + _text + ", " + nameof(timeShow) + "=" + _timeShow + ", " + nameof(badgeNumber) + "=" + _badgeNumber + ", " + nameof(userData) + "=" + _userData + nameof(notificationProfile) + "=" + _notificationProfile + "}"; } /// <inheritdoc /> public override bool Equals(object obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != GetType()) return false; return Equals((PushNotification) obj); } /// <inheritdoc /> public override int GetHashCode() { unchecked { var hashCode = id.GetHashCode(); hashCode = (hashCode * 397) ^ title.GetHashCode(); hashCode = (hashCode * 397) ^ text.GetHashCode(); hashCode = (hashCode * 397) ^ timeShow.GetHashCode(); hashCode = (hashCode * 397) ^ badgeNumber.GetHashCode(); hashCode = (hashCode * 397) ^ notificationProfile.GetHashCode(); return hashCode; } } /// <inheritdoc/> protected override void DoManagedDispose() { _id = 0; _title = null; _text = null; _timeShow = default; _userData = null; _badgeNumber = -1; _notificationProfile = null; } private bool Equals(PushNotification other) { return id.Equals(other.id) && title.Equals(other.title) && text.Equals(other.text) && timeShow.Equals(other.timeShow) && badgeNumber.Equals(other.badgeNumber) && userData.IsEquivalentTo(other.userData) && string.Equals(notificationProfile, other.notificationProfile); } } |
В этом примере, также невооруженным взглядом видно, где проблема, не так ли? Обычно подобные проблемы происходят по мере развития кодовой базы. И они должны быть знакомы большинству. Так что же не так?
1. Реализация GetHashCode не учитывает userData. 2. Реализация GetHashCode не учитывает, что notificationProfile может быть null.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
public class Student { public string name; public int course; public bool isExcellent; public Student(string name, int course, bool isExcellent) { if (string.IsNullOrEmpty(name)) throw new ArgumentException(nameof(name)); this.name = name; this.course = course; this.isExcellent = isExcellent; } protected bool Equals(Student other) { return name == other.name && course == other.course && isExcellent == other.isExcellent; } public override bool Equals(object obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; return Equals((Student) obj); } public override int GetHashCode() { var hash = 17; hash = hash * 31 + name.GetHashCode(); hash = hash * 31 + course.GetHashCode(); hash = hash * 31 + isExcellent.GetHashCode(); return hash; } public override string ToString() { return $"{name}" + (isExcellent ? "*" : ""); } } public class ExcellentStudentComparer : EqualityComparer<Student> { public override bool Equals(Student x, Student y) { return x.name.Equals(y.name) && x.isExcellent == y.isExcellent; } public override int GetHashCode(Student obj) { return obj.GetHashCode(); } } var comparer = new ExcellentStudentComparer(); var excellentStudents2018 = new List<Student>(); excellentStudents2018.Add(new Student("Ivan Ivanov", 1, true)); excellentStudents2018.Add(new Student("Peter Petrov", 2, true)); excellentStudents2018.Add(new Student("Rita Selezneva", 3, true)); excellentStudents2018.Add(new Student("Vera Fedorova", 3, true)); var excellentStudents2019 = new List<Student>(); excellentStudents2019.Add(new Student("Ivan Ivanov", 2, true)); excellentStudents2019.Add(new Student("Peter Petrov", 3, true)); excellentStudents2019.Add(new Student("Nasty Ershova", 1, true)); excellentStudents2019.Add(new Student("Katya Kotyiza", 4, true)); var excellentStudents2018_2019 = excellentStudents2018.Intersect(excellentStudents2019, comparer); Console.WriteLine(string.Join(", ", excellentStudents2018_2019)); // Empty string |
А в этом примере что реализовано не так? Почему мы получили результат, который не ожидали? Конечно, ошибка просто элементарная и сказать, что пошло не так, можно, даже несмотря на реализацию методов 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#. Вот как это может выглядеть:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public struct Point { public double x; public double y; public Point(double x, double y) { this.x = x; this.y = y; } public bool Equals(Point other) { return (x, y).Equals((other.x, other.y)); } public override bool Equals(object obj) { return obj is Point other && Equals(other); } public override int GetHashCode() { return (x, y).GetHashCode(); } } |
На самом деле, нечто подобное было доступно и раньше (с использованием анонимных классов). Но это — тоже не панацея, не всегда получится использовать подобный код. К тому же, скорее всего, это несколько медленнее, чем описание идентичного кода вручную.
Заключение
На первый взгляд, идея о возможности использования любого объекта в качестве ключа в хэштаблице кажется удобной. И, когда начинаешь работать с ЯП, в котором нельзя просто так взять и использовать любой объект в качестве ключа, приходят мысли о том, как это такое; вроде бы, элементарная возможность, как же без нее жить? Но проходит время, и ты осознаешь: а ведь действительно, может быть это и правильно. Так ли нужна такая возможность, как любой объект в качестве ключа хэштаблицы? Конечно, если эта возможность достается бесплатно и нет необходимости постоянно что-то править ручками, то это удобно. Но вот с C# совсем другой случай. Equals и GetHashCode — еще пара мест, в которых легко можно выстрелить в ногу, и на которые необходимо обращать внимание, тратить время (которого обычно не хватает). И, если бы дизайнеры языка выбрали иной путь, то сейчас этот ЯП мог бы быть совсем другим, и, возможно, много лучше,чем он есть сейчас. Трудно точно ответить на вопрос, почему именно такое решение приняли разработчики, но, скорее всего, это было сделано под копирку (слизано из Java). Вот как бы мог быть реализован метод Object.Equals :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class Object { public virtual Boolean Equals(Object obj) { if (obj == null) return false; if (this.GetType() != obj.GetType()) return false; // true, если все поля совпадают. Но т.к. System.Object не объявляет полей, // то сразу возвращаем true return true; } } |
Подобный код упрощал бы реализацию метода Equals в наследниках.
Возможно, также стоило бы полностью отказаться от объявления Equals и GetHashCode в базовом классе, что позволило бы реализовывать соответствующие методы только в том случае, когда они действительно нужны, а неиспользуемый код не висел бы мертвым грузом; время, потраченное на реализацию и поддержку работоспособности этого мертвого код, было бы использовано более полезно.
Еще одним возможным вариантом решения многих проблем была бы автоматическая генерация методов Equals и GetHashCode компилятором, с возможностью, при необходимости, реализации этих методов вручную. А, учитывая то, что это бы покрыло процентов 90 (если не больше) случаев переписывания этих методов, такая возможность в одной из будущих версий языка смогла бы значительно упростить жизнь большинству разработчиков.
Казалось бы, не самый сложный функционал превратился в громадное нечто, и даже в статьях (возможно, и в этой статье тоже есть?) и книгах порой можно встретить не только неточности, но даже и ошибки. Что, конечно, не может не радовать. И, хотя существуют средства, помогающие несколько упростить жизнь (позволяющие генерировать необходимые реализации полуавтоматически, например, ReSharper), это все равно не спасает от механических, Copy&Paste и других подобных ошибок. И, опять-таки, наличие подобных средств открытым текстом говорит, что не все так гладко в датском королевстве.
Ну, а на сегодня все, спасибо за внимание. Если есть какие-то вопросы или нашли ошибку, то, милости просим в комментарии. Увидимся в следующей статье.