Value Type vs. Reference Type (.NET)

Различие между value- и reference-типами реализовано на двух уровнях:

  • После компиляции получается разный машинный код, который по-разному размещает объекты в памяти:

    Value Type Reference Type
    Где? В регистрах, на стеке, в составе массива или другого объекта В управляемой куче выделяется память; в код отдаётся ссылка на неё
    Что? Только поля объекта Заголовок, указатель на метаданные, поля объекта
  • У System.ValueType перекрытые методы Equals и GetHashCode выполняют универсальное структурное сравнение.

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

Главное преимущество value-типов — эффективное и компактное размещение в памяти. Оно же является причиной особенностей, о которых необходимо знать:

  • При присвоении или передаче в виде аргумента копируются все поля. Если полей много, это может быть дорого, и тогда следует использовать модификаторы ref, out, in.

  • Неожиданное защитное копирование (defensive copy) при использовании изменяемых value-типов в readonly-контекстах — подробности.

  • Окружающему коду должен быть известен конкретный тип. Поэтому для value-типов запрещено наследование и полиморфизм, а при приведении к базовому типу или интерфейсу происходит boxing.

  • Стандартные Equals и GetHashCode неэффективны.

In general, don’t use the value types unless you’re sure it’s going to give you the performance increases you need. When you have to use them, avoid passing them around and do so by reference, if you must. Document the dangers in your source.

Mutable value types: the good, the bad and the ugly

Действительно, если нужна только value-семантика, вполне подойдёт неизменяемый reference-тип с правильно перекрытыми Equals/GetHashCode. Примеры таких типов: String, Tuple, record, а также анонимные.

Аналогия из мира баз данных. Value-тип — это как таблица без ключа. У её строк нет “ручки” (no handle, no identity), не на что сослаться (no reference). Нужно каждый раз перечислять значения всех полей.

К недостаткам reference-типов можно отнести:

  • Null references, известные также как billion dollar mistake. Для борьбы с ними в современном C# есть nullable reference types.

  • Аллокация в куче даже для небольших локальных объектов. Это может быть улучшено в будущих версиях .NET через escape analysis#4584, #11192.

Boxing

Боксинг, известен также как упаковка — это приведение экземпляра value-типа к базовому классу (Object, ValueType, Enum) или к интерфейсу (IComparable, IConvertible и др).

  • При боксинге теряются преимущества value-типа — происходит аллокация и копирование объекта в кучу.

  • Боксинг часто скрывается за неявным приведением типа. В этом его коварство. Например, String.Format("…", 123) подразумевает преобразование (object)123.

  • Вызов не-перекрытых стандартных методов (ToString, Equals, GetHashCode) также подразумевает приведение к базовому типу. Equals требует два боксинга — для this и для аргумента.

  • Но боксинга не происходит, если интерфейс указан через generic constraint:

    class GenericEqualityComparer<T> where T : IEquatable<T> {
        bool Equals(T x, T y);
    }
    

    В таком коде параметры x и y компилируются особым образом. На уровне IL используется инструкция constrained. На уровне JIT генерируется отдельный код для каждого типа T.

    Если ваш value-тип будет участвовать в сравнениях (поиск по коллекции, ключ в словаре), обязательно реализуйте IEquatable<T>. А за ним потянется всё остальное:

    record struct хороши тем, что для них всё это обеспечивается автоматом. Компилятор синтезирует нужный код за кулисами. Плюс есть immutability и nondestructive mutation.

    Для обычных struct можно попросить Visual Studio сгенерировать методы в явном виде.

  • Боксинг запрещён для ref struct.

  • Внутри .NET можно встретить явный боксинг, например StrongBox в SemaphoreSlim или класс StateMachineBox.

Unboxing (распаковка) — обратный процесс, явное приведение к value-типу:

object boxed = 123;
var unboxed = (int)boxed;

ref, out, in

Если перед параметром функции стоит один из этих модификаторов, то говорят “передаётся по ссылке” — в отличие от передачи “по значению” без модификатора.

Возникает путаница. Одинаковые слова используются и для типов, и для способов передачи параметра:

  • Value Type vs. Reference Type.
  • Pass by Value vs. Pass by Reference (byval vs. byref).

Низкоуровневый механизм, лежащий в основе ref/out/in, назван более удачно — managed pointers. Все три модификатора основаны на нём, но различаются нюансами. Их можно сочетать и с value-типами, и с reference-типами. Работают как безопасные указатели или псевдонимы, которые сами всегда расположены на стеке, а указывать могут куда угодно.

В современном C# слово ref можно писать не только перед параметрами, но и в других местах. Это позволяет манипулировать экземплярами value-типов без лишнего копирования. Имеет смысл в нагруженном коде, если важен каждый байт.

Подробнее: