Наследование, полиморфизм, абстрактные классы и интерфейсы¶
Об этом материале
Объём: четыре темы, ориентировочно неделя. Отчётность: письменный отчёт и код практического задания в репозитории. Требование: выводы о диспетчеризации и вариантности подтверждаются экспериментом.
Материал для самостоятельной работы после лекции 1. В лекции наследование, полиморфизм и абстракции были даны на уровне, достаточном для работы; здесь — механика и границы этих утверждений: как устроен виртуальный вызов, что именно требует принцип подстановки, где проходит грань между интерфейсом и абстрактным классом. Язык примеров — C#, параллели — там, где другой язык показывает понятие с новой стороны. Ориентировочно неделя.
Что входит в этот материал¶
- Как работает виртуальный вызов
- Принцип подстановки строго
- Абстрактные классы, интерфейсы и то, что между ними
- Когда наследование — ошибка
Что уже было в лекции¶
Наследование выражает «является» и даёт сильнейшую связанность; полиморфизм бывает ad-hoc, параметрический и подтиповый; интерфейс отвечает на «что умею», абстрактный класс — на «чем являюсь». Теперь — механика и границы этих утверждений.
Как работать с этим документом¶
- Читайте не подряд, а по темам: у каждой есть вопрос, на который она отвечает.
- Каждый блок кода запускайте, а не просматривайте. Особенно там, где результат контринтуитивен, — сама постановка эксперимента и есть обучение.
- В конце — письменные вопросы. Отвечайте текстом, а не «понял». Если ответ не формулируется в три предложения, тема не закрыта.
- Инструменты: sharplab.io — смотреть, во что компилируется вызов виртуального метода и обобщение; дизассемблер IL; любой компилятор F# или Rust под рукой для сравнения статической и динамической диспетчеризации.
Тема 1. Как работает виртуальный вызов¶
- Таблица методов и почему вызов виртуального метода на один уровень косвенности дороже
обычного. Что делает JIT: девиртуализация для
sealed-типов и мономорфных площадок вызова, инлайнинг. virtual/override/new/sealed override. Почему C# требует явности, в отличие от Java (где всё виртуально по умолчанию) и C++ (гдеvirtualтоже явный, но забытыйvirtualв базе ломает диспетчеризацию молча).- Сокрытие вместо замещения (
new) — воспроизведите ловушку из лекции 1 и объясните, почемуA x = new B(); x.Hi();печатает «A». - Диспетчеризация по интерфейсу дороже, чем по классу: отдельная таблица интерфейсов, кеш площадки вызова.
- Явная реализация интерфейса (
void IFoo.Bar()): метод недоступен через тип класса. Зачем это нужно — конфликт имён двух интерфейсов и сокрытие «служебной» части контракта.
Другие языки. В Go нет наследования вообще: есть встраивание структур и неявная реализация интерфейсов — тип удовлетворяет интерфейсу просто потому, что имеет нужные методы, объявлять это не нужно. В Rust полиморфизм разделён явно: статический (обобщения, мономорфизация — код специализируется под каждый тип, вызовов по указателю нет) и динамический (
dyn Trait, таблица виртуальных методов). Это лучшая иллюстрация к тому, что «параметрический» и «подтиповый» полиморфизм — разные механизмы, а не оттенки.
Тема 2. Принцип подстановки строго¶
В лекции LSP был дан по-бытовому. Формально (Барбара Лисков и Жанетт Уинг) подтип обязан:
- не усиливать предусловия — не требовать от вызывающего больше, чем база;
- не ослаблять постусловия — гарантировать не меньше, чем база;
- сохранять инварианты базового типа;
- соблюдать историческое ограничение — не менять состояние способами, невозможными для базового типа (классический пример: неизменяемая точка и изменяемый наследник).
Разберите на бумаге три канонических случая: квадрат и прямоугольник; Stream и поток,
не поддерживающий Seek; коллекция только для чтения как наследник изменяемой.
Отдельно — ковариантность и контравариантность:
IEnumerable<object> objects = new List<string>(); // ковариантно: out T
Action<string> action = (Action<object>)(o => { }); // контравариантно: in T
// но:
// IList<object> list = new List<string>(); // ошибка — и это правильно
Понять, почему массивы в C# ковариантны (object[] a = new string[1];) и почему это
считается ошибкой дизайна языка: проверка переезжает в рантайм и падает
ArrayTypeMismatchException. Та же история — в Java. Сравните с обобщениями, где
вариантность выражается in/out и проверяется компилятором.
Тема 3. Абстрактные классы, интерфейсы и то, что между ними¶
- Реализация по умолчанию в интерфейсах (C# 8): зачем добавили (версионирование библиотек без ломающих изменений), как разрешается «ромб» при двух интерфейсах с одинаковым методом, почему такой метод не виден через тип класса.
- Статические абстрактные члены интерфейсов (C# 11) и обобщённая математика:
where T : INumber<T>— интерфейс начинает описывать не только поведение экземпляра, но и операции самого типа. Ранее это выражалось только внешними стратегиями. - Явная и неявная реализация, реализация интерфейса в базовом классе и переопределение в наследнике.
- Абстрактный класс как шаблон алгоритма — прямой мост к паттерну Template Method, который будет в лекции по каталогу.
- Номинальная типизация против структурной: в C# нужно объявить
: IFoo, в TypeScript достаточно совпадения формы, в Python есть оба варианта (ABCиProtocol). Разберите, какие ошибки ловит номинальная система и какую гибкость даёт структурная.
// Один и тот же алгоритм для int, double, decimal — без интерфейсов-стратегий
public static T Sum<T>(IEnumerable<T> items) where T : INumber<T>
{
var total = T.Zero; // статический член интерфейса
foreach (var item in items) total += item;
return total;
}
Другие языки. Трейты Rust и Scala — это интерфейсы с реализацией по умолчанию, которые при этом можно добавлять к чужим типам (в Rust —
impl MyTrait for String), чего в C# нельзя. Примеси Python через множественное наследование и MRO дают состояние в примеси, чего не даёт ни один интерфейс C#. В Swift протоколы с расширениями стали основой стиля «protocol-oriented programming» — интересный контрпример к иерархиям классов.
Тема 4. Когда наследование — ошибка¶
Соберите собственный список признаков. Ориентиры:
- наследник переопределяет метод заглушкой или исключением — Refused Bequest;
- вы наследуетесь ради переиспользования кода, а не ради подстановки — берите композицию;
- иерархия глубже трёх уровней — почти всегда сигнал;
- вы наследуетесь от чужого класса, чья реализация может измениться — «хрупкий базовый класс»;
- одна иерархия пытается выразить две независимые оси классификации (тип платежа × страна) — это Bridge, а не наследование в квадрате.
Прочитайте про проблему хрупкого базового класса и про то, почему sealed по умолчанию
считается разумной практикой (в Kotlin классы final по умолчанию — сравните аргументацию).
Вопросы для самопроверки¶
- Почему
IList<T>не может быть ковариантным, аIEnumerable<T>может? - Что произойдёт при
object[] arr = new string[2]; arr[0] = 42;и на каком этапе? - Как разрешается конфликт, если класс реализует два интерфейса с одинаковым default-методом?
- Приведите пример нарушения LSP через усиление предусловия.
- Чем
dyn Traitв Rust отличается от обобщений, и какой аналог у каждого в C#? - Когда явная реализация интерфейса предпочтительнее неявной?
Практика¶
Спроектировать иерархию платёжных методов (карта, банковский перевод, наличные, криптовалюта) двумя способами: наследованием и композицией со стратегиями. Показать на конкретном новом требовании («добавить возврат средств, доступный не для всех методов»), какой вариант выдержал изменение, а какой нарушил LSP или ISP. Вывод — текстом на полстраницы.
Литература¶
- Э. Гамма и др. Приёмы объектно-ориентированного проектирования — глава 1: наследование класса против наследования интерфейса, «прозрачный ящик», примеси, а также рекомендация предпочитать композицию наследованию.
- Статья Барбары Лисков и Жанетт Уинг о подтипах — первоисточник LSP.
- Документация .NET: вариантность обобщений (
in/out), реализация интерфейсов по умолчанию, статические абстрактные члены и обобщённая математика.
Как я буду это проверять¶
Письменный отчёт: ответы на вопросы для самопроверки и вывод по практике, плюс код в репозитории. Критерий — не «сделано», а объяснено: почему выбран такой вариант, чем платим, что сломается при изменении требования. Формулировка «так принято» не засчитывается: принято кем и почему.