В предыдущей публикации мы вывели наиболее полный и корректный способ реализации сравнения по значению объектов — экземпляров классов (являющихся ссылочными типами — Reference Types) для платформы .NET.
Каким образом нужно модифицировать предложенный способ для корректной реализации сравнения по значению объектов — экземпляров структур (являющихся "типами по значению" — Value Types)?
Экземпляры структур, в силу своей природы, всегда сравниваются по значению.
Для предопределенных типов, таких как Boolean или Int32, под сравнением по значению понимается сравнение непосредственно значений экземпляров структур.
Если структура определена разработчиком — пользователем платформы (User defined struct), то сравнение по умолчанию автоматически реализуется как сравнение значений полей экземпляров структур. (Подробности см. в описании метода ValueType.Equals(Object) и операторов == и !=). Также при этом автоматически определенным образом реализуется метод ValueType.GetHashCode(), перекрывающий метод Object.GetHashCode().
И в этом случае есть несколько существенных подводных камней:
При сравнении значений полей используется рефлексия, что влияет на производительность.
Поле структуры может иметь не "значимый", а ссылочный тип, а в этом случае сравнение полей по ссылке может не подойти с предметной (доменной) точки зрения, и может потребоваться сравнение полей по значению (хотя в общем случае использование в структуре ссылочный полей можно считать неверным архитектурным решением).
(В документации рекомендуется создать для такой структуры собственную реализацию сравнения по значению для повышения производительности и наиболее точного отражения значения равенства для данного типа.)
Может оказаться, что с предметной точки зрения не все поля должны участвовать в сравнении (хотя, опять же, для структур в общем случае это можно считать неверным решением).
- И наконец, дефолтная реализация метода ValueType.GetHashCode() не соответствует общим требованиям к реализации метода GetHashCode() (о которых мы говорили в первой публикации):
- значение хеш-кода, полученное с помощью ValueType.GetHashCode(), может оказаться непригодным для использования в качестве ключа в хеш-таблице;
- если значение одного или нескольких полей объекта изменилось, то значение, полученное с помощью ValueType.GetHashCode(), также может оказаться непригодным для использования ключа в хеш-таблице;
- в документации рекомендуется создавать собственную реализацию метода GetHashCode(), наиболее точно отражающую концепцию хеш-кода для данного типа.
Таким образом, с одной стороны, есть несколько причин общего характера, подталкивающих к реализации у структур собственного механизма сравнения объектов по значению (производительность, соответствие доменной модели).
С другой стороны, необходимость корректной реализации метода GetHashCode() автоматически приводит к необходимости реализации сравнения по значению, т.к. метод GetHashCode() в силу природы хеш-кода (см. первую публикацию) должен "знать", какие данные (поля) и как участвуют в сравнении по значению.
С третьей стороны, возможен и особый случай, когда есть "простая" структура, состоящая, например, только из полей-структур, для которых побайтовое сравнение с помощью рефлексии заведомо дает семантически верный результат (например, Int32).
В этом случае возможно реализовать GetHashCode() корректным образом (чтобы для равных объектов хеш-код всегда был один и тот же), не создавая при этом собственную реализацию сравнения по значению.
Например:
public struct Point
{
private int x;
private int y;
public int X {
get { return x; }
set { x = value; }
}
public int Y
{
get { return y; }
set { y = value; }
}
public override int GetHashCode() => x.GetHashCode() ^ y.GetHashCode();
}
Однако, в случае переписывания этого простого примера с использованием "автоматически реализуемых свойств" картина выглядит менее ясной:
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
public override int GetHashCode() => X.GetHashCode() ^ Y.GetHashCode();
}
В документации к "автосвойствам" говорится об автоматическом создании anonymous backing field, соответствующим публичным свойствам.
Строго говоря, из описания неясно, будут ли равными с точки зрения дефолтной реализации сравнения по значению два объекта Point с попарно одинаковыми значениями X и Y:
- Если дефолтная реализация сравнивает с помощью рефлексии значения полей, то как для разных объектов происходит сопоставление анонимных полей — что эти поля соответствуют друг друга, т.к. каждое соответствует свойству X, а эти соответствуют друг другу, т.к. каждое соответствует Y?
Что если в двух разных объектах создаются backing-поля с разными именами вида (x1, y1) и (x2, y2)?
Будет ли учитываться при сравнении, что x1 соответствует x2, а y1 соответствует y2?
- Создаются ли при этом еще какие-то вспомогательные поля, которые могут иметь разные значения для одинаковых с точки зрения интерфейса (X, Y) объектов? Если да, то будут ли учитываться эти поля при сравнении?
- Или, возможно, в случае структуры с автосвойствами, будет использоваться побайтовое сравнение всего содержимого структуры, без сравнения отдельных полей? Если да, то backing-поля для каждого объекта будут создаваться в памяти всегда в одном и том же порядке и с одинаковыми смещениями?
Скорее всего, где-то в недрах документации или книгах авторов, имеющих отношение к разработке платформы, можно найти положительные ответы на эти вопросы — в том смысле, что поведение для структур с явно объявленными полями и структур с автосвойствами будет давать одинаковый результат при дефолтном сравнении по значению.
Либо, если какая-то часть окажется недокументированной, то, скорее всего, поведение компилятора и исполняющей среды окажется ожидаемым.
С учетом общей совокупности аргументов, представляется, что в общем случае для структур предпочтительнее реализовывать собственное сравнение по значению.
Развернутый пример с подробными комментариями, на основе знакомой по предыдущим публикациям сущности Person, рассмотрим в следующей публикации.
Комментарии (40)
a-tk
08.01.2017 19:56+1Вот
реализация ValueType.Equalspublic override bool Equals(object obj) { if (obj == null) return false; RuntimeType runtimeType = (RuntimeType) this.GetType(); if ((RuntimeType) obj.GetType() != runtimeType) return false; object a = (object) this; if (ValueType.CanCompareBits((object) this)) return ValueType.FastEqualsCheck(a, obj); FieldInfo[] fields = runtimeType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); for (int index = 0; index < fields.Length; ++index) { object obj1 = ((RtFieldInfo) fields[index]).UnsafeGetValue(a); object obj2 = ((RtFieldInfo) fields[index]).UnsafeGetValue(obj); if (obj1 == null) { if (obj2 != null) return false; } else if (!obj1.Equals(obj2)) return false; } return true; }
sand14
08.01.2017 20:01+1object obj1 = ((RtFieldInfo) fields[index]).UnsafeGetValue(a); object obj2 = ((RtFieldInfo) fields[index]).UnsafeGetValue(obj); if (obj1 == null) { if (obj2 != null) return false; } else if (!obj1.Equals(obj2)) return false;
Интересно. Т.е., при получении/сравнения значений полей используется упаковка/распаковка, несмотря на то, что скорее всего можно ожидать, что поля структуры — тоже структуры.lair
08.01.2017 20:38Для случая, когда все поля структуры — структуры, обычно срабатывает
CanCompareBits
, который приводит к прямому сравнению памяти.sand14
08.01.2017 20:42но даже в этом случае происходит упаковка самой структуры при передаче в CanCompareBits
что-то недоработано со структурами в платформеlair
08.01.2017 20:49но даже в этом случае происходит упаковка самой структуры при передаче в CanCompareBits
А происходит ли? Я понимаю, что там сверху написано
(object) this
, но нельзя однозначно сказать, это действительно боксинг, или просто "взятие адреса от" — потому что если бы туда был передан простоthis
, было бы копирование. АCanCompareBits
—extern
иMethodImplOptions.InternalCall
, так что там может быть любая магия, на самом деле.a-tk
08.01.2017 22:31Возможно там что-то действительно более хитрое прячется за внутренним вызовом, чем упаковка.
Deosis
09.01.2017 08:10+1Посмотрел в IL. Там
ldarg.0
Упаковки нет.
call bool System.ValueType::CanCompareBits(object)
А то, что приведено в виде C# может быть ошибкой декомпилятора.sand14
09.01.2017 08:16+1Странно:
dotPeek не декомпилирует, а закачивает исходники с сайта MS — и для .NET 4.6.2 показывается код, отличающийся от приведенного в этой ветке, но очень похожий, те же вызовы, формально должные привести к упаковке.
Видимо, в .NET много магии в платформенных вызовах.a-tk
09.01.2017 12:24Я использовал dotPeek с ValueType из сборки «mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089».
Вообще когда дело доходит до столь низкоуровневых вещей, начинаются прикольные вещи вроде такого (System.Double):
public static bool operator ==(double left, double right) { return left == right; } public static bool operator !=(double left, double right) { return left != right; } public static bool operator <(double left, double right) { return left < right; } // и много-много ещё
PsyHaSTe
14.01.2017 20:24Ага, а в определении структуры
Int32
значение хранится в полеInt32 Value
:)
lair
09.01.2017 12:28Если посмотреть IL через dotPeek, то там преобразований нет:
ldarg.0 // this stloc.2 // thisObj ldarg.0 // this call bool System.ValueType::CanCompareBits(object) brfalse.s IL_003a ldloc.2 // thisObj ldarg.1 // obj call bool System.ValueType::FastEqualsCheck(object, object)
Для сравнения, боксинг выглядит вот так:
// int i = 12; ldc.i4.s 12 // 0x0c stloc.0 // i // object obj = i; ldloc.0 // i box [mscorlib]System.Int32 stloc.1 // obj
PsyHaSTe
14.01.2017 20:24@sand14 у меня к дотпику вообще много претензий. Например у нас был баг, что словарь не реализовывал
IReadOnlyDictionary
или подобный интерфейс. Смотрим MSDN, да нет, должен реализовывать. Но в рантайме ошибка. Начали думать, что сбилдили не с той версией, смотрим, действительно, в 4.5.2 интерфейс такой появился. Декомпилируем сборку, чтобы понять, какой версии словарь там использовался — да нет, все нормально… Долго ломали голову, в итоге плюнули и поставили другой декомпилятор...
Так вот, дотпик каким-то образом кэширует сборки, и если у него есть сборка в кэше, он показывает данные из неё несмотря на то, что она может отличаться от того, что на диске… Очень неприятное поведение. Так что, ILSpy FTW.
Deosis
09.01.2017 08:16+1По первому подводному камню уже писали выше.
Документация говорит, что если все поля значимого типа, то идет побитовое сравнение.
При этом поле тоже может быть структурой.
Насколько глубоко идет проверка возможности побитового сравнения?sand14
09.01.2017 08:34+1Хороший вопрос.
Проблема в том, что в MSDN по многим «тонким» вопросам нет исчерпывающей документации.
И есть ли исчерпывающая спецификация на платформу?
Или только по фактическому поведению/исходниками смотреть?
P.S. То же самое со спецификацией на C# 6.0: много публикаций в технических блогах, включая блоги MSDN, то спецификацию в виде документа с сайта MS можно закачать только по версии 5.0.
А справочные разделы MSDN не дают всей точной картины.
T-D-K
09.01.2017 08:53У структур ещё GetHashCode написан так, что иногда он считает хэш только от первого филда. Например:
struct SomeStruct { public int SomeInt; public double SomeDouble; } class Program { static void Main(string[] args) { int someInt = 42; SomeStruct struct1 = new SomeStruct { SomeInt = someInt, SomeDouble = 2 }; SomeStruct struct2 = new SomeStruct { SomeInt = someInt, SomeDouble = 3 }; Console.WriteLine(struct1.GetHashCode()); Console.WriteLine(struct2.GetHashCode()); } }
Выведет два одинаковых числа в консоль, но стоит поменять местами филды в структуре и хэши становятся разными. Проверено прямо сейчас в vs2015, .net 4.5.
Аналогично, если сделать первым филдом double и задать одинаковым его, то тоже хэши будут одинаковые.Deosis
09.01.2017 11:47К GetHashCode есть только два требования:
- Если хэш-коды двух объектов различны, то объекты различны.
- Хэш-код должен считаться очень быстро. Иначе не будет смысла сначала считать хэш-код, а потом при совпадении сравнивать по содержимому.
- Ещё желательно, чтобы он не менялся со временем.
Хэш-коду ничего не мешает быть одинаковым у разных объектов.
T-D-K
09.01.2017 11:53я не спорю. Но плохой хэшкод будет вызывать большое количество сравнений в хэш-таблицах. И всё начинает тормозить у людей, которые не переопределяют Equals и GetHashCode у структур. Где-то читал что у структур GetHashCode работает в двух режимах: если есть ссылки на референс-типы в филдах структуры, или если поля в ней не выровнены, то считаем хэш от первого филда. В противном случае ксорим память структуры. Могу ошибаться.
Deosis
09.01.2017 12:19+3ValueType.GetHashCode remarks
Тут явно говорится, что для вычисления используется одно или несколько полей структуры.
А также, что реализация по умолчанию не очень подходит для хэш-таблиц.
lair
Сравнение двух структур разных типов по значению не имеет смысла. А структуры одного типа будут иметь одинаковые (и одинаково расположенные) backing fields, поэтому ваши вопросы не имеют смысла.
sand14
Естественно, речь о сравнении структур одинакового типа.
Вопросы имеют смысл, т.к. поведение структур с автосвойствами строго не описано (хотя и ясно, какое поведение можно ожидать).
В этом цикле я ставлю целью рассмотреть наиболее полно теоретические аспекты object equality, чтобы вывести законченные практические решения.
К вопросу об ожидаемом поведении: однажды у коллег в каких-то случаях были некие неясности при автоматической сериализации сущностей с автоствойствами, т.к. движок сериализации не знал, к каким полям обращаться, или что-то в таком духе.
lair
Эмм. Автосвойства — это фича C#, в то время как поведение структур — фича BCL. С точки зрения CLR, структура с автосвойствами — это структура со странно именованными полями, вот и все. Поскольку поля создаются в типе — они, очевидно, имеют одинаковое наименование и расположение.
Эта проблема возникает при попытке десериализации автосвойств от предыдущей версии сборки — известно, что именование backing fields не гарантировано стабильно. Но в вашем случае, поскольку вы имеете дело с типом в одной и той же сборке, вас это не касается.
sand14
Мне хотелось бы видеть в языке фичу, чтобы при объявлении свойства автоматом бы создавалось backing field (вида: свойство PropName, поле $PropName), и чтобы это поле было доступно только в геттере и сеттере.
Тогда не было бы мешанины явно объявленных backing field, к которым кто угодно может получить доступ вне геттера/сеттера и поменять их, и автосвойств с их отсутствием возможности получить явный доступ к полю и недетерминированым именем этого поля.
Возможно, с поддержкой этого даже не в языке, а в CLR.
lair
А не выйдет. Если это поле будет доступно только в геттере/сеттере, у вас сломается рефлекшн, который работает на полях (например, сериализация и, как раз, value types), а если оно будет доступно через рефлекшн, то нет разницы с "обычными" backing fields.
Собственно, для задач, отличных от сериализации стандартным BinaryFormatter, я уже и не помню, зачем я использовал не-readonly backing fields.
Ну то есть да, фича милая, но я подозреваю, что она если и есть в списке команды .net, то о-о-очень далеко.
Ну и да, к сравнению структур она отношения не имеет.
sand14
Да, это уже не про тему структур.
Но тем не менее:
Сейчас свойства это сахар над полем, и методами — геттерам/сеттером.
При этом геттер и сеттер на уровне CLR имеют атрибуты, придающие им определенную семантику.
Получается, застряли где-то посередине.
Моя идея в том, чтобы свойство было своего рода объектом, инкапсулирующим в себе единственное значение.
Вручную можно так писать и сейчас — например, такой подход применен в MS-библиотеке работы с форматом ooxml.
Но хотелось бы видеть это именно в объектной модели/платформе.
Понятно, что в существующих платформах этого или не сделают, или когда-нибудь сделают, но криво, и это будет соседствовать со старыми подходами ради backward compatibility.
lair
Я просто не очень понимаю, зачем это нужно.
sand14
Вам приходилось наблюдать в legacy-проекте разросшийся класс со множеством backing-полей и свойств, где внутри самого класса происходит бессистемное обращение то к полю, то свойству — и когда уже не восстановить логику, где точно нужен прямой доступ к полю, и где доступ нужен через сеттер с проверками, доп. действими,
(и иногда нужен доступ и через геттер, если в месте вызова лучше абстрагироваться от источника значение и/или выполнить проверку на инвариант объекта),
и т.д.?
Бывает всегда достаточно обращать изнутри всегда к полю, а все равно написана каша разнородных обращений.
А если авторы еще открыли internal-доступ к полю, то вообще тушите свет.
Так что эта идея ради лучшей инкапсуляции.
lair
Когда вам нужна инкапсуляция внутри класса — что-то пошло не так (в моем понимании).
Ну то есть да, бывает, не спорю. Но обычно это признак того, что класс уже вышел из-под контроля, и с ним надо бороться всеми средствами.
a-tk
Автореализуемые свойства — это тривиальные свойства, когда нет разницы, обращение идёт к полю или свойству. Рекомендуется использовать именно свойства вместо полей для того, чтобы всегда можно было изменить реализацию аксессоров без изменения контракта (интерфейса) класса. То есть через метаинформацию известно, что есть свойство с таким-то именем. А замена поля на свойство приводит к поломке зависимых сборок и требует их перекомпиляции для повторного разрешения ссылок.
PsyHaSTe
@a-tk Свойство нужно еще и затем, что его можно описать в интерфейсе (ведь это просто пара методов), а вот потребовать в интерфейсе наличие поля нельзя.
На самом деле поля сами по себе это лишняя сущность, лучше бы изначально сделали только свойства, которые например являются врапперами других свойств. А на самом нижнем уровне автосвойства (то, что сейчас является полями). Но, тут уже наследие других языков и принятых архитектурных решений. Так и живем.
a-tk
По поводу внесения в интерфейс — согласен.
Однако поля не лишние, они нужны для того, чтобы хранить состояние. Свойство в общем случае может не быть частью состояния объекта. Иными словами, поле — это всегда данные объекта, свойство — это всего лишь пара специальных методов. Автореализуемые свойства опираются на backing-поля, реализуемые компилятором.
PsyHaSTe
Концепция полей не нужна на уровне разработчика, вот о чем речь. Автосвойства для него ничем не отличаются от полей, кроме иконки в IDE (ну и вышеупомянутой возможности описывать их в интерфейсах). Просто какая разница, написать приватное поле или приватное автосвойство? В обоих случаях гетеры и сетеры будут заинлайнены и будет прямой доступ к полю. Но теперь программисту нужно различать 2 разных вида полей (собственно поля и свойства), учитывать это в рефлексии (нельзя просто вызывать GetProperties(), потому что часть представления может быть в полях) и т.п…
a-tk
А как насчёт компилятора? Рано или поздно надо опускаться до уровня данных.
PsyHaSTe
На уровне компилятора есть много всего такого, чего нет на уровне языка (класс __Cannon, например). Так что на его уровне да, поля бы появлялись, но для разработчика была бы единая концепция.
irriss
у структуры может быть поле float или double, сравнение может учитывать погрешность вычислений, чтобы возврачащать true, несмотря на отличие, например, в 12-ом разряде
lair
… и как это связано с тем, автосвойства в структуре, или нет?
irriss
никак, мне показалось, что ваш комментарий о бессмысленности кастомного сравнения структур
lair
Нет, мой комментарий о том, что имплементация свойств (ручная или автоматическая) не влияет на встроенное поведение
Equals
иGetHashCode
.a-tk
Нигде в недрах не найдено ничего, что указывало на какие-либо сравнения, отличные от побитовых для чисел (в смысле для не не-чисел)
a-tk
Интересно, чем руководствовался человек, поставивший минус?
Явно не объективными аргументами типа ildasm-а и прочих инструментов. Тем временем реализация типов System.Single aka float и System.Double aka double от Microsoft не указывают на сравнение с погрешностями. Если такие реализации есть, то хотелось бы увидеть пруф, чтобы принять позицию.