Перейти к содержимому

Типы и память

Как .NET хранит данные: чем значение отличается от ссылки, что лежит в стеке и в куче, откуда берётся boxing и как работать с памятью без лишних аллокаций. На этой теме держатся «Сборка мусора» и «Асинхронность».

Перед этой темой: Платформа .NET.

Junior+

Чем отличаются значимые и ссылочные типы?

Заголовок раздела «Чем отличаются значимые и ссылочные типы?»
Ответ

Что это

  • Значимый тип — переменная хранит само значение.
  • Ссылочный тип — переменная хранит ссылку на объект, а сам объект лежит в куче.

Какие типы к чему относятся

Значимые Ссылочные
числа, bool, char class, interface
DateTime, enum string, object
любые struct массивы, делегаты, record

Как это проявляется

  1. Присваивание. b = a у структуры создаёт независимую копию, у класса — вторую ссылку на тот же объект.
  2. Передача в метод. Передаётся копия: копия структуры или копия ссылки. Метод может изменить поля объекта класса, но не может подменить сам объект без ref.
  3. null. Значимый тип не бывает null, если это не int?, то есть Nullable<int>.

Частая ошибка

Считать string значимым типом, потому что он «ведёт себя как значение». Он ссылочный, но неизменяемый: любая «правка» строки создаёт новую строку.

Как устроено

  • Структуры неявно наследуются от System.ValueType и запечатаны, но могут реализовывать интерфейсы.
  • У структуры всегда есть значение default — все поля нулевые. С C# 10 можно объявить свой конструктор без параметров, но default(T) и создание массива его не вызывают.
  • ref, out и in передают в метод саму переменную, а не её копию.

Когда что выбирать

Что моделируем Тип
Маленькое значение: точка, деньги, диапазон дат readonly struct или readonly record struct
Сущность с идентичностью и жизненным циклом class
Данные с равенством по значению: DTO, событие record

Ловушки

  • Boxing. Приведение структуры к object или к интерфейсу копирует её в новый объект в куче. Это аллокация, и изменения «коробки» не видны в исходной переменной.
  • Изменение копии. Структура из свойства или из List<T> по индексу — копия. Поэтому list[0].X = 1 не компилируется (ошибка CS1612).
  • Медленное сравнение. ValueType.Equals сравнивает поля, а если среди них есть ссылочные — через рефлексию.

Правило

Для своей структуры реализуйте IEquatable<T> и переопределите GetHashCode. Помните, что == у классов сравнивает ссылки, а у record — значения.

Под капотом

  • Значение лежит там же, где переменная. Локальная переменная — в кадре стека или в регистре, поле класса — внутри объекта в куче, элемент массива — внутри массива в куче.
  • Локальная переменная, захваченная лямбдой или живущая в async-методе или итераторе, переезжает в сгенерированный класс замыкания или state machine — тоже в кучу.
  • Для generic-кода JIT генерирует отдельную версию под каждый значимый тип и общую для ссылочных. Поэтому List<int> хранит числа без boxing.

Цена

  • Структуры не создают аллокаций и улучшают локальность: массив структур — один непрерывный блок памяти.
  • Большие структуры дорого копировать. Для них используют in-параметры и ref readonly.
  • Вызов метода обычной структуры через readonly-поле заставляет компилятор сделать защитную копию. У readonly struct её нет.

Компромиссы

  • Framework Design Guidelines советуют struct, только если тип представляет одно значение, весит меньше 16 байт, неизменяемый и редко упаковывается.
  • ref struct (Span<T>) живёт только на стеке: его нельзя упаковать, сохранить в поле класса или захватить в лямбду. С C# 13 такие типы могут реализовывать интерфейсы, быть аргументами generic с allows ref struct и использоваться в async-методах и итераторах, если не переживают await или yield.
  • Ограничение where T : struct, IComparable<T> позволяет вызывать методы интерфейса без boxing.

Как проверить в проде

Boxing — это аллокации. Их видно в BenchmarkDotNet с [MemoryDiagnoser] и в профилировщике аллокаций (dotnet-trace, Visual Studio).

Итог: struct или class — это выбор семантики (копия или идентичность) и цены (копирование или аллокации), а не места в памяти.

Пример
var s1 = new PointS { X = 1 };
var s2 = s1; // копия значения
s2.X = 2;
Console.WriteLine(s1.X); // 1
var c1 = new PointC { X = 1 };
var c2 = c1; // копия ссылки
c2.X = 2;
Console.WriteLine(c1.X); // 2
object boxed = s1; // boxing: копия в куче
s1.X = 10;
Console.WriteLine(((PointS)boxed).X); // 1
struct PointS { public int X; }
class PointC { public int X; }
Junior+

Что такое boxing и unboxing и где они возникают незаметно?

Заголовок раздела «Что такое boxing и unboxing и где они возникают незаметно?»
Ответ

Что это

  • Boxing (упаковка) — значение значимого типа копируется в новый объект в куче: object o = 42;.
  • Unboxing (распаковка) — значение копируется из объекта обратно в переменную: int x = (int)o;.

Зачем

Так структуру можно передать туда, где ждут object или интерфейс: в ArrayList, string.Format, переменную типа IComparable.

Как пользоваться

  1. Распаковывайте только в исходный тип: (long)o для упакованного int бросит InvalidCastException.
  2. Вместо ArrayList и Hashtable берите List<T> и Dictionary<TKey, TValue> — они хранят значения без упаковки.
  3. «Коробка» — независимая копия: её изменения не видны в исходной переменной.

Частая ошибка

Сравнивать упакованные значения через ==. Для object он сравнивает ссылки, а у двух «коробок» они разные. Нужен a.Equals(b).

Как устроено

  1. Компилятор видит приведение значимого типа к object или интерфейсу и вставляет инструкцию box.
  2. Рантайм выделяет объект в куче: заголовок, указатель на тип и копия значения.
  3. При распаковке инструкция unbox.any проверяет точный тип и копирует значение в переменную.

Ловушки

Где Почему boxing Как избежать
string.Format("{0}", x) аргументы идут в params object[] интерполяция $"{x}"
logger.LogInformation("Id {Id}", id) тот же params object[] генератор [LoggerMessage]
foreach по IEnumerable<T>, LINQ упаковывается enumerator-структура foreach по самому List<T>
ключ-структура в Dictionary сравнение через Equals(object) реализовать IEquatable<T>
ToString() у структуры вызывается реализация из object переопределить в структуре

Правило

Принимайте значения через generic-параметры (void Log<T>(T value)), а не через object. Для своих структур реализуйте IEquatable<T> и переопределяйте ToString и GetHashCode.

Под капотом

  • Упакованный int на x64 занимает 24 байта: заголовок объекта, указатель на тип и значение с выравниванием.
  • Вызов метода у значения generic-типа T компилируется с префиксом constrained.. Если метод переопределён в структуре, вызов идёт напрямую, без упаковки.
  • Если ToString, GetHashCode или Equals(object) в структуре не переопределены, рантайм упакует значение, чтобы вызвать базовую реализацию.
  • Упакованный int? — это упакованный int или null: отдельной «коробки» для Nullable<T> не бывает.
  • State machine async-метода в Release-сборке — структура. При первом await, который не завершился сразу, она упаковывается в кучу.

Цена

Одна упаковка незаметна. Миллионы упаковок в секунду — это мегабайты мусора, частые сборки поколения 0 и промахи кеша.

Компромиссы

  • В коде, специализированном под значимый T, JIT компилирует (int)(object)value без аллокации.
  • С .NET 9 JIT размещает на стеке «коробку», которая не покидает метод после инлайнинга. Рассчитывать на это в дизайне API нельзя.
  • Изменяемая структура за интерфейсом — ловушка: метод меняет копию в «коробке». Это ещё один довод за readonly struct.

Как проверить в проде

  • dotnet-trace или PerfView: если в событиях AllocationTick видны System.Int32, System.DateTime или ваши структуры, это boxing.
  • BenchmarkDotNet с [MemoryDiagnoser]: после исправления колонка Allocated должна показать 0.
  • В IL (ILSpy, SharpLab) ищут инструкцию box.

Итог: boxing — это аллокации, которых не видно в коде, поэтому их находят профилировщиком, а не чтением.

Пример
int n = 42;
object boxed = n; // boxing: новый объект в куче
int back = (int)boxed; // unboxing: проверка типа и копия
// long l = (long)boxed; // InvalidCastException
object a = 1, b = 1;
Console.WriteLine(a == b); // False: две разные «коробки»
Console.WriteLine(a.Equals(b)); // True: сравниваются значения
var list = new List<int> { 1, 2, 3 };
foreach (int x in list) { } // enumerator-структура, без аллокаций
IEnumerable<int> seq = list;
foreach (int x in seq) { } // тот же enumerator, но упакованный
string s1 = string.Format("{0}", n); // params object[]: boxing
string s2 = $"{n}"; // C# 10+: без boxing
var counts = new Dictionary<Key, int> { [new Key { Id = 1 }] = 1 };
// у Key нет IEquatable<Key>: Equals(object) упаковывает ключ
struct Key { public int Id; }
Junior+

Почему строки неизменяемы, что такое интернирование и когда нужен StringBuilder?

Заголовок раздела «Почему строки неизменяемы, что такое интернирование и когда нужен StringBuilder?»
Ответ

Что это

string — ссылочный тип, который нельзя изменить после создания. Методы Trim, Replace, ToUpper возвращают новую строку, а исходная остаётся прежней.

Зачем

  • Строку можно передавать куда угодно: никто не изменит её у вас за спиной.
  • Рантайм может хранить одинаковые литералы одним объектом — это интернирование.

Как пользоваться

  1. Присваивайте результат: s = s.Trim();.
  2. Сравнивайте через == — он сравнивает содержимое. Без учёта регистра — string.Equals(a, b, StringComparison.OrdinalIgnoreCase).
  3. Для сборки строки в цикле берите StringBuilder: он дописывает в свой буфер и создаёт строку один раз в ToString().

Частая ошибка

result += item в цикле. На каждой итерации создаётся новая строка и копируется всё накопленное, так что работа растёт квадратично.

Как устроено

  • Неизменяемость. Строку безопасно хранить ключом Dictionary и отдавать в другие потоки: хеш и содержимое не изменятся.
  • Интернирование. Рантайм ведёт пул (intern pool) — один экземпляр на значение. Литералы обычно попадают туда сами, string.Intern(s) добавляет строку вручную, string.IsInterned(s) только ищет.
  • Склейка. a + b + c компилируется в один string.Concat, а константы склеиваются ещё при компиляции.
  • Интерполяция. $"..." с C# 10 собирается через DefaultInterpolatedStringHandler с буфером из пула.

Когда что выбирать

Задача Инструмент
2–4 части в одном выражении + или $"..."
Элементы коллекции через разделитель string.Join
Цикл, условия, неизвестное число частей StringBuilder с начальной Capacity

Ловушки

  • string.Intern для данных из БД или JSON: пул живёт до конца процесса, и память не вернётся.
  • StartsWith(string), IndexOf(string) и string.Compare без параметров учитывают текущую культуру. Для идентификаторов передавайте StringComparison.Ordinal.

Правило

Если код создаёт много промежуточных строк, замените их одним StringBuilder или string.Join.

Под капотом

  • Строка — объект с полем длины и символами UTF-16, в конце — нулевой символ. Строка больше 85 000 байт (около 42 500 символов) уходит в LOH.
  • С .NET 8 литералы размещаются в NonGC heap, поэтому GC.GetGeneration("x") может вернуть int.MaxValue.
  • C# по умолчанию помечает сборку флагом CompilationRelaxations.NoStringInterning. Документация прямо говорит: интернирование литералов не гарантировано, в NativeAOT в том числе.

Цена

  • Каждая промежуточная строка — аллокация и копирование символов.
  • Интернированная строка не освобождается до конца процесса, а перед интернированием её всё равно нужно создать.

Компромиссы

  • Повторяющиеся значения из БД (коды стран, статусы) дедуплицируют своим словарём в пределах сервиса, а не глобальным string.Intern.
  • Меньше строк — меньше аллокаций. Помогают разбор через срезы ReadOnlySpan<char> без копирования, string.Create для записи прямо в итоговую строку и CompositeFormat (.NET 8) для повторяющихся форматов.
  • Для UTF-8 протоколов есть литералы "..."u8 (C# 11): байты без промежуточной строки.
  • string.GetHashCode() рандомизирован для каждого процесса — его нельзя сохранять в БД или распределённый кеш.

Как проверить в проде

  • Дамп через dotnet-dump и команда dumpheap -stat: строки часто лидируют по объёму.
  • Дубликаты строк показывает dotMemory.
  • dotnet-counters — скорость аллокаций до и после исправления.

Итог: строки дёшевы для чтения и дороги для сборки, поэтому в горячем коде их стараются не создавать.

Пример
using System.Text;
string s = " hello ";
s.Trim(); // результат потерян: s не изменилась
s = s.Trim(); // правильно: присвоить новую строку
string a = "hello";
string b = "hel" + "lo"; // константа: компилятор склеит в "hello"
string c = new StringBuilder("hel").Append("lo").ToString(); // создана в рантайме
Console.WriteLine(ReferenceEquals(a, b)); // обычно True: литералы интернированы
Console.WriteLine(ReferenceEquals(a, c)); // False: другой объект
Console.WriteLine(a == c); // True: == сравнивает содержимое
Console.WriteLine(ReferenceEquals(string.Intern(a), string.Intern(c))); // True
var sb = new StringBuilder(capacity: 4096);
for (int i = 0; i < 1000; i++)
sb.Append(i).Append(','); // один растущий буфер вместо 1000 строк
string csv = sb.ToString(); // строка создаётся один раз
Console.WriteLine(csv.Length); // 3890
Middle+
Ответ

Что это

  • Стек — своя память у каждого потока. При вызове метода туда кладётся его кадр: параметры, локальные переменные, адрес возврата.
  • Куча — общая память процесса для объектов: экземпляров классов, массивов, строк, упакованных значений.

Зачем

Стек освобождается сам: метод завершился — кадр снят. Объекты в куче живут, пока на них есть ссылки, и могут пережить метод, который их создал. Их удаляет сборщик мусора.

Как пользоваться

  1. Создавайте объекты как обычно — место выбирает рантайм.
  2. Помните: переменная ссылочного типа хранит только ссылку, а сам объект лежит в куче.

Частая ошибка

Бесконечная или слишком глубокая рекурсия. Стек небольшой, при переполнении возникает StackOverflowException, и его нельзя поймать в catch: процесс завершается.

Как устроено

  • Кадр стека. Адрес возврата, сохранённые регистры, локальные переменные и аргументы, которым не хватило регистров. Что держать в регистрах, решает JIT.
  • Размер стека. По документации .NET по умолчанию — 1,5 МБ на Windows и macOS и 8 МБ на Linux. Для своего потока его задают в new Thread(start, maxStackSize), с .NET 10 для всех потоков рантайма — настройкой System.Threading.DefaultStackSize.
  • Аллокация в куче. Поток выделяет память из своей области (allocation context) сдвигом указателя. Новые объекты попадают в поколение 0, и чем больше аллокаций, тем чаще сборки.

Ловушки

  • StackOverflowException. Не перехватывается — процесс завершается. Причины: рекурсия по глубоким данным (деревья, вложенный JSON), свойство, которое вызывает само себя, большой буфер на стеке через stackalloc.
  • Большие объекты. Объекты от 85 000 байт сразу идут в LOH, который собирается только вместе с поколением 2.

Правило

Глубокую рекурсию переписывайте на цикл с явным Stack<T> или проверяйте RuntimeHelpers.TryEnsureSufficientExecutionStack(). Буферы stackalloc ограничивайте небольшим лимитом, например 1 КБ.

Под капотом

  • Спецификация C# говорит о времени жизни переменных, а не о стеке и куче. JIT раскладывает локальные переменные и поля структур по регистрам.
  • Escape analysis: объект «убегает», если его сохраняют в поле или статическую переменную, возвращают или передают в метод, который JIT не встроил. Неубегающий объект JIT может разместить на стеке и разобрать на отдельные значения.
  • С .NET 9 так размещаются «коробки». .NET 10 добавил маленькие массивы фиксированного размера, делегаты и объекты, на которые ссылаются поля локальных структур. Объект замыкания пока остаётся в куче.

Цена

  • Аллокация в куче — сдвиг указателя и обнуление памяти. Частота сборок поколения 0 растёт вместе со скоростью аллокаций.
  • Объекты, пережившие сборку, копируются в поколения 1 и 2, а запись ссылки в поле объекта идёт через write barrier.
  • LOH по умолчанию не уплотняется и со временем фрагментируется.

Компромиссы

  • Стековая аллокация в JIT — оптимизация, а не контракт: проектировать API в расчёте на неё нельзя.
  • Ссылки в кадрах стека — корни для GC, а stackalloc без лимита или в цикле может обрушить процесс.

Как проверить в проде

  • dotnet-counters — скорость аллокаций и число сборок по поколениям.
  • dotnet-trace — события AllocationTick с типами объектов.
  • BenchmarkDotNet с [MemoryDiagnoser] — убрал ли JIT аллокацию.
  • Переполнение стека — дамп (DOTNET_DbgEnableMiniDump=1) и стек вызовов в нём.

Итог: место хранения выбирает рантайм, а ваша задача — держать низкой скорость аллокаций.

Пример
var numbers = new List<int> { 1, 2, 3 }; // List и его массив — в куче
Console.WriteLine(numbers.Count); // 3; сама ссылка numbers — в стеке или регистре
Console.WriteLine(Sum(100)); // 4950
Console.WriteLine(Depth(1_000)); // 1000
// Depth(10_000_000); // StackOverflowException: процесс упадёт
static int Sum(int count)
{
// маленький буфер — на стеке, большой — в куче
Span<int> buffer = count <= 256 ? stackalloc int[count] : new int[count];
for (int i = 0; i < count; i++) buffer[i] = i;
int total = 0; // локальная переменная: регистр или кадр стека
foreach (int x in buffer) total += x;
return total;
}
static int Depth(int n) => n == 0 ? 0 : 1 + Depth(n - 1); // каждый вызов — новый кадр
Middle+
Ответ

Что это

Тип Главное
class объект в куче, == сравнивает ссылки
struct значение, копируется при присваивании
record (C# 9) класс с равенством по значению и with
record struct (C# 10) то же для структуры

Зачем

Record избавляет от ручного кода. Строка public record User(string Name, int Age); даёт конструктор, свойства, Equals, GetHashCode и ToString.

Как пользоваться

  1. Сервис или сущность с поведением — class.
  2. Данные, которые передают и сравнивают (DTO, сообщение), — record.
  3. Маленькое значение вроде координаты или суммы денег — readonly record struct.
  4. Копию с изменением делайте через with: user with { Age = 31 }.

Частая ошибка

Считать record полностью неизменяемым: список внутри него по-прежнему можно изменить.

Как устроено

  • Позиционный record генерирует свойства, конструктор и Deconstruct. У record class и readonly record struct свойства init, у record struct — { get; set; }.
  • Equals, GetHashCode и == сравнивают все поля. У record struct это сгенерированный код без рефлексии, в отличие от ValueType.Equals.
  • Record наследуется только от record, а класс не может наследоваться от record.

Когда что выбирать

Что моделируем Тип
Сущность с Id и жизненным циклом class
DTO, команда, событие, ответ API record
Value object из пары полей, ключ словаря readonly record struct
Элементы большого массива на горячем пути readonly struct

Ловушки

  • Коллекции внутри record сравниваются по ссылке.
  • Изменяемый record — плохой ключ Dictionary: после изменения меняется хеш, и ключ уже не найти.
  • Свойство, вычисленное при создании, не пересчитается после with.

Правило

По умолчанию берите class или record. Структуру выбирайте, только если она маленькая и неизменяемая.

Под капотом

  • Для with у record class компилятор генерирует скрытый метод клонирования и конструктор копирования. Копия поверхностная.
  • EqualityContract возвращает runtime-тип. Поэтому Teacher и Student с одинаковыми данными не равны, даже если обе переменные объявлены как Person.
  • GetHashCode обходит все поля при каждом вызове, результат не кешируется.

Цена

  • Большой record в HashSet или ключом словаря — сравнение и хеширование по всем полям на каждую операцию.
  • Структура больше 16 байт дорого копируется при передаче, возврате и чтении из List<T>. Для неё нужны in и ref readonly или класс.
  • Любую структуру можно получить как default(T) без вызова конструктора с валидацией. Money с Currency = null — легальное значение.

Компромиссы

  • Смена class на struct или новый позиционный параметр в публичном record — ломающее изменение: меняются семантика, конструктор и Deconstruct.
  • EF Core опирается на ссылочное равенство, поэтому records и record structs не подходят как сущности.
  • System.Text.Json десериализует позиционные records через конструктор с параметрами.

Как проверить в проде

Выбор struct против class подтверждают BenchmarkDotNet с [MemoryDiagnoser] (аллокации против времени копирования) и профиль аллокаций под нагрузкой.

Итог: record — генератор кода для данных, а struct — компромисс «нет аллокаций, но есть копирование», который подтверждают измерением.

Пример
var price = new Money(10m, "USD");
var discounted = price with { Amount = 8m }; // новая копия, price не изменился
Console.WriteLine(price == new Money(10m, "USD")); // True: равенство по значению
Console.WriteLine(discounted); // Money { Amount = 8, Currency = USD }
var u1 = new UserDto("Ann", ["ann@example.com"]);
var u2 = new UserDto("Ann", ["ann@example.com"]);
Console.WriteLine(u1 == u2); // False: массивы сравниваются по ссылке
var pos = new Position(1, 2);
pos.X = 5; // record struct без readonly изменяем
var order = new Order(); // сущность: важна идентичность
order.Lines.Add(price);
public readonly record struct Money(decimal Amount, string Currency);
public record UserDto(string Name, string[] Emails);
public record struct Position(int X, int Y);
public class Order
{
public Guid Id { get; } = Guid.NewGuid();
public List<Money> Lines { get; } = [];
}
Middle+

Что дают nullable reference types и как включить их в существующий проект?

Заголовок раздела «Что дают nullable reference types и как включить их в существующий проект?»
Ответ

Что это

Режим компилятора, в котором ссылочные типы по умолчанию не допускают null:

  • string name — null здесь быть не должно, присвоение null даёт предупреждение;
  • string? nick — null допустим, но nick.Length без проверки даёт предупреждение CS8602.

Зачем

Без NRT любая ссылка может оказаться null, и узнаёте вы об этом по NullReferenceException в рантайме. С NRT намерение записано в типе.

Как пользоваться

  1. Включите <Nullable>enable</Nullable> в csproj; в шаблонах проектов с .NET 6 он уже есть.
  2. Помечайте ? то, что может быть null.
  3. Перед использованием проверяйте: if (nick is not null), nick?.Length, nick ?? "—".

Частая ошибка

Глушить предупреждения оператором ! (nick!.Length). Он ничего не проверяет, а только говорит компилятору «поверь мне».

Как устроено

Контекст Аннотации ? Предупреждения
enable да да
warnings нет да
annotations да нет
disable нет нет

Контекст задаётся <Nullable> в csproj и переопределяется в коде директивами #nullable enable, #nullable disable и #nullable restore.

Как перевести проект

  1. Выберите умолчание: enable и #nullable disable в непереведённых файлах — для активного кода, disable и #nullable enable по файлам — для стабильной библиотеки.
  2. Переводите файлы снизу вверх по зависимостям: аннотация типа порождает предупреждения у тех, кто его вызывает.
  3. Исправляйте предупреждения проверками и атрибутами, а не оператором !.
  4. Когда все файлы переведены, удалите директивы и оставьте <Nullable>enable</Nullable>.

Ловушки

  • Предупреждение CS8618 о неинициализированном свойстве решают конструктором или модификатором required (C# 11), а не = null!.
  • Для TryGet-методов нужен [NotNullWhen(true)], для методов инициализации — [MemberNotNull]. Иначе компилятор не поверит проверке.
  • Сгенерированные файлы (*.g.cs, комментарий <auto-generated>) компилятор анализирует так, будто NRT выключены.

Правило

Закрепите результат: <WarningsAsErrors>nullable</WarningsAsErrors> не даст новым предупреждениям попасть в основную ветку.

Под капотом

  • Аннотации хранятся в метаданных атрибутами NullableAttribute и NullableContextAttribute. Рантайм их не проверяет, рефлексия читает через NullabilityInfoContext (.NET 6).
  • Анализ оптимистичен: если после проверки поля вызвать метод, который его обнуляет, состояние не сбросится. Другие потоки анализ не видит вовсе.
  • Дыры: new string[10] — массив из null с типом string[], поля структуры, созданной через default, объекты из десериализации и рефлексии.

Цена

  • EF Core делает non-nullable свойства обязательными: включение NRT меняет модель и порождает миграцию с NOT NULL.
  • ASP.NET Core MVC валидирует non-nullable свойства моделей как [Required], и клиенты могут начать получать 400.
  • System.Text.Json с .NET 9 при RespectNullableAnnotations = true бросает исключение на null в non-nullable свойстве.

Компромиссы

  • Аннотации — часть публичного контракта библиотеки: смена string на string? в возвращаемом значении ломает сборку потребителей с TreatWarningsAsErrors.
  • Каждый ! — либо доказанный инвариант с комментарием, либо долг.
  • На границах публичного API проверки ArgumentNullException.ThrowIfNull остаются: вызывающий код может быть без NRT.

Как проверить в проде

  • Число nullable-предупреждений в CI — метрика миграции, а WarningsAsErrors запрещает регресс.
  • До выкладки сравните сгенерированную миграцию EF Core и ответы 400 на интеграционных тестах.

Итог: NRT — статический контракт, который меняет поведение фреймворков, поэтому его включение — изменение с тестами и миграцией, а не просто флаг.

Пример
using System.Diagnostics.CodeAnalysis;
string? nick = FindNick("ann"); // может быть null
// Console.WriteLine(nick.Length); // CS8602: возможное разыменование null
Console.WriteLine(nick?.Length ?? 0); // 0
if (TryGetEmail("ann", out var email))
Console.WriteLine(email.Length); // 15, без предупреждения благодаря [NotNullWhen]
var user = new User { Login = "ann" }; // без Login — ошибка CS9035
Console.WriteLine(user.Phone is null); // True
static string? FindNick(string login) => login.Length > 3 ? login : null;
static bool TryGetEmail(string login, [NotNullWhen(true)] out string? email)
{
email = login == "ann" ? "ann@example.com" : null;
return email is not null;
}
class User
{
public required string Login { get; init; } // C# 11: обязателен при создании
public string? Phone { get; set; }
}
Senior+
Ответ

Что это

  • Span<T> — «окно» на часть памяти без копирования: изменения через него видны в исходном массиве.
  • ReadOnlySpan<T> — то же, но только для чтения; строки дают именно его.
  • Memory<T> — окно, которое можно хранить в поле класса.

Зачем

Работать с частью массива или строки без новых массивов и строк, то есть без лишних аллокаций.

Как пользоваться

  1. array.AsSpan(2, 3) — три элемента массива, начиная с индекса 2.
  2. "id=42".AsSpan(3) — часть строки без Substring; int.Parse принимает её напрямую.
  3. Span<byte> buf = stackalloc byte[64]; — буфер на стеке.

Частая ошибка

Пытаться сохранить Span<T> в поле класса, захватить в лямбду или держать через await — компилятор не даст. Для этого есть Memory<T>.

Как устроено

  • Span<T> и ReadOnlySpan<T> — ref struct: ссылка на первый элемент и длина. Индексатор проверяет границы, а срез (span[2..5], Slice) — новая пара без копирования.
  • Источники span: массив, строка, stackalloc, неуправляемая память.
  • Memory<T> — обычная структура: объект (массив, строка или MemoryManager<T>), смещение и длина. Её можно хранить в полях и держать через await, а к данным обращаются через .Span.

Когда что выбирать

Ситуация Тип
Синхронный метод читает данные ReadOnlySpan<T>
Синхронный метод пишет в буфер Span<T>
Асинхронный метод или поле класса Memory<T> или ReadOnlyMemory<T>
Передача владения буфером IMemoryOwner<T>

Ловушки

  • ArrayPool<T>.Shared.Rent(n) возвращает массив длиной не меньше n — используйте только первые n элементов.
  • После Return массив использовать нельзя, а сам Return ставят в finally.
  • CollectionsMarshal.AsSpan(list) после Add может смотреть на старый массив списка.

Правило

Принимайте самый узкий тип: ReadOnlySpan<T> для синхронного чтения, Memory<T> — только там, где span нельзя. С C# 13 span можно объявлять в async-методах, если он не живёт через await, а C# 14 добавил неявные преобразования массивов в span.

Под капотом

  • С .NET 7 Span<T> устроен как ref T плюс int длины — это стало возможно благодаря ref-полям C# 11.
  • Управляемая ссылка может указывать внутрь объекта или массива. GC обновляет такие ссылки только в стеке и регистрах, поэтому span нельзя положить в кучу.
  • Структура из двух полей в куче могла бы «порваться» при гонке потоков и дать выход за границы.
  • Компилятор проверяет ref safety: нельзя вернуть span на stackalloc-буфер, а scoped сужает время жизни параметра.

Цена

  • Memory<T>.Span проверяет тип владельца (массив, строка, MemoryManager<T>), поэтому в цикле span берут один раз.
  • Пул убирает аллокации буферов, но добавляет ручное управление временем жизни.

Компромиссы

  • Буфер, возвращённый в ArrayPool, может уже использовать другой запрос. Ошибка проявится чужими данными в ответе, а не исключением; двойной Return даёт то же самое.
  • У буфера ровно один владелец, и передача IMemoryOwner<T> — это передача ответственности за Dispose.
  • Для потоковых данных из System.IO.Pipelines используют ReadOnlySequence<T> — цепочку сегментов, которую SequenceReader<T> разбирает без склейки.
  • MemoryMarshal и Unsafe обходят проверки границ — только с бенчмарком и ревью.

Как проверить в проде

  • BenchmarkDotNet с [MemoryDiagnoser] — аллокации до и после перехода на span.
  • dotnet-counters — скорость аллокаций и сборки поколения 0 под нагрузкой.
  • Ошибки времени жизни ищут ревью и тестами с параллельной нагрузкой: профилировщик их не покажет.

Итог: span даёт работу с памятью без аллокаций ценой правил времени жизни, которые для пулов приходится соблюдать вручную.

Пример
using System.Buffers;
ReadOnlySpan<char> line = "id=42;name=Ann";
int eq = line.IndexOf('=');
int semi = line.IndexOf(';');
int id = int.Parse(line[(eq + 1)..semi]); // срез без новой строки
Console.WriteLine(id); // 42
byte[] rented = ArrayPool<byte>.Shared.Rent(4096); // длина не меньше 4096
try
{
Memory<byte> buffer = rented.AsMemory(0, 4096);
await FillAsync(buffer); // Memory<T> переживает await
Console.WriteLine(buffer.Span[0]); // 1
}
finally
{
ArrayPool<byte>.Shared.Return(rented); // после Return массив не трогать
}
static async Task FillAsync(Memory<byte> buffer)
{
await Task.Yield();
buffer.Span[0] = 1;
}

Код с await на верхнем уровне — асинхронный метод. Span-переменные в нём разрешены с C# 13, потому что не живут через await.