Привет, Хабр!

CRTP — это метод в C++, при котором класс наследуется от шаблона класса, используя себя в качестве параметра шаблона. Это выглядит примерно так: класс X наследуется от класса-шаблона Y<X>. Этот паттерн позволяет базовому классу напрямую обращаться к методам производного класса. С помощью CRTP можно можно обогатить интерфейс производного класса, внеся в него дополнительные методы через базовый класс-шаблон.

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

Как реализовать в C++

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

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

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

Пример: счетчик объектов

Создадим CRTP на классе для счетчика объектов:

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

template<typename T>
class Counter {
public:
    Counter() {
        ++created;
        ++alive;
    }

    Counter(const Counter&) {
        ++created;
        ++alive;
    }

    ~Counter() {
        --alive;
    }

    static int howManyAlive() {
        return alive;
    }

    static int howManyCreated() {
        return created;
    }

private:
    static int created;
    static int alive;
};

template<typename T>
int Counter<T>::created(0);

template<typename T>
int Counter<T>::alive(0);

Теперь можно создать производные классы, которые будут наследоваться от Counter, передавая себя в качестве параметра шаблона. Это позволит каждому производному классу иметь свои счетчики созданных и существующих объектов:

class MyClass : public Counter<MyClass> {
    // класс с каким-то функционалом
};

class AnotherClass : public Counter<AnotherClass> {
    // еще один класс с другим функционалом
};

Можно создать объекты этих классов и посмотреть, как работает счетчик, определенный в базовом классе Counter:

int main() {
    MyClass a, b;
    AnotherClass c;

    std::cout << "MyClass alive: " << MyClass::howManyAlive() << std::endl;
    std::cout << "AnotherClass alive: " << AnotherClass::howManyAlive() << std::endl;

    {
        MyClass d;
        std::cout << "MyClass alive (in scope): " << MyClass::howManyAlive() << std::endl;
    }

    std::cout << "MyClass alive (out of scope): " << MyClass::howManyAlive() << std::endl;
    std::cout << "MyClass created: " << MyClass::howManyCreated() << std::endl;
    std::cout << "AnotherClass alive: " << AnotherClass::howManyAlive() << std::endl;
    std::cout << "AnotherClass created: " << AnotherClass::howManyCreated() << std::endl;

    return 0;
}

Пример: статистический полиморфизм

Определим базовый класс Vehicle, который будет использоваться для реализации статического полиморфизма через CRTP.Класс будет содержать метод getNumberOfWheels, который делегирует вызов производному классу:

template<typename Derived>
class Vehicle {
public:
    int getNumberOfWheels() const {
        // статическое приведение к производному типу и вызов его реализации
        return static_cast<const Derived*>(this)->getNumberOfWheelsImpl();
    }
};

Определим несколько производных классов, таких как Car и Bicycle, каждый из которых наследуется от Vehicle, передавая себя в качестве параметра шаблона. Эти классы будут предоставлять собственную реализацию метода getNumberOfWheelsImpl:

class Car : public Vehicle<Car> {
public:
    int getNumberOfWheelsImpl() const {
        return 4; // автомобили обычно имеют 4 колеса
    }
};

class Bicycle : public Vehicle<Bicycle> {
public:
    int getNumberOfWheelsImpl() const {
        return 2; // у велосипедов обычно 2 колеса
    }
};

Теперь можно создать объекты транспортных средств и запросить количество колес, используя единственный интерфейс, предоставляемый базовым классом Vehicle.

int main() {
    Car myCar;
    Bicycle myBicycle;

    std::cout << "Car has " << myCar.getNumberOfWheels() << " wheels.\n";
    std::cout << "Bicycle has " << myBicycle.getNumberOfWheels() << " wheels.\n";

    return 0;
}

Частые ошибки

Скрытие методов

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

Чтобы избежать этой проблемы, нужно адекватно планировать иерархию наследования и именование методов, чтобы предотвратить нежелательное скрытие методов. Также можно использовать квалифицированные вызовы методов через this->method() для явного указания на методы базового класса.

Неопределенное поведение при неправильном наследовании

Если производный класс неправильно использует CRTP, т.е наследуется от Base<AnotherClass> вместо Base<DerivedClass>, это может привести к неопределенному поведению.

Чтобы предотвратить такие ошибки, можно добавить в базовый класс конструктор по умолчанию в закрытой секции и сделать производный класс дружественным.


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

В завершение хочу порекомендовать вам бесплатный урок курса C++ Developer. Professional про контейнеры STL. Регистрация доступна по ссылке.

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


  1. MaxxONE
    04.04.2024 06:31

    Смысл примера не понятен. В чем преимущество перед обычным наследованием?

    код
    #include <iostream>
    
    using namespace std;
    
    class Vehicle {
    public:
        virtual int geNumberOfWheels() const = 0;
    };
    
    class Bicycle: public Vehicle {
    public:
        virtual int geNumberOfWheels() const override {
            return 2;
        }
    };
    
    class Car: public Vehicle {
    public:
        virtual int geNumberOfWheels() const override {
            return 4;
        }
    };
    
    int main()
    {
        Vehicle *one = new Car();
        Vehicle *another = new Bicycle();
        cout << one->geNumberOfWheels() << endl;
        cout << another->geNumberOfWheels() << endl;
        return 0;
    }
    


    1. ZirakZigil
      04.04.2024 06:31
      +6

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


    1. nickolaym
      04.04.2024 06:31
      +2

      Преимущества начинаются, когда у наследников есть полиморфные

      • статические члены: обычными виртуальными функциями их уже не пробросить, нужен механизм словарей/фабрик (в шарпе и яве это сделано на дженериках - ну или ручками, ручками всё)

      • свойства времени компиляции: типы, константы и всё такое - тут уже даже дженерики не спасут

      template<class Impl> class Vehicle {
        // пример статической функции
        static Impl* create_road_train() {
          if (!Impl::can_tow() || !Impl::can_be_towed()) return nullptr;
          Impl* tow = Impl::create();
          Impl* trailer = Impl::create();
          return Impl::bind(tow, trailer);
          // или, может быть
          return tow->bind(trailer);
        }
      
        // пример свойств времени компиляции
        Impl::WheelType wheels_[Impl::number_of_wheels];
      };


      1. ZirakZigil
        04.04.2024 06:31
        +1

        Можно в копилку докинуть возможность запилить частичный интерфейс: базовый класс может детектировать наличие опциональных функций у наследников и вызывать их только если они есть. И всё это проверится статически

        Impl::WheelType wheels_[Impl::number_of_wheels];

        Прямо вот так оно работать не будет, Impl -то неполный в этот момент.


    1. AnimeSlave
      04.04.2024 06:31
      +1

      Обычное наследование использует механизм виртуальных функций, который отнимает некоторое количество циклов процессора при работе, так как программа тратит некоторое время на «поиск» виртуальной функции перед её вызовом. Статический полиморфизм через CRTP избавляет от прослойки в виде виртуальных функций. Следствием является некоторое ускорение выполнение программы при определённых условиях. То есть, если у вас нет задачи выжимать наносекунды при выполнении вашей программы, то CRTP для конкретно вашей задачи не будет иметь преимуществ


  1. Naf2000
    04.04.2024 06:31
    +2

    Стоит упомянуть про нововведение c++23 Explicit object member functions

    https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0847r7.html


  1. Dasfex
    04.04.2024 06:31

    Призываю @Kelbonнакинуть : )


    1. Kelbon
      04.04.2024 06:31

      что тут писать ,это же блог компании отус, итак всё ясно


      1. Dasfex
        04.04.2024 06:31

        Ору.


  1. andy_p
    04.04.2024 06:31

    Я такую штуку делал, когда создавал шаблон для описания графа с вершинами и ребрами. Но я не знал, что это так называется.


    1. nickolaym
      04.04.2024 06:31

      Такую штуку делали в каком-то очень дремучем году, например, Microsoft ATL/WTL.