Статический и динамический полиморфизм в C++
Привет, Хабр! К сегодняшнему дню написано уже немало учебников и статей по полиморфизму в целом и его воплощения в C++ в частности. Однако, к моему удивлению, при описании полиморфизма никто (или почти никто) не затрагивает тот факт, что помимо динамического полиморфизма в C++ имеется и достаточно мощная возможность использования его младшего брата — полиморфизма статического. Более того, он является одной из основных концепций STL — неотъемлемой части его стандартной библиотеке.
Поэтому в данной статье мне хотелось бы хотя бы в общих чертах рассказать о нём и его отличиях от всем известного динамического полиморфизма. Надеюсь, эта статья будет интересна для тех, кто только начал изучать принципы ООП, и они смогут посмотреть на его «третьего слона» с новой стороны.
Сразу оговорюсь, что часто к полиморфизму относится также и перегрузка функций, однако эту тему мы затрагивать не будем. Также в С++ имеется довольно любопытный способ реализации статического полиморфизма при помощи макросов, однако этот аспект тянет на отдельную статью, и в виду специфики использования представляет мало практического интереса.
Полиморфизм
Полиморфизм предоставляет возможность объединять различные виды поведения при помощи общей записи, что как правило означает использование функций для обработки данных различных типов; считается одним из трёх столпов объектно-ориентированного программирования. По особенностям реализации полиморфизм можно разделить на ограниченный и неограниченный, динамический и статический.
Понятие ограниченный (bounded) означает, что интерфейсы полностью определены заранее, например, конструкцией базового класса. Неограниченный же полиморфизм же не накладывает ограничений на тип, а лишь требует реализацию определённого синтаксиса.
Статический означает, что связывание интерфейсов происходит на этапе компиляции, динамический — на этапе выполнения.
Язык программирования C++ предоставляет ограниченный динамический полиморфизм при использовании наследования и виртуальных функций и неограниченный статический — при использовании шаблонов. Поэтому в рамках данной статьи данные понятия будут именоваться просто статический и динамический полиморфизм. Однако, вообще говоря, различные средства в различных ЯП могут предоставлять различные комбинации типов полиморфизма.
Динамический полиморфизм
Динамический полиморфизм — наиболее частое воплощение полиморфизма в целом. В С++ данная возможность реализуется при помощи объявления общих возможностей с использованием функционала виртуальных функций. При этом в объекте класса хранится указатель на таблицу виртуальных методов (vtable), а вызов метода осуществляется путём разыменования указателя и вызова метода, соответствующего типу, с которым был создан объект. Таким образом можно управлять этими объектами при помощи ссылок или указателей на базовый класс (однако нельзя использовать копирование или перемещение).
Рассмотрим следующий простой пример: пусть есть абстрактный класс Property, который описывает облагаемую налогом собственность с единственным чисто виртуальным методом getTax
, и полем worth
, содержащим стоимость; и три класса: CountryHouse
, Car
, Apartment
, которые реализуют данный метод, определяя различную налоговую ставку:
Пример
class Property
{
protected:
double worth;
public:
Property(double worth) : worth(worth) {}
virtual double getTax() const = 0;
};
class CountryHouse :
public Property
{
public:
CountryHouse(double worth) : Property(worth) {}
double getTax() const override { return this->worth / 500; }
};
class Car :
public Property
{
public:
Car(double worth) : Property(worth) {}
double getTax() const override { return this->worth / 200; }
};
class Apartment :
public Property
{
public:
Apartment(double worth) : Property(worth) {}
double getTax() const override { return this->worth / 1000; }
};
void printTax(Property const& p)
{
std::cout << p.getTax() << "\n";
}
// Или так
void printTax(Property const* p)
{
std::cout << p->getTax() << "\n";
}
int main()
{
Property* properties[3];
properties[0] = new Apartment(1'000'000);
properties[1] = new Car(400'000);
properties[2] = new CountryHouse(750'000);
for (int i = 0; i < 3; i++)
{
printTax(properties[i]);
delete properties[i];
}
return 0;
}
Если заглянуть «под капот», то можно увидеть, что компилятор (в моём случае это gcc) неявно добавляет в начало класса Property указатель на vtable, а в конструктор — инициализацию этого указателя в соответствии с нужным типом. А вот так в дизассемблированном коде выглядит фрагмент с вызовом метода getTax ():
mov rbp, QWORD PTR [rbx]; В регистр rbp помещаем указатель на объект
mov rax, QWORD PTR [rbp+0]; В регистр rax помещаем указатель на vtable
call [QWORD PTR [rax]]; Вызываем функцию, адрес которой лежит по адресу, лежащему в rax (первое разыменование даёт vtable, второе – адрес функции.
Статический полиморфизм
Перейдём, наконец, к самому интересному. В C++ средством статического полиморфизма являются шаблоны. Впрочем, это весьма мощный инструмент, который имеет огромное количество применений, и их подробное рассмотрение и изучение потребует полноценного учебного курса, поэтому в рамках данной статьи мы ограничимся лишь поверхностным рассмотрением.
Перепишем предыдущий пример с использованием шаблонов, заодно в целях демонстрации воспользуемся тем, на этот раз мы используем неограниченный полиморфизм.
Пример
class CountryHouse
{
private:
double worth;
public:
CountryHouse(double worth) : worth(worth) {}
double getTax() const { return this->worth / 500; }
};
class Car
{
private:
double worth;
public:
Car(double worth) : worth(worth) {}
double getTax() const { return this->worth / 200; }
};
class Apartment
{
private:
unsigned worth;
public:
Apartment(unsigned worth) : worth(worth) {}
unsigned getTax() const { return this->worth / 1000; }
};
template
void printTax(T const& p)
{
std::cout << p.getTax() << "\n";
}
int main()
{
Apartment a(1'000'000);
Car c(400'000);
CountryHouse ch(750'000);
printTax(a);
printTax(c);
printTax(ch);
return 0;
}
Здесь я заменил возвращаемый тип Apartment::GetTax()
. Так как, благодаря перегрузке оператора >>, синтаксис (и, в данном случае, семантика) остался корректным, то данный код вполне успешно компилируется, в то время, как аппарат виртуальных функций нам бы такой вольности не простил.
В данном случае, как и положено при использовании шаблонов, компилятор инстанцировал (то есть создал из шаблона путём подстановки параметров) три различных функции и подставил нужную на этапе компиляции — поэтому полиморфизм на основе шаблонов и является статическим.
Как я уже отмечал во введении, хорошим примером использования статического полиморфизма может послужить STL. Так, например, выглядит простая реализация функции std::for_each
:
template
constexpr UnaryFunc for_each(InputIt first, InputIt last, UnaryFunc f)
{
for (; first != last; ++first)
f(*first);
return f;
}
При вызове функции нам необходимо лишь предоставить объекты, для которых будет корректен синтаксис имеющихся в теле функции операций (плюс, в виду того, что параметры передаются, а результат возвращается, по значению для них должен быть определён конструктор копирования (перемещения)). Однако следует понимать, что шаблон лишь задаёт синтаксис, поэтому несоответствия между принятым синтаксисом и семантикой могут привести к неожиданному результату. Так, например, естественно предположить, *first не изменяет first, хотя синтаксически никаких ограничений на это нет.
Концепты
Несколько усилить требования к подставляемым типам могут помочь введённый в стандарт относительно недавно (начиная с C++20) аппарат концептов. В принципе, подобного эффекта можно было достичь и раньше с использованием принципа SFINAE (substitution failure is not an error — неудачная подстановка не является ошибкой) и производных инструментов, таких, как std: enable_if, однако их синтаксис является достаточно громоздкий, и полученный код становится читать не очень приятно. Использование концептов, в частности, позволяет получать куда более прозрачное сообщение об ошибке при попытке использования неподходящего типа.
На нашем простом примере концепт мог бы выглядеть, например, так:
template
concept Property = requires (T const& p) { p.getTax(); };
А объявление printTax:
template
void printTax(T const& p);
Теперь, если мы попытаемся передать в качестве параметра int, мы получим весьма точный вывод сообщения об ошибке:
Заключение
Конечно, каждый способ в чём-то хорош, а в чём-то не очень. Так, использование шаблонов позволяет немного сэкономить время при выполнении программы, однако имеет и свои недостатки, свойственные шаблонам. Так, каждое инстанцирование одного и того же шаблона с разными типами создаёт отдельную функцию, что может существенно увеличить размер исполняемого кода. К тому же, сам механизм шаблонов в том виде, в котором он есть в языке, требует, чтобы их исходный код был доступен на этапе компиляции, что также создаёт свои неудобства.
На этом на сегодня всё, надеюсь, что читатель узнал из этой статьи что-то новое или освежил хорошо забытое старое.