Привет, Хабр! К сегодняшнему дню написано уже немало учебников и статей по полиморфизму в целом и его воплощения в 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/
Добавить комментарий