C# / .NET Типы и абстракции C#: наследование, интерфейсы, дженерики, делегаты и события
0%

Типы и абстракции C#: наследование, интерфейсы, дженерики, делегаты и события

Типы и абстракции C#: от наследования до дженериков

В статье «Основы C#» мы разбирали типы как контейнеры данных: где они лежат и как копируются. Эта статья — про типы как абстракции: как из классов, интерфейсов, дженериков и делегатов собирают конструкцию, которую можно расширять, подменять в тестах и читать через полгода. Вопрос всей статьи один: как переиспользовать поведение, не привязав себя к конкретной реализации.

Три способа переиспользовать поведение

Практическое правило: интерфейс плюс композиция — выбор по умолчанию, наследование — исключение. Наследование связывает вас с чужим классом навсегда: его защищённые поля, порядок вызовов и побочные эффекты становятся частью вашего контракта. Композиция связывает только с публичным API вложенного объекта.

Классы: наследование и полиморфизм

C# поддерживает одиночное наследование реализации (один базовый класс) и множественную реализацию интерфейсов. Ключевые модификаторы:

public abstract class PaymentMethod          // abstract — нельзя создать, только унаследовать
{
    // Абстрактный член ОБЯЗАН быть переопределён в наследнике
    public abstract Task<Receipt> ChargeAsync(Money amount, CancellationToken ct);

    // virtual — есть реализация по умолчанию, наследник МОЖЕТ её заменить
    public virtual bool Supports(Money amount) => amount.Amount > 0;
}

public sealed class CardPayment(IPaymentGateway gateway) : PaymentMethod
{
    // override — настоящее переопределение, работает полиморфизм
    public override Task<Receipt> ChargeAsync(Money amount, CancellationToken ct) =>
        gateway.ChargeCardAsync(amount, ct);

    public override bool Supports(Money amount) =>
        base.Supports(amount) && amount.Currency is "RUB" or "USD";   // base — вызов родителя
}

Что здесь важно понять:

  • virtual/override — это позднее связывание. Вызов идёт через таблицу виртуальных методов: рантайм смотрит на фактический тип объекта, а не на тип переменной.
  • sealed на классе запрещает наследование — не «жадность», а дизайн-решение: класс, не спроектированный под наследование, не должен наследоваться. Бонусом JIT девиртуализирует вызовы sealed-типов.
  • new (сокрытие метода) — не полиморфизм: какой метод вызовется, определяется типом переменной, а не объектом. Если B : A объявляет public new void Hi(), то для A x = new B(); x.Hi(); выполнится версия из A. Почти всегда это баг, а не намерение.

Ловушка: виртуальный вызов в конструкторе

public abstract class Base
{
    protected Base() => Init();                        // ❌ virtual из конструктора
    protected virtual void Init() { }
}
public sealed class Derived : Base
{
    private readonly List<int> _items = new();
    protected override void Init() => _items.Add(1);   // 💥 NullReferenceException
}

Порядок инициализации: сначала конструктор базового класса, и только потом — инициализаторы полей наследника. То есть _items в момент вызова Init() ещё null. Правило: не вызывайте virtual-методы из конструкторов.

Иерархия глазами диаграммы

Обратите внимание на LoggingGatewayDecorator: он реализует тот же интерфейс и содержит другую реализацию внутри. Это декоратор — способ добавить поведение (логи, кэш, ретраи) без единой строчки наследования; каталог таких приёмов — в треке паттернов.

Интерфейсы: контракт без реализации

Интерфейс описывает, что объект умеет, ничего не говоря о том, как. В .NET это основа тестируемости и DI: слой Application объявляет порт IOrderRepository, Infrastructure его реализует, а связывает их контейнер (см. «Архитектура прод-приложений»). Три возможности интерфейсов, о которых часто не знают:

1. Явная реализация. Метод «прячется» и доступен только через тип интерфейса. Нужна, когда два интерфейса требуют метод с одинаковой сигнатурой, или когда реализация — техническая деталь, которую не стоит показывать в публичном API класса.

public sealed class Cache : IDisposable, IAsyncDisposable
{
    public void Dispose() { /* видно на типе Cache */ }

    // Явно: вызывается только как ((IAsyncDisposable)cache).DisposeAsync()
    ValueTask IAsyncDisposable.DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
}

2. Реализация по умолчанию (DIM, C# 8). Интерфейс может нести тело метода — это инструмент эволюции библиотек: добавить метод в опубликованный интерфейс, не сломав существующие реализации. Не используйте DIM как «множественное наследование».

public interface IValidator<T>
{
    ValidationResult Validate(T value);
    bool IsValid(T value) => Validate(value).IsValid;   // старые реализации не сломаются
}

3. Интерфейс на структуре = boxing. Если struct реализует интерфейс и вы кладёте её в переменную типа интерфейса, значение упаковывается в кучу — скрытая аллокация в горячем коде. Спасение — дженерик с ограничением: JIT сгенерирует специализацию под конкретную структуру, и упаковки не будет.

static bool Greater(IComparable<int> a, int b) => a.CompareTo(b) > 0;   // ❌ boxing
static bool Greater<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) > 0;  // ✅

Про размер: держите интерфейсы узкими. Толстый IRepository с двадцатью методами заставляет каждый тест-дублёр реализовывать двадцать методов ради двух — это буква I в SOLID.

Дженерики: один код для многих типов

Дженерики решают задачу «одинаковый алгоритм, разные типы» без потери типобезопасности и без boxing: List<int> хранит числа прямо в массиве, а не упакованные объекты.

Ограничения (constraints)

Внутри T вы можете вызывать только то, что гарантировано ограничениями. Полный набор:

Ограничение Смысл
where T : class ссылочный тип
where T : struct non-nullable value-тип
where T : notnull нельзя nullable-контекст
where T : unmanaged value-тип без ссылок внутри (для Span, интеропа)
where T : new() есть публичный конструктор без параметров
where T : IComparable<T> реализует интерфейс
where T : BaseClass наследуется от класса или другого параметра типа
public sealed class Repository<TEntity, TKey>(AppDbContext db)
    where TEntity : class, IEntity<TKey>     // класс, реализующий IEntity
    where TKey : struct, IEquatable<TKey>    // ключ — сравнимый value-тип
{
    public Task<TEntity?> FindAsync(TKey id, CancellationToken ct) =>
        db.Set<TEntity>().FirstOrDefaultAsync(e => e.Id.Equals(id), ct);
}

Как CLR исполняет дженерики: почему это быстро

Здесь принципиальное отличие от JVM. В Java дженерики стираются (type erasure): List<Integer> в байт-коде — просто List с приведениями и упаковкой (см. «Коллекции и дженерики» в треке Java). В .NET дженерики реифицированы: информация о типах живёт в метаданных, и JIT генерирует отдельный машинный код для каждого value-типа (List<int> и List<double> — разный код, нулевой boxing) и один общий код на все ссылочные типы (List<string> и List<Order> делят реализацию, потому что все ссылки одного размера).

Практические следствия: typeof(T) работает в рантайме, можно писать new T(), коллекции value-типов не аллоцируют, а Dictionary<int, int> реально хранит числа, а не объекты.

Вариантность: out и in

Вариантность отвечает на вопрос: если Dog наследник Animal, является ли IEnumerable<Dog> подтипом IEnumerable<Animal>?

  • Ковариантность (out T) — тип только «на выходе»: IEnumerable<out T>, последовательность собак это и последовательность животных.
  • Контравариантность (in T) — тип только «на входе»: IComparer<in T>, Action<in T>, сравниватель животных умеет сравнить и собак.
  • Инвариантность — тип и принимается, и возвращается (List<T>): подстановка небезопасна и запрещена компилятором.
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings;      // ✅ ковариантность: out T

Action<object> printAny = o => Console.WriteLine(o);
Action<string> printStr = printAny;         // ✅ контравариантность: in T

object[] arr = new string[2];               // ⚠️ массивы ковариантны — компилятор пропустит
arr[0] = 42;                                // 💥 ArrayTypeMismatchException в рантайме

Ковариантность массивов — историческая ошибка, унаследованная ради совместимости: проверка типа уехала из компиляции в рантайм. Поэтому для API предпочитайте IReadOnlyList<T>/IEnumerable<T> вместо T[].

Статические абстрактные члены и generic math (C# 11)

Долгие годы нельзя было написать обобщённый Sum<T>: у T не было гарантии оператора +. Теперь интерфейс может требовать статические члены, а BCL раздаёт числовым типам интерфейс INumber<T>:

using System.Numerics;

// Работает для int, long, double, decimal, BigInteger — для всего, что реализует INumber<T>
public static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;                    // статический член, объявленный в интерфейсе
    foreach (var v in values) total += v;
    return total;
}

Тем же механизмом объявляют статические фабрики (static abstract T Parse(...) в IParsable<TSelf>) — это способ выразить «конструктор в интерфейсе», которого раньше не хватало для парсинга и десериализации обобщённого кода.

Делегаты и лямбды: поведение как значение

Делегат — типобезопасный указатель на метод. В современном коде почти всегда берут встроенные: Action (ничего не возвращает), Func (возвращает), Predicate<T>.

// Стратегия как параметр — вместо иерархии классов
public decimal ApplyPricing(Order order, Func<Order, decimal> strategy) => strategy(order);

var total = ApplyPricing(order, o => o.Items.Sum(i => i.Price));   // лямбда
var withVat = ApplyPricing(order, PricingRules.WithVat);           // группа методов

Именно на делегатах построен LINQ: Where(Func<T, bool>), Select(Func<T, TResult>).

Замыкания: где прячутся аллокации и баги

Лямбда, использующая переменную из внешней области, захватывает её: компилятор создаёт скрытый класс-замыкание, и переменная переезжает в кучу.

int threshold = 100;
Func<Order, bool> isBig = o => o.Total > threshold;   // threshold захвачен: аллокация класса

// ✅ Если захват не нужен — пометьте лямбду static: компилятор запретит захват
Func<Order, bool> isPositive = static o => o.Total > 0;   // кэшируется, аллокации нет

Классическая ловушка — захват переменной цикла:

var actions = new List<Action>();
for (int i = 0; i < 3; i++)
    actions.Add(() => Console.Write(i));      // ❌ захвачена ОДНА переменная i
foreach (var a in actions) a();               // напечатает 333 вместо 012

for (int i = 0; i < 3; i++) { int copy = i; actions.Add(() => Console.Write(copy)); }  // ✅

В foreach эту проблему исправили ещё в C# 5 (переменная цикла стала новой на каждой итерации), а в обычном for она живёт до сих пор — по стандарту языка.

События: издатель и подписчики

event — синтаксический сахар над делегатом-мультикастом с защитой: снаружи можно только подписаться (+=) и отписаться (-=), но нельзя вызвать или обнулить.

public sealed class OrderProcessor
{
    // Канонический паттерн: EventHandler<TArgs>
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    public void Place(Order order)
    {
        Save(order);
        // ?.Invoke — потокобезопасный вызов: копия ссылки на момент проверки
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(order.Id));
    }
}

public sealed class OrderPlacedEventArgs(int orderId) : EventArgs
{ public int OrderId { get; } = orderId; }

Три вещи, на которых горят:

  1. Утечка памяти через подписку. Издатель держит ссылку на подписчика: если издатель живёт долго (singleton), а подписчик короткий, тот никогда не будет собран GC. Всегда отписывайтесь в Dispose.
  2. Исключение в одном обработчике прерывает вызов остальных — мультикаст выполняется последовательно. Нужна независимость — оборачивайте в try/catch.
  3. События синхронны и не дружат с async. async void-обработчик — единственное место, где async void легален, но исключение из него поймать негде (см. «Конкурентность»). Для асинхронных потоков берите Channel<T> или IAsyncEnumerable<T>, а между сервисами — брокер сообщений.

Внутри домена вместо .NET-событий обычно моделируют доменные события как записи и публикуют их явно — так их можно сохранить, повторить и протестировать.

Итераторы: yield return и ленивость

yield превращает метод в машину состояний — ровно как async, только для последовательностей: компилятор генерирует класс-перечислитель, который «замораживается» между элементами.

public static IEnumerable<int> Fibonacci(int count)
{
    int a = 0, b = 1;
    for (int i = 0; i < count; i++)
    {
        yield return a;                 // отдали элемент и заморозились здесь
        (a, b) = (b, a + b);            // продолжим отсюда на следующем MoveNext()
    }
}

var firstFive = Fibonacci(1_000_000).Take(5).ToList();   // посчитаются ровно 5 чисел

Из этой схемы следуют две неочевидные вещи. Первая: проверки аргументов выполняются лениво. Тело метода не запускается до первого MoveNext(), поэтому ArgumentException вылетит не при вызове, а при перечислении — иногда в совсем другом месте кода. Лечится разделением на жадную обёртку и ленивый итератор:

public static IEnumerable<int> First(this IEnumerable<int> src, int count)
{
    ArgumentOutOfRangeException.ThrowIfNegative(count);   // проверка — сразу, жадно
    return Iterator(src, count);                          // сам итератор — ленивый

    static IEnumerable<int> Iterator(IEnumerable<int> src, int count)
    {
        foreach (var x in src)
        {
            if (count-- <= 0) yield break;
            yield return x;
        }
    }
}

Вторая: Dispose вызывается при досрочном выходе. foreach оборачивает перечисление в try/finally, поэтому finally внутри итератора отработает даже при break — это делает безопасным чтение файлов через yield. Асинхронный аналог — IAsyncEnumerable<T> с await foreach из «Конкурентности».

Равенство, хеш-код и сравнение

Три контракта, которые нужно знать наизусть, потому что их нарушение даёт тихие, трудноуловимые баги.

public sealed class Sku(string code) : IEquatable<Sku>, IComparable<Sku>
{
    public string Code { get; } = code;          // ⚠️ ТОЛЬКО неизменяемое поле в ключе!

    public bool Equals(Sku? other) =>
        other is not null && StringComparer.Ordinal.Equals(Code, other.Code);
    public override bool Equals(object? obj) => Equals(obj as Sku);

    // Контракт: равные объекты ОБЯЗАНЫ давать равный хеш
    public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Code);
    public int CompareTo(Sku? other) => string.CompareOrdinal(Code, other?.Code);
}

Правила:

  • Равные объекты — равный хеш. Обратное не обязано выполняться (коллизии допустимы).
  • GetHashCode должен быть стабилен во времени. Поменяли поле, участвующее в хеше, у объекта-ключа — он «теряется»: лежит не в той корзине, и TryGetValue его не найдёт.
  • IComparable<T> — естественный порядок, IComparer<T> — альтернативные. Порядок должен быть согласован с равенством, иначе сортировка и бинарный поиск ведут себя странно.

Для value-объектов всё это уже написано за вас: record и record struct генерируют Equals/GetHashCode/ToString по значению — используйте их вместо ручного кода.

Перегрузка операторов и преобразования

Для доменных типов — деньги, единицы измерения, идентификаторы — перегрузка делает код читаемым:

public readonly record struct Money(decimal Amount, string Currency)
{
    public static Money operator +(Money a, Money b) =>
        a.Currency == b.Currency
            ? a with { Amount = a.Amount + b.Amount }
            : throw new InvalidOperationException("Нельзя складывать разные валюты");

    // Явное преобразование: пишем (decimal)money — намеренно, а не случайно
    public static explicit operator decimal(Money m) => m.Amount;
}

Дисциплина: перегружайте оператор, только когда смысл очевиден; делайте преобразования explicit там, где возможна потеря данных; помните, что == для класса по умолчанию сравнивает ссылки — перегружаете ==, перегружайте и Equals/GetHashCode.

Мини-чеклист по дизайну типа

  • Это данные — значит record; сервис с зависимостями — sealed class.
  • Класс sealed, пока не доказана необходимость наследования.
  • Зависимости выражены интерфейсами, и интерфейс узкий — один повод для изменения.
  • Изменяемое состояние минимально; поля readonly, свойства init.
  • Дженерик с ограничениями вместо object и приведений.
  • Ключ словаря неизменяем и корректно реализует Equals/GetHashCode.

Типичные ошибки

  1. Наследование ради переиспользования кода. OrderService : DbHelper — классический способ получить неразвязываемый клубок. Композиция.
  2. Глубокие иерархии. Три уровня — уже повод спросить, что здесь делает стратегия или декоратор.
  3. new вместо override — вызывается «не тот» метод.
  4. Толстые интерфейсы — тесты обрастают заглушками на десять методов.
  5. Мутабельный ключ словаря — данные «пропадают» из коллекции.
  6. Забытая отписка от события — утечка памяти, видимая только под нагрузкой.
  7. Незамеченный захват в лямбде — включая this, что продлевает жизнь всему объекту.
  8. Интерфейс на структуре в горячем цикле — незаметный boxing на каждой итерации.

Итог

  • Наследование выражает «является», композиция — «использует»; по умолчанию берите вторую.
  • Интерфейсы — контракты; держите их узкими, реализуйте явно, когда деталь техническая.
  • Дженерики в .NET реифицированы: код специализируется под value-типы, boxing не возникает.
  • out/in дают безопасную подстановку; массивы ковариантны и потому опасны.
  • Делегаты — поведение как значение; следите за замыканиями и аллокациями.
  • События синхронны и текут памятью без отписки; для async-потоков есть Channel.
  • yield — машина состояний: ленивость, отложенные проверки аргументов, корректный Dispose.
  • Равенство и хеш — контракт; ключи коллекций обязаны быть неизменяемыми.

Источники: спецификация C#, руководство по дженерикам, Jon Skeet «C# in Depth», разборы вариантности у Eric Lippert (ericlippert.com).

Что дальше

Ввод-вывод, JSON и HTTP — как .NET разговаривает с внешним миром: потоки, System.Text.Json с генераторами кода, IHttpClientFactory и типичные способы исчерпать сокеты.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше