Полиморфизм в программировании — это способность одного и того же кода работать с данными разных типов. Вызов один, а что именно произойдёт, решает объект или тип значения. Слово греческое и означает «много форм».
Ниже разбираем виды полиморфизма, раннее и позднее связывание и типичные ошибки. Примеры на Python и C++ мы запустили на Python 3.14 и 3.12, а также на clang 17 и g++ 13: вывод в статье взят из терминала. Если слова «класс» и «метод» пока путаются, начните со статьи «ООП простыми словами», а место полиморфизма среди четырёх принципов показано в разборе принципов ООП.
В биологии и химии полиморфизмом называют другое: разные формы одного вида или кристаллы одного вещества с разной решёткой. Здесь речь только о коде.
Что такое полиморфизм простыми словами

Представьте кнопку «Оплатить» в интернет-магазине. Покупатель выбирает карту, СБП или баланс бонусов, а кнопка одна. Магазину не нужно знать, как каждый способ списывает деньги: он просит «оплати 1500 рублей», и способ оплаты делает это по-своему.
Так же устроен полиморфизм в коде. Функция вызывает общую операцию, а конкретное поведение подставляется в момент вызова.
С полиморфизмом код пишут один раз для общего интерфейса и не трогают, когда появляется новый тип данных.
Если кратко, в объектно-ориентированном программировании это значит: объекты с одинаковым набором методов могут иметь разную реализацию этих методов.
Вы уже пользовались полиморфизмом, даже если не знали термина. Оператор + в Python складывает числа, склеивает строки и объединяет списки, а len() считает длину чего угодно:
print(2 + 3)
print("2" + "3")
print([2] + [3])
print(len("кот"), len([1, 2, 3]), len({"a": 1}))
Вывод:
5
23
[2, 3]
3 3 1
Символ один, поведение зависит от типа. Как работают остальные знаки, разобрано в статье об операторах Python. Запустить примеры у себя поможет инструкция как создать файл Python в VS Code.
Виды полиморфизма

Два вида выделил британский учёный Кристофер Стрейчи в лекциях 1967 года для летней школы по программированию в Копенгагене. Текст лекций переиздали в 2000 году в журнале Higher-Order and Symbolic Computation. Это параметрический полиморфизм, когда функция одинаково работает с целым набором типов, и ad hoc, когда для каждого типа может быть своё, никак не связанное поведение.
В 1985 году Лука Карделли и Питер Вегнер уточнили схему в статье «On Understanding Types, Data Abstraction, and Polymorphism» (Computing Surveys, т. 17, № 4). Они добавили третий вид, полиморфизм включения, чтобы описать подтипы и наследование. Получилось четыре разновидности в двух группах:
| Вид | Группа | Как выглядит в коде | Пример |
|---|---|---|---|
| Параметрический | Универсальный | Обобщённые функции и классы с параметром типа | Дженерики Python и Java, шаблоны C++ |
| Включения (подтипов) | Универсальный | Наследник подставляется вместо родителя и переопределяет метод | Классы оплаты с общим методом pay() |
| Перегрузка | Ad hoc | Несколько функций с одним именем и разными параметрами | show(int) и show(string) в C++ |
| Приведение типов | Ad hoc | Язык сам преобразует значение к нужному типу | 1 + 2.5 в Python даёт 3.5, целое стало дробным |
Когда на собеседовании спрашивают про полиморфизм в ООП, обычно имеют в виду полиморфизм подтипов. С него и начнём.
Подтипы на Python: один метод, разные классы
Задача: выгрузить отчёт о продажах курсов в CSV или JSON. Функция save_report не знает, какой формат ей передали. Она вызывает export(), а каждый класс выгрузки отвечает за свой формат сам.
import json
class Exporter:
def export(self, rows):
raise NotImplementedError
class CsvExporter(Exporter):
def export(self, rows):
return "\n".join(f"{name};{price}" for name, price in rows)
class JsonExporter(Exporter):
def export(self, rows):
return json.dumps(dict(rows), ensure_ascii=False)
def save_report(exporter, rows):
print(exporter.export(rows))
rows = [("Python", 4900), ("SQL", 3900)]
for exporter in (CsvExporter(), JsonExporter()):
save_report(exporter, rows)
Вывод:
Python;4900
SQL;3900
{"Python": 4900, "SQL": 3900}
Понадобится выгрузка в Excel, и вы добавите класс XlsxExporter с тем же методом. Функцию save_report трогать не придётся.
Наследовать Exporter в Python, строго говоря, не обязательно. Интерпретатор не проверяет тип, он просто вызывает метод, и этот стиль называют утиной типизацией (определение есть в глоссарии Python). Базовый класс полезен как договор: по нему видно, какой метод обязан быть у каждого формата.
Чем заменить цепочку isinstance
Признак того, что полиморфизма не хватает, выглядит так: функция перебирает типы вручную.
if isinstance(x, CsvExporter): ...elif isinstance(x, JsonExporter): ...- и ещё десяток веток на каждый новый формат.
Каждый новый тип заставляет править эту функцию. Решение: перенести ветку в метод класса, как в примере выше. Это прямое применение принципа открытости и закрытости из SOLID: код открыт для новых типов и закрыт для правок.
Выбор по типу и дженерики в Python
Перегрузки методов в Python нет: второе определение с тем же именем затирает первое (пример ошибки ниже). Но разное поведение для разных типов получить можно.
singledispatch: своя функция под каждый тип
Декоратор @singledispatch из модуля functools превращает функцию в обобщённую. Реализация выбирается по типу первого аргумента, так написано в документации functools. Декоратор появился в Python 3.4, а вариант для методов класса singledispatchmethod добавили в 3.8.
from functools import singledispatch
@singledispatch
def describe(value):
return f"неизвестный тип: {type(value).__name__}"
@describe.register
def _(value: int):
return f"целое число, {value.bit_length()} бит"
@describe.register
def _(value: list):
return f"список из {len(value)} элементов"
print(describe(255))
print(describe([1, 2, 3]))
print(describe(3.14))
Вывод:
целое число, 8 бит
список из 3 элементов
неизвестный тип: float
Для float отдельной версии нет, поэтому сработала базовая функция. Это и есть ad hoc полиморфизм в чистом виде: на каждый тип своя реализация.
Дженерики: одна функция для любого типа
Параметрический полиморфизм в Python записывается через параметр типа в квадратных скобках. Такой синтаксис появился в Python 3.12:
def first[T](items: list[T]) -> T:
return items[0]
print(first([10, 20, 30]))
print(first(["кот", "пёс"]))
Вывод:
10
кот
Код внутри first один и тот же для чисел и строк. Учтите, что интерпретатор аннотации не проверяет: T нужен анализаторам типов и редактору кода, а на выполнение не влияет. В Java и C++ параметр типа проверяет компилятор.
Справка по модулям functools и typing лежит на официальном сайте языка. Где там что искать, показано в статье о python.org и документации Python.
Раннее и позднее связывание на примере C++
Чтобы полиморфизм подтипов работал, программа должна выбрать нужную версию метода во время выполнения, а не при компиляции. Это называют поздним (динамическим) связыванием. Если версия выбирается по типу переменной ещё при сборке, связывание раннее.
В C++ разницу видно лучше всего: метод становится полиморфным, только если объявлен virtual. Ниже у pay() слово virtual есть, а у kind() нет.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Payment {
virtual std::string pay(int rub) const { return "оплата " + std::to_string(rub) + " ₽"; }
std::string kind() const { return "платёж"; }
virtual ~Payment() = default;
};
struct Card : Payment {
std::string pay(int rub) const override { return "карта: " + std::to_string(rub) + " ₽"; }
std::string kind() const { return "карта"; }
};
struct Sbp : Payment {
std::string pay(int rub) const override { return "СБП: " + std::to_string(rub) + " ₽"; }
std::string kind() const { return "СБП"; }
};
int main() {
std::vector<std::unique_ptr<Payment>> cart;
cart.push_back(std::make_unique<Card>());
cart.push_back(std::make_unique<Sbp>());
for (const auto& p : cart) {
std::cout << p->kind() << " | " << p->pay(1500) << "\n";
}
}
Вывод:
платёж | карта: 1500 ₽
платёж | СБП: 1500 ₽
Указатель имеет тип Payment, а объекты внутри разные. Метод pay() вызвался у настоящего объекта, карты или СБП. Метод kind() компилятор привязал к типу указателя, поэтому оба раза вышло «платёж», хотя в наследниках он переопределён. Компиляторы обычно реализуют позднее связывание через таблицу виртуальных функций: у каждого такого класса своя таблица, а объект хранит указатель на неё.
В разных языках умолчания разные, и на этом часто ловят новичков:
| Язык | Методы полиморфны по умолчанию | Что важно знать |
|---|---|---|
| Python | Да | Метод ищется у объекта при каждом вызове |
| Java | Да, у методов экземпляра | Статический метод наследника не переопределяет, а скрывает метод родителя (руководство Oracle) |
| C# | Нет | Нужны virtual у родителя и override у наследника (справочник C#) |
| C++ | Нет | Без virtual метод связывается рано, как kind() выше |
В Java механизм называется вызовом виртуального метода: JVM вызывает метод того объекта, на который указывает переменная, а не того типа, которым переменная объявлена. Так он описан в разделе о полиморфизме руководства Oracle. Кто выбирает между JVM-языками, найдёт сравнение в статье Java или Kotlin.
Перегрузка и шаблоны в C++
Две другие формы в C++ работают при компиляции. Перегрузка выбирает функцию по типам аргументов, шаблон генерирует версию функции под каждый тип:
#include <iostream>
#include <string>
void show(int x) { std::cout << "int: " << x << "\n"; }
void show(double x) { std::cout << "double: " << x << "\n"; }
void show(const std::string& x) { std::cout << "строка: " << x << "\n"; }
template <typename T>
T max_of(T a, T b) { return a > b ? a : b; }
int main() {
show(42);
show(2.5);
show(std::string("кот"));
std::cout << max_of(3, 7) << " " << max_of(2.5, 1.5) << "\n";
}
Вывод:
int: 42
double: 2.5
строка: кот
7 2.5
Если вы идёте в C++ с чистого Си, где классов и перегрузки нет, разницу двух профессий объясняет статья кто такой разработчик на Си.
Частые ошибки и что с ними делать

Ниже пять ошибок, которые встречаются в коде новичков и в вопросах на собеседованиях.
1. Две версии метода с одним именем в Python. Кажется, что это перегрузка, но второе определение просто заменяет первое:
class Report:
def render(self, title):
return f"<h1>{title}</h1>"
def render(self, title, footer):
return f"<h1>{title}</h1><p>{footer}</p>"
print(Report().render("Итоги"))
Вывод:
TypeError: Report.render() missing 1 required positional argument: 'footer'
Решение: один метод с аргументом по умолчанию (footer=None) или singledispatchmethod, если ветки различаются по типу.
2. Срезка объекта в C++. Наследника передали в функцию по значению, и от него осталась только родительская часть. 3. Вызов виртуального метода из конструктора. Пока работает конструктор родителя, наследника ещё нет. Обе ошибки видны в одной программе:
#include <iostream>
#include <string>
struct Payment {
Payment() { std::cout << "конструктор: " << name() << "\n"; }
virtual std::string name() const { return "платёж"; }
virtual ~Payment() = default;
};
struct Card : Payment {
std::string name() const override { return "карта"; }
};
void by_value(Payment p) { std::cout << "по значению: " << p.name() << "\n"; }
void by_ref(const Payment& p) { std::cout << "по ссылке: " << p.name() << "\n"; }
int main() {
Card card;
by_value(card);
by_ref(card);
}
Вывод:
конструктор: платёж
по значению: платёж
по ссылке: карта
Конструктор напечатал «платёж», хотя создавалась карта: справочник cppreference прямо говорит, что во время конструирования вызывается версия класса, чей конструктор сейчас работает. Функция by_value тоже напечатала «платёж», потому что получила копию родителя. Решение: передавать объекты по ссылке или указателю, а в конструкторе виртуальные методы не вызывать.
4. Опечатка в сигнатуре. В наследнике написали pay(long) вместо pay(int), и получился новый метод вместо переопределения. Спасает слово override: компилятор проверит, что метод переопределяет родительский, а не создаёт новый. Clang 17 на таком коде пишет:
error: non-virtual member function marked 'override' hides virtual member function
В Java ту же проверку делает аннотация @Override.
5. Ручной перебор типов. Цепочки isinstance в Python или instanceof в Java на каждый новый класс. Решение то же, что в разделе про подтипы: перенести поведение в метод.
Как ответить на собеседовании
Вопрос «что такое полиморфизм» входит в базовые на собеседовании джуна. Хороший ответ укладывается в минуту:
- Определение одной фразой: один интерфейс, разные реализации, выбор по типу объекта.
- Виды: подтипов, параметрический и ad hoc (перегрузка, приведение типов).
- Пример из своего кода, хотя бы как с выгрузкой отчёта выше.
- Отличие от наследования: наследование передаёт код, полиморфизм позволяет подставить наследника туда, где ждут родителя.
- Разница переопределения и перегрузки: первое выбирается при выполнении, второе при компиляции.
Остальные этапы и типичные провалы на интервью собраны в статье как пройти собеседование разработчику.
Где освоить ООП системно
Разобраться в полиморфизме по примерам можно за вечер. Уверенность приходит позже, на своём проекте с десятком классов и код-ревью. Удобнее всего это делать на курсе, где ООП разбирают на практике и проверяют домашние задания. Если пока выбираете язык, посмотрите путь в Python-разработку и в C++, а уровень оплаты на старте показан в обзоре зарплат Python-разработчиков.
Ниже курсы, в программе которых есть ООП:
| Курс | Школа | Стоимость со скидкой | В рассрочку | Длительность | Обзор курса от Checkroi |
|---|---|---|---|---|---|
| Профессия «Python-разработчик» Перейти на сайт курса | 157 335 ₽ | 5987 ₽/мес. | 10 месяцев | Обзор курса | |
| PHP-разработчик с нуля до PRO Перейти на сайт курса | 114 876 ₽ | 4176 ₽/мес. | 7 месяцев | Обзор курса | |
| Frontend-разработчик с нуля Перейти на сайт курса | 113 400 ₽ | 5385 ₽/мес. | 10 месяцев | Обзор курса | |
| Fullstack-разработчик на Python Перейти на сайт курса | 161 200 ₽ | 7125 ₽/мес. | 21 месяц | Обзор курса | |
| PHP-разработчик. Базовый уровень Перейти на сайт курса | 81 107 ₽ | 5227 ₽/мес. | 3 месяца | Обзор курса |
Больше программ — в полном каталоге курсов по оОП




