Boxing и unboxing
Вопрос звучит невинно: «Что происходит в строке object o = 42;?» За ним прячется целая лекция про кучу, GC и цену одной беспечной строки в цикле.
Boxing (упаковка) — заворачивание значимого типа в объект на управляемой куче, чтобы с ним можно было работать как со ссылочным. Unboxing (распаковка) — обратная операция: достать значение из «коробки» обратно в переменную значимого типа.
Вопрос: связаны ли коробка и оригинал?
using System;
class Program
{
static void Main()
{
int i = 42;
object boxed = i;
i = 100;
int unboxed = (int)boxed;
Console.WriteLine($"i = {i}, boxed = {boxed}, unboxed = {unboxed}");
}
}
Результат:
i = 100, boxed = 42, unboxed = 42
Упаковка — это копирование, а не «оборачивание указателя». В момент object boxed = i; рантайм выделил в куче новый объект и скопировал туда четвёрку байт. Дальше i и содержимое коробки живут независимыми жизнями. Кандидаты, которые считают boxing «просто приведением типа», на этом примере отвечают boxed = 100 — и это ошибка.
Распаковка требует ТОЧНОГО типа
Второй классический подвох. Логика подсказывает, что int прекрасно помещается в long. Рантайму на логику всё равно:
using System;
class Program
{
static void Main()
{
object boxed = 42;
try
{
long value = (long)boxed;
Console.WriteLine(value);
}
catch (InvalidCastException)
{
Console.WriteLine("InvalidCastException: в коробке лежит int, а не long");
}
long ok = (long)(int)boxed;
Console.WriteLine(ok);
}
}
Результат:
InvalidCastException: в коробке лежит int, а не long
42
Распаковка — не конвертация. Она проверяет, что в объекте лежит ровно тот тип, который вы просите, и только потом отдаёт значение. Расширять до long нужно после распаковки — отсюда двойное приведение (long)(int)boxed.
Сколько это стоит
Самое убедительное на собеседовании — цифры. Сравним старую не-generic коллекцию ArrayList (хранит object) и List<int>:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
class Program
{
static void Main()
{
const int N = 1000000;
Stopwatch sw = Stopwatch.StartNew();
ArrayList old = new ArrayList();
for (int i = 0; i < N; i++)
{
old.Add(i);
}
long sum1 = 0;
foreach (object o in old)
{
sum1 += (int)o;
}
sw.Stop();
Console.WriteLine($"ArrayList: сумма {sum1}, время {sw.ElapsedMilliseconds} мс");
sw.Restart();
List<int> modern = new List<int>();
for (int i = 0; i < N; i++)
{
modern.Add(i);
}
long sum2 = 0;
foreach (int x in modern)
{
sum2 += x;
}
sw.Stop();
Console.WriteLine($"List<int>: сумма {sum2}, время {sw.ElapsedMilliseconds} мс");
}
}
Результат (точные миллисекунды зависят от машины, но соотношение устойчиво):
ArrayList: сумма 499999500000, время 38 мс
List<int>: сумма 499999500000, время 8 мс
Разница не в «магии дженериков», а в миллионе лишних объектов. ArrayList.Add(i) упаковывает каждое число, а (int)o в цикле — распаковывает. List<int> хранит числа в массиве int[] вплотную: ни одной аллокации, ни байта мусора. Именно ради этого в C# 2.0 и появились generic-коллекции.
Где упаковка прячется незаметно
// 1. Не-generic коллекции: ArrayList, Hashtable, Queue, Stack из System.Collections
arrayList.Add(42); // int -> object
// 2. string.Format и перегрузки WriteLine с object-параметрами
string s = string.Format("{0} из {1}", 3, 10); // упакованы обе цифры
Console.WriteLine("Итого: {0}", 42); // params object[] -> boxing
// 3. Присваивание значимого типа интерфейсу
IComparable c = 7; // int -> интерфейс = коробка в куче
// 4. Вызов Equals(object) вместо типизированного Equals
bool eq = 1.Equals((object)1); // аргумент упакован
// 5. object-ключи и object-параметры в старых API
Dictionary<object, object> cache = new Dictionary<object, object>();
cache[userId] = timestamp; // упакованы и ключ, и значение
// 6. Приведение List<int> к IEnumerable<int> в горячем цикле
// у List<T> enumerator — структура; через интерфейс он упаковывается
Приятная новость: интерполяция строк в современном .NET (6 и новее) значимые типы не упаковывает — компилятор разворачивает $"..." в DefaultInterpolatedStringHandler с обобщённым методом AppendFormatted<T>. А вот string.Format с его params object[] упаковывает по-прежнему. Это отличный ответ «сверх программы», если вас спросят про производительность логирования.
Как это работает под капотом
На уровне IL это две отдельные инструкции: box и unbox (чаще unbox.any). Что делает box:
- Просит у GC участок в куче поколения Gen0.
- Пишет служебный заголовок объекта: указатель на таблицу методов типа и слово синхронизации. На 64-битной платформе это 16 байт, а минимальный размер объекта — 24 байта. То есть упакованный
intразмером 4 байта занимает в куче 24 байта — шестикратные накладные расходы. - Копирует значение внутрь этого объекта.
unbox сам по себе аллокаций не делает: он проверяет таблицу методов (тот ли тип?) и возвращает указатель на данные внутри коробки, а unbox.any ещё и копирует их обратно в переменную. Дорога именно упаковка — миллион упакованных чисел это не только время на аллокацию, но и миллион объектов, которые придётся обойти сборщику мусора. В нагруженном сервисе такой цикл превращается в частые Gen0-сборки и «пилу» на графике латентности.
Отсюда универсальный рецепт: дженерики существуют, чтобы не боксить. List<T>, Dictionary<TKey, TValue>, EqualityComparer<T>.Default, Span<T> — вся современная библиотека спроектирована так, чтобы значимые типы оставались значимыми.
Частые ошибки на собеседовании
- «Boxing — это приведение типов». Нет: это выделение памяти в куче и копирование значения. Приведение — лишь синтаксис.
- «Коробка ссылается на исходную переменную». Нет: связи нет, внутри лежит копия.
- Распаковывать в «более широкий» тип.
(long)boxedInt— этоInvalidCastException, а не расширение. - Не уметь назвать места скрытой упаковки. Спросят обязательно:
ArrayList,string.Format, интерфейсы,object-параметры. - «Дженерики тоже боксят». Наоборот:
List<int>хранит числа без единой коробки — рантайм генерирует отдельный машинный код под каждый значимый тип. - Считать
asуниверсальным.boxed as intне компилируется: операторasработает только со ссылочными и nullable-типами. Безопасный вариант —boxed is int n.
Итоги-шпаргалка
- Boxing — копия значимого типа в новом объекте кучи; unboxing — извлечение копии обратно.
- Упакованный
intзанимает в куче около 24 байт вместо 4 и создаёт работу для GC. - Распаковка требует точного совпадения типа, иначе
InvalidCastException. - Скрытая упаковка живёт в не-generic коллекциях,
string.Format, присваивании интерфейсу,Equals(object). - Интерполяция строк в .NET 6+ упаковку не делает,
string.Format— делает. - Лекарство одно: дженерики и типизированные API вместо
object.