Типи й пам'ять
Як .NET зберігає дані: чим значення відрізняється від посилання, що лежить у стеку й у купі, звідки береться boxing і як працювати з пам’яттю без зайвих алокацій. На цій темі тримаються «Збирання сміття» та «Асинхронність».
Перед цією темою: Платформа .NET.
Чим відрізняються значущі та посилальні типи?
Section titled “Чим відрізняються значущі та посилальні типи?”Що це
- Значущий тип — змінна зберігає саме значення.
- Посилальний тип — змінна зберігає посилання на об’єкт, а сам об’єкт лежить у купі.
Які типи до чого належать
| Значущі | Посилальні |
|---|---|
числа, 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 і де вони виникають непомітно?
Section titled “Що таке 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?
Section titled “Чому рядки незмінні, що таке інтернування і коли потрібен 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? — Пише просто в пам’ять підсумкового рядка, якщо довжина відома заздалегідь.
Що зберігається в стеку, а що в купі?
Section titled “Що зберігається в стеку, а що в купі?”Що це
- Стек — власна пам’ять кожного потоку. Під час виклику методу туди кладеться його кадр: параметри, локальні змінні, адреса повернення.
- Купа — спільна пам’ять процесу для об’єктів: екземплярів класів, масивів, рядків, упакованих значень.
Навіщо
Стек звільняється сам: метод завершився — кадр знято. Об’єкти в купі живуть, доки на них є посилання, і можуть пережити метод, який їх створив. Їх видаляє збирач сміття.
Як користуватися
- Створюйте об’єкти як зазвичай — місце обирає рантайм.
- Пам’ятайте: змінна посилального типу зберігає лише посилання, а сам об’єкт лежить у купі.
Типова помилка
Нескінченна або надто глибока рекурсія. Стек невеликий, під час переповнення виникає 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?
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.
Як користуватися
- Сервіс або сутність із поведінкою —
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 і як увімкнути їх в наявному проєкті?
Section titled “Що дають 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> і навіщо вони потрібні?
Section titled “Що таке 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? — Обіцяє компілятору, що посилання не переживе виклик методу.