О сравнении объектов по значению — 5: Structure Equality Problematic
В предыдущей публикации мы вывели наиболее полный и корректный способ реализации сравнения по значению объектов — экземпляров классов (являющихся ссылочными типами — Reference Types) для платформы .NET.
Каким образом нужно модифицировать предложенный способ для корректной реализации сравнения по значению объектов — экземпляров структур (являющихся «типами по значению» — Value Types)?
Экземпляры структур, в силу своей природы, всегда сравниваются по значению.
Для предопределенных типов, таких как Boolean или Int32, под сравнением по значению понимается сравнение непосредственно значений экземпляров структур.
Если структура определена разработчиком — пользователем платформы, то сравнение по умолчанию автоматически реализуется как сравнение значений полей экземпляров структур.
(Подробности см. в описании метода 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, рассмотрим в следующей публикации.
Комментарии (9)
7 января 2017 в 22:54
0↑
↓
Сравнение двух структур разных типов по значению не имеет смысла. А структуры одного типа будут иметь одинаковые (и одинаково расположенные) backing fields, поэтому ваши вопросы не имеют смысла.
7 января 2017 в 23:09
0↑
↓
Естественно, речь о сравнении структур одинакового типа.Вопросы имеют смысл, т.к. поведение структур с автосвойствами строго не описано (хотя и ясно, какое поведение можно ожидать).
В этом цикле я ставлю целью рассмотреть наиболее полно теоретические аспекты object equality, чтобы вывести законченные практические решения.К вопросу об ожидаемом поведении: однажды у коллег в каких-то случаях были некие неясности при автоматической сериализации сущностей с автоствойствами, т.к. движок сериализации не знал, к каким полям обращаться, или что-то в таком духе.
7 января 2017 в 23:16 (комментарий был изменён)
0↑
↓
Вопросы имеют смысл, т.к. поведение структур с автосвойствами строго не описано
Эмм. Автосвойства — это фича C#, в то время как поведение структур — фича BCL. С точки зрения CLR, структура с автосвойствами — это структура со странно именованными полями, вот и все. Поскольку поля создаются в типе — они, очевидно, имеют одинаковое наименование и расположение.
К вопросу об ожидаемом поведении: однажды у коллег в каких-то случаях были некие неясности при автоматической сериализации сущностей с автоствойствами, т.к. движок сериализации не знал, к каким полям обращаться, или что-то в таком духе.
Эта проблема возникает при попытке десериализации автосвойств от предыдущей версии сборки — известно, что именование backing fields не гарантировано стабильно. Но в вашем случае, поскольку вы имеете дело с типом в одной и той же сборке, вас это не касается.
7 января 2017 в 23:24
+1↑
↓
Мне хотелось бы видеть в языке фичу, чтобы при объявлении свойства автоматом бы создавалось backing field (вида: свойство PropName, поле $PropName), и чтобы это поле было доступно только в геттере и сеттере.Тогда не было бы мешанины явно объявленных backing field, к которым кто угодно может получить доступ вне геттера/сеттера и поменять их, и автосвойств с их отсутствием возможности получить явный доступ к полю и недетерминированым именем этого поля.
Возможно, с поддержкой этого даже не в языке, а в CLR.
7 января 2017 в 23:29
0↑
↓
А не выйдет. Если это поле будет доступно только в геттере/сеттере, у вас сломается рефлекшн, который работает на полях (например, сериализация и, как раз, value types), а если оно будет доступно через рефлекшн, то нет разницы с «обычными» backing fields.
Собственно, для задач, отличных от сериализации стандартным BinaryFormatter, я уже и не помню, зачем я использовал не-readonly backing fields.
Ну то есть да, фича милая, но я подозреваю, что она если и есть в списке команды .net, то о-о-очень далеко.
Ну и да, к сравнению структур она отношения не имеет.
7 января 2017 в 23:42
0↑
↓
Да, это уже не про тему структур.Но тем не менее:
Сейчас свойства это сахар над полем, и методами — геттерам/сеттером.
При этом геттер и сеттер на уровне CLR имеют атрибуты, придающие им определенную семантику.
Получается, застряли где-то посередине.Моя идея в том, чтобы свойство было своего рода объектом, инкапсулирующим в себе единственное значение.
Вручную можно так писать и сейчас — например, такой подход применен в MS-библиотеке работы с форматом ooxml.
Но хотелось бы видеть это именно в объектной модели/платформе.
Понятно, что в существующих платформах этого или не сделают, или когда-нибудь сделают, но криво, и это будет соседствовать со старыми подходами ради backward compatibility.8 января 2017 в 00:51
0↑
↓
Моя идея в том, чтобы свойство было своего рода объектом, инкапсулирующим в себе единственное значение.
Я просто не очень понимаю, зачем это нужно.
8 января 2017 в 01:31
0↑
↓
Вам приходилось наблюдать в legacy-проекте разросшийся класс со множеством backing-полей и свойств, где внутри самого класса происходит бессистемное обращение то к полю, то свойству — и когда уже не восстановить логику, где точно нужен прямой доступ к полю, и где доступ нужен через сеттер с проверками, доп. действими,
(и иногда нужен доступ и через геттер, если в месте вызова лучше абстрагироваться от источника значение и/или выполнить проверку на инвариант объекта),
и т.д.?Бывает всегда достаточно обращать изнутри всегда к полю, а все равно написана каша разнородных обращений.
А если авторы еще открыли internal-доступ к полю, то вообще тушите свет.Так что эта идея ради лучшей инкапсуляции.
8 января 2017 в 02:16
0↑
↓
Так что эта идея ради лучшей инкапсуляции.
Когда вам нужна инкапсуляция внутри класса — что-то пошло не так (в моем понимании).
Ну то есть да, бывает, не спорю. Но обычно это признак того, что класс уже вышел из-под контроля, и с ним надо бороться всеми средствами.