Идиома CRTP и написание общих функций в C++

Что такое идиома CRTP?

Термин CRTP (curiously recurring template pattern) появился в 1995 году в одноименной статье, написанной Джеймсом О. Коплином. Он предполагает специализацию базовых классов с использованием производных классов в качестве аргументов шаблона. Выглядит это так:

template <typename T>
class Base {
public:
void interface() {
static_cast<T*>(this)->implementation();
};
};

class Derived : public Base<Derived> {
public:
void implementation() {
std::cout<<"implementation\n";
}
};

Это очень распространенный в интернете пример на тему CRTP. Но после беглого взгляда на него не очень понятно, какую проблему мы хотим решить с помощью этой идиомы. Попробуем разобраться.

Можно догадаться, что, когда мы создаем объект класса Derived, называемый derived, и вызываем derived.interface(), он выводит “implementation”.

Инвертированное наследование

Класс, производный от базового, наследует его функции. Затем производный класс может вызывать функции public и protected, как в следующем примере:

class Base {
public:
virtual void interface() = 0;
void public_function() {}
protected:
void protected_function() {}
};

class Derived : public Base {
public:
virtual void interface() override {
public_function();
protected_function();
}
};

В случае с CRTP все наоборот, базовый класс вызывает функции в производном классе (пример выше).

Это инвертированное наследование, потому что при обычном наследовании мы расширяем функциональность базового класса, тогда как в CRTP в основном расширяется функциональность производного класса путем наследования от базового класса.

Откуда взялось название CRTP?

Вероятно, оно прижилось из-за того, что его легче запомнить, чем F-bounded quantification” (F-ограниченная количественная оценка).

Для чего используется?

Основное, что нужно знать при изучении структур, шаблонов или идиом,  —  это решаемые с их помощью проблемы. Рассмотрим более подробно возможности CRTP.

Добавление общих функций в классы

Всем известно, что следует избегать дублирования кода, чтобы не повторять одни и те же функции в разных классах. Для ООП, вероятно, в первую очередь следует использовать парадигму полиморфизма.

Например, чтобы название типа класса выводилось с помощью оператора typeid, нужна функция PrintType(). Реализация такой функции в базовом классе невозможна, потому что она выведет тип базового класса.

class Base {
public:
void PrintType() const {
std::cout << typeid(*this).name() << "\n";
}
};

class Test1 : public Base {};
class Test2 : public Base {};

int main() {
Test1 test1;
Test2 test2;

test1.PrintType();
test2.PrintType();

return 0;
}

Приведенный выше код дважды выведет “4Base” (с GCC). Это не то, что нам нужно. Решить проблему можно с помощью полиморфизма, сделав PrintType() виртуальным.

class Base {
public:
virtual void PrintType() const {
std::cout << typeid(*this).name() << "\n";
}
};

class Test1 : public Base {
public:
virtual void PrintType() const override {
std::cout << typeid(*this).name() << "\n";
}
};

class Test2 : public Base {
public:
virtual void PrintType() const override {
std::cout << typeid(*this).name() << "\n";
}
};

void Print(const Base& base) {
base.PrintType();
}

int main() {
Test1 test1;
Test2 test2;

test1.PrintType();
test2.PrintType();
Print(test1);
Print(test2);

return 0;
}

Теперь код, как и ожидалось, выводит “5Test1” и “5Test2”. Обратите внимание, что получить те же самые результаты можно, используя функцию Print().

Однако виртуальные функции увеличивают время выполнения из-за динамической диспетчеризации с использованием виртуальных таблиц. Это может быть и не критично для приложения, поскольку здесь все зависит от особенностей применения виртуальных функций, среды исполнения приложения и т. д. Если виртуальные функции используются не так часто, как в цикле, нагрузка для современных процессоров может оказаться незначительной. Однако, если динамическая диспетчеризация все же неприемлема, тогда можно использовать CRTP.

Вот как это выглядит с CRTP:

template <typename Derived>
class Base {
public:
void PrintType() const {
std::cout << typeid(Derived).name() << "\n";
}
};

class Test1 : public Base<Test1> {};
class Test2 : public Base<Test2> {};

template <typename Derived>
void Print(const Base<Derived>& base) {
base.PrintType();
}

int main() {
Test1 test1;
Test2 test2;

test1.PrintType();
test2.PrintType();
Print(test1);
Print(test2);

return 0;
}

Этот код выводит те же результаты, что и предыдущая версия, но при этом является более компактным, поскольку в производных классах не нужна реализация PrintType(). Без использования виртуальных функций здесь нет дополнительных затрат на динамическую диспетчеризацию. Все решается в процессе компиляции.

Видно, что Test1 и Test2 не обязательно должны быть связаны, как в знакомой концепции наследования. Можно рассматривать это как добавление функциональности: чтобы Test1 и Test2 выводили тип, то нужно добавить такую функциональность, наследуя от Base<Derived>.

О статическом полиморфизме

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

CRTP позволяет применять статический полиморфизма вместо динамического. Например, в следующем коде использован хорошо известный класс Animal. При динамическом полиморфизме с виртуальными функциями это выглядит так:

class Animal {
public:
virtual void MakeSound() const = 0;
};

class Duck : public Animal {
public:
virtual void MakeSound() const override {
std::cout << "Quack\n";
}
};

class Cow : public Animal {
public:
virtual void MakeSound() const override {
std::cout << "Moo\n";
}
};

void MakeSound(const Animal& animal) {
animal.MakeSound();
}

А это реализация с CRTP:

template <typename Derived> 
struct Animal {
void MakeSound() const {
static_cast<const Derived*>(this)->Sound();
}
};

struct Duck : Animal<Duck> {
void Sound() const {
std::cout << "Quack\n";
}
};

struct Cow : Animal<Cow> {
void Sound() const {
std::cout << "Moo\n";
}
};

template <typename Derived>
void MakeSound(const Animal<Derived>& animal) {
animal.MakeSound();
}

Такое решение повышает производительность кода. Но если нужен унифицированный интерфейс, в данном случае это функция MakeSound(), то, вероятно, следует использовать во время компиляции латентную типизацию:

struct Duck {
void MakeSound() const {
std::cout << "Quack\n";
}
};

struct Cow {
void MakeSound() const {
std::cout << "Moo\n";
}
};

template <typename Animal>
void MakeSound(const Animal& animal) {
animal.MakeSound();
}

int main()
{
Duck duck;
Cow cow;

MakeSound(duck);
MakeSound(cow);

return 0;
}

Компилятор выдаст ошибку, если объект, которому передается функция MakeSound(), не реализует ::MakeSound().

Да, с CRTP можно реализовать статический полиморфизм. Но при этом нужно выбрать грамотную реализацию классов: динамический или статический полиморфизм с CRTP, время компиляции с латентной типизацией и др.

Почему это возможно?

Теперь посмотрим, почему возможен CRTP. На первый взгляд синтаксис может показаться немного запутанным.

Шаблонная реализация в C++

Еще раз обратимся к примеру выше (здесь тот же самый код).

template <typename Derived>
class Base {
public:
void PrintType() const {
std::cout << typeid(Derived).name() << "\n";
}
};

class Test1 : public Base<Test1> {};
class Test2 : public Base<Test2> {};

int main() {
Test1 test1;
test1.PrintType();
return 0;
}

Когда компилятор создает экземпляр Base<Test1> в строке 9, Test1 передается в качестве аргумента шаблона, даже если Test1 на этом этапе не является полным типом. Однако ошибка компиляции не происходит, потому что в C++ функции-члены шаблонов классов генерируются только в том случае, если они используются/вызываются. Назовем это шаблонным созданием экземпляра.

В этом примере функция-член Print Type() генерируется только в строке 14. На этом этапе Test1 является полным типом и поэтому работает.

Чего нельзя сказать о переменных членах. Следующий код выдаст ошибку компиляции.

template <typename Derived>
class Base {
public:
void PrintType() const {
std::cout << typeid(Derived).name() << "\n";
}
Derived derived;
};

class Test1 : public Base<Test1> {};
class Test2 : public Base<Test2> {};

int main() {
Test1 test1;
test1.PrintType();
return 0;
}

Единственное отличие в строке 7 вызовет следующую ошибку.

error: ‘Base<Derived>::derived’ has incomplete type Derived derived;

Понятно, что, если компилятор не знает, что такое Derived, он не знает структуру памяти Base<Derived>.

Наследование предполагает расширение структуры

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

template <typename T>
class Base {
public:
void interface() {
static_cast<T*>(this)->implementation();
};
private:
int a;
};

class Derived : public Base<Derived> {
public:
void implementation() {
std::cout<<"implementation\n";
}
private:
int x;
};

структура класса Derived выглядит так:

Распределение памяти Derived

Таким образом, использование static_cast для понижающего приведения (преобразуем Base<Derived> в Derived в interface()) в CRTP безопасно, потому что Derived является единственным дочерним элементом Base<Derived>.

Еще одной важной особенностью CRTP является то, что базовый класс должен точно знать тип производного класса, что неизвестно при обычном наследовании. Используя шаблоны, можно этого добиться.

Визуализации

Как отмечено в примерах выше, Test1 и Test2 на самом деле не связаны. Они используют один и тот же шаблон класса для добавления функциональности. Наглядное представление с помощью диаграмм классов показывает два отдельных класса.

Диаграммы классов Test1 и Test2

Заключение

  • При написании кода нужно избегать дублирования, выделяя общие функции.
  • Сделать это можно разными способами, включая динамический полиморфизм (наследование), CRTP и др.
  • Выбираемый способ должен соответствовать потребностям приложения и учитывать эффективность и масштабируемость.
  • CRTP предлагает один из способов для описания общей функциональности.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Debby Nirwan: Writing Common Functionality With CRTP Idiom in C++

Предыдущая статьяКак продвигаться в роли разработчика?
Следующая статьяВнимание: работает пакет Python Tweepy!