Перейти до вмісту

Типи й пам'ять

Як .NET зберігає дані: чим значення відрізняється від посилання, що лежить у стеку й у купі, звідки береться boxing і як працювати з пам’яттю без зайвих алокацій. На цій темі тримаються «Збирання сміття» та «Асинхронність».

Перед цією темою: Платформа .NET.

Junior+

Чим відрізняються значущі та посилальні типи?

Section titled “Чим відрізняються значущі та посилальні типи?”
Відповідь

Що це

  • Значущий тип — змінна зберігає саме значення.
  • Посилальний тип — змінна зберігає посилання на об’єкт, а сам об’єкт лежить у купі.

Які типи до чого належать

Значущі Посилальні
числа, 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 і де вони виникають непомітно?

Section titled “Що таке 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?

Section titled “Чому рядки незмінні, що таке інтернування і коли потрібен 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+

Що зберігається в стеку, а що в купі?

Section titled “Що зберігається в стеку, а що в купі?”
Відповідь

Що це

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

Навіщо

Стек звільняється сам: метод завершився — кадр знято. Об’єкти в купі живуть, доки на них є посилання, і можуть пережити метод, який їх створив. Їх видаляє збирач сміття.

Як користуватися

  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+

Коли використовувати struct, class, record і record struct?

Section titled “Коли використовувати 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.

Як користуватися

  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 і як увімкнути їх в наявному проєкті?

Section titled “Що дають 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> і Memory<T> і навіщо вони потрібні?

Section titled “Що таке Span<T> і Memory<T> і навіщо вони потрібні?”
Відповідь

Що це

  • 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.