Статический и динамический полиморфизм в 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 <class T> 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<class InputIt, class UnaryFunc> 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 <class T> concept Property = requires (T const& p) { p.getTax(); };

А объявление printTax:

template <Property T> void printTax(T const& p);

Теперь, если мы попытаемся передать в качестве параметра int, мы получим весьма точный вывод сообщения об ошибке:

<source>:46:13: error: no matching function for call to 'printTax(int)'    46 |     printTax(5);       |     ~~~~~~~~^~~ <source>:34:6: note: candidate: 'template<class T>  requires  Property<T> void printTax(const T&)'    34 | void printTax(T const& p)       |      ^~~~~~~~ <source>:34:6: note:   template argument deduction/substitution failed: <source>:34:6: note: constraints not satisfied 

Заключение

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

На этом на сегодня всё, надеюсь, что читатель узнал из этой статьи что-то новое или освежил хорошо забытое старое.


ссылка на оригинал статьи https://habr.com/ru/articles/822509/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *