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.
Проверьте себя
1. int i = 42; object boxed = i; i = 100; Что выведет Console.WriteLine(boxed)?
A100 — boxed ссылается на переменную i
B42 — при упаковке значение было скопировано в объект
C0 — значение обнулилось
DОшибка компиляции
2. object boxed = 42; long value = (long)boxed; Что произойдёт?
Avalue станет равным 42: int расширится до long
BОшибка компиляции
CБудет выброшено InvalidCastException: распаковка требует точного типа
Dvalue станет равным 0