Мова C#
Питання про саму мову, які ставлять на будь-якому рівні: успадкування й інтерфейси, винятки, розширення, generics, делегати, рівність і pattern matching. Мало знати синтаксис — треба пояснити, що компілятор і рантайм роблять із вашим кодом.
Перед цією темою: Типи й пам’ять.
Чим інтерфейс відрізняється від абстрактного класу?
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;}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 })); // 6Console.WriteLine(Sum(new[] { 1.5, 2.5 })); // 4- Чи можна викликати default-метод інтерфейсу через змінну класу? — Ні, лише через посилання типу інтерфейсу.
- Що буде за ромба з інтерфейсів із реалізаціями за замовчуванням? — Помилка компіляції, доки клас не дасть найспецифічнішу реалізацію.
- Чи може абстрактний клас не мати абстрактних методів? — Так,
abstractпросто забороняє створювати екземпляр. - Навіщо явна реалізація інтерфейсу? — Сховати член з API класу або розвести однакові сигнатури двох інтерфейсів.
- Чому
INumber<T>не можна було зробити до C# 11? — Інтерфейси не вміли вимагати статичні члени й оператори.
Чим відрізняються 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 у базовому класі й написати в нащадку метод із тим самим ім’ям. Вийде приховування, а не перевизначення, і компілятор видасть попередження.
Як влаштовано
- Компілятор бачить виклик
b.M()і знаходить метод за статичним типомb. - Якщо метод віртуальний, генерується віртуальний виклик: адресу береться з таблиці методів об’єкта під час виконання.
- Якщо метод невіртуальний або прихований через
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"; // нижче перевизначити не можна}- Що виведе виклик прихованого методу через посилання на базовий клас? — Базову версію:
newне бере участі у віртуальному виклику. - Чому небезпечно викликати віртуальний метод у конструкторі? — Спрацює
overrideнащадка до ініціалізації його полів. - Чи можна перевизначити
staticметод? — Ні, статичні методи не віртуальні; можна лише приховати черезnew. - Навіщо запечатувати класи, якщо від них ніхто не збирається успадковуватися? — Явний контракт плюс девіртуалізація й дешеві перевірки типу.
- Що таке covariant return types? — Із C# 9
overrideможе повернути похідний тип від типу базового методу.
Як правильно обробляти винятки?
Section titled “Як правильно обробляти винятки?”Що це
Виняток — об’єкт-нащадок Exception, який описує помилку. Після throw виконання переривається, і рантайм шукає найближчий відповідний catch угору стеком викликів.
Навіщо
Виняток не можна випадково проігнорувати, як код помилки: якщо його ніхто не перехопив, процес завершиться.
Як користуватися
try— код, який може впасти.catch (ТипВинятку ex)— обробка. Блоки пишуть від часткового до загального:FileNotFoundExceptionраніше заIOException.finallyвиконується за будь-яких умов — туди кладуть звільнення ресурсів. ДляIDisposableпростішеusing.throw;усерединіcatchпрокидає той самий виняток далі.
Типова помилка
throw ex; замість throw;. Стек почнеться з поточного методу, і місце помилки загубиться.
Як влаштовано
throwстворює виняток і запам’ятовує стек.- Рантайм шукає вгору стеком
catchвідповідного типу, у якого фільтрwhen, якщо він є, повернувtrue. - На шляху до знайденого обробника виконуються всі
finally. - Якщо обробника немає, процес завершується.
Коли що обирати
| Ситуація | Що робити |
|---|---|
| Можна повторити або підставити запасний варіант | 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) { }}- Чим
throw;відрізняється відthrow ex;? —throw ex;перезаписуєStackTrace, місце помилки губиться. - Коли виконується фільтр
whenі чому це важливо? — До розмотування стека: вихідний стек цілий для дампа й налагоджувача. - Чи виконається
finally, якщо вtryєreturn? — Так; не виконається лише при падінні процесу,FailFastабо переповненні стека. - Як прокинути виняток з іншого потоку з вихідним стеком? —
ExceptionDispatchInfo.Capture(ex).Throw(). - Чому не варто керувати логікою через винятки? — Викидання дороге, а потік керування стає неочевидним.
Як працюють методи розширення і що змінили extension members у C# 14?
Section titled “Як працюють методи розширення і що змінили extension members у C# 14?”Що це
Статичний метод, який викликають через крапку, як метод екземпляра. Компілятор переписує s.WordCount() на StringExtensions.WordCount(s).
Навіщо
Додати зручний метод типу, який ви не можете змінити: string, інтерфейсу з BCL, класу сторонньої бібліотеки.
Як користуватися
- Оголосіть
static-клас — не вкладений і не generic. - Додайте
static-метод, перший параметр якого —this ТипЯкийРозширюємо. - Підключіть простір імен класу через
usingтам, де метод потрібен.
У C# 14 те саме пишуть блоком extension(string s), і всередині можна оголосити ще й властивість: public bool IsBlank => string.IsNullOrWhiteSpace(s);.
Типова помилка
Думати, що розширення перевизначить метод класу або побачить його приватні поля. За збігу сигнатур викличеться метод самого типу.
Як влаштовано
- Компілятор шукає відповідний член у самому типі.
- Не знайшов — шукає розширення в усіх імпортованих просторах імен.
- Вирішує статичний тип виразу: для
object o = "x"розширенняstringне видно. - Виклик стає звичайним статичним, тому розширення на
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()); // 3Console.WriteLine(" ".IsBlank); // TrueConsole.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 => "—"; }}- Що викличеться, якщо тип має метод із тією самою сигнатурою, що й розширення? — Метод типу: розширення шукаються лише після власних членів.
- Чи впаде виклик розширення на
null? — Сам виклик ні, це статичний метод; впаде, якщо метод звернеться до отримувача. - Чи можна додати розширенням поле або автовластивість зі станом? — Ні, розширення не мають сховища; потрібен
ConditionalWeakTableабо обгортка. - Чи ламає перехід на блоки
extensionбінарну сумісність? — Ні, для методів генерується той самий IL. - Як викликати статичне розширення? — Через ім’я типу, який розширюють:
IEnumerable<int>.Identity.
Що таке обмеження 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 TprintString("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- Чому
IEnumerable<int>не приводиться доIEnumerable<object>? — Варіантність лише для посилальних типів:intдовелося б упакувати. - Чому
IList<T>не можна зробити коваріантним? —Tє й у вхідних позиціях (Add, сетер індексатора). - Де в BCL контраваріантність? —
Action<in T>,IComparer<in T>,IEqualityComparer<in T>, вхідні параметриFunc. - Що дає обмеження
unmanaged? — Гарантію, що в типі немає посилань: можна брати вказівник,sizeof, працювати з пам’яттю напряму. - Навіщо
allows ref struct? — Із C# 13 дозволяє передаватиSpan<T>та іншіref structяк аргумент типу.
Чим делегат відрізняється від події і що таке замикання?
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()); // 333Console.WriteLine();
actions.Clear();foreach (var n in new[] { 0, 1, 2 }) actions.Add(() => Console.Write(n)); // із C# 5 — нова n на кожній ітераціїactions.ForEach(a => a()); // 012Console.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);}- Що поверне multicast-делегат
Func<int>із трьох методів? — Результат останнього; решта загубиться. - Чому
Changed?.Invoke()безпечніше, ніж перевірка наnullі окремий виклик? — Поле читається один раз, відписка між перевіркою й викликом не завадить. - Де зберігаються захоплені лямбдою локальні змінні? — У полях об’єкта display class у купі.
- Що забороняє модифікатор
staticу лямбди? — Захоплення локальних змінних іthis; помилка компіляції замість прихованої алокації. - Як знайти витік через підписку на подію? — Дамп пам’яті й
gcrootдо підписника: у ланцюжку буде делегат видавця.
Як працює рівність у 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, записи різних типів не рівні.
Як реалізувати вручну
IEquatable<T>.Equals(T)— основне порівняння, без приведення й без boxing для структур.override Equals(object)— делегує йому.override GetHashCode()— за тими самими полями:HashCode.Combine(X, Y).==і!=— узгоджено з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.EqualsConsole.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);}- Чому, перевизначаючи
Equals, треба перевизначатиGetHashCode? — Інакше рівні об’єкти потраплять у різні кошики хеш-таблиці. - Чим
x is nullкраще заx == null? — Не викликає користувацький оператор==. - Чи рівні два record з однаковими масивами всередині? — Ні, масив порівнюється за посиланням.
- Чому змінюваний об’єкт небезпечний як ключ словника? — Хеш зміниться, і об’єкт не можна буде знайти чи видалити.
- Навіщо потрібен
EqualityContract? — Щоб записи різних типів в ієрархії не вважалися рівними.
Що вміє 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)); // digitConsole.WriteLine(Classify(new Order("paid", 5000, ["a", "b"]))); // large paid orderConsole.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);- Що буде, якщо жодна гілка
switch-виразу не підійшла? —SwitchExpressionExceptionпід час виконання. - Чому для
enumпотрібна гілка_, навіть якщо перелічено всі значення? — Вenumможна покласти будь-яке число базового типу. - Чим
is not nullкраще за!= null? — Не викликає перевантажений оператор і читається однозначно. - Як зіставити перший і останній елементи масиву? — List pattern
[var first, .., var last](C# 11). - Коли pattern matching гірший за поліморфізм? — Коли часто додаються нові варіанти: доведеться правити кожен
switch.