Статический и динамический полиморфизм в C++

b7ca77aa92fdebe6dd0dc7e38a2264ba

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

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

Заключение

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

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

© Habrahabr.ru