Язык C#
Вопросы о самом языке, которые задают на любом уровне: наследование и интерфейсы, исключения, расширения, generics, делегаты, равенство и pattern matching. Мало знать синтаксис — нужно объяснить, что компилятор и рантайм делают с вашим кодом.
Перед этой темой: Типы и память.
Чем интерфейс отличается от абстрактного класса?
Заголовок раздела «Чем интерфейс отличается от абстрактного класса?»Что это
- Интерфейс — набор членов, которые тип обещает реализовать:
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?
Заголовок раздела «Чем отличаются 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может вернуть производный тип от типа базового метода.
Как правильно обрабатывать исключения?
Заголовок раздела «Как правильно обрабатывать исключения?»Что это
Исключение — объект-наследник 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?
Заголовок раздела «Как работают методы расширения и что изменили 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, ковариантность и контравариантность?
Заголовок раздела «Что такое ограничения 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как аргумент типа.
Чем делегат отличается от события и что такое замыкание?
Заголовок раздела «Чем делегат отличается от события и что такое замыкание?»Что это
- Делегат — тип для ссылки на метод. Готовые:
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?
Заголовок раздела «Как работает равенство в 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#?
Заголовок раздела «Что умеет 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.