Типы и память
Как .NET хранит данные: чем значение отличается от ссылки, что лежит в стеке и в куче, откуда берётся boxing и как работать с памятью без лишних аллокаций. На этой теме держатся «Сборка мусора» и «Асинхронность».
Перед этой темой: Платформа .NET.
Чем отличаются значимые и ссылочные типы?
Заголовок раздела «Чем отличаются значимые и ссылочные типы?»Что это
- Значимый тип — переменная хранит само значение.
- Ссылочный тип — переменная хранит ссылку на объект, а сам объект лежит в куче.
Какие типы к чему относятся
| Значимые | Ссылочные |
|---|---|
числа, bool, char |
class, interface |
DateTime, enum |
string, object |
любые struct |
массивы, делегаты, record |
Как это проявляется
- Присваивание.
b = aу структуры создаёт независимую копию, у класса — вторую ссылку на тот же объект. - Передача в метод. Передаётся копия: копия структуры или копия ссылки. Метод может изменить поля объекта класса, но не может подменить сам объект без
ref. 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; }- Где хранится поле
intу объекта класса? — Внутри объекта, то есть в куче. - Что такое boxing и почему это дорого? — Аллокация в куче плюс копирование, больше работы для GC.
- Почему
list[0].X = 5не компилируется, если элементы — структуры? — Индексатор возвращает копию, менять её бессмысленно (CS1612). - Когда вы сделаете тип структурой? — Маленький, неизменяемый, одно логическое значение, редко упаковывается.
- Чем
record structотличается отrecord class? — Семантикой копирования; равенство по значению есть у обоих.
Что такое boxing и unboxing и где они возникают незаметно?
Заголовок раздела «Что такое boxing и unboxing и где они возникают незаметно?»Что это
- Boxing (упаковка) — значение значимого типа копируется в новый объект в куче:
object o = 42;. - Unboxing (распаковка) — значение копируется из объекта обратно в переменную:
int x = (int)o;.
Зачем
Так структуру можно передать туда, где ждут object или интерфейс: в ArrayList, string.Format, переменную типа IComparable.
Как пользоваться
- Распаковывайте только в исходный тип:
(long)oдля упакованногоintброситInvalidCastException. - Вместо
ArrayListиHashtableберитеList<T>иDictionary<TKey, TValue>— они хранят значения без упаковки. - «Коробка» — независимая копия: её изменения не видны в исходной переменной.
Частая ошибка
Сравнивать упакованные значения через ==. Для object он сравнивает ссылки, а у двух «коробок» они разные. Нужен a.Equals(b).
Как устроено
- Компилятор видит приведение значимого типа к
objectили интерфейсу и вставляет инструкциюbox. - Рантайм выделяет объект в куче: заголовок, указатель на тип и копия значения.
- При распаковке инструкция
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[]: boxingstring 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; }- Почему
a == bдля двух упакованных1даётfalse? —==уobjectсравнивает ссылки, а «коробки» разные. - Будет ли boxing при вызове
CompareToуTс ограничениемwhere T : IComparable<T>? — Нет: вызов идёт черезconstrained.напрямую. - Что получится при упаковке
int?без значения? — Обычная ссылкаnull, объект в куче не создаётся. - Как избежать boxing при логировании? — Генератор
[LoggerMessage]со строго типизированными параметрами. - Почему метод структуры, вызванный через интерфейс, «не меняет» её? — Он меняет копию в «коробке», а не исходную переменную.
Почему строки неизменяемы, что такое интернирование и когда нужен StringBuilder?
Заголовок раздела «Почему строки неизменяемы, что такое интернирование и когда нужен StringBuilder?»Что это
string — ссылочный тип, который нельзя изменить после создания. Методы Trim, Replace, ToUpper возвращают новую строку, а исходная остаётся прежней.
Зачем
- Строку можно передавать куда угодно: никто не изменит её у вас за спиной.
- Рантайм может хранить одинаковые литералы одним объектом — это интернирование.
Как пользоваться
- Присваивайте результат:
s = s.Trim();. - Сравнивайте через
==— он сравнивает содержимое. Без учёта регистра —string.Equals(a, b, StringComparison.OrdinalIgnoreCase). - Для сборки строки в цикле берите
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- Почему
s.Replace("a", "b")«не работает»? — Метод возвращает новую строку, результат нужно присвоить. - Всегда ли
StringBuilderбыстрее+? — Нет: несколько+в одном выражении — это одинstring.Concatбез лишнего объекта. - Можно ли сравнивать строки через
ReferenceEquals? — Нет: интернирование не гарантировано, одинаковые строки из рантайма — разные объекты. - Можно ли сохранить
GetHashCode()строки в базе? — Нет: хеш строки рандомизирован для каждого процесса. - Чем
string.CreateлучшеStringBuilder? — Пишет прямо в память итоговой строки, если длина известна заранее.
Что хранится в стеке, а что в куче?
Заголовок раздела «Что хранится в стеке, а что в куче?»Что это
- Стек — своя память у каждого потока. При вызове метода туда кладётся его кадр: параметры, локальные переменные, адрес возврата.
- Куча — общая память процесса для объектов: экземпляров классов, массивов, строк, упакованных значений.
Зачем
Стек освобождается сам: метод завершился — кадр снят. Объекты в куче живут, пока на них есть ссылки, и могут пережить метод, который их создал. Их удаляет сборщик мусора.
Как пользоваться
- Создавайте объекты как обычно — место выбирает рантайм.
- Помните: переменная ссылочного типа хранит только ссылку, а сам объект лежит в куче.
Частая ошибка
Бесконечная или слишком глубокая рекурсия. Стек небольшой, при переполнении возникает 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)); // 4950Console.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); // каждый вызов — новый кадр- Почему
StackOverflowExceptionнельзя поймать? — Потоку не хватает стека даже на обработчик, и рантайм завершает процесс. - Где хранятся локальные переменные async-метода? — Те, что живут через
await, — в полях state machine, которая при первом незавершённомawaitпереезжает в кучу. - Что такое LOH и чем он опасен? — Куча для объектов от 85 000 байт; собирается с поколением 2 и по умолчанию не уплотняется.
- Как проверить, что JIT убрал аллокацию? — BenchmarkDotNet с
[MemoryDiagnoser]: в колонке Allocated будет 0. - Зачем ограничивать размер
stackalloc? — Размер стека зависит от среды, а большой буфер может обрушить процесс.
Когда использовать struct, class, record и record struct?
Заголовок раздела «Когда использовать struct, class, record и record struct?»Что это
| Тип | Главное |
|---|---|
class |
объект в куче, == сравнивает ссылки |
struct |
значение, копируется при присваивании |
record (C# 9) |
класс с равенством по значению и with |
record struct (C# 10) |
то же для структуры |
Зачем
Record избавляет от ручного кода. Строка public record User(string Name, int Age); даёт конструктор, свойства, Equals, GetHashCode и ToString.
Как пользоваться
- Сервис или сущность с поведением —
class. - Данные, которые передают и сравнивают (DTO, сообщение), —
record. - Маленькое значение вроде координаты или суммы денег —
readonly record struct. - Копию с изменением делайте через
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; } = [];}- Чем
recordотличается от класса с переопределённымEquals? — По сути ничем: компилятор пишет тот же код плюсwith,ToString,DeconstructиEqualityContract. - Почему два record с одинаковыми списками внутри не равны? — Список сравнивается по ссылке; нужно своё
EqualsиGetHashCode. - Можно ли сделать record сущностью EF Core? — Не стоит: EF опирается на ссылочное равенство.
- Что будет, если изменить record, который лежит ключом в
Dictionary? — Хеш изменится, и ключ станет недоступен для поиска. - Зачем
readonly record struct, если естьrecord struct? — Свойстваinitвместоsetи отсутствие защитных копий.
Что дают nullable reference types и как включить их в существующий проект?
Заголовок раздела «Что дают nullable reference types и как включить их в существующий проект?»Что это
Режим компилятора, в котором ссылочные типы по умолчанию не допускают null:
string name—nullздесь быть не должно, присвоениеnullдаёт предупреждение;string? nick—nullдопустим, ноnick.Lengthбез проверки даёт предупреждение CS8602.
Зачем
Без NRT любая ссылка может оказаться null, и узнаёте вы об этом по NullReferenceException в рантайме. С NRT намерение записано в типе.
Как пользоваться
- Включите
<Nullable>enable</Nullable>в csproj; в шаблонах проектов с .NET 6 он уже есть. - Помечайте
?то, что может бытьnull. - Перед использованием проверяйте:
if (nick is not null),nick?.Length,nick ?? "—".
Частая ошибка
Глушить предупреждения оператором ! (nick!.Length). Он ничего не проверяет, а только говорит компилятору «поверь мне».
Как устроено
| Контекст | Аннотации ? |
Предупреждения |
|---|---|---|
enable |
да | да |
warnings |
нет | да |
annotations |
да | нет |
disable |
нет | нет |
Контекст задаётся <Nullable> в csproj и переопределяется в коде директивами #nullable enable, #nullable disable и #nullable restore.
Как перевести проект
- Выберите умолчание:
enableи#nullable disableв непереведённых файлах — для активного кода,disableи#nullable enableпо файлам — для стабильной библиотеки. - Переводите файлы снизу вверх по зависимостям: аннотация типа порождает предупреждения у тех, кто его вызывает.
- Исправляйте предупреждения проверками и атрибутами, а не оператором
!. - Когда все файлы переведены, удалите директивы и оставьте
<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: возможное разыменование nullConsole.WriteLine(nick?.Length ?? 0); // 0
if (TryGetEmail("ann", out var email)) Console.WriteLine(email.Length); // 15, без предупреждения благодаря [NotNullWhen]
var user = new User { Login = "ann" }; // без Login — ошибка CS9035Console.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; }}- Что делает оператор
!? — Подавляет предупреждение; в рантайме ничего не проверяет. - Почему после включения NRT EF Core создал миграцию? — Non-nullable свойства стали обязательными, столбцы —
NOT NULL. - Чем
T?для неограниченногоTотличается отint?? — Для значимогоTэто неNullable<T>, а пометка «может бытьdefault». - Как не дать предупреждениям вернуться? —
<WarningsAsErrors>nullable</WarningsAsErrors>и проверка в CI. - Что нового в C# 14 для работы с
null? — Null-conditional присваивание:customer?.Order = GetOrder()выполнится, только еслиcustomerнеnull.
Что такое Span<T> и Memory<T> и зачем они нужны?
Заголовок раздела «Что такое Span<T> и Memory<T> и зачем они нужны?»Что это
Span<T>— «окно» на часть памяти без копирования: изменения через него видны в исходном массиве.ReadOnlySpan<T>— то же, но только для чтения; строки дают именно его.Memory<T>— окно, которое можно хранить в поле класса.
Зачем
Работать с частью массива или строки без новых массивов и строк, то есть без лишних аллокаций.
Как пользоваться
array.AsSpan(2, 3)— три элемента массива, начиная с индекса 2."id=42".AsSpan(3)— часть строки безSubstring;int.Parseпринимает её напрямую.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); // длина не меньше 4096try{ 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.
- Почему
Span<T>нельзя держать черезawait? — Послеawaitсостояние метода живёт в куче, а span — только на стеке. - Чем
ReadOnlySpan<char>лучшеSubstring? — Срез без аллокации;int.Parseи многие другие API принимают span. - Что вернёт
ArrayPool<byte>.Shared.Rent(1000)? — Массив длиной не меньше 1000, часто 1024; лишний хвост не трогать. - Когда нужен
IMemoryOwner<T>? — Когда владение буфером передаётся между компонентами: владелец вызываетDispose. - Зачем
scopedу параметра-span? — Обещает компилятору, что ссылка не переживёт вызов метода.