базовые концепции ООП — полиморфизмbaseline OOP concepts — polymorphism
C++ даёт два принципиально разных способа «один интерфейс — много реализаций»: динамический полиморфизм через виртуальные функции (решение — в рантайме, цена — косвенный вызов) и статический полиморфизм через шаблоны/перегрузку (решение — на этапе компиляции, цена — рост размера бинарника). Выбор между ними — это выбор между гибкостью и производительностью.C++ offers two fundamentally different ways to get "one interface, many implementations": dynamic polymorphism via virtual functions (resolved at runtime, at the cost of an indirect call) and static polymorphism via templates/overloading (resolved at compile time, at the cost of binary size growth). Choosing between them is a choice between flexibility and performance.
Полиморфизм («много форм») — свойство кода работать одинаково с объектами разных типов через общий интерфейс, вызывая у каждого объекта именно его собственную реализацию операции. В C++ это не одна возможность, а две принципиально разные: динамическая (что вызывать — решается в рантайме, через virtual) и статическая (что вызывать — решается на этапе компиляции, через шаблоны/перегрузку).Polymorphism ("many forms") is code's ability to work uniformly with objects of different types through a shared interface, while each object runs its own specific implementation of an operation. In C++ this isn't one capability but two fundamentally different ones: dynamic (what to call is decided at runtime, via virtual) and static (what to call is decided at compile time, via templates/overloading).
struct Shape { virtual double area() const = 0; virtual ~Shape() = default; };
struct Circle : Shape { double r; double area() const override { return 3.14159 * r * r; } };
struct Square : Shape { double side; double area() const override { return side * side; } };
void printArea(const Shape& s) {
std::cout << s.area(); // один и тот же вызов, разный результат в зависимости от реального типа s// the same call, a different result depending on s's actual type
}
printArea(Circle{{}, 2}); // напечатает площадь круга// prints the circle's area
printArea(Square{{}, 3}); // та же функция printArea — напечатает площадь квадрата// the same printArea function — prints the square's area
printArea написана один раз в терминах Shape и ничего не знает ни про Circle, ни про Square — это и есть суть полиморфизма: код, зависящий от интерфейса, а не от конкретного типа. Ниже — как именно C++ реализует это через virtual (динамически) и как альтернативно через шаблоны (статически), и что каждый вариант стоит по производительности.printArea is written once in terms of Shape and knows nothing about either Circle or Square — that's the essence of polymorphism: code that depends on an interface, not a concrete type. Below is exactly how C++ implements this via virtual (dynamically), the template-based alternative (statically), and what each option costs in performance.
Виртуальная функция разрешается не по статическому типу указателя/ссылки, а по реальному типу объекта, на который он указывает — и это разрешение происходит в рантайме через таблицу виртуальных функций (подробный разбор устройства — на отдельной странице).A virtual function is resolved not by the static type of the pointer/reference, but by the actual type of the object it points to — and that resolution happens at runtime via a virtual function table (the detailed mechanics live on a dedicated page).
struct Shape {
virtual double area() const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
double r;
double area() const override { return 3.14159 * r * r; }
};
struct Square : Shape {
double side;
double area() const override { return side * side; }
};
double totalArea(const std::vector<std::unique_ptr<Shape>>& shapes) {
double sum = 0;
for (const auto& s : shapes) sum += s->area(); // какая area() вызовется — решается в рантайме// which area() runs is decided at runtime
return sum;
}
Ключевое свойство: totalArea компилируется один раз и работает с любым будущим наследником Shape, даже написанным в другой единице трансляции или подключённым позже как плагин через динамическую библиотеку. Это и есть настоящий полиморфизм в духе runtime-расширяемости.The key property: totalArea is compiled once and works with any future descendant of Shape, even one written in a different translation unit or loaded later as a plugin via a shared library. That's genuine polymorphism in the runtime-extensibility sense.
Вызов s->area() — это не call area по фиксированному адресу, а: разыменовать s → прочитать vptr → прочитать нужный слот vtable → перейти по адресу из слота. Три дополнительных обращения к памяти по сравнению с обычным вызовом функции, каждое — потенциальный промах кэша.Calling s->area() isn't a call area to a fixed address — it's: dereference s → read the vptr → read the right vtable slot → jump to the address in that slot. Three extra memory accesses compared to an ordinary function call, each one a potential cache miss.
Инлайнинг требует знать тело функции на этапе компиляции. Если целевой адрес известен только в рантайме, компилятор не может подставить тело функции на место вызова — теряется не только сам инлайнинг, но и все оптимизации, которые он открывает (constant folding через границу вызова, устранение мёртвого кода и т.д.). Исключение — devirtualization, когда компилятор статически доказывает единственный возможный тип (final-классы, локальный объект без полиморфного присваивания).Inlining requires knowing the function body at compile time. If the target address is only known at runtime, the compiler can't substitute the function body at the call site — this loses not just inlining itself, but every optimization it unlocks (constant folding across the call boundary, dead code elimination, and so on). The exception is devirtualization, where the compiler statically proves there's only one possible type (final classes, a local object with no polymorphic assignment).
Каждый объект полиморфного типа несёт скрытый указатель (обычно 8 байт на 64-битной платформе) — для маленьких объектов (например, точки из двух int) это может увеличить размер вдвое. Сама vtable — одна на класс, не на объект, поэтому её вклад в общий размер незначителен при большом числе экземпляров, но не равен нулю в бинарнике (подробнее — на странице про vtable).Every object of a polymorphic type carries a hidden pointer (typically 8 bytes on a 64-bit platform) — for small objects (say, a point made of two ints) that can double the size. The vtable itself is one per class, not per object, so its contribution to total memory is negligible with many instances, but it's not zero in the binary (more on that on the vtable page).
Шаблоны и перегрузка дают «один интерфейс — много реализаций» без единого виртуального вызова: компилятор подставляет конкретный тип уже на этапе компиляции (монорфизация), генерируя отдельную специализацию функции под каждый использованный тип.Templates and overloading provide "one interface, many implementations" without a single virtual call: the compiler substitutes the concrete type at compile time (monomorphization), generating a separate specialization of the function for each type actually used.
template<typename T>
double totalArea(const std::vector<T>& shapes) {
double sum = 0;
for (const auto& s : shapes) sum += s.area(); // прямой вызов — T::area известен на этапе компиляции// a direct call — T::area is known at compile time
return sum;
}
// totalArea<Circle>(...) и totalArea<Square>(...) — две разные функции в бинарнике// totalArea<Circle>(...) and totalArea<Square>(...) are two separate functions in the binary
Здесь s.area() — прямой вызов известной на этапе компиляции функции, полностью доступный для инлайнинга. Плата за это — totalArea должна быть скомпилирована заново (как минимум — инстанцирована) для каждого нового T, и вызывающий код обязан знать конкретный тип на этапе компиляции: смешать Circle и Square в одном std::vector<T> нельзя — для этого и нужен динамический полиморфизм.Here s.area() is a direct call to a function known at compile time, fully available for inlining. The price is that totalArea must be compiled anew (at minimum, instantiated) for every new T, and the calling code must know the concrete type at compile time: you can't mix Circle and Square in one std::vector<T> — that's exactly what dynamic polymorphism is for.
| динамический (virtual)dynamic (virtual) | статический (templates)static (templates) | |
|---|---|---|
| когда разрешаетсяwhen resolved | в рантайме, по реальному типу объектаat runtime, by the object's actual type | на этапе компиляции, по типу выраженияat compile time, by the expression's type |
| инлайнингinlining | невозможен (кроме devirtualization)not possible (except devirtualization) | полностью доступенfully available |
| размер объектаobject size | +8 байт на vptrbytes for the vptr | без измененийunchanged |
| размер бинарникаbinary size | одна функция + одна vtable на классone function + one vtable per class | растёт с числом инстанцированных Tgrows with the number of instantiated T's |
| неоднородные контейнерыheterogeneous containers | да — vector<unique_ptr<Base>>yes — vector<unique_ptr<Base>> | нет — нужен один конкретный Tno — a single concrete T is required |
| расширяемость без пересборкиextensibility without a rebuild | да (плагины через .so/.dll)yes (plugins via .so/.dll) | нет — новый T требует перекомпиляцииno — a new T requires recompilation |
| C++20 аналог ограничения типовtype-constraint analog | абстрактный класс — контракт в рантаймеan abstract class — a runtime contract | concepts — контракт на этапе компиляции— a compile-time contract |
Пока выполняется конструктор базового класса, объект ещё не стал производным типом — vptr указывает на vtable именно текущего (базового) класса. Вызов виртуальной функции из конструктора базы вызовет версию базового класса, даже если снаружи создаётся производный объект — переопределение в наследнике ещё не «активно».While a base class's constructor is running, the object hasn't become the derived type yet — the vptr points at the vtable of the currently-executing (base) class. Calling a virtual function from the base's constructor invokes the base class's version, even when a derived object is being constructed from the outside — the override in the derived class isn't "active" yet.
Если Shape хранит виртуальные функции, но не виртуальный деструктор, а объект Circle удаляется через Shape*, вызовется только ~Shape() — деструктор Circle и его полей не выполнится, что почти всегда утечка ресурсов.If Shape has virtual functions but not a virtual destructor, and a Circle is deleted through a Shape*, only ~Shape() runs — Circle's destructor and its fields' destructors never execute, almost always a resource leak.
Функция, принимающая Shape по значению (не по ссылке/указателю), обрежет любой переданный Circle до базовой части — vptr внутри неё будет указывать на vtable Shape, и любой вызов внутри такой функции окажется невиртуальным по сути, вне зависимости от того, что было передано снаружи.A function taking Shape by value (not by reference/pointer) truncates any passed-in Circle down to its base portion — the vptr inside it will point at Shape's vtable, and any call inside that function is effectively non-virtual regardless of what was actually passed in.
Curiously Recurring Template Pattern (class Derived : Base<Derived>) даёт статическую диспетчеризацию без единого виртуального вызова и без vptr, ценой более сложного синтаксиса и невозможности хранить разнородные объекты в одном контейнере — типичный компромисс «производительность вместо гибкости».The Curiously Recurring Template Pattern (class Derived : Base<Derived>) gives static dispatch without a single virtual call and without a vptr, at the cost of more convoluted syntax and the inability to store heterogeneous objects in one container — a textbook "performance instead of flexibility" trade-off.