Pull to refresh

CRTP. Static polymorphism. MixIn. Размышления на тему

Reading time9 min
Views38K
В этом посте я поразмышляю на тему статического полиморфизма в С++, архитектурных решениях, строящихся на его основе. Рассмотрю интересную идиому — CRTP. Приведу несколько примеров ее использования. В частности, рассмотрю концепцию MixIn классов. Пишу, чтобы систематизировать собственные знания, но может быть и вы сможете найти что-то интересное для себя.

Введение


Как известно, С++ является мультипарадигмовым языком. На нем можно писать в процедурном стиле, использовать языковые конструкции, обеспечивающие поддержку объектно-ориентированного программирования, шаблоны делают возможным обобщенное программирование, STL и новые возможности языка (lambda, std::function, std::bind) позволяют при желании писать в функциональном стиле в рантайме, а метапрограммирование шаблонов представляет собой функциональное программирование в чистом виде в compile time.
Несмотря на то, что в любой реальной большой программе скорее всего можно встретить смесь всех этих техник, объектно-ориентированная парадигма, реализуемая с помощью концепции классов, открытого интерфейса и закрытой реализации (инкапсуляция), наследования, и динамического полиморфизма, реализуемого посредством виртуальных функций, несомненно, является наиболее широко используемой.

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

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


В то время как динамический полиморфизм является полиморфизмом времени выполнения и явных интерфейсов, статический полиморфизм является полиморфизмом времени компиляции и неявных интерфейсов. Давайте разберемся что это значит.
Глядя на следующий код

void process(base* b)
{
	b->prepare();
	b->work();
	...
}


мы можем сказать следующее: переданный в функцию process() указатель, должен указывать на объект, реализующий интерфейс (наследующий) base, и выбор реализаций функций prepare() и work() будет осуществлен во время выполнения программы в зависимости от того на объект какого именно производного от base типа указывает b.

Если же мы рассмотрим следущий код:

template<typename T>
void process(T t)
{
	t.prepare();
	t.work();
}

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

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

Объективные же причины можно так или иначе свести к тому, что после инстанциирования шаблонные классы (функции) имеют разные, часто никак не связанные друг с другом типы.
Почему это плохо? Объекты таких типов без дополнительных ухищрений (см. boost::variant, boost::tuple, boost::any, boost::fusion etc.) невозможно положить в один контейнер и следовательно пакетно обработать. Невозможно, например, во время исполнения подменить объект — член класса, объектом другого типа, в рамках реализации “Стратегии” или “Состояния”. И хотя и эти паттерны можно реализовать и другими способами без классовых иерархий, например используя std::function или просто указатели на функции, ограничение, тем не менее, на лицо.

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

CRTP


CRTP (Curiously Recurring Template Pattern) — это идиома проектирования, заключающаяся в том, что класс наследует от базового шаблонного класса с самим собой в качестве параметра шаблона базового класса. Звучит запутано, но в коде выглядит довольно просто.

template <class T> class base{};
class derived : public base<derived> {};


Что это может нам дать? Такая конструкция делает возможным обращение к производному классу из базового.

template<typename D>
struct base
{
	void foo() {static_cast<D*>(this)->bar();}
};

struct derived : base<derived>
{
	void bar();
};


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

Явный интерфейс


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

template<typename D>
struct base_worker
{
	void work() {static_cast<D*>(this)->work_impl();}
    void prepare() {static_cast<D*>(this)->prepare_impl();}
};

struct some_concrete_worker : base_worker<some_concrete_worker>
{
	void work_impl();    // Без этих функций вызывающий
	void prepare_impl(); // код не скомпилируется
};

template<typename Worker>
void polymorhic_work(const Worker& w)
{
	w.prepare();
	w.work();
};


int main()
{
	some_concrete_worker w1;
	some_concrete_worker_2 w2;
	polymorhic_work(w1); // Скомпилируется только при наличии
	polymorhic_work(w2); // функций prepare_impl() и work_impl() в w1 и w2
}


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

MixIn


MixIn — это прием проектирования, при котором класс (интерфейс, модуль и т.п.) реализует некоторую функциональность, которую можно “подмешать”, внести в другой класс. Самостоятельно же MixIn класс обычно не используется. Этот прием не является специфическим для С++, и в некоторых других языках он поддерживается на уровне языковых конструкций.
В С++ нет нативной поддержки MixIn’ов, но тем не менее эту идиому вполне можно реализовать с помощью CRTP.
Например, MixIn класс может реализовывать функциональность синглтона или подсчета ссылок на объект. А для того чтобы использовать такой класс достаточно отнаследовать от него с “собой” в качестве параметра шаблона.

template<typename D>
struct singleton{...};

class my_class : public singleton<my_class>{...};


Зачем здесь CRTP? Почему бы просто не наследовать от класса, реализующую некую нужную нам функциональность?

struct singleton{...};
class my_class : singleton{...};


Дело в том, что внутри MixIn’а нам нужен доступ к функциям наследуемого класса (в случае синглтона к конструктору) и здесь на помощь приходит CRTP. И если пример с синглтоном кажется надуманным (действительно, кто сегодня использует синглтон?), то ниже вы найдете два более близких к реальности примера.

Enable_shared_from_this

MixIn структура (boost)std::enable_shared_from_this позволяет получить shared_ptr на объект, не создавая новую группу владения.

struct bad
{
	std::shared_ptr<bad> get() {return std::shared_ptr<bad>(this);}
};


В этом случае каждый shared_ptr, полученный с помощью функции bad::get(), открывает новую группу владения объектом, и когда настанет время уничтожения shared_ptr’ов, delete для нашего объекта вызовется больше чем один раз.

Правильно же делать вот так:

struct good : std::enable_shared_from_this<good>
{
	std::shared_ptr<good> get() 
  {
    return shared_from_this(); // Эта функция наследуется
                           // из enable_shared_from_this
  }
};


Устроена эта вспомогательная структура примерно так:

template<typename T>
struct enable_shared
{
	weak_ptr<T> t_;
	enable_shared()
	{
		t_ = weak_ptr<T>(static_cast<T*>(this));
	}
	shared_ptr<T> shared_from_this()
	{
		return shared_ptr<T>(t_); 
	}
};


Как видите, здесь CRTP позволяет базовому классу “увидеть” тип производного класса и вернуть указатель именно на него.

MixIn функции

MixIn функциональность не обязательно должна быть включена внутрь некоторого класса. Иногда возможно ее реализовать в виде свободной функции. В качестве примера реализуем оператор “!=” для всех классов, у которых определен оператор “==”.

template<typename D>
struct non_equalable{};

template<typename D>
bool operator != (const non_equalable<D>& lhs, const non_equalable<D>& rhs)
{
    return !(static_cast<const D&>(lhs) == static_cast<const D&>(rhs));
}


Как видите, внутри operator != мы используем тот факт, что non_equalable может воспользоваться оператором “==”, определенным в производном типе.
Использовать этот MixIn можно следующим образом:

struct some_struct : non_equalable<some_struct>
{
    some_struct(int w) : i_(w){}
    int i_;
};

bool operator == (const some_struct& lhs, const some_struct& rhs)
{
    return lhs.i_ == rhs.i_;
}
 
int main()
{
    some_struct s1(3);
    some_struct s2(4);
    std::cout << (s1 != s2) << std::endl;
}


MixIn наоборот


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

class space_ship
{
public:
    // ...
    void move()
    {
        if(!fuel()) return;
        int current_speed = speed();
	  // further actions ...
    }
    virtual ~space_ship(){}
private:
    virtual bool fuel() const = 0;
    virtual int speed() const = 0;
};


class interceptor : public space_ship
{
public:
    // ...
private:
    bool fuel() const { ... }
    int speed() const { ... }
};

class other_ship : public space_ship { ... };
class other_ship_2 : public space_ship { ... };
// …


Теперь попробуем применить CRTP.

template<typename D>
class space_ship
{
public:
    void move()
    {
        if(!static_cast<D*>(this)->fuel())
            return;
        int current_speed = static_cast<D*>(this)->speed();
	  // ...
    }
};


class interceptor : public space_ship<interceptor>
{
public:
    bool fuel() const;
    int speed() const;
};


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

Концепция MixIn’а при таком подходе переворачивается с ног на голову. Основная работа делается в базовом классе, а дополнительную (различающуюся) функциональность мы “подмешиваем” из производных классов.

Хочу акцентировать ваше внимание на этом приеме проектирования и Mixin’ах в целом. Пусть вас не смущает искусственный пример с космическими кораблями или синглтоном. В реальных задачах этот подход позволяет строить очень гибкие архитектуры, избегать повторяющегося кода, локализовывать функциональность в небольших классах и впоследствии “микшировать” их в нужную в данный момент смесь. Особенно он начинает блистать в кооперации со средствами, позволяющими пакетно обрабатывать множество объектов разных типов (см. boost::fusion).

MixIn вариации


Главная теорема разработки программного обеспечения (FTSE) гласит: “Любую проблему можно решить, вводя дополнительные уровни косвенности”. Посмотрим, как это можно применить к CRTP MixIn’ам.
Возможно, вы заметили в предыдущих главах “Явный интерфейс” и “MixIn наоборот” я использовал открытые функции в производном классе. Вообще говоря, это не очень хорошо, так как нарушает инкапсуляцию. Получается, что наружу у нас “торчат” функции, которые не предназначены для того, что пользователь вызывал их напрямую.
Можно решить эту проблему, сделав базовые классы друзьями производного. После этого можно вносить эти функции в private секцию, но представьте, что вам нужно отнаследовать от нескольких базовых MixIn’ов. Придется делать друзьями все базовые классы. Для комплексного решения этой проблемы, а также для того, чтобы обеспечить компиляцию на некоторых старых компиляторах, можно ввести новый уровень косвенности. Он представляет из себя структуру, функции которой перенаправляют вызовы из базы в производный класс.

struct access
{
    template<typename Impl>
    static void on_handle_connect(Impl* impl) {impl->handle_connect();}

    template<typename Impl>
    static void on_handle_response(Impl* impl) {impl->handle_response();}
};


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

template<typename D>
struct connection_handler
{
    // ...
    void on_connection()
    {
        access::on_handle_connect(static_cast<D*>(this));
    }
};

template<typename D>
struct response_handler
{
    // ...
    void on_response()
    {
        access::on_handle_response(static_cast<D*>(this));
    }
};


В производном же классе нам достаточно внести в друзья только структуру access.

class combined_handler : public connection_handler<worker>, 
public response_handler<worker>
{
private:
    friend struct access;
    void handle_connect(){ std::cout << __PRETTY_FUNCTION__ << std::endl; }
    void handle_response(){ std::cout << __PRETTY_FUNCTION__ << std::endl; }
};


К дополнительным плюсам такого подхода можно отнести то, что базовые классы перестают что либо “знать” о своих производных классах, в частности какие конкретные функции из них нужно вызвать, а слабосвязанная система, как правило более гибка, чем сильносвязанная, а также то, что все вызовы к производному классу собраны в одном месте (в структуре access), позволяя тем самым легче визуально отделить их от функций производного класса, выполняющих другую работу.
Минусом же, как это часто бывает, является усложнение проектного решения. Поэтому я ни в коем случае не призываю использовать такую схему и в хвост и в гриву, но иметь о ней представление, мне кажется, не будет лишним.
Tags:
Hubs:
+32
Comments6

Articles