image1.png

В последнее время модной темой стали nullable reference types. Однако старые добрые nullable value types никуда не делись и всё так же активно используются. Хорошо ли вы помните нюансы работы с ними? Предлагаю освежить или проверить свои знания, ознакомившись с этой статьёй. Примеры кода на C# и IL, обращения к спецификации CLI и коду CoreCLR прилагаются. Начать предлагаю с интересной задачки.

Примечание. Если вас интересуют nullable reference types, можете познакомиться с несколькими статьями моих коллег: "Nullable Reference типы в C# 8.0 и статический анализ", "Nullable Reference не защищают, и вот доказательства".

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

static void NullableTest()
{
  int? a = null;
  object aObj = a;

  int? b = new int?();
  object bObj = b;

  Console.WriteLine(Object.ReferenceEquals(aObj, bObj)); // True or False?
}

image2.png

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

1. Исходим из того, что int? — ссылочный тип.

Давайте рассуждать так, что int? — это ссылочный тип. В таком случае в а будет записано значение null, оно же будет записано и в aObj после присвоения. В b будет записана ссылка на какой-то объект. Она также будет записана и в bObj после присвоения. В итоге, Object.ReferenceEquals примет в качестве аргументов значение null и ненулевую ссылку на объект, так что…

Всё очевидно, ответ — False!

2. Исходим из того, что int? — значимый тип.

А может быть вы сомневаетесь, что int? — ссылочный тип? И вы уверены в этом, несмотря на выражение int? a = null? Что ж, давайте зайдём с другой стороны и будем отталкиваться от того, что int? — значимый тип.

В таком случае выражение int? a = null выглядит немного странно, но предположим, что это опять в C# сахара сверху насыпали. Получается, что a хранит какой-то объект. b тоже хранит какой-то объект. При инициализации переменных aObj и bObj будет произведена упаковка объектов, хранимых в a и b, в результате чего в aObj и в bObj будут записаны разные ссылки. Получается, что Object.ReferenceEquals в качестве аргументов принимает ссылки на разные объекты, следовательно…

Всё очевидно, ответ — False!

3. Исходим из того, что здесь используется Nullable<T>.

Допустим, варианты выше вам не понравились. Потому что вы отлично знаете, что никакого int? на самом деле нет, а есть значимый тип Nullable<T>, и в данном случае будет использован Nullable<int>. Также вы понимаете, что на самом деле в a и b будут одинаковые объекты. При этом вы не забыли, что при записи значений в aObj и в bObj произойдёт упаковка, и в итоге будут получены ссылки на разные объекты. Так как Object.ReferenceEquals принимает ссылки на разные объекты, то…

Всё очевидно, ответ — False!

4. ;)

Для тех, кто отталкивался от значимых типов, — если у вас вдруг закрались какие-то сомнения про сравнение ссылок, то можно посмотреть документацию по Object.ReferenceEquals на docs.microsoft.com. В частности, там тоже затрагивают тему значимых типов и упаковки/распаковки. Правда, там описывается кейс, когда экземпляры значимых типов передаются непосредственно в метод, мы же упаковку вынесли отдельно, но суть та же.

When comparing value types. If objA and objB are value types, they are boxed before they are passed to the ReferenceEquals method. This means that if both objA and objB represent the same instance of a value type, the ReferenceEquals method nevertheless returns false, as the following example shows.

Казалось бы, здесь статью можно и закончить, вот только… правильный ответ — True.

Что ж, давайте разбираться.

Разбираемся


Есть два пути — простой и интересный.

Простой путь


int? — это Nullable<int>. Открываем документацию по Nullable<T>, где смотрим раздел "Boxing and Unboxing". В принципе, на этом всё — поведение там описано. Но если хочется побольше деталей, приглашаю на интересный путь. ;)

Интересный путь


На этой тропинке нам будет недостаточно документации. Она описывает поведение, но не отвечает на вопрос 'почему'?

Что такое на самом деле int? и null в соответствующем контексте? Почему это работает так? В IL коде используются разные команды или нет? Отличается поведение на уровне CLR? Ещё какая-то магия?

Начнём с разбора сущности int?, чтобы вспомнить основы, и постепенно дойдём до разбора первоначального кейса. Так как C# — язык достаточно "приторный", периодически будем обращаться к IL коду, чтобы смотреть в суть вещей (да, документация по C# — не наш путь сегодня).

int?, Nullable<T>


Здесь рассмотрим основы nullable value types в принципе (что из себя представляет, во что компилируются в IL и т.п.). Ответ на вопрос из задания рассмотрен в следующем разделе.

Рассмотрим фрагмент кода.

int? aVal = null;
int? bVal = new int?();
Nullable<int> cVal = null;
Nullable<int> dVal = new Nullable<int>();

Несмотря на то, что на C# инициализация этих переменных выглядит по-разному, для всех них будет сгенерирован один и тот же IL код.

.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              valuetype [System.Runtime]System.Nullable`1<int32> V_1,
              valuetype [System.Runtime]System.Nullable`1<int32> V_2,
              valuetype [System.Runtime]System.Nullable`1<int32> V_3)

// aVal
ldloca.s V_0
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

// bVal
ldloca.s V_1
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

// cVal
ldloca.s V_2
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

// dVal
ldloca.s V_3
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

Как видно, в C# всё от души сдобрено синтаксическим сахаром, чтобы нам с вами жилось лучше, по факту же:

  • int? — значимый тип.
  • int? — то же самое, что Nullable<int>. В IL коде идёт работа с Nullable<int32>.
  • int? aVal = null — то же самое, что Nullable<int> aVal = new Nullable<int>(). В IL это разворачивается в инструкцию initobj, которая выполняет инициализацию по умолчанию по загруженному адресу.

Рассмотрим следующий фрагмент кода:

int? aVal = 62;

С инициализацией по умолчанию мы разобрались — соответствующий IL код мы видели выше. Что же происходит здесь, когда мы хотим проинициализировать aVal значением 62?

Взглянем на IL код:

.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0)
ldloca.s   V_1
ldc.i4.s   62
call       instance void valuetype 
           [System.Runtime]System.Nullable`1<int32>::.ctor(!0)

Опять же, ничего сложного — на evaluation stack загружается адрес aVal, а также значение 62, после чего вызывается конструктор с сигнатурой Nullable<T>(T). То есть два следующих выражения будут полностью идентичны:

int? aVal = 62;
Nullable<int> bVal = new Nullable<int>(62);

В этом же можно убедиться, опять взглянув на IL код:

// int? aVal;
// Nullable<int> bVal;
.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              valuetype [System.Runtime]System.Nullable`1<int32> V_1)

// aVal = 62
ldloca.s   V_0
ldc.i4.s   62
call       instance void valuetype                           
           [System.Runtime]System.Nullable`1<int32>::.ctor(!0)

// bVal = new Nullable<int>(62)
ldloca.s   V_1
ldc.i4.s   62
call       instance void valuetype                             
           [System.Runtime]System.Nullable`1<int32>::.ctor(!0)

А что же касается проверок? Например, что на самом деле представляет из себя код следующего вида?

bool IsDefault(int? value) => value == null;

Правильно, для понимания вновь обратимся к соответствующему IL коду.

.method private hidebysig instance bool
IsDefault(valuetype [System.Runtime]System.Nullable`1<int32> 'value')
cil managed
{
  .maxstack  8
  ldarga.s   'value'
  call       instance bool valuetype 
             [System.Runtime]System.Nullable`1<int32>::get_HasValue()
  ldc.i4.0
  ceq
  ret
}

Как вы уже догадались, никакого null на самом деле нет — всё, что происходит, — это обращение к свойству Nullable<T>.HasValue. То есть, ту же логику в C# можно написать более явно с точки зрения используемых сущностей следующим образом.

bool IsDefaultVerbose(Nullable<int> value) => !value.HasValue;

IL код:

.method private hidebysig instance bool 
IsDefaultVerbose(valuetype [System.Runtime]System.Nullable`1<int32> 'value')
cil managed
{
  .maxstack  8
  ldarga.s   'value'
  call       instance bool valuetype 
             [System.Runtime]System.Nullable`1<int32>::get_HasValue()
  ldc.i4.0
  ceq
  ret
}


Подытожим:

  • Nullable value types реализуются за счёт типа Nullable<T>;
  • int? — на самом деле сконструированный тип обобщённого значимого типа Nullable<T>;
  • int? a = null — инициализация объекта типа Nullable<int> значением по умолчанию, никакого null на самом деле здесь нет;
  • if (a == null) — опять же, никакого null нет, есть обращение к свойству Nullable<T>.HasValue.

Исходный код типа Nullable<T> можно посмотреть, например, на GitHub в репозитории dotnet/runtime — прямая ссылка на файл с исходным кодом. Кода там немного, так что ради интереса советую полистать. Оттуда же можно узнать (или вспомнить) следующие факты.

Для удобства работы тип Nullable<T> определяет:

  • оператор неявного преобразования из T в Nullable<T>;
  • оператор явного преобразования из Nullable<T> в T.

Основная логика работы реализуется за счёт двух полей (и соответствующих свойств):

  • T value — само значение, обёрткой над которым является Nullable<T>;
  • bool hasValue — флаг, указывающий, "содержит ли обёртка значение". В кавычках, так как по факту Nullable<T> всегда содержит значение типа T.

Теперь, когда мы освежили память по поводу nullable value types, посмотрим, что же там с упаковкой.

Упаковка Nullable<T>


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

int aVal = 62;
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));

Результатом сравнения ссылок ожидаемо будет false, так как произошло 2 операции упаковки и создание двух объектов, ссылки на которые были записаны в obj1 и obj2.

Теперь меняем int на Nullable<int>.

Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));

Результат всё также ожидаем — false.

А теперь вместо 62 прописываем дефолтное значение.

Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));

Иии… результатом неожиданно становится true. Казалось бы, имеем всё те же 2 операции упаковки, создание двух объектов и ссылки на два разных объекта, но результат-то — true!

Ага, наверняка опять дело в сахаре, и что-то поменялось на уровне IL кода! Давайте посмотрим.

Пример N1.

C# код:

int aVal = 62;
object aObj = aVal;

IL код:

.locals init (int32 V_0,
              object V_1)

// aVal = 62
ldc.i4.s   62
stloc.0

// упаковка aVal
ldloc.0
box        [System.Runtime]System.Int32

// сохранение полученной ссылки в aObj
stloc.1

Пример N2.

C# код:

Nullable<int> aVal = 62;
object aObj = aVal;

IL код:

.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              object V_1)

// aVal = new Nullablt<int>(62)
ldloca.s   V_0
ldc.i4.s   62
call       instance void
           valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0)

// упаковка aVal
ldloc.0
box        valuetype [System.Runtime]System.Nullable`1<int32>

// сохранение полученной ссылки в aObj
stloc.1

Пример N3.

C# код:

Nullable<int> aVal = new Nullable<int>();
object aObj = aVal;

IL код:

.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              object V_1)

// aVal = new Nullable<int>()
ldloca.s   V_0
initobj    valuetype [System.Runtime]System.Nullable`1<int32>

// упаковка aVal
ldloc.0
box        valuetype [System.Runtime]System.Nullable`1<int32>

// сохранение полученной ссылки в aObj
stloc.1

Как мы видим, везде упаковка происходит идентичным образом — значения локальных переменных загружается на evaluation stack (инструкция ldloc), после чего происходит сама упаковка за счёт вызова команды box, для которой указывается, какой, собственно, тип будем упаковывать.

Обращаемся к спецификации Common Language Infrastructure, смотрим описание команды box и находим интересное примечание касаемо nullable типов:

If typeTok is a value type, the box instruction converts val to its boxed form.… If it is a nullable type, this is done by inspecting val's HasValue property; if it is false, a null reference is pushed onto the stack; otherwise, the result of boxing val's Value property is pushed onto the stack.

Отсюда следует несколько выводов, расставляющих точки над 'i':

  • учитывается состояние объекта Nullable<T> (проверяется рассмотренный нами ранее флаг HasValue). Если Nullable<T> не содержит значения (HasValuefalse), результатом упаковки будет null;
  • если Nullable<T> содержит значение (HasValuetrue), то упакован будет не объект Nullable<T>, а экземпляр типа T, который хранится в поле value типа Nullable<T>;
  • специфичная логика обработки упаковки Nullable<T> реализована не на уровне C# и даже не на уровне IL — она реализована в CLR.

Возвращаемся к примерам с Nullable<T>, которые рассматривали выше.

Первый:

Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));

Состояние экземпляра перед упаковкой:

  • T -> int;
  • value -> 62;
  • hasValue -> true.

Два раза происходит упаковка значения 62 (помним, что в данном случае пакуются экземпляры типа int, а не Nullable<int>), создаются 2 новых объекта, получаются 2 ссылки на разные объекты, результат сравнения которых, — false.

Второй:

Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));

Состояние экземпляра перед упаковкой:

  • T -> int;
  • value -> default (в данном случае, 0 — значение по умолчанию для int);
  • hasValue -> false.

Так как hasValue имеет значение false, не происходит создания объектов в куче, а операция упаковки возвращает значение null, которое и записывается в переменные obj1 и obj2. Сравнение этих значений ожидаемо даёт true.

В исходном примере, который был в самом начале статьи, происходит точно то же самое:

static void NullableTest()
{
  int? a = null;       // default value of Nullable<int>
  object aObj = a;     // null

  int? b = new int?(); // default value of Nullable<int>
  object bObj = b;     // null

  Console.WriteLine(Object.ReferenceEquals(aObj, bObj)); // null == null
}

Ради интереса заглянем в исходный код CoreCLR из упомянутого ранее репозитория dotnet/runtime. Нас интересует файл object.cpp, конкретно — метод Nullable::Box, который и содержит нужную нам логику:

OBJECTREF Nullable::Box(void* srcPtr, MethodTable* nullableMT)
{
  CONTRACTL
  {
    THROWS;
    GC_TRIGGERS;
    MODE_COOPERATIVE;
  }
  CONTRACTL_END;

  FAULT_NOT_FATAL();      // FIX_NOW: why do we need this?

  Nullable* src = (Nullable*) srcPtr;

  _ASSERTE(IsNullableType(nullableMT));
  // We better have a concrete instantiation, 
  // or our field offset asserts are not useful
  _ASSERTE(!nullableMT->ContainsGenericVariables());

  if (!*src->HasValueAddr(nullableMT))
    return NULL;

  OBJECTREF obj = 0;
  GCPROTECT_BEGININTERIOR (src);
  MethodTable* argMT = nullableMT->GetInstantiation()[0].AsMethodTable();
  obj = argMT->Allocate();
  CopyValueClass(obj->UnBox(), src->ValueAddr(nullableMT), argMT);
  GCPROTECT_END ();

  return obj;
}

Здесь всё то, о чём мы говорили выше. Если не храним значение — возвращаем NULL:

if (!*src->HasValueAddr(nullableMT))
    return NULL;

Иначе производим упаковку:

OBJECTREF obj = 0;
GCPROTECT_BEGININTERIOR (src);
MethodTable* argMT = nullableMT->GetInstantiation()[0].AsMethodTable();
obj = argMT->Allocate();
CopyValueClass(obj->UnBox(), src->ValueAddr(nullableMT), argMT);

Заключение


Ради интереса предлагаю показать пример из начала статьи своим коллегам и друзьям. Смогут ли они дать верный ответ и обосновать его? Если нет, приглашайте их познакомиться со статьёй. Если же смогут — что ж, моё уважение!

Надеюсь, это было небольшое, но увлекательное приключение. :)

P.S. У кого-то мог возникнуть вопрос: а с чего вообще началось погружение в эту тему? Мы делали новое диагностическое правило в PVS-Studio на тему того, что Object.ReferenceEquals работает с аргументами, один из которых представлен значимым типом. Вдруг оказалось, что с Nullable<T> есть неожиданный момент в поведении при упаковке. Посмотрели IL код — box как box. Посмотрели спецификацию CLI — ага, вот оно! Показалось, что это достаточно интересный кейс, про который стоит рассказать — раз! — и статья перед вами.


Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Sergey Vasiliev. Check how you remember nullable value types. Let's peek under the hood.

P.P.S. Кстати, я с недавних времён несколько более активно веду твиттер, где размещаю какие-то интересные фрагменты кода, ретвичу некоторые интересные новости мира .NET и что-то в таком духе. Предлагаю полистать, если заинтересует — подписывайтесь (ссылка на профиль).

Комментарии (5)