Типы и абстракции C#: от наследования до дженериков
В статье «Основы C#» мы разбирали типы как контейнеры данных: где они лежат и как копируются. Эта статья — про типы как абстракции: как из классов, интерфейсов, дженериков и делегатов собирают конструкцию, которую можно расширять, подменять в тестах и читать через полгода. Вопрос всей статьи один: как переиспользовать поведение, не привязав себя к конкретной реализации.
Три способа переиспользовать поведение
для разных типов данных"| G["Дженерики
List<T>, Repository<T>"] Q -->|"Одинаковый контракт,
разные реализации"| I["Интерфейс
IOrderRepository"] Q -->|"Готовое поведение
с точками расширения"| A["Абстрактный класс
+ virtual-методы"] Q -->|"Кусок логики
как параметр"| D["Делегат
Func<T, TResult>"] A --> W{"Есть настоящее
«является»?"} W -->|"Нет"| C["Композиция:
вложить объект и делегировать"] W -->|"Да"| A2["Наследование"]
Практическое правило: интерфейс плюс композиция — выбор по умолчанию, наследование — исключение. Наследование связывает вас с чужим классом навсегда: его защищённые поля, порядок вызовов и побочные эффекты становятся частью вашего контракта. Композиция связывает только с публичным 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; }
Три вещи, на которых горят:
- Утечка памяти через подписку. Издатель держит ссылку на подписчика: если издатель
живёт долго (singleton), а подписчик короткий, тот никогда не будет собран GC. Всегда
отписывайтесь в
Dispose. - Исключение в одном обработчике прерывает вызов остальных — мультикаст выполняется последовательно. Нужна независимость — оборачивайте в try/catch.
- События синхронны и не дружат с 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.
Типичные ошибки
- Наследование ради переиспользования кода.
OrderService : DbHelper— классический способ получить неразвязываемый клубок. Композиция. - Глубокие иерархии. Три уровня — уже повод спросить, что здесь делает стратегия или декоратор.
newвместоoverride— вызывается «не тот» метод.- Толстые интерфейсы — тесты обрастают заглушками на десять методов.
- Мутабельный ключ словаря — данные «пропадают» из коллекции.
- Забытая отписка от события — утечка памяти, видимая только под нагрузкой.
- Незамеченный захват в лямбде — включая
this, что продлевает жизнь всему объекту. - Интерфейс на структуре в горячем цикле — незаметный 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 и
типичные способы исчерпать сокеты.