Программа курса← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF

Статический и динамический полиморфизм

TODO: отвалидировать – текст собран автоматически из материалов курса на Google Drive, проверьте формулировки и код.

Полиморфизм – это выбор: какой именно код выполнится за одним и тем же обращением. Вопрос в том, когда этот выбор делается. Вариантов два, и они дают два вида полиморфизма.

Но прежде чем разбирать эти два вида, посмотрим, из каких этапов вообще состоит путь программы – от исходного текста до работающего процесса. Именно на этих этапах выбор и происходит.

Код программы Компиляция Выполнение C#, C++, Go, Java Python, JS – без компиляции статический полиморфизм динамический полиморфизм

В общем случае этапов три:

  • Код программы – то, что написал разработчик: текст, в котором есть имена типов, сигнатуры методов и вызовы. На этом этапе ничего не выполняется, зато видна вся информация о типах.
  • Компиляция – компилятор разбирает текст, проверяет типы и превращает его в исполняемый код. Здесь решается всё, что можно решить заранее: какая перегрузка подойдёт, во что развернётся шаблон. У Python и JavaScript отдельного шага нет – они переходят к выполнению сразу, разбирая код по ходу дела.
  • Выполнение – программа работает, в переменных лежат конкретные объекты. Только здесь становится известно, что на самом деле пришло в метод, и только здесь можно выбрать реализацию по фактическому типу.

Дальше – два вида полиморфизма, по одному на каждый из двух последних этапов.

Статический полиморфизм

Выбор делается до запуска программы – компилятором, по типам, которые он видит в коде. Отсюда второе название: раннее связывание.

Сюда относятся перегрузка методов и операторов, а также обобщения: шаблоны C++, дженерики C# и Java, Box[T] в Go.

void Print(int value) => Console.WriteLine($"число {value}");
void Print(string value) => Console.WriteLine($"строка {value}");

Print(42);      // компилятор уже выбрал первую версию
Print("hello"); // и здесь – вторую
// Шаблон разворачивается под каждый использованный тип ещё при сборке:
// в машинном коде окажутся две разные функции
template <typename T>
void Print(const T& value) { std::cout << value << "\n"; }

Print(42);
Print(std::string("hello"));

Что это даёт:

  • скорость – в готовой программе стоит прямой вызов, решать во время работы нечего;
  • проверки заранее – несовпадение типов не соберётся, а не упадёт у пользователя;
  • подсказки среды – IDE точно знает, какой метод будет вызван.

Нарушили условия – программа не соберётся

Раз выбор делает компилятор, он же и проверяет, что выбрать есть из чего. Вызовите Print(3.14), когда перегрузки объявлены только для int и string, – и сборка упадёт с «no overload takes a double». То же самое с шаблоном: Print(myOrder) не скомпилируется, если у Order нет оператора вывода.

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

Чем платим: выбор зафиксирован при сборке. Подменить реализацию на ходу – например, подсунуть тестовую заглушку вместо настоящего платёжного шлюза – уже нельзя.

И вторая цена – время сборки. Вся работа, которой не будет при выполнении, никуда не исчезает, она просто переезжает в компилятор: перебрать перегрузки, вывести типы, развернуть шаблон под каждый использованный тип. В больших проектах на C++ это выливается в минуты ожидания после каждой правки, а сообщения об ошибке в шаблонах разрастаются на десятки строк. Быстрая программа и быстрая сборка – разные вещи, и статический полиморфизм выбирает первое.

Динамический полиморфизм

Выбор делается во время выполнения – по фактическому типу объекта, который лежит в переменной прямо сейчас. Отсюда название позднее связывание.

Сюда относятся виртуальные методы, интерфейсы и утиная типизация.

Notifier notifier = new EmailNotifier();

// Компилятор знает только тип переменной – Notifier.
// Какой Send выполнится, решает рантайм по фактическому объекту
notifier.Send("hi");
def speak(animal):
    # Никаких типов вообще: метод ищется у объекта в момент вызова
    animal.say()

speak(Dog())
speak(Cat())

Что это даёт:

  • гибкость – реализацию можно подменить, не трогая вызывающий код;
  • расширяемость – новый класс добавляется без правки старых;
  • тестируемость – настоящий сервис заменяется заглушкой в одну строку.

Нарушили условия – узнаете во время работы

Компилятор здесь проверить ничего не может: он видит тип Notifier, а какой объект окажется в переменной, выяснится при запуске. Передайте в speak объект без метода say – в Python это AttributeError в момент вызова, в C# обращение к пустой ссылке даст NullReferenceException, а неудачное приведение типа – InvalidCastException.

Всё это происходит уже у пользователя, и сборка о проблеме молчала. Поэтому там, где статическому полиморфизму хватает компилятора, динамическому нужны тесты.

Чем платим: вызов стоит дороже – нужно определить, чей метод звать (в C++, C# и Java это таблица виртуальных методов), – и ошибки всплывают позже, уже во время работы.

Что выбрать

Оба вида решают одну задачу, но по-разному распределяют работу и риски:

Статический Динамический
Скорость компиляции ниже: перебор перегрузок и разворачивание шаблонов ложатся на компилятор выше: решать при сборке почти нечего
Скорость исполнения выше: в готовой программе стоит прямой вызов ниже: реализацию нужно найти при каждом вызове
Обнаружение ошибок при сборке, до запуска при выполнении, у пользователя
Когда выбирается реализация компилятором, по типам в коде рантаймом, по объекту в переменной
Чем записывается перегрузка, шаблоны, дженерики виртуальные методы, интерфейсы, утиная типизация

Пара практических следствий.

Языки без отдельного шага компиляции – Python, JavaScript – динамические по устройству: там почти любой вызов разрешается в момент выполнения. Языки со строгой компиляцией дают оба вида и позволяют выбирать.

И главное: статический и динамический – это классификация, а не сами механизмы. Так мы делим полиморфизм по тому, когда делается выбор, а записывается он конкретными конструкциями языка – и в одном языке их обычно несколько сразу: в C# есть и перегрузка, и дженерики, и виртуальные методы, и интерфейсы.

Каждую такую конструкцию – что это, в каких языках есть и как выглядит – разберём в следующем уроке.