TODO: отвалидировать – текст собран автоматически из материалов курса на Google Drive, проверьте формулировки и код.
Полиморфизм – это выбор: какой именно код выполнится за одним и тем же обращением. Вопрос в том, когда этот выбор делается. Вариантов два, и они дают два вида полиморфизма.
Но прежде чем разбирать эти два вида, посмотрим, из каких этапов вообще состоит путь программы – от исходного текста до работающего процесса. Именно на этих этапах выбор и происходит.
В общем случае этапов три:
- Код программы – то, что написал разработчик: текст, в котором есть имена типов, сигнатуры методов и вызовы. На этом этапе ничего не выполняется, зато видна вся информация о типах.
- Компиляция – компилятор разбирает текст, проверяет типы и превращает его в исполняемый код. Здесь решается всё, что можно решить заранее: какая перегрузка подойдёт, во что развернётся шаблон. У 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# есть и перегрузка, и дженерики, и виртуальные методы, и интерфейсы.
Каждую такую конструкцию – что это, в каких языках есть и как выглядит – разберём в следующем уроке.