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

Мова C#

Питання про саму мову, які ставлять на будь-якому рівні: успадкування й інтерфейси, винятки, розширення, generics, делегати, рівність і pattern matching. Мало знати синтаксис — треба пояснити, що компілятор і рантайм роблять із вашим кодом.

Перед цією темою: Типи й пам’ять.

Junior+

Чим інтерфейс відрізняється від абстрактного класу?

Section titled “Чим інтерфейс відрізняється від абстрактного класу?”
Відповідь

Що це

  • Інтерфейс — набір членів, які тип обіцяє реалізувати: IDisposable, IComparable<T>.
  • Абстрактний клас — клас із модифікатором abstract. Створити його екземпляр не можна, лише успадкуватися.

Навіщо

  • Інтерфейс дає змогу підміняти реалізацію: у тестах, через DI.
  • Абстрактний клас позбавляє нащадків від повторення спільного коду.

Чим відрізняються

Інтерфейс Абстрактний клас
Скільки можна успадкувати скільки завгодно один
Поля й конструктори немає є
Підходить структурам так ні

Типова помилка

Зробити абстрактний клас там, де потрібен лише контракт. Згодом нащадку знадобиться інший базовий клас, а слот уже зайнятий.

Як влаштовано

  • Абстрактний клас — звичайний клас: поля, конструктори, будь-які модифікатори доступу, abstract- і virtual-члени.
  • Інтерфейс не зберігає стан: { get; set; } у ньому оголошує властивість, а не автовластивість.
  • Із C# 8 член інтерфейсу може мати тіло — default interface method. Клас його не успадковує, викликати можна лише через посилання типу інтерфейсу. Потрібен рантайм .NET Core 3.0+.
  • Із C# 11 (.NET 7) інтерфейс може вимагати статичні члени й оператори — static abstract. Їх викликають через параметр типу: T.Zero, T.Parse(...).

Коли що обирати

Задача Вибір
Контракт для DI, моків, структур інтерфейс
Спільний код та інваріанти нащадків абстрактний клас
Числовий алгоритм для будь-яких типів інтерфейс зі static abstract
Публічна бібліотека інтерфейс плюс абстрактний базовий клас із типовою реалізацією

Пастки

  • Default-метод не видно через змінну класу: rect.Describe() не скомпілюється.
  • Ромб: I1 і I2 успадковують I0 й обидва реалізують його метод за замовчуванням. Клас, що реалізує I1 і I2, мусить дати власну реалізацію, інакше помилка компіляції.
  • Явна реалізація void IFoo.M() ховає метод з API класу — його теж викликають лише через інтерфейс.

Правило

Починайте з інтерфейсу. Абстрактний базовий клас додавайте, коли реалізації починають дублювати той самий код.

Під капотом

  • Віртуальний метод класу викликається через слот у таблиці методів — за фіксованим зміщенням.
  • Виклик через інтерфейс потребує знайти реалізацію для конкретного типу. Рантайм кешує результат, але зазвичай це трохи дорожче за віртуальний виклик.
  • static abstract-член не диспетчеризується за об’єктом. Виклик T.Zero розв’язується для конкретного типу-аргументу, і лише через параметр типу, а не через сам інтерфейс.

Ціна

  • Приведення структури до інтерфейсу — boxing. Без нього — generic з обмеженням where T : IFoo: JIT генерує окремий код під кожен значущий тип, і виклик прямий.
  • У гарячих мономорфних місцях dynamic PGO (за замовчуванням із .NET 8) перетворює інтерфейсний виклик на перевірку типу й прямий виклик.

Компроміси

  • Додати метод до опублікованого інтерфейсу — breaking change для всіх реалізацій. Default interface methods це пом’якшують, але на .NET Framework не працюють.
  • Абстрактний клас розширюється новим віртуальним методом без ламання нащадків, але нав’язує ієрархію.
  • Generic math над int і double працює без boxing і віртуальних викликів, але сигнатури з where T : INumber<T> читати важче.

Як перевірити в продакшені

  • Boxing і алокації видно в BenchmarkDotNet з [MemoryDiagnoser].
  • Чим прямий виклик відрізняється від віртуального та інтерфейсного, показує [DisassemblyDiagnoser].

Підсумок: інтерфейс дає гнучкість, але його важче розвивати; абстрактний клас простіше розвивати, але він зв’язує ієрархію.

Приклад
var r = new Rect(2, 3);
// r.Describe(); // помилка: клас не успадковує default-метод
Console.WriteLine(((IShape)r).Describe()); // Area: 6
public interface IShape
{
double Area();
// реалізація за замовчуванням (C# 8)
string Describe() => $"Area: {Area()}";
}
public abstract class Shape(string name) : IShape
{
public string Name { get; } = name; // стан і конструктор
public abstract double Area();
}
public sealed class Rect(double w, double h) : Shape("rectangle")
{
public override double Area() => w * h;
}
static abstract, C# 11 · Middle
using System.Numerics;
static T Sum<T>(IEnumerable<T> items) where T : INumber<T>
{
T total = T.Zero; // статичний член інтерфейсу
foreach (var x in items) total += x; // оператор з інтерфейсу
return total;
}
Console.WriteLine(Sum(new[] { 1, 2, 3 })); // 6
Console.WriteLine(Sum(new[] { 1.5, 2.5 })); // 4
Junior+

Чим відрізняються virtual/override, new і sealed?

Section titled “Чим відрізняються virtual/override, new і sealed?”
Відповідь

Що це

  • virtual — метод базового класу, який нащадок може замінити.
  • override — заміна віртуального методу в нащадку.
  • new — новий метод із тим самим ім’ям, що приховує базовий.
  • sealed — заборона: на класі — успадковуватися, на override — перевизначати далі.

Чим відрізняються

Виклик Base b = new Derived(); b.M(); Що викличеться
M перевизначено через override Derived.M — за типом об’єкта
M приховано через new Base.M — за типом змінної

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

  • abstract — той самий virtual, але без тіла: нащадок зобов’язаний перевизначити.
  • base.M() викликає базову реалізацію з перевизначеної.

Типова помилка

Забути virtual у базовому класі й написати в нащадку метод із тим самим ім’ям. Вийде приховування, а не перевизначення, і компілятор видасть попередження.

Як влаштовано

  1. Компілятор бачить виклик b.M() і знаходить метод за статичним типом b.
  2. Якщо метод віртуальний, генерується віртуальний виклик: адресу береться з таблиці методів об’єкта під час виконання.
  3. Якщо метод невіртуальний або прихований через new, виклик прив’язується до методу статичного типу ще під час компіляції.

Коли що обирати

Мета Модифікатор
Нащадки мають змінювати поведінку virtual або abstract + override
Зупинити ланцюжок перевизначень sealed override
Клас не призначений для успадкування sealed class
У новій версії базового класу з’явився метод із тим самим ім’ям new

Пастки

  • Метод із тим самим ім’ям без модифікатора — приховування з попередженням CS0108 (або CS0114, якщо базовий метод віртуальний).
  • Віртуальний метод, викликаний з конструктора базового класу, потрапляє в override нащадка до тіла його конструктора. Поля, які той заповнює, ще порожні (аналізатор CA2214).
  • Метод, що реалізує інтерфейс, у C# не віртуальний. Щоб нащадки могли його змінювати, оголосіть його virtual.
  • Не можна перевизначити static метод або змінити доступність при override.

Правило

Віртуальними робіть лише точки розширення, класи без нащадків — sealed.

Під капотом

  • Кожен тип має таблицю методів зі слотами віртуальних методів. override кладе свою реалізацію в той самий слот, new virtual заводить новий (newslot в IL).
  • Віртуальний виклик завантажує адресу зі слота таблиці об’єкта й переходить за нею. Вбудувати такий виклик JIT не може.

Ціна

  • Сам непрямий перехід дешевий. Дорожче те, що він заважає inlining та оптимізаціям коду, який викликає.
  • Якщо JIT знає точний тип — клас або метод sealed, об’єкт щойно створено, — він викликає метод напряму й може його вбудувати.
  • Dynamic PGO (за замовчуванням із .NET 8) робить guarded devirtualization: «якщо тип найчастіший — прямий виклик, інакше віртуальний».
  • Для sealed-класу перевірка is і приведення зводяться до порівняння вказівника на таблицю методів.

Компроміси

  • Незапечатаний клас із віртуальними методами — публічний контракт для нащадків. Його важко змінювати, не зламавши чужий код (проблема крихкого базового класу).
  • Аналізатор CA1852 пропонує запечатувати internal-типи без нащадків.
  • Якщо клас відкритий, документуйте, які методи можна перевизначати й у якому порядку вони викликаються.

Як перевірити в продакшені

  • Ефект від sealed міряють BenchmarkDotNet.
  • Що JIT девіртуалізував і вбудував, видно в дизасемблері: [DisassemblyDiagnoser] або змінна оточення DOTNET_JitDisasm.

Підсумок: sealed — і захист дизайну, і підказка JIT; відкритість до успадкування треба проєктувати.

Приклад
Animal a = new Dog();
Console.WriteLine(a.Speak()); // Woof: вибір за типом об'єкта
Console.WriteLine(a.Kind()); // animal: вибір за типом змінної
Console.WriteLine(((Dog)a).Kind()); // dog
class Animal
{
public virtual string Speak() => "...";
public string Kind() => "animal";
}
class Dog : Animal
{
public override string Speak() => "Woof"; // перевизначення
public new string Kind() => "dog"; // приховування
}
class Puppy : Dog
{
public sealed override string Speak() => "Yip"; // нижче перевизначити не можна
}
Junior+

Як правильно обробляти винятки?

Section titled “Як правильно обробляти винятки?”
Відповідь

Що це

Виняток — об’єкт-нащадок Exception, який описує помилку. Після throw виконання переривається, і рантайм шукає найближчий відповідний catch угору стеком викликів.

Навіщо

Виняток не можна випадково проігнорувати, як код помилки: якщо його ніхто не перехопив, процес завершиться.

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

  • try — код, який може впасти.
  • catch (ТипВинятку ex) — обробка. Блоки пишуть від часткового до загального: FileNotFoundException раніше за IOException.
  • finally виконується за будь-яких умов — туди кладуть звільнення ресурсів. Для IDisposable простіше using.
  • throw; усередині catch прокидає той самий виняток далі.

Типова помилка

throw ex; замість throw;. Стек почнеться з поточного методу, і місце помилки загубиться.

Як влаштовано

  1. throw створює виняток і запам’ятовує стек.
  2. Рантайм шукає вгору стеком catch відповідного типу, у якого фільтр when, якщо він є, повернув true.
  3. На шляху до знайденого обробника виконуються всі finally.
  4. Якщо обробника немає, процес завершується.

Коли що обирати

Ситуація Що робити
Можна повторити або підставити запасний варіант catch конкретного типу
Треба додати контекст новий виняток з innerException
Треба відкотити й прокинути далі catch, відкат, throw;
Нічого зробити не можна не перехоплювати, хай дійде до глобального обробника

Пастки

  • finally не виконається при Environment.FailFast, StackOverflowException і вбивстві процесу.
  • Виняток, кинутий з finally, затирає вихідний (CA2219).
  • Виняток з async-методу зберігається в Task і викидається на await. Скасування перехоплюють як OperationCanceledException, а не TaskCanceledException.

Правило

Спершу шукайте відповідний стандартний тип: ArgumentException, InvalidOperationException. Власний виняток — нащадок Exception із суфіксом Exception і трьома конструкторами: порожнім, із повідомленням, із повідомленням та innerException. Для перевірки аргументів — ArgumentNullException.ThrowIfNull(x) (.NET 6+).

Під капотом

  • Перший прохід шукає обробник і виконує фільтри when. Стек ще не розмотано, локальні змінні вихідного кадру живі.
  • Другий прохід розмотує стек і виконує finally.
  • Тому catch (Exception ex) when (Log(ex)), де Log повертає false, логує без перехоплення.
  • ExceptionDispatchInfo.Capture(ex).Throw() викидає виняток в іншому місці чи потоці з вихідним стеком — так await прокидає виняток задачі.

Ціна

  • Викидання — алокація, збирання стека, прохід кадрами: на порядки дорожче за звичайне повернення.
  • У .NET 9 CoreCLR перейшов на модель обробки винятків із NativeAOT: у мікробенчмарках у 2–4 рази швидше. Для валідації й «не знайдено» винятки все одно дорогі.

Компроміси

  • Винятки не можна пропустити непомітно, на відміну від коду повернення. Але в гарячому шляху їх замінюють на Try-патерн або тип-результат.
  • Необроблений виняток у будь-якому потоці та StackOverflowException завершують процес. Перехоплення всього — один раз на межі: middleware, глобальний обробник.
  • Конструктор (SerializationInfo, StreamingContext) у .NET 8 позначено застарілим (SYSLIB0051) — у нових винятках його не пишуть.

Як перевірити в продакшені

  • dotnet-counters monitor показує кількість винятків (метрика dotnet.exceptions із .NET 9). Зростання означає, що винятки стали частиною звичайного потоку виконання.
  • Хто їх кидає, покажуть dotnet-trace і подія AppDomain.FirstChanceException.

Підсумок: виняток — дорогий сигнал про помилку, а не спосіб керувати логікою; фільтри й throw; зберігають контекст для діагностики.

Приклад
using System.Data.Common;
using System.Net;
try
{
ProcessOrder(id);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// перехоплюємо лише 404, решта йде далі без розмотування стека
throw new OrderNotFoundException($"Order {id} not found", ex); // причина — в InnerException
}
catch (DbException)
{
tx.Rollback();
throw; // стек збережеться; throw ex; почав би його звідси
}
finally
{
tx.Dispose(); // виконається за будь-яких умов
}
public sealed class OrderNotFoundException : Exception
{
public OrderNotFoundException() { }
public OrderNotFoundException(string message) : base(message) { }
public OrderNotFoundException(string message, Exception inner) : base(message, inner) { }
}
Junior+

Як працюють методи розширення і що змінили extension members у C# 14?

Section titled “Як працюють методи розширення і що змінили extension members у C# 14?”
Відповідь

Що це

Статичний метод, який викликають через крапку, як метод екземпляра. Компілятор переписує s.WordCount() на StringExtensions.WordCount(s).

Навіщо

Додати зручний метод типу, який ви не можете змінити: string, інтерфейсу з BCL, класу сторонньої бібліотеки.

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

  1. Оголосіть static-клас — не вкладений і не generic.
  2. Додайте static-метод, перший параметр якого — this ТипЯкийРозширюємо.
  3. Підключіть простір імен класу через using там, де метод потрібен.

У C# 14 те саме пишуть блоком extension(string s), і всередині можна оголосити ще й властивість: public bool IsBlank => string.IsNullOrWhiteSpace(s);.

Типова помилка

Думати, що розширення перевизначить метод класу або побачить його приватні поля. За збігу сигнатур викличеться метод самого типу.

Як влаштовано

  1. Компілятор шукає відповідний член у самому типі.
  2. Не знайшов — шукає розширення в усіх імпортованих просторах імен.
  3. Вирішує статичний тип виразу: для object o = "x" розширення string не видно.
  4. Виклик стає звичайним статичним, тому розширення на null-отримувачі не кине NullReferenceException у точці виклику.

Що додав C# 14

Блок extension оголошують у не вкладеному не generic статичному класі. Блок може мати власні параметри типу й обмеження: extension<T>(IEnumerable<T> source) where T : IEquatable<T>.

Синтаксис Що можна оголосити
this-параметр (C# 3) методи екземпляра
extension(T x) (C# 14) методи й властивості екземпляра, статичні члени, оператори
extension(T) без імені (C# 14) лише статичні члени: IEnumerable<int>.Identity

Пастки

  • Отримувач-структура передається за значенням, зміни копії не видно. Потрібен this ref або extension(ref Point p).
  • Полів у розширень немає, тому властивість розширення завжди обчислюється.
  • Не можна virtual, override, protected та init-аксесори. Індексатори в розширеннях з’являються лише в C# 15.

Правило

Тип ваш — додайте метод до нього. Розширення — для чужих типів та інтерфейсів, в окремому просторі імен.

Під капотом

  • Метод із блоку extension перетворюється на статичний метод з атрибутом [Extension]. Параметри типу й отримувач блоку йдуть першими — як у класичного методу.
  • Для властивості P генеруються методи-реалізації вигляду get_P(receiver).
  • Сигнатура блоку кодується у вкладених службових типах. За ними компілятор та IDE відновлюють розширення зі скомпільованої збірки.

Ціна

  • Це статичний виклик: без віртуальності, легко вбудовується.
  • Розширення на інтерфейс упакує структуру-отримувача. Для структур пишуть generic-розширення з обмеженням: extension<T>(T value) where T : struct, IFoo.

Компроміси

  • Перехід бібліотеки на блоки extension не ламає споживачів: для методів IL той самий, сумісність і бінарна, і за вихідним кодом.
  • Статичні розширення й оператори додають чужим типам фабрики та +. Це потужно, але поведінка починає залежати від набору using.
  • Усі блоки одного статичного класу — спільний простір оголошень: однакові сигнатури для одного отримувача — помилка, навіть у різних блоках.

Як розбиратися з конфліктами

  • Якщо два імпортовані класи дають однакове розширення, виклик неоднозначний. Вихід — явний виклик E1.M(x), для властивості — E1.get_P(x).
  • Що саме згенеровано, видно в декомпіляторі IL (ILSpy, ildasm).

Підсумок: C# 14 розширив синтаксис, а не модель: розширення, як і раніше, статичні методи без стану.

Приклад
Console.WriteLine("one two three".WordCount()); // 3
Console.WriteLine(" ".IsBlank); // True
Console.WriteLine(string.Dash); // —
public static class StringExtensions
{
// класичний метод розширення (C# 3)
public static int WordCount(this string s) =>
s.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
// блок розширення (C# 14): s — отримувач
extension(string s)
{
public bool IsBlank => string.IsNullOrWhiteSpace(s); // властивість розширення
}
// без імені отримувача — лише статичні члени
extension(string)
{
public static string Dash => "—";
}
}
Middle+

Що таке обмеження generics, коваріантність і контраваріантність?

Section titled “Що таке обмеження generics, коваріантність і контраваріантність?”
Відповідь

Що це

  • Обмеження — вимога до параметра типу: where T : IComparable<T>.
  • Коваріантність (out) — IEnumerable<string> можна використати як IEnumerable<object>.
  • Контраваріантність (in) — Action<object> можна використати як Action<string>.

Навіщо

Без обмежень про T нічого не відомо — доступні лише члени object. Варіантність дає змогу передавати колекції й обробники без копіювання та приведень.

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

  • where T : ISomething — можна викликати методи інтерфейсу.
  • where T : new() — можна писати new T().
  • where T : class або struct — лише посилальні або лише значущі типи.

Типова помилка

Очікувати, що List<string> приведеться до List<object>. Не приведеться: інакше до списку рядків можна було б додати число.

Як влаштовано

  • Обмеження на вид типу: class, class?, struct, notnull (C# 8), unmanaged (C# 7.3), default (C# 9), allows ref struct (C# 13).
  • Обмеження на можливості: new() (пишеться останнім), базовий клас, інтерфейс, Enum і Delegate (C# 7.3), інший параметр where T : U.
  • out T може стояти лише в значеннях, що повертаються, in T — лише в параметрах.

Де це в BCL

Коваріантні (out) Контраваріантні (in)
IEnumerable<out T> Action<in T>
IReadOnlyList<out T> IComparer<in T>
Func<out TResult> IEqualityComparer<in T>

Коли що обирати

  • Метод лише читає колекцію — приймайте IEnumerable<T> або IReadOnlyList<T>: вони коваріантні.
  • Власний інтерфейс-джерело позначайте out, інтерфейс-обробник — in.

Пастки

  • List<T> та IList<T> інваріантні: T і на вході (Add), і на виході (індексатор).
  • Класи й структури варіантними не бувають: class Box<out T> не скомпілюється.
  • IEnumerable<int> до IEnumerable<object> не приводиться: варіантність лише для посилальних типів.

Правило

Приймайте найзагальніший варіантний інтерфейс, який справді потрібен, а не List<T>.

Під капотом

  • Компілятор перевіряє позиції: out T не можна прийняти параметром, in T не можна повернути. Тому варіантне приведення — те саме посилання, без копіювання й перевірок.
  • Для всіх посилальних аргументів JIT використовує одну спільну реалізацію generic-коду, для кожного значущого — окрему.

Ціна

  • Обмеження інтерфейсом плюс значущий тип дають прямий виклик без boxing (constrained call).
  • У спільному коді для посилальних типів частина операцій іде через пошук у словнику generic-контексту — трохи дорожче.
  • string[] можна присвоїти object[] із .NET 1.0. За це платять перевіркою типу під час запису в масив посилальних елементів, і JIT не завжди може її прибрати.

Компроміси

  • Варіантність є лише для посилальних типів: int в object — boxing з новим об’єктом, а не те саме посилання.
  • Коваріантність масивів зручна, але помилка ArrayTypeMismatchException проявиться лише під час виконання.
  • where T : INumber<T> (static abstract, C# 11, .NET 7) дає T.Zero і a + b для всіх числових типів. Ціна — складні сигнатури, які важче читати.

Як перевірити в продакшені

  • Boxing у generic-коді видно в BenchmarkDotNet з [MemoryDiagnoser] і в профайлері алокацій.
  • Який машинний код згенеровано під конкретний аргумент, показує [DisassemblyDiagnoser].

Підсумок: варіантність безпечна, бо перевіряється під час компіляції; масиви — виняток, за який платять під час виконання.

Приклад
IEnumerable<string> names = new List<string> { "a", "b" };
IEnumerable<object> objects = names; // коваріантність: out T
Action<object> printAny = o => Console.WriteLine(o);
Action<string> printString = printAny; // контраваріантність: in T
printString("ok"); // ok
// List<object> list = new List<string>(); // помилка: List<T> інваріантний
object[] array = new string[1]; // коваріантність масивів
// array[0] = 42; // компілюється, але ArrayTypeMismatchException
Console.WriteLine(Max(3, 7)); // 7
static T Max<T>(T a, T b) where T : IComparable<T> =>
a.CompareTo(b) >= 0 ? a : b; // обмеження дає CompareTo
Middle+

Чим делегат відрізняється від події і що таке замикання?

Section titled “Чим делегат відрізняється від події і що таке замикання?”
Відповідь

Що це

  • Делегат — тип для посилання на метод. Готові: Func<...> повертає значення, Action<...> — ні.
  • Подія — делегат з обмеженим доступом: public event EventHandler? Changed;.
  • Замикання — лямбда, яка використовує локальні змінні зовнішнього методу.

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

  • У делегат кладуть метод або лямбду: Func<int, int> square = x => x * x;.
  • Підписка — obj.Changed += Handler;, відписка — -=.
  • Викликати подію може лише сам клас: Changed?.Invoke(this, EventArgs.Empty).

Типова помилка

Підписатися на подію довгоживучого об’єкта й не відписатися. Видавець тримає посилання на підписника, і того не збере збирач сміття.

Як влаштовано

  • += викликає Delegate.Combine і створює новий незмінний делегат зі списком викликів.
  • Обробники викликаються по черзі. Виняток перериває ланцюжок, у Func повертається результат останнього. Потрібно інакше — перебирайте GetInvocationList().
  • Лямбда захоплює змінну, а не значення: зміни після створення лямбди їй видно.

Коли що обирати

Публічне поле-делегат Подія
Підписатися й відписатися ззовні так так
Викликати або обнулити ззовні так ні
Потокобезпечні add/remove ні так, їх генерує компілятор

Пастки

  • Цикл for. Змінна i одна на весь цикл, і після нього всі лямбди бачать останнє значення. Лікується локальною копією всередині тіла.
  • Цикл foreach. Із C# 5 змінна своя на кожну ітерацію, проблеми немає.
  • Відписка лямбдою. x.Changed -= (s, e) => ... нічого не відпише: це новий делегат, не рівний підписаному.

Правило

Викликайте подію через Changed?.Invoke(...): поле читається один раз, і відписка з іншого потоку не дасть NullReferenceException.

Під капотом

  • Захоплені змінні стають полями згенерованого класу, лямбда — його методом.
  • Об’єкт створюється під час входу в область видимості змінної, а не під час створення лямбди. Алокація станеться, навіть якщо гілка з лямбдою не виконається.
  • Лямбди однієї області ділять один display class. Коротка лямбда може продовжити життя важкого об’єкта, захопленого сусідньою.

Ціна

  • Лямбда без захоплення кешується в статичному полі й створюється один раз.
  • Із захопленням — новий display class і новий делегат на кожен прохід.
  • У гарячому шляху допомагають static-лямбди (C# 9), перевантаження з явним станом (GetOrAdd(key, factory, arg) у ConcurrentDictionary) і статичні локальні функції.

Компроміси

  • Delegate.Target — сильне посилання на підписника. Відписка в Dispose надійна, але потребує дисципліни.
  • Альтернативи — слабкі події (WeakEventManager у WPF) і підписка, що повертає IDisposable, як в IObservable<T>.
  • Делегат незмінний, тому виклик працює зі знімком підписників. Обробник, що відписався з іншого потоку, може бути викликаний ще раз.

Як перевірити в продакшені

  • Зростання пам’яті видно в dotnet-counters, склад купи — у dotnet-gcdump.
  • dotnet-dump з командою gcroot покаже ланцюжок від кореня до підписника — у ньому буде делегат видавця.
  • Алокації замикань видно в BenchmarkDotNet з [MemoryDiagnoser].

Підсумок: замикання — це алокація, а підписка — сильне посилання; обидва продовжують життя об’єктів.

Приклад
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
actions.Add(() => Console.Write(i)); // одна змінна i на весь цикл
actions.ForEach(a => a()); // 333
Console.WriteLine();
actions.Clear();
foreach (var n in new[] { 0, 1, 2 })
actions.Add(() => Console.Write(n)); // із C# 5 — нова n на кожній ітерації
actions.ForEach(a => a()); // 012
Console.WriteLine();
var counter = new Counter();
counter.Changed += (_, _) => Console.WriteLine("changed");
counter.Increment(); // changed
// counter.Changed = null; // помилка: ззовні лише += і -=
class Counter
{
public event EventHandler? Changed;
public void Increment() => Changed?.Invoke(this, EventArgs.Empty);
}
Middle+

Як працює рівність у C#: ==, Equals, ReferenceEquals, records?

Section titled “Як працює рівність у C#: ==, Equals, ReferenceEquals, records?”
Відповідь

Що це

Спосіб Що перевіряє
ReferenceEquals(a, b) чи це той самий об’єкт; перевизначити не можна
a.Equals(b) чи рівні за змістом; у класів за замовчуванням — посилання
a == b оператор; у класів без перевантаження — посилання

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

  • string, int, DateTime порівнюють значення і через ==, і через Equals.
  • record автоматично порівнює всі властивості: new Point(1, 2) == new Point(1, 2) дає true.
  • У власної структури Equals порівнює поля, а == без перевантаження не скомпілюється.

Типова помилка

Порівнювати об’єкти власного класу через == і очікувати порівняння полів.

Як влаштовано

  • == обирається за статичними типами операндів: object a = "aaa", b = new string('a', 3); дасть a == b — false, а a.Equals(b) — true.
  • Equals — віртуальний метод: навіть через змінну типу object виклик потрапить у реалізацію реального типу.
  • Records генерують Equals(R?), GetHashCode, ==, !=, IEquatable<R> та EqualityContract. Поля порівнюються через EqualityComparer<T>.Default, записи різних типів не рівні.

Як реалізувати вручну

  1. IEquatable<T>.Equals(T) — основне порівняння, без приведення й без boxing для структур.
  2. override Equals(object) — делегує йому.
  3. override GetHashCode() — за тими самими полями: HashCode.Combine(X, Y).
  4. == і != — узгоджено з Equals.

Пастки

  • Контракт: якщо a.Equals(b), то хеші рівні. Порушите — об’єкт «загубиться» в Dictionary.
  • Equals не кидає винятків і повертає false для null.
  • Для T a, T b без обмежень a == b не компілюється — потрібен EqualityComparer<T>.Default.Equals(a, b).

Правило

Перевірку на null пишіть як x is null: вона не викликає перевантажений ==.

Під капотом

  • Dictionary і HashSet за хешем обирають кошик, потім викликають Equals.
  • Змінили поле, що бере участь у хеші, в об’єкта всередині HashSet — він «зник»: Contains поверне false, видалити його не можна.
  • EqualityComparer<T>.Default обирає IEquatable<T>, якщо тип його реалізує, а JIT девіртуалізує цей виклик.

Ціна

  • Equals(object) у структури упаковує аргумент. IEquatable<T> прибирає boxing.
  • Рядки порівнюйте з явним StringComparison: Ordinal швидший за культурне порівняння й не залежить від локалі.

Компроміси

  • Успадкування ламає симетричність: point.Equals(colorPoint) може повернути true, а зворотний виклик — false. Рішення — sealed-клас або порівняння точного типу (GetType(), EqualityContract).
  • Records порівнюють поверхнево: масив або List<T> у властивості — за посиланням. Потрібні власні Equals і GetHashCode.
  • Records не підходять для сутностей EF Core: той спирається на посилальну рівність.
  • double.NaN == double.NaN — false за IEEE 754, але double.NaN.Equals(double.NaN) — true, інакше NaN не можна було б знайти в словнику.

Як перевірити в продакшені

  • Симптом порушеного контракту — дублікати в HashSet або промахи TryGetValue для «того самого» ключа. Ловиться юніт-тестом: рівні об’єкти — рівні хеші.
  • Поганий хеш із безліччю колізій перетворює словник на лінійний пошук — у dotnet-trace це видно за часом в Equals.

Підсумок: рівність — контракт для колекцій: незмінні ключі, узгоджені Equals і GetHashCode, інше правило — через IEqualityComparer<T>.

Приклад
object a = "aaa";
object b = new string('a', 3);
Console.WriteLine(a == b); // False: оператор для object — посилання
Console.WriteLine(a.Equals(b)); // True: віртуальний string.Equals
Console.WriteLine(object.ReferenceEquals(a, b)); // False
Console.WriteLine(new Point(1, 2) == new Point(1, 2)); // True: record порівнює значення
Console.WriteLine(new Tags(["x"]) == new Tags(["x"])); // False: масив — за посиланням
public record Point(int X, int Y);
public record Tags(string[] Items);
public sealed class Money(decimal amount, string currency) : IEquatable<Money>
{
public decimal Amount { get; } = amount;
public string Currency { get; } = currency;
public bool Equals(Money? other) =>
other is not null && Amount == other.Amount && Currency == other.Currency;
public override bool Equals(object? obj) => Equals(obj as Money);
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
}
Middle+

Що вміє pattern matching у сучасному C#?

Section titled “Що вміє pattern matching у сучасному C#?”
Відповідь

Що це

Шаблон — умова на форму значення. Якщо значення підходить, шаблон може одразу оголосити змінну потрібного типу.

Навіщо

Без шаблонів пишуть as, перевірку на null і приведення. Шаблон робить це одним виразом.

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

  • Тип: if (shape is Circle c) — перевірка й приведення одразу.
  • null: if (x is null), if (x is not null).
  • Діапазон: if (age is >= 18 and < 65).
  • switch-вираз: ліворуч шаблон, праворуч результат, гілки через кому. _ означає «все інше».

Типова помилка

Забути гілку _. Компілятор попередить, а на неврахованому значенні програма впаде з винятком.

Як влаштовано

Шаблон Приклад Версія
Тип з оголошенням Circle c C# 7
Властивості й позиційний { Length: > 0 }, (var x, 0) C# 8
Реляційний і логічний >= 0 and < 10, not null C# 9
Вкладені властивості { Address.City: "Kyiv" } C# 10
Список і slice [1, .., > 5] C# 11

Позиційний шаблон викликає Deconstruct. List pattern працює з масивами, List<T>, Span<T> і всім, що має Length або Count та індексатор.

Коли що обирати

  • Потрібне значення — switch-вираз. Потрібні дії в гілках — оператор switch із шаблонами в case.
  • Кілька входів — кортеж: (state, command) switch { (Open, Close) => Closed, ... }.

Пастки

  • Немає гілки для якогось значення — попередження CS8509, під час виконання — SwitchExpressionException.
  • Для enum потрібен _: у змінній може опинитися будь-яке число.
  • List patterns попереджень про вичерпність не дають.
  • Гілка, повністю перекрита попередніми, — помилка компіляції.

Правило

Перевіряйте null через is null та is not null: шаблон не викликає користувацький operator ==.

Під капотом

  • Усі гілки зводяться в дерево рішень (decision DAG). Спільні перевірки — тип, Length, значення властивостей — виконуються один раз.
  • Порядок обчислення підшаблонів не гарантовано. Властивості в шаблонах мають бути дешевими й без побічних ефектів.

Ціна

  • switch за константами компілятор перетворює на таблиці переходів або дерево порівнянь — зазвичай не повільніше за ручні if.
  • Перевірка типу — звичайна інструкція isinst, для запечатаного типу це порівняння вказівника.

Компроміси

  • Ієрархія класів відкрита: компілятор не знає всіх нащадків, тому _ потрібен. У C# 15 (.NET 11) з’являються union-типи — для них вичерпність перевіряється без _; closed-класи з тією ж метою поки лише запропоновані.
  • Pattern matching — функціональний стиль: дані окремо, операції в switch. Додати операцію легко, додати варіант — треба пройти по всіх switch. З поліморфізмом навпаки (expression problem).
  • Вкладені property patterns у чотири рівні читаються гірше за пару if. Складну умову виносьте в when або в метод.

Як перевірити в продакшені

  • Попередження CS8509 варто зробити помилкою (WarningsAsErrors): тоді новий варіант не пройде збірку без нової гілки.
  • SwitchExpressionException у логах означає невраховане значення; яке саме, покаже властивість UnmatchedValue.

Підсумок: pattern matching — для даних із закритим набором варіантів, поліморфізм — для поведінки, яка розширюватиметься.

Приклад
Console.WriteLine(Classify(5)); // digit
Console.WriteLine(Classify(new Order("paid", 5000, ["a", "b"]))); // large paid order
Console.WriteLine(Classify(new[] { 1, 2, 3 })); // array 1..3
static string Classify(object? input) => input switch
{
null => "null",
int n when n < 0 => "negative",
int and >= 0 and <= 9 => "digit",
string { Length: 0 } => "empty string",
Order { Status: "paid", Total: > 1000m } => "large paid order",
Order { Items: [var single] } => $"single item: {single}",
int[] and [var first, .., var last] => $"array {first}..{last}",
_ => "something else",
};
public record Order(string Status, decimal Total, string[] Items);