Введение

Паттерн Шаблонный Метод (Template Method), описанный в книге по паттернам проектирования за авторством “банды четырех” (GoF), не связан с шаблонами (templates) C++ и является поведенческим шаблоном. Curiously Recurring Template Pattern (CRTP или “странно повторяющийся шаблон”) является усовершенствованием паттерна Шаблонный Метод и представляет собой идиому C++, в которой класс X наследуется от реализации шаблонного класса, используя сам X в качестве шаблонного аргумента. Название этой идиоме было дано Джимом Коплиеном (Jim Coplien), который наблюдал ее в самых первых образцах шаблонного кода C++. Эта методика позволяет достигнуть эффекта, аналогичного использованию виртуальных функций, без накладных расходов (и некоторой гибкости) динамического полиморфизма. CRTP можно использовать вместо Шаблонного Метода при условии, что вам не нужен динамический полиморфизм во время выполнения. Этот паттерн широко используется в библиотеках Windows ATL и WTL.

Шаблонный Метод

Давайте сначала рассмотрим классический паттерн Шаблонный Метод. В своей работе Шаблонный Метод полагается на полиморфизм и, как вы могли догадаться из названия, шаблонный метод. В нашем примере абстрактный базовый класс AbstractTextout имеет 11 перегрузок функции Print и одну чисто виртуальную функцию Process, которая будет реализована только в производных классах. Среди возможных примеров полезных производных классов можно выделить логирование, вывод на консоль и вывод отладочной информации. В этом руководстве мы ограничимся реализацией класса для вывода отладочной информации.

class AbstractTextout
{
public:
    void Print(const wchar_t* fmt);
    // ... плюс еще 10 других перегруженных Print с разным количеством аргументов Box
protected:
    virtual void Process(const std::wstring& str) = 0;
};

Ниже вы можете наблюдать код одной из функций Print. Аргумент Box отвечает за преобразование POD (plain old data) в строку. Отличие от остальных перегрузок функции Print заключается в том, что они просто набивают больше аргументов Box в vs. Я не буду вдаваться в подробности реализации класса Box, так как цель этой статьи заключается в другом. Вы можете посмотреть ее в полном коде примера, ссылки на который приведены в конце статьи.

void AbstractTextout::Print(const wchar_t* fmt, Box D1)
{
    std::wstring wsfmtstr = fmt;

    std::vector<std::wstring> vs;
    vs.push_back( D1.ToString() );

    std::wstring str = StrReplace( wsfmtstr, vs );

    Process(str); // реализуется только в производном классе.
}

Вот как производный класс DebugPrint реализует функцию Process в DebugPrint.cpp:

void Process(const std::wstring& str)
{
#ifdef _DEBUG
    OutputDebugStringW( str.c_str() );
#endif
}

А так мы взаимодействуем с классом DebugPrint:

#include "DebugPrint.h"

void main()
{
    DebugPrint dp;

    dp.Print(L"Product:{0}, Qty:{1}, Price is ${2}\n", L"Shampoo", 1200, 2.65);
    // выводит "Product:Shampoo, Qty:1200, Price is $2.650000"
}

Для класса с виртуальными функциями создается специальная виртуальная таблица (vtbl). И конечно же наличие виртуальной таблицы подразумевает накладные расходы, связанные с определением правильной функции для вызова. Curiously Recurring Template Pattern же использует статический полиморфизм, который устраняет необходимость в этих накладных расходах. В следующем разделе мы разберемся, за счет чего это достигается.

Curiously Recurring Template Pattern

AbstractTextout теперь является шаблонным классом, что означает, что весь код, определенный в cpp, должен быть перемещен в заголовочный файл. Прежде чем вызывать Process, код сначала приводит (cast) себя к производному типу.

template <typename Derived> 
class AbstractTextout
{
public:
    void Print(const wchar_t* fmt, Box D1 )
    {
        std::wstring wsfmtstr = fmt;

        std::vector<std::wstring> vs;
        vs.push_back( D1.ToString() );

        std::wstring str = StrReplace( wsfmtstr, vs );

        static_cast<Derived*>(this)->Process(str);
    }
}

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

class DebugPrint : public AbstractTextout<DebugPrint>
{
public:
    void Process(const std::wstring& str)
    {
#ifdef _DEBUG
        OutputDebugStringW( str.c_str() );
#endif
    }
};

Использовать класс DebugPrint мы будем точно так же, как и раньше:

#include "DebugPrint.h"

void main()
{
    DebugPrint dp;

    dp.Print(L"Product:{0}, Qty:{1}, Price is ${2}\n", L"Shampoo", 1200, 2.65);
    // выводит "Product:Shampoo, Qty:1200, Price is $2.650000"
}

Существует предвзятое мнение, что с C++ даже простую программу писать долго. Современный С++ и набор его библиотек легко могут опровергнуть это. Приглашаю вас на вебинар, где за 40 минут практической части мы создадим настоящий сетевой сервис на языке C++ с использованием библиотеки Boost.Asio.

Полезные ссылки 

Лицензия

Эта статья вместе со всем исходным кодом и файлами находится под лицензией Code Project Open License (CPOL)

Код примеров

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


  1. Sklott
    04.08.2023 13:23
    +3

    мы должны сделать функцию Process не виртуальной и переместить ее в заголовочный файл

    Перемещать имплементацию функции Process в заголовочный файл вовсе не обязательно, класс DebugPrint  не является темплейтом и имплементация его функций вполне может остаться в .cpp файле.

    И кстати, если уж взялись писать про CRTP можно было бы рассказать насколько проще и удобней эту функциональность можно сделать в C++23.


  1. old2ev
    04.08.2023 13:23
    +1

    CRTP так же довольно удобно использовать для реализации шаблонных интерфейсов взаимодействия между множеством классов-наследников от CRTP-интерфейса, не задействуя при этом виртуальные функции. Поскльку все наследники будут явно иметь методы CRTP-интерфейса, и при этом наилучшим вариантом будет применять ограничения шаблона через requires, чтобы шаблонные методы не инстанциировались на объекты иных типов.

    Для ограничения шаблонов взаимодействия CRTP-наследников обычно использую в requires концепт is_crtp_base_of_v из объявления ниже:

    #include <type_traits> 
    #include <concepts>
    
     template <class T> 
     struct remove_cvref : std::remove_cv<std::remove_reference_t<T>> {}; 
      
     template <class T> 
     using remove_cvref_t = typename remove_cvref<T>::type; 
      
     template <class T, class U> 
     struct is_base_of : std::is_base_of<remove_cvref_t<T>, remove_cvref_t<U>> {}; 
      
     template <class T, class U> 
     concept is_base_of_v = is_base_of<T, U>::value; 
      
     template <template<typename T> class CRTP_Base, class CRTP_Derived> 
     struct is_crtp_base_of : std::is_base_of<remove_cvref_t<CRTP_Base<remove_cvref_t<CRTP_Derived>>>, remove_cvref_t<CRTP_Derived>> {}; 
      
     template <template<typename T> class CRTP_Base, class CRTP_Derived> 
     concept is_crtp_base_of_v = is_crtp_base_of<CRTP_Base, CRTP_Derived>::value;
    

    Допустим, нам нужно чтобы CRTP-наследники могли сравниваться между собой:

    // В теле CRTP-интерфейса Base
    // Где: Derived - наследник передаваемый через шаблон
    
    int getValue() const {
      return static_cast<Derived*>(this)->getValueImpl();
    }
    
    template<typename T>
    requires is_crtp_base_of_v<Base, T>
    bool operator==(T&& other) const { return getValue() == other.getValue(); }
    
    // Предполагается что метод Derived::getValueImpl приватный/защищённый,
    // чтобы лишние "потраха" не торчали наружу, и при этом CRTP-интерфейс Base дружественный
    // для Derived, в ином случае (если метод public) в шаблоне оператора можно напрямую
    // цеплять getValueImpl (ну или другой метод) без лишних обёрток, но если не будет указанного выше
    // метода Base<Derived>::getValue то нет и гарантий, что наследник интерфейса обязан содержать
    // метод getValueImpl, в следствии чего об ошибке мы узнаем только
    // при инстанциировании оператора сравнения, так что лучше использовать
    // подобную обёртку как контракт того что функция getValueImpl есть в наследнике, и если обёртка нигде не нужна,
    // то просто бъявить её как private с модификатором inline в CRTP-интерфейсе.
    

    На выходе мы имеем результат, что все наследники CRTP-интерфейса могут взаимодействовать между друг-другом без каких-либо побочных эффектов и торчащих наружу "кишков" - полиморфизм на компилтайме.