В предыдущей публикации мы вывели наиболее полный и корректный способ реализации сравнения по значению объектов — экземпляров классов (являющихся ссылочными типами — 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)


  1. lair
    07.01.2017 22:54

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


    1. sand14
      07.01.2017 23:09

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

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

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


      1. lair
        07.01.2017 23:16

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

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


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

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


        1. sand14
          07.01.2017 23:24
          +1

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

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

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


          1. lair
            07.01.2017 23:29

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


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


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


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


            1. sand14
              07.01.2017 23:42

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

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

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


              1. lair
                08.01.2017 00:51

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

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


                1. sand14
                  08.01.2017 01:31

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

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

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


                  1. lair
                    08.01.2017 02:16

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

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


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


                  1. a-tk
                    08.01.2017 22:27
                    +1

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


                    1. PsyHaSTe
                      14.01.2017 20:17

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


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


                      1. a-tk
                        15.01.2017 09:44

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


                        1. PsyHaSTe
                          15.01.2017 18:20

                          Концепция полей не нужна на уровне разработчика, вот о чем речь. Автосвойства для него ничем не отличаются от полей, кроме иконки в IDE (ну и вышеупомянутой возможности описывать их в интерфейсах). Просто какая разница, написать приватное поле или приватное автосвойство? В обоих случаях гетеры и сетеры будут заинлайнены и будет прямой доступ к полю. Но теперь программисту нужно различать 2 разных вида полей (собственно поля и свойства), учитывать это в рефлексии (нельзя просто вызывать GetProperties(), потому что часть представления может быть в полях) и т.п…


                          1. a-tk
                            15.01.2017 19:42

                            А как насчёт компилятора? Рано или поздно надо опускаться до уровня данных.


                            1. PsyHaSTe
                              16.01.2017 13:30

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


    1. irriss
      08.01.2017 15:35
      +1

      у структуры может быть поле float или double, сравнение может учитывать погрешность вычислений, чтобы возврачащать true, несмотря на отличие, например, в 12-ом разряде


      1. lair
        08.01.2017 15:39

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


        1. irriss
          08.01.2017 16:40

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


          1. lair
            08.01.2017 16:45

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


      1. a-tk
        08.01.2017 22:36
        -1

        Нигде в недрах не найдено ничего, что указывало на какие-либо сравнения, отличные от побитовых для чисел (в смысле для не не-чисел)


        1. a-tk
          09.01.2017 12:14

          Интересно, чем руководствовался человек, поставивший минус?
          Явно не объективными аргументами типа ildasm-а и прочих инструментов. Тем временем реализация типов System.Single aka float и System.Double aka double от Microsoft не указывают на сравнение с погрешностями. Если такие реализации есть, то хотелось бы увидеть пруф, чтобы принять позицию.


  1. a-tk
    08.01.2017 19:56
    +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;
        }
    
    


    1. sand14
      08.01.2017 20:01
      +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;
      

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


      1. lair
        08.01.2017 20:38

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


        1. sand14
          08.01.2017 20:42

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


          1. lair
            08.01.2017 20:49

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

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


            1. a-tk
              08.01.2017 22:31

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


            1. Deosis
              09.01.2017 08:10
              +1

              Посмотрел в IL. Там
              ldarg.0
              call bool System.ValueType::CanCompareBits(object)
              Упаковки нет.
              А то, что приведено в виде C# может быть ошибкой декомпилятора.


              1. sand14
                09.01.2017 08:16
                +1

                Странно:
                dotPeek не декомпилирует, а закачивает исходники с сайта MS — и для .NET 4.6.2 показывается код, отличающийся от приведенного в этой ветке, но очень похожий, те же вызовы, формально должные привести к упаковке.
                Видимо, в .NET много магии в платформенных вызовах.


                1. Deosis
                  09.01.2017 08:23
                  +1

                  Смотрел код через Ildasm.


                1. 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;
                      }
                      // и много-много ещё
                  


                  1. PsyHaSTe
                    14.01.2017 20:24

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


                1. 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


                1. PsyHaSTe
                  14.01.2017 20:24

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


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


  1. Deosis
    09.01.2017 08:16
    +1

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


    1. sand14
      09.01.2017 08:34
      +1

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

      P.S. То же самое со спецификацией на C# 6.0: много публикаций в технических блогах, включая блоги MSDN, то спецификацию в виде документа с сайта MS можно закачать только по версии 5.0.
      А справочные разделы MSDN не дают всей точной картины.


  1. 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 и задать одинаковым его, то тоже хэши будут одинаковые.


    1. Deosis
      09.01.2017 11:47

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


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

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


      1. T-D-K
        09.01.2017 11:53

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


        1. Deosis
          09.01.2017 12:19
          +3

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