Перейти к содержанию

Наследование, полиморфизм, абстрактные классы и интерфейсы

Об этом материале

Объём: четыре темы, ориентировочно неделя. Отчётность: письменный отчёт и код практического задания в репозитории. Требование: выводы о диспетчеризации и вариантности подтверждаются экспериментом.

Материал для самостоятельной работы после лекции 1. В лекции наследование, полиморфизм и абстракции были даны на уровне, достаточном для работы; здесь — механика и границы этих утверждений: как устроен виртуальный вызов, что именно требует принцип подстановки, где проходит грань между интерфейсом и абстрактным классом. Язык примеров — C#, параллели — там, где другой язык показывает понятие с новой стороны. Ориентировочно неделя.

Что входит в этот материал

  1. Как работает виртуальный вызов
  2. Принцип подстановки строго
  3. Абстрактные классы, интерфейсы и то, что между ними
  4. Когда наследование — ошибка

Что уже было в лекции

Наследование выражает «является» и даёт сильнейшую связанность; полиморфизм бывает 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 по умолчанию — сравните аргументацию).


Вопросы для самопроверки

  1. Почему IList<T> не может быть ковариантным, а IEnumerable<T> может?
  2. Что произойдёт при object[] arr = new string[2]; arr[0] = 42; и на каком этапе?
  3. Как разрешается конфликт, если класс реализует два интерфейса с одинаковым default-методом?
  4. Приведите пример нарушения LSP через усиление предусловия.
  5. Чем dyn Trait в Rust отличается от обобщений, и какой аналог у каждого в C#?
  6. Когда явная реализация интерфейса предпочтительнее неявной?

Практика

Спроектировать иерархию платёжных методов (карта, банковский перевод, наличные, криптовалюта) двумя способами: наследованием и композицией со стратегиями. Показать на конкретном новом требовании («добавить возврат средств, доступный не для всех методов»), какой вариант выдержал изменение, а какой нарушил LSP или ISP. Вывод — текстом на полстраницы.

Литература

  • Э. Гамма и др. Приёмы объектно-ориентированного проектирования — глава 1: наследование класса против наследования интерфейса, «прозрачный ящик», примеси, а также рекомендация предпочитать композицию наследованию.
  • Статья Барбары Лисков и Жанетт Уинг о подтипах — первоисточник LSP.
  • Документация .NET: вариантность обобщений (in/out), реализация интерфейсов по умолчанию, статические абстрактные члены и обобщённая математика.

Как я буду это проверять

Письменный отчёт: ответы на вопросы для самопроверки и вывод по практике, плюс код в репозитории. Критерий — не «сделано», а объяснено: почему выбран такой вариант, чем платим, что сломается при изменении требования. Формулировка «так принято» не засчитывается: принято кем и почему.