Для вызова ВФ необходимо считать адрес из таблицы ВФ, и только после этого вызывать. У нас же обращение прямое.
Все это, безусловно, делает CRTP быстрее ВФ. Или не делает? Есть только один способ убедится в нашей теории – измерить!
Используем вышеуказанные классы и добавим вариант с ВФ. Код получился несколько длинным, но достаточно простым; весь код содержится в одном файле:
#include <numeric>
#include <vector>
#include <algorithm>
#include <chrono>
#include <iostream>
#include <string>
#include <ctime>
class VirtualBase
{
public:
virtual void increment() = 0;
virtual void decrement() = 0;
virtual void doubleCounter() = 0;
virtual void halfCounter() = 0;
virtual void print() = 0;
};
class VirtualImpl: public VirtualBase
{
public:
void increment() override
{
m_Counter++;
}
void decrement() override
{
m_Counter--;
}
void doubleCounter() override
{
m_Counter *= 2;
}
void halfCounter() override
{
m_Counter /= 2;
}
void print()
{
std::cout << "Virtual Impl counter: " << m_Counter << "\n";
}
private:
int m_Counter = 0;
};
template<typename Derived>
class CrtpBase
{
public:
void increment()
{
self()->incrementImpl();
}
void decrement()
{
self()->decrementImpl();
}
void doubleCounter()
{
self()->doubleCounterImpl();
}
void halfCounter()
{
self()->halfCounterImpl();
}
void print()
{
self()->printImpl();
}
private:
Derived* self()
{
return static_cast<Derived*>(this);
}
};
class CrtpImpl: public CrtpBase<CrtpImpl>
{
friend class CrtpBase<CrtpImpl>;
private:
void incrementImpl()
{
m_Counter++;
}
void decrementImpl()
{
m_Counter--;
}
void doubleCounterImpl()
{
m_Counter *= 2;
}
void halfCounterImpl()
{
m_Counter /= 2;
}
void printImpl()
{
std::cout << "Crtp Impl counter: " << m_Counter << "\n";
}
private:
int m_Counter = 0;
};
template<typename Impl>
void executeCrtp(CrtpBase<Impl>& crtp,
const std::vector<unsigned>& indicies)
{
for(auto index : indicies)
{
switch(index)
{
case 0:
crtp.increment();
break;
case 1:
crtp.decrement();
break;
case 2:
crtp.doubleCounter();
break;
case 3:
crtp.halfCounter();
break;
}
}
}
void executeVirtual(VirtualBase& virt,
const std::vector<unsigned>& indicies)
{
for(auto index : indicies)
{
switch(index)
{
case 0:
virt.increment();
break;
case 1:
virt.decrement();
break;
case 2:
virt.doubleCounter();
break;
case 3:
virt.halfCounter();
break;
}
}
}
int main()
{
std::vector<unsigned> indicies(10000000);
unsigned counter = 0;
std::generate(std::begin(indicies), std::end(indicies),
[&counter]{return counter++ % 4; });
std::srand(static_cast<unsigned>(std::time(0)));
std::random_shuffle(std::begin(indicies), std::end(indicies));
CrtpImpl crtpImpl;
VirtualImpl virtImpl;
auto start = std::chrono::high_resolution_clock::now();
executeCrtp(crtpImpl, indicies);
auto crtpElapsed = (std::chrono::high_resolution_clock::now() - start).count();
std::cout << "CRTP time elapsed: " << crtpElapsed << "\n";
start = std::chrono::high_resolution_clock::now();
executeVirtual(virtImpl, indicies);
auto virtElapsed = (std::chrono::high_resolution_clock::now() - start).count();
std::cout << "Virtual time elapsed: " << virtElapsed << "\n";
float ratio = std::max<float>(virtElapsed, crtpElapsed)/
std::min<float>(virtElapsed, crtpElapsed);
std::string relation = crtpElapsed < virtElapsed ?
std::string("faster") : std::string("slower");
std::cout << "CRTP is " << ratio << " " << relation << "!\n";
//Force implementation to not optimize out crtpImpl&virtImpl objects
crtpImpl.print();
virtImpl.print();
std::cout << "CRTP size: " << sizeof(CrtpImpl) << "\n";
std::cout << "Virtual size: " << sizeof(VirtualImpl) << "\n";
return 0;
}
Как вы можете видеть мы используем 10 миллионов вызовов различных методов объектов CrtpImpl и VirtImpl. Введение элемента случайности, большое количество вызовов и использование разных методов служат одной цели: нивелировать влияние предсказателя ветвлений(branch predictor). Так как мы используем один массив индексов функций, то можно говорить о некой честности, при анализе производительности двух различных методов вызовов функций. Давайте посмотрим на результаты:
MSVC2013
|
GCC 4.9
|
Clang(Apple LLVM version 5.0)
|
CRTP time elapsed: 630157 Virtual time elapsed: 570133 CRTP is 1.10528 slower! Crtp Impl counter: -26613 Virtual Impl counter: -26613 CRTP size: 4 Virtual size: 16
|
CRTP time elapsed: 70539291 Virtual time elapsed: 76674468 CRTP is 1.08698 faster! Crtp Impl counter: 3142498 Virtual Impl counter: 3142498 CRTP size: 4 Virtual size: 16
|
CRTP time elapsed: 87236368 Virtual time elapsed: 108518577 CRTP is 1.24396 faster! Crtp Impl counter: -147204 Virtual Impl counter: -147204 CRTP size: 4 Virtual size: 16
|
Ну как, впечатляют результаты? Я думаю,что не очень. CRTP оказался медленнее на MSVC, быстрее на 8% при использование gcc(Ubuntu 14.04), и имеет преимущество в 24% при использование clang(OS X 10.8). Не густо, прямо скажем. Надо сказать, что результат довольно стабильный на gcc и clang, в то время как на MSVC он скачет то в плюс, то в минус. Поэтому разницу между ВФ и CRTP в gcc и MSVC можно считать погрешностью – CRTP и ВФ имеют одинаковую производительность в данном тесте, на данных компиляторах. Почему так происходит? Я не изучал код, сгенерированный gcc, но MSVC провёл девиртуализацию и встроил все виртуальные функции точно так же, как он поступил и с шаблонным классом. Разницы в сгенерированном коде нет никакой. Я полагаю, что gcc сделал тоже самое. А это значит, что наше предположение, о преимуществах шаблонов перед ВФ, за счёт встраивания, не верно! По крайней мере в данном, конкретном случае.
Что же, пример банален и прост, но реальные программы сложнее. В реальных программах у нас много модулей, а не один. А много модулей может уже и не позволить встроить ВФ, с шаблонами же такой проблемы нет – они всегда полностью видны в каждом используемом модуле. Поэтому предлагаю разбить наш проект на модули, и проверить, что же мы получим в результате:
Classes.h:
#pragma once
#include <iostream>
#include <vector>
class VirtualBase
{
public:
virtual void increment() = 0;
virtual void decrement() = 0;
virtual void doubleCounter() = 0;
virtual void halfCounter() = 0;
virtual void print() = 0;
};
class VirtualImpl : public VirtualBase
{
public:
void increment() override;
void decrement() override;
void doubleCounter() override;
void halfCounter() override;
void print();
private:
int m_Counter = 0;
};
template<typename Derived>
class CrtpBase
{
public:
void increment()
{
self()->incrementImpl();
}
void decrement()
{
self()->decrementImpl();
}
void doubleCounter()
{
self()->doubleCounterImpl();
}
void halfCounter()
{
self()->halfCounterImpl();
}
void print()
{
self()->printImpl();
}
private:
Derived* self()
{
return static_cast<Derived*>(this);
}
};
class CrtpImpl : public CrtpBase<CrtpImpl>
{
friend class CrtpBase<CrtpImpl>;
private:
void incrementImpl()
{
m_Counter++;
}
void decrementImpl()
{
m_Counter--;
}
void doubleCounterImpl()
{
m_Counter *= 2;
}
void halfCounterImpl()
{
m_Counter /= 2;
}
void printImpl()
{
std::cout << "Crtp Impl counter: " << m_Counter << "\n";
}
private:
int m_Counter = 0;
};
template<typename Impl>
void executeCrtp(CrtpBase<Impl>& crtp,
const std::vector<unsigned>& indicies)
{
for(auto index : indicies)
{
switch(index)
{
case 0:
crtp.increment();
break;
case 1:
crtp.decrement();
break;
case 2:
crtp.doubleCounter();
break;
case 3:
crtp.halfCounter();
break;
}
}
}
void executeVirtual(VirtualBase& virt, const std::vector<unsigned>& indicies);
Classes.cpp:
#include "Classes.h"
void VirtualImpl::increment()
{
m_Counter++;
}
void VirtualImpl::decrement()
{
m_Counter--;
}
void VirtualImpl::doubleCounter()
{
m_Counter *= 2;
}
void VirtualImpl::halfCounter()
{
m_Counter /= 2;
}
void VirtualImpl::print()
{
std::cout << "Virtual Impl counter: " << m_Counter << "\n";
}
void executeVirtual(VirtualBase& virt,
const std::vector<unsigned>& indicies)
{
for(auto index : indicies)
{
switch(index)
{
case 0:
virt.increment();
break;
case 1:
virt.decrement();
break;
case 2:
virt.doubleCounter();
break;
case 3:
virt.halfCounter();
break;
}
}
}
main.cpp:
#include <numeric>
#include <vector>
#include <algorithm>
#include <chrono>
#include <iostream>
#include <string>
#include <ctime>
#include "Classes.h"
int main()
{
std::vector<unsigned> indicies(10000000);
unsigned counter = 0;
std::generate(std::begin(indicies), std::end(indicies),
[&counter]{return counter++ % 4; });
std::srand(static_cast<unsigned>(std::time(0)));
std::random_shuffle(std::begin(indicies), std::end(indicies));
CrtpImpl crtpImpl;
VirtualImpl virtImpl;
auto start = std::chrono::high_resolution_clock::now();
executeCrtp(crtpImpl, indicies);
auto crtpElapsed = (std::chrono::high_resolution_clock::now() - start).count();
std::cout << "CRTP time elapsed: " << crtpElapsed << "\n";
start = std::chrono::high_resolution_clock::now();
executeVirtual(virtImpl, indicies);
auto virtElapsed = (std::chrono::high_resolution_clock::now() - start).count();
std::cout << "Virtual time elapsed: " << virtElapsed << "\n";
float ratio = std::max<float>(virtElapsed, crtpElapsed)/
std::min<float>(virtElapsed, crtpElapsed);
std::string relation = crtpElapsed < virtElapsed ?
std::string("faster") : std::string("slower");
std::cout << "CRTP is " << ratio << " " << relation << "!\n";
//Force implementation to not optimize out crtpImpl&virtImpl objects
crtpImpl.print();
virtImpl.print();
std::cout << "CRTP size: " << sizeof(CrtpImpl) << "\n";
std::cout << "Virtual size: " << sizeof(VirtualImpl) << "\n";
return 0;
}
Как вы можете видеть, мы вынесли часть кода в отдельный cpp, теперь на входе линковщика будут два объектных файла: main.obj и Classes.obj. Теперь то CRTP покажет свою силу!
MSVC2013
|
GCC 4.9
|
Clang(Apple LLVM version 5.0)
|
CRTP time elapsed: 639996 Virtual time elapsed: 570268 CRTP is 1.12227 slower! Crtp Impl counter: 2 Virtual Impl counter: 2 CRTP size: 4 Virtual size: 16
|
CRTP time elapsed: 69916442 Virtual time elapsed: 75035399 CRTP is 1.07322 faster! Crtp Impl counter: 36944663 Virtual Impl counter: 36944663 CRTP size: 4 Virtual size: 16
|
CRTP time elapsed: 92507645 Virtual time elapsed: 119262093 CRTP is 1.28921 faster! Crtp Impl counter: -147204 Virtual Impl counter: -147204 CRTP size: 4 Virtual size: 16
|
Мда, опять мимо… Новые результаты мало отличаются от предыдущих. Более того, любые отличия от предыдущих результатов можно списать на погрешность. А это значит, что никакой разницы просто-напросто нет!
На MSVC очень легко получить разницу между первым и вторым вариантом, просто уберите переключатель /GL у компилятора.
Как можно трактовать полученные результаты? Очень просто: компиляторы(и линковщики) гораздо умнее, чем многие программисты себе представляют. Вообще говоря, у ��еня складывается впечатление, что многие, из тех не многих, кто интересуется оптимизациями до сих пор свято верят, что компиляторы тупы и им надо помогать. Помогать надо, естественно, используя ассемблер и различные “хитрые трюки”. Но это далеко не так,- оптимизация является краеугольным камнем любого современного компилятора, поэтому все компиляторы постоянно совершенствуются на этом поприще. Поэтому оба наши предположения оказались неверны. И всё из-за того, что мы не учли способность к девиртуализации и меж-модульному анализу современных компиляторов. Что ещё раз доказывает уже набившую оскомину истину: прежде чем что-то оптимизировать – измерь! Интуиция и логика, в оптимизации, сталкиваясь с “суровой” реальностью, как правило, оказываются неверны!.
Мне могут возразить: “А что если в момент вызова неизвестен тип, скрывающийся за указателем\ссылкой? Тогда компилятор не сможет применить ни ту, ни другую оптимизацию!”. Я соглашусь, но как тогда, в данном случае, может быть использован CRTP? CRTP это, по определению, решение, которое зиждется на том факте, что компилятор знает тип в точке вызова. CRTP не является полноценной заменой ВФ и никогда ей являться не будет: динамическое связывание не реализуется посредством статического – это разные сущности.
Возможно есть случаи, когда CRTP может выиграть у ВФ без перекраивания архитектуры, но мне такие случаи не известны. Поэтому, нужно помнить, что CRTP не является панацеей, которая гарантирует более высокую производительность. Нужно измерять каждый конкретный случай. Но я думаю, что после того, как вы воочию убедились в том, что CRTP не является ускорителем(по крайней мере в наших примерах), то и когда вы будете рассматривать как же ускорить тот или иной код,- CRTP не будет первым кандидатом на испытание. На мой взгляд, CRTP, как замена ВФ,– нежизнеспособен. Если только, по какой-то причине у вас есть масса объектов с ВФ – тогда, если не нужно настоящее динамическое связывание, то лучше обратиться к CRTP, что позволит снизить нагрузку на память и, как следствие, на кэш.
Смешивание типов
Хоть я и начал повествование о CRTP со сравнения оного с ВФ, первоначальный документ, посвящённый CRTP, был совсем о другом. Да и в “природе” CRTP встречается куда чаще с совершенно иной целью, не имеющей никакого отношения к [мнимой] производительности. Настоящая красота CRTP раскрывается в т.к. называемом смешивание(mixin) типов. Под этим подразумевается ситуация, когда мы добавляем функционал базового класса в наш класс-наследник, который использует знание о наследнике. Это позволяет не просто наследовать интерфейс, а наследовать реализацию, которая будет специфичная именно нашему классу. Это очень полезная и широко применимая техника, которую мы и рассмотрим в рамках данного параграфа. Чтобы лучше понять, как элегантно можно решить проблему, с использованием CRTP, предлагаю рассмотреть несколько примеров, которые и раскроют суть т.н. “смешивания”.
Boost.Operators
Boost.Operators является великолепной иллюстрацией применения CRTP: реализуя один из операторов и унаследовав один из классов представленных в Operators мы получаем весь спектр необходимых операторов. Например:
#include <cassert>
#include <boost/operators.hpp>
class Number: boost::less_than_comparable<Number>,
boost::equivalent<Number>
{
public:
Number(int value):
m_Value{value}
{}
bool operator<(const Number& other) const
{
return m_Value < other.m_Value;
}
private:
int m_Value;
};
int main()
{
Number one{1};
Number two{2};
Number three{3};
Number four{4};
assert(one >= one);
assert(three <= four);
assert(two == two);
assert(three > two);
assert(one < two);
return 0;
}
Таким образом, определив один оператор < мы получили весь спектр операторов сравнения! Я думаю, что если вы поняли суть CRTP, то реализация boost::less_than_comparable<Number> и boost::equivalent<Number> не должна быть для вас загадкой. Всё же я приведу возможную реализацию Equivalent:
template<class Derived>
struct Equivalent
{
bool operator==(const Derived& other)
{
Derived* self = static_cast<Derived*>(this);
return !(*self < other) && !(other < *self);
}
};
class Number: boost::less_than_comparable<Number>,
public Equivalent<Number>
{
public:
Number(int value):
m_Value{value}
{}
bool operator<(const Number& other) const
{
return m_Value < other.m_Value;
}
private:
int m_Value;
};
Конечно, реализация в boost несколько интереснее. Но, всё равно, довольно простая. Если кому интересно просто посмотрите соответствующий заголовок. К примеру, одно очень важное отличие в том, что вы можете наследовать любой из Operators приватно и всё будет работать как часы, в отличии от моей, менее элегантной реализации. Но суть остаётся той же. Как можно заметить из примера выше, мы именно смешиваем 2 типа: базовый и наследующий, в результате получая некий симбиоз, обладающий большими возможностями по сравнению с каждый из источников.
enable_shared_from_this
Еще одним великолепным примером применения CRTP является std::enable_shared_from_this. Я уже писал о нём, поэтому заострять внимания на том, как он применяется я не буду. Давайте просто посмотрим как его можно реализовать:
template<typename T>
class EnableSharedFromThis
{
public:
std::shared_ptr<T> shared_from_this()
{
return std::shared_ptr<T>{m_Weak};
}
//...
private:
std::weak_ptr<T> m_Weak;
};
Как вы можете заметить, здесь используется знание о типе потомка для создания и возврата корректного std::shared_ptr – вполне типичное применение CRTP.
Приведённый выше класс, естественно, не покрывает весь функционал std::enable_shared_from_this. К примеру, если вы создаёте shared_ptr из класса-наследника enable_from_this, то вы получите корректное поведение. В то время как с использованием моего класса, вы, вероятнее всего, получите падение – всё дело в том, что std::make_shared, а так же конструктор shared_ptr, используют знания о enable_shared_from_this для “особого” создания объектов классов, которые от него наследуются.
Инициализатор
Итак мы рассмотрели два прекрасных примера использования CRTP наследуя функционал. Теперь предлагаю зайти с другого конца и подмешать функционал “снизу”, т.е. мы не будем менять класс, наследуя его от какой-то CRTP сущности. Вместо этого мы будем “декорировать” наш объект с помощью класса, использующего CRTP технику.
Многие программисты сталкивались со следующей проблемой: предположим у нас есть базовый класс, в котором определена функция инициализации. При этом, мы обязуем всех, кто наследует наш класс вызывать функцию инициализации. Но как это сделать? Как заставить пользователя не забыть вызвать функцию инициализации? Т.к. функция виртуальная, мы не можем вызывать её в конструкторе родителя. Эту проблему мы и попытаемся решить с помощью CRPT.
Так вот выглядит наша иерархия:
class Base
{
public:
virtual void init() = 0;
};
class Testee: public Base
{
public:
Testee()
{
std::cout << "Default ctor\n";
}
Testee(int i)
{
std::cout << "Complex ctor\n";
}
Testee(int i, int j)
{
std::cout << "Even more complex ctor\n";
}
void init() override
{
std::cout << "Initialized\n";
}
};
А вот так будет выглядеть наш класс-помощник:
template<typename T>
class Initializer: public T
{
public:
template<typename... Args>
Initializer(Args&&... arg) : T(std::forward<Args>(arg)...)
{
this->init();
}
};
И его использование:
int main()
{
Initializer<Testee> test1{};
Initializer<Testee> test2{1};
Initializer<Testee> test3{1, 2};
return 0;
}
Теперь пользователь не должен помнить о том, что он обязан вызвать функцию инициализации! Правда он теперь должен помнить, что он должен создавать объекты только через Initializer<T>. Но это , на мой взгляд, куда лучше чем помнить о вызове метода. Более того, мы можем пойти дальше и сделать все конструкторы в Testee protected, тогда пользователь точно не сможет забыть, что он должен создавать объект Testee посредством Initializer<T>.
Таким образом мы убили двух зайцев: мы избавили пользователя от необходимости явно вызвать функции инициализации и мы, по-сути, вызвали корректную виртуальную функцию в конструкторе. Правда для этого пришлось добавить лишнюю сущность. Можно пойти ещё дальше и, добавив особой шаблонной магии, сделать Initializer<T> весьма общим – пусть старается определить какая функция инициализации есть у T, и, в зависимости от этого, вызывает корректную(естественно это будет работать с узким набором имён)
Заключение
Я надеюсь, что данная статья не оставит вопросов(если они вообще были) по тому, для чего же нужен CRTP и почему он так популярен. Я надеюсь читатель сделает для себя собственные выводы, но для меня вывод очевиден: CRTP как техника смешивания типов является мощным средством, которое можно и нужно применять в повседневной жизни. В то же время CRTP, как средство увеличения производительности ПО, себя не оправдывает.
Конечно, я мог бы привести еще массу примеров, как CRPT облегчает жизнь программистов, но я не думаю, что в этом есть смысл, т.к. каждый новый пример чем-то схож с предыдущим. Главное понимать суть. Более того, я уверен, что многие из вас могут привести примеров не меньше,- просто кто-то раньше не знал, что у подобной техники есть закреплённое название. Кстати, одним из ярких примеров применения CRTP являются библиотеки ATL/WTL, которые им буквально пронизаны насквозь.