﻿# Хорошо ли вы помните nullable value types? Заглядываем "под капот"

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

![0772_NullableValueTypes_ru/image1.png](https://import.viva64.com/docx/blog/0772_NullableValueTypes_ru/image1.png)

**Примечание**\. Если вас интересуют nullable reference types, можете познакомиться с несколькими статьями моих коллег: "[Nullable Reference типы в C\# 8\.0 и статический анализ](https://pvs-studio.ru/ru/blog/posts/csharp/0631/)", "[Nullable Reference не защищают, и вот доказательства](https://pvs-studio.ru/ru/blog/posts/csharp/0764/)"\.

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

```cpp
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?
}
```

![0772_NullableValueTypes_ru/image2.png](https://import.viva64.com/docx/blog/0772_NullableValueTypes_ru/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](https://docs.microsoft.com/en-us/dotnet/api/system.object.referenceequals?view=netcore-3.1)\. В частности, там тоже затрагивают тему значимых типов и упаковки/распаковки\. Правда, там описывается кейс, когда экземпляры значимых типов передаются непосредственно в метод, мы же упаковку вынесли отдельно, но суть та же\.

_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\>_](https://docs.microsoft.com/en-us/dotnet/api/system.nullable-1?view=netcore-3.1), где смотрим раздел "Boxing and Unboxing"\. В принципе, на этом всё \- поведение там описано\. Но если хочется побольше деталей, приглашаю на интересный путь\. ;\)

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

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

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

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

#### int?, Nullable<T\>

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

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

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



Несмотря на то, что на C\# инициализация этих переменных выглядит по\-разному, для всех них будет сгенерирован один и тот же [IL](https://pvs-studio.ru/ru/blog/terms/7006/) код\.



```cpp
.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_, которая выполняет инициализацию по умолчанию по загруженному адресу\.

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

```cpp
int? aVal = 62;
```

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

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

```cpp
.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\)_\. То есть два следующих выражения будут полностью идентичны:

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

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

```cpp
// 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)
```

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

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

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

```cpp
.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\# можно написать более явно с точки зрения используемых сущностей следующим образом\.

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

IL код:

```cpp
.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 \- [прямая ссылка на файл с исходным кодом](https://github.com/dotnet/runtime/blob/master/src/libraries/System.Private.CoreLib/src/System/Nullable.cs)\. Кода там немного, так что ради интереса советую полистать\. Оттуда же можно узнать \(или вспомнить\) следующие факты\.

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

* оператор неявного преобразования из _T_ в _Nullable<T\>_;
* оператор явного преобразования из _Nullable<T\>_ в _T_\.

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

* _T value_ \- само значение, обёрткой над которым является _Nullable<T\>_;
* _bool hasValue_ \- флаг, указывающий, "содержит ли обёртка значение"\. В кавычках, так как по факту _Nullable<T\>_ всегда содержит значение типа _T_\.

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

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

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

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

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

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

Теперь меняем _int_ на _Nullable<int\>_\.

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

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

Результат всё также ожидаем \- _false_\.

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

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

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

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

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

Пример N1\.

C\# код:

```cpp
int aVal = 62;
object aObj = aVal;
```

IL код:

```cpp
.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\# код:

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

IL код:

```cpp
.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\# код:

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

IL код:

```cpp
.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](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-335.pdf), смотрим описание команды _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\>_ не содержит значения \(_HasValue_ \- _false_\), результатом упаковки будет _null_;
* если _Nullable<T\>_ содержит значение \(_HasValue_ \- _true_\), то упакован будет не объект _Nullable<T\>_, а экземпляр типа _T_, который хранится в поле _value_ типа _Nullable<T\>_;
* специфичная логика обработки упаковки _Nullable<T\>_ реализована не на уровне C\# и даже не на уровне IL \- она реализована в CLR\.

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

Первый:

```cpp
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_\.

Второй:

```cpp
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_\.

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

```cpp
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](https://github.com/dotnet/runtime)\. Нас интересует файл [object\.cpp](https://github.com/dotnet/runtime/blob/master/src/coreclr/src/vm/object.cpp), конкретно \- метод _Nullable::Box_, который и содержит нужную нам логику:

```cpp
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_:

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

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

```cpp
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](https://pvs-studio.ru/ru/pvs-studio/) на тему того, что _Object\.ReferenceEquals_ работает с аргументами, один из которых представлен значимым типом\. Вдруг оказалось, что с _Nullable<T\>_ есть неожиданный момент в поведении при упаковке\. Посмотрели IL код \- _box_ как _box_\. Посмотрели спецификацию CLI \- ага, вот оно\! Показалось, что это достаточно интересный кейс, про который стоит рассказать \- раз\! \- и статья перед вами\.

**P\.P\.S\.** Кстати, я с недавних времён несколько более активно веду твиттер, где размещаю какие\-то интересные фрагменты кода, ретвичу некоторые интересные новости мира \.NET и что\-то в таком духе\. Предлагаю полистать, если заинтересует — подписывайтесь \([ссылка на профиль](https://twitter.com/_SergVasiliev_)\)\.