О сравнении объектов по значению — 5: Structure Equality Problematic

    В предыдущей публикации мы вывели наиболее полный и корректный способ реализации сравнения по значению объектов — экземпляров классов (являющихся ссылочными типами — Reference Types) для платформы .NET.


    Каким образом нужно модифицировать предложенный способ для корректной реализации сравнения по значению объектов — экземпляров структур (являющихся "типами по значению" — Value Types)?


    Экземпляры структур, в силу своей природы, всегда сравниваются по значению.


    Для предопределенных типов, таких как Boolean или Int32, под сравнением по значению понимается сравнение непосредственно значений экземпляров структур.


    Если структура определена разработчиком — пользователем платформы (User defined struct), то сравнение по умолчанию автоматически реализуется как сравнение значений полей экземпляров структур. (Подробности см. в описании метода ValueType.Equals(Object) и операторов == и !=). Также при этом автоматически определенным образом реализуется метод ValueType.GetHashCode(), перекрывающий метод Object.GetHashCode().


    И в этом случае есть несколько существенных подводных камней:


    1. При сравнении значений полей используется рефлексия, что влияет на производительность.


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


    3. Может оказаться, что с предметной точки зрения не все поля должны участвовать в сравнении (хотя, опять же, для структур в общем случае это можно считать неверным решением).


    4. И наконец, дефолтная реализация метода ValueType.GetHashCode() не соответствует общим требованиям к реализации метода GetHashCode() (о которых мы говорили в первой публикации):

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

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


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


    С третьей стороны, возможен и особый случай, когда есть "простая" структура, состоящая, например, только из полей-структур, для которых побайтовое сравнение с помощью рефлексии заведомо дает семантически верный результат (например, Int32).


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


    Например:


    Simple Point Structure
        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();
        }

    Однако, в случае переписывания этого простого примера с использованием "автоматически реализуемых свойств" картина выглядит менее ясной:


    Simple Point Structure with Auto-Implemented Properties
        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

      0

      Сравнение двух структур разных типов по значению не имеет смысла. А структуры одного типа будут иметь одинаковые (и одинаково расположенные) backing fields, поэтому ваши вопросы не имеют смысла.

        0
        Естественно, речь о сравнении структур одинакового типа.

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

        К вопросу об ожидаемом поведении: однажды у коллег в каких-то случаях были некие неясности при автоматической сериализации сущностей с автоствойствами, т.к. движок сериализации не знал, к каким полям обращаться, или что-то в таком духе.
          0
          Вопросы имеют смысл, т.к. поведение структур с автосвойствами строго не описано

          Эмм. Автосвойства — это фича C#, в то время как поведение структур — фича BCL. С точки зрения CLR, структура с автосвойствами — это структура со странно именованными полями, вот и все. Поскольку поля создаются в типе — они, очевидно, имеют одинаковое наименование и расположение.


          К вопросу об ожидаемом поведении: однажды у коллег в каких-то случаях были некие неясности при автоматической сериализации сущностей с автоствойствами, т.к. движок сериализации не знал, к каким полям обращаться, или что-то в таком духе.

          Эта проблема возникает при попытке десериализации автосвойств от предыдущей версии сборки — известно, что именование backing fields не гарантировано стабильно. Но в вашем случае, поскольку вы имеете дело с типом в одной и той же сборке, вас это не касается.

            +1
            Мне хотелось бы видеть в языке фичу, чтобы при объявлении свойства автоматом бы создавалось backing field (вида: свойство PropName, поле $PropName), и чтобы это поле было доступно только в геттере и сеттере.

            Тогда не было бы мешанины явно объявленных backing field, к которым кто угодно может получить доступ вне геттера/сеттера и поменять их, и автосвойств с их отсутствием возможности получить явный доступ к полю и недетерминированым именем этого поля.

            Возможно, с поддержкой этого даже не в языке, а в CLR.
              0

              А не выйдет. Если это поле будет доступно только в геттере/сеттере, у вас сломается рефлекшн, который работает на полях (например, сериализация и, как раз, value types), а если оно будет доступно через рефлекшн, то нет разницы с "обычными" backing fields.


              Собственно, для задач, отличных от сериализации стандартным BinaryFormatter, я уже и не помню, зачем я использовал не-readonly backing fields.


              Ну то есть да, фича милая, но я подозреваю, что она если и есть в списке команды .net, то о-о-очень далеко.


              Ну и да, к сравнению структур она отношения не имеет.

                0
                Да, это уже не про тему структур.

                Но тем не менее:
                Сейчас свойства это сахар над полем, и методами — геттерам/сеттером.
                При этом геттер и сеттер на уровне CLR имеют атрибуты, придающие им определенную семантику.
                Получается, застряли где-то посередине.

                Моя идея в том, чтобы свойство было своего рода объектом, инкапсулирующим в себе единственное значение.
                Вручную можно так писать и сейчас — например, такой подход применен в MS-библиотеке работы с форматом ooxml.
                Но хотелось бы видеть это именно в объектной модели/платформе.
                Понятно, что в существующих платформах этого или не сделают, или когда-нибудь сделают, но криво, и это будет соседствовать со старыми подходами ради backward compatibility.
                  0
                  Моя идея в том, чтобы свойство было своего рода объектом, инкапсулирующим в себе единственное значение.

                  Я просто не очень понимаю, зачем это нужно.

                    0
                    Вам приходилось наблюдать в legacy-проекте разросшийся класс со множеством backing-полей и свойств, где внутри самого класса происходит бессистемное обращение то к полю, то свойству — и когда уже не восстановить логику, где точно нужен прямой доступ к полю, и где доступ нужен через сеттер с проверками, доп. действими,
                    (и иногда нужен доступ и через геттер, если в месте вызова лучше абстрагироваться от источника значение и/или выполнить проверку на инвариант объекта),
                    и т.д.?

                    Бывает всегда достаточно обращать изнутри всегда к полю, а все равно написана каша разнородных обращений.
                    А если авторы еще открыли internal-доступ к полю, то вообще тушите свет.

                    Так что эта идея ради лучшей инкапсуляции.
                      0
                      Так что эта идея ради лучшей инкапсуляции.

                      Когда вам нужна инкапсуляция внутри класса — что-то пошло не так (в моем понимании).


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

                        +1
                        Автореализуемые свойства — это тривиальные свойства, когда нет разницы, обращение идёт к полю или свойству. Рекомендуется использовать именно свойства вместо полей для того, чтобы всегда можно было изменить реализацию аксессоров без изменения контракта (интерфейса) класса. То есть через метаинформацию известно, что есть свойство с таким-то именем. А замена поля на свойство приводит к поломке зависимых сборок и требует их перекомпиляции для повторного разрешения ссылок.
                          0

                          @a-tk Свойство нужно еще и затем, что его можно описать в интерфейсе (ведь это просто пара методов), а вот потребовать в интерфейсе наличие поля нельзя.


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

                            0
                            По поводу внесения в интерфейс — согласен.
                            Однако поля не лишние, они нужны для того, чтобы хранить состояние. Свойство в общем случае может не быть частью состояния объекта. Иными словами, поле — это всегда данные объекта, свойство — это всего лишь пара специальных методов. Автореализуемые свойства опираются на backing-поля, реализуемые компилятором.
                              0
                              Концепция полей не нужна на уровне разработчика, вот о чем речь. Автосвойства для него ничем не отличаются от полей, кроме иконки в IDE (ну и вышеупомянутой возможности описывать их в интерфейсах). Просто какая разница, написать приватное поле или приватное автосвойство? В обоих случаях гетеры и сетеры будут заинлайнены и будет прямой доступ к полю. Но теперь программисту нужно различать 2 разных вида полей (собственно поля и свойства), учитывать это в рефлексии (нельзя просто вызывать GetProperties(), потому что часть представления может быть в полях) и т.п…
                                0
                                А как насчёт компилятора? Рано или поздно надо опускаться до уровня данных.
                                  0
                                  На уровне компилятора есть много всего такого, чего нет на уровне языка (класс __Cannon, например). Так что на его уровне да, поля бы появлялись, но для разработчика была бы единая концепция.
            +1
            у структуры может быть поле float или double, сравнение может учитывать погрешность вычислений, чтобы возврачащать true, несмотря на отличие, например, в 12-ом разряде
              0

              … и как это связано с тем, автосвойства в структуре, или нет?

                0
                никак, мне показалось, что ваш комментарий о бессмысленности кастомного сравнения структур
                  0

                  Нет, мой комментарий о том, что имплементация свойств (ручная или автоматическая) не влияет на встроенное поведение Equals и GetHashCode.

                –1
                Нигде в недрах не найдено ничего, что указывало на какие-либо сравнения, отличные от побитовых для чисел (в смысле для не не-чисел)
                  0
                  Интересно, чем руководствовался человек, поставивший минус?
                  Явно не объективными аргументами типа ildasm-а и прочих инструментов. Тем временем реализация типов System.Single aka float и System.Double aka double от Microsoft не указывают на сравнение с погрешностями. Если такие реализации есть, то хотелось бы увидеть пруф, чтобы принять позицию.
              +1
              Вот
              реализация ValueType.Equals
                  public 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;
                  }
              
              


              Первым делом проверяется идентичность типов структур, дальше рефлексией разбираются поля, потом для каждого поля с помощью метода экземпляра Equals(object) сравниваются значения двух сторон сравнения.
                +1
                        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;
                

                Интересно. Т.е., при получении/сравнения значений полей используется упаковка/распаковка, несмотря на то, что скорее всего можно ожидать, что поля структуры — тоже структуры.
                  0

                  Для случая, когда все поля структуры — структуры, обычно срабатывает CanCompareBits, который приводит к прямому сравнению памяти.

                    0
                    но даже в этом случае происходит упаковка самой структуры при передаче в CanCompareBits
                    что-то недоработано со структурами в платформе
                      0
                      но даже в этом случае происходит упаковка самой структуры при передаче в CanCompareBits

                      А происходит ли? Я понимаю, что там сверху написано (object) this, но нельзя однозначно сказать, это действительно боксинг, или просто "взятие адреса от" — потому что если бы туда был передан просто this, было бы копирование. А CanCompareBitsextern и MethodImplOptions.InternalCall, так что там может быть любая магия, на самом деле.

                        0
                        Возможно там что-то действительно более хитрое прячется за внутренним вызовом, чем упаковка.
                          +1
                          Посмотрел в IL. Там
                          ldarg.0
                          call bool System.ValueType::CanCompareBits(object)
                          Упаковки нет.
                          А то, что приведено в виде C# может быть ошибкой декомпилятора.
                            +1
                            Странно:
                            dotPeek не декомпилирует, а закачивает исходники с сайта MS — и для .NET 4.6.2 показывается код, отличающийся от приведенного в этой ветке, но очень похожий, те же вызовы, формально должные привести к упаковке.
                            Видимо, в .NET много магии в платформенных вызовах.
                              +1
                              Смотрел код через Ildasm.
                                0
                                Я использовал 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;
                                    }
                                    // и много-много ещё
                                
                                  0

                                  Ага, а в определении структуры Int32 значение хранится в поле Int32 Value :)

                                  0

                                  Если посмотреть 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
                                    0

                                    @sand14 у меня к дотпику вообще много претензий. Например у нас был баг, что словарь не реализовывал IReadOnlyDictionary или подобный интерфейс. Смотрим MSDN, да нет, должен реализовывать. Но в рантайме ошибка. Начали думать, что сбилдили не с той версией, смотрим, действительно, в 4.5.2 интерфейс такой появился. Декомпилируем сборку, чтобы понять, какой версии словарь там использовался — да нет, все нормально… Долго ломали голову, в итоге плюнули и поставили другой декомпилятор...


                                    Так вот, дотпик каким-то образом кэширует сборки, и если у него есть сборка в кэше, он показывает данные из неё несмотря на то, что она может отличаться от того, что на диске… Очень неприятное поведение. Так что, ILSpy FTW.

                        +1
                        По первому подводному камню уже писали выше.
                        Документация говорит, что если все поля значимого типа, то идет побитовое сравнение.
                        При этом поле тоже может быть структурой.
                        Насколько глубоко идет проверка возможности побитового сравнения?
                          +1
                          Хороший вопрос.
                          Проблема в том, что в MSDN по многим «тонким» вопросам нет исчерпывающей документации.
                          И есть ли исчерпывающая спецификация на платформу?
                          Или только по фактическому поведению/исходниками смотреть?

                          P.S. То же самое со спецификацией на C# 6.0: много публикаций в технических блогах, включая блоги MSDN, то спецификацию в виде документа с сайта MS можно закачать только по версии 5.0.
                          А справочные разделы MSDN не дают всей точной картины.
                          0
                          У структур ещё 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 и задать одинаковым его, то тоже хэши будут одинаковые.
                            0

                            К GetHashCode есть только два требования:


                            1. Если хэш-коды двух объектов различны, то объекты различны.
                            2. Хэш-код должен считаться очень быстро. Иначе не будет смысла сначала считать хэш-код, а потом при совпадении сравнивать по содержимому.
                            3. Ещё желательно, чтобы он не менялся со временем.

                            Хэш-коду ничего не мешает быть одинаковым у разных объектов.

                              0
                              я не спорю. Но плохой хэшкод будет вызывать большое количество сравнений в хэш-таблицах. И всё начинает тормозить у людей, которые не переопределяют Equals и GetHashCode у структур. Где-то читал что у структур GetHashCode работает в двух режимах: если есть ссылки на референс-типы в филдах структуры, или если поля в ней не выровнены, то считаем хэш от первого филда. В противном случае ксорим память структуры. Могу ошибаться.
                                +3

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

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое