Что такое идиома 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
выглядит так:
Таким образом, использование static_cast
для понижающего приведения (преобразуем Base<Derived>
в Derived
в interface()
) в CRTP безопасно, потому что Derived
является единственным дочерним элементом Base<Derived>
.
Еще одной важной особенностью CRTP является то, что базовый класс должен точно знать тип производного класса, что неизвестно при обычном наследовании. Используя шаблоны, можно этого добиться.
Визуализации
Как отмечено в примерах выше, Test1
и Test2
на самом деле не связаны. Они используют один и тот же шаблон класса для добавления функциональности. Наглядное представление с помощью диаграмм классов показывает два отдельных класса.
Заключение
- При написании кода нужно избегать дублирования, выделяя общие функции.
- Сделать это можно разными способами, включая динамический полиморфизм (наследование), CRTP и др.
- Выбираемый способ должен соответствовать потребностям приложения и учитывать эффективность и масштабируемость.
- CRTP предлагает один из способов для описания общей функциональности.
Читайте также:
- Эффективная передача сообщений между процессами в C++
- Как продвигаться в роли разработчика?
- Зачем переходить с Gitbook на Readme
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Debby Nirwan: Writing Common Functionality With CRTP Idiom in C++