Перейти к содержимому

Язык C#

Вопросы о самом языке, которые задают на любом уровне: наследование и интерфейсы, исключения, расширения, generics, делегаты, равенство и pattern matching. Мало знать синтаксис — нужно объяснить, что компилятор и рантайм делают с вашим кодом.

Перед этой темой: Типы и память.

Junior+

Чем интерфейс отличается от абстрактного класса?

Заголовок раздела «Чем интерфейс отличается от абстрактного класса?»
Ответ

Что это

  • Интерфейс — набор членов, которые тип обещает реализовать: 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 — запрет: на классе — наследоваться, на 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+
Ответ

Что это

Исключение — объект-наследник 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?

Заголовок раздела «Как работают методы расширения и что изменили 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, ковариантность и контравариантность?

Заголовок раздела «Что такое ограничения 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+

Чем делегат отличается от события и что такое замыкание?

Заголовок раздела «Чем делегат отличается от события и что такое замыкание?»
Ответ

Что это

  • Делегат — тип для ссылки на метод. Готовые: 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?

Заголовок раздела «Как работает равенство в 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+
Ответ

Что это

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

Зачем

Без шаблонов пишут 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);