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

Заключение

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

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

Комментарии (22)


  1. mmMike
    18.06.2024 02:49

    Однако, к моему удивлению, при описании полиморфизма никто (или почти никто) не затрагивает тот факт, что помимо динамического полиморфизма в C++ имеется и достаточно мощная возможность использования его младшего брата – полиморфизма статического.

    Наверное потому, что ни у кого не возникает желания придумывать собственную терминологию.
    Шаблоны - одно. Таблица виртуальных функций - другое. И не надо, pls, придумывать им свои термины "динамический/статический полиморфизм" и пр.
    Тем более, что использование слова "динамический" к результату компиляции (ассемблерный код процессора реализующий выбор виртуальной" функции) и противопоставление это шаблонам (то же ассемблерный код процессора) весьма спорно.

    Так, использование шаблонов позволяет немного сэкономить время при выполнении программы

    может все же при разработке программы?

    Шаблоны в С++ это "ой. макросами как то не очень. Давайте придумаем новый инструмент".
    С++ стар и полон концептуальных заплат (надо поддержать совместимость).


    1. voldemar_d
      18.06.2024 02:49

      Шаблоны в С++ это

      А где это лучше, и чем?


      1. mmMike
        18.06.2024 02:49

        Появление и развитие C++ от C наблюдал своими глазами (и лет 15 на C/C++ активно писал с 1990).
        Стандарты и "куда идем" определяются конкретными людьми и группами и модными на данный момент подходами. Появление STL либ и обсуждение этой темы то же помню (быть или не быть). И код где STL пихался куда только можно (ну модно же. "современная" концепция шаблонов жж).

        Особого смысла спорить и пережевывать это не вижу. "На вкус и цвет все фломастеры разные".
        И что получилось - то получилось в С++. Смысл спорить с зафиксированной данностью.


        1. voldemar_d
          18.06.2024 02:49

          Я в курсе всего этого. Вы так рассуждаете, как будто это чем-то плохо. Шаблоны выполняют свою задачу, что ещё надо? Хотелось бы лучше, как в каких-то других языках? Вот я и пытаюсь понять, в каких, и чего не хватает.

          И код где STL пихался куда только можно

          А с STL-то что не так? Просто не нравится? Кому не нравится, пишут велосипеды или пользуются другими библиотеками. Это всё тоже зафиксированная данность.


          1. mmMike
            18.06.2024 02:49

            Под "код пихался куда только можно" я имею в виду чужие исходники (прикладные не либы) где определяется STL класс и используется только с одном типом и в принципе (100%) не предполагается использовать с другим типом. И этим грешили многие.

            Просто потому что (предполагаю) человек(и) изучал STL и впихнул его "потому что модно".

            Ничего против шаблонов в хорошо продуманном коде не имею.


            1. voldemar_d
              18.06.2024 02:49

              Просто потому что (предполагаю) человек(и) изучал STL и впихнул его "потому что модно".

              Честно, не понял, о чем Вы. Пример кода можете привести?


              1. mmMike
                18.06.2024 02:49
                +1

                что то типа h файл в котором

                template<class T>
                class BlaBla {
                ..

                А потом использование этого класса/шаблона с только одним типом. Потому что в принципе не предполагалось других типов.
                На вопрос "а зачем делать через шаблон?" ответ "ну... так красивее и модно" (давно было).


                1. voldemar_d
                  18.06.2024 02:49

                  Теперь понятно.

                  Я и другое видел: есть базовый класс с виртуальной функцией. От него наследован производный класс, в нём своя реализация этой виртуальной функции. В программе используется только один экземпляр производного класса, больше классов нет. Зачем это всё, никто сказать не может.


                  1. mmMike
                    18.06.2024 02:49

                    ну это то же самое "не правильно спроектировано" или "Изучил тему чего то и теперь везде это пихаю".
                    ну или "авось пригодится" но.. в 99% случаях не пригождается


                    1. voldemar_d
                      18.06.2024 02:49
                      +1

                      Только, ИМХО, влияние STL тут ни при чём.


                1. eao197
                  18.06.2024 02:49
                  +4

                  Так а при чем здесь STL вообще?


                  1. mmMike
                    18.06.2024 02:49

                    Это просто иллюстрация к фразе "код пихался куда только можно"


                    1. eao197
                      18.06.2024 02:49

                      У вас не получилось донести свою мысль (если таковая вообще была).

                      Можно предположить, что вы недовольны тем, что после появления STL разработчики на C++ стали активно использовать шаблоны. Возможно вы про это.

                      Ну так у меня для вас плохие новости. Шаблоны в C++ появились еще до появления STL, и начали широко использоваться (насколько это допускали тогдашние компиляторы) еще до того, как STL вошла в стандарт C++. И даже до того, как этот самый стандарт C++ был принят.


                      1. mmMike
                        18.06.2024 02:49

                        Я вас чем то персонально задел, обидел?
                        Есть люди, которых можно назвать "токсичным" и "душным".

                        Вы додумываете за другого что то, а потом на ровном месте пытаетесь оскорбить используя придуманные Вами же мысли и недосказанности собеседника.
                        Даже не буду пытаться дискутировать и опровергать придуманные Вами за меня мои мысли. Это путь в никуда.

                        Уже после того как написал, пробежался бегло по вашим комментам в других темах.
                        Бр.. не дай боже с таким человеком вместе работать.
                        Что то у Вас в жизни не так..


                      1. eao197
                        18.06.2024 02:49

                        Вы пришли на публичную площадку и высказались так, что вас оказалось непросто понять. Например (лишнее поскипано):

                        я имею в виду чужие исходники (прикладные не либы) где определяется STL класс...

                        Что такое "STL класс"?

                        STL -- это standard template library. Как кто-то может взять и определить класс из standard template library?

                        У вас пытаются выяснить что вы имеете в виду, вы не можете ничего внятного ответить. Выяснить же пытаются для того, чтобы понять что вы сказали.

                        Если вам пофиг на то, что ваши же слова пытаются воспринять всерьез, то OK.


                1. rukhi7
                  18.06.2024 02:49
                  +2

                  А потом использование этого класса/шаблона с только одним типом. Потому что в принципе не предполагалось других типов.

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

                  return this->worth / 500;

                  return this->worth / 200;

                  return this->worth / 1000;

                  вместо того чтобы завести параметр в исходном классе и инициализировать его при создании объекта, мы нагородили модный полиморфизм! Круто же!

                  Это очень показательная статья о том как превратить CPP в настоящий Copy Past Programming.


        1. kekoz
          18.06.2024 02:49

          Привет, коллега. Я вот тоже активно использовал C++ ещё со времён второго Cfront, но то, куда двинулся язык после внедрения шаблонов, меня сначала изрядно тормознуло, а позже и вовсе отвратило от него. Нахожу весьма забавным и показательным тот факт, что А. Степанов глубоко разочарован тем, во что эволюционировал C++ с тех пор, как он создал STL. И немалую роль в этом всём сыграл создатель C++, который из своего тщеславия соглашался практически на всё, что предлагали члены WG (а тут важно понимать, что немалая их часть — вовсе не самостоятельные люди, а лоббисты разных групп, хотелки которых нередко конфликтуют). Ох, не зря Томпсон всю дорогу в AT&T чморил и C++, и самого Страуструпа, чем регулярно вызывал у последнего полыхание пятой точки.


    1. NeoCode
      18.06.2024 02:49
      +11

      Терминология (статический/динамический полиморфизм и т.д.) абсолютно верная. Просто нужно воспринимать ее не как терминологию конкнретного языка, а как терминологию программирования в целом.


    1. big_dig_dev
      18.06.2024 02:49
      +7

      И не надо, pls, придумывать им свои термины "динамический/статический полиморфизм" и пр

      Это широко распространненная терминология. Просто как пример:
      Джосаттис Николаи М., Грегор Дуглас, Вандевурд Дэвид. Шаблоны C++. Справочник разработчика. Второе издание. Часть 3, Глава 22: Статический и Динамический полиморфизм. (стр. 581)
      И это еще до C++20 и концептов концептов.

      может все же при разработке программы?

      Подозреваю, что именно при выполнении, так как не происхоит косвенных вызовов по vtable.

      Шаблоны в С++ это "ой. макросами как то не очень. Давайте придумаем новый инструмент".

      Это не так даже в первом приближении.

      ИМХО, статический полиморфизм хорошая альтернатива динамическому в очень многих ситуациях.


      1. mmMike
        18.06.2024 02:49

        Это не так даже в первом приближении.

        Я имел в виду не "сейчас", а то как это появлялось на базе первых разработок STL в HP.
        Концепция шаблонизирования, которая вошла в стандарт это уже конечно не макросы.


    1. nronnie
      18.06.2024 02:49

      На самом деле термин давно есть, только, как я помню, это принято называть не "статический", а "параметрический".


  1. Kyoki
    18.06.2024 02:49
    +6

    Однако, к моему удивлению, при описании полиморфизма никто (или почти никто) не затрагивает тот факт, что помимо динамического полиморфизма в C++ имеется и достаточно мощная возможность использования его младшего брата – полиморфизма статического

    Серьезно. Вы смотрели видео с cppcon? А про CRTP слышали? Люди даже вариации vtable пишут...