В предыдущей серии мы изобретали нечто, называемое XXX_traits, где под XXX скрывается какой-то концепт. А сам трейт описывает, какие операции доступны для типов, реализующих данный концепт. И все взаимодействие с шаблонными параметрами происходило с помощью вспомогательной структуры.
template <class T, class S>
auto accumulate(const std::vector<T>& arr, S&& op)
-> RESULT<T>::REQUIRES<SummatorTraits<S>::value> {
using STraits = SummatorTraits<S>;
auto sum = STraits::Zero(op);
for (auto x : arr) {
sum = STraits::Sum(op, sum, x);
}
return sum;
}
При таком подходе замечательно все, кроме громоздкости при обращении к интересующим нас методам. Но C++ не был бы самим собой, если бы в нем нельзя было бы решить задачу множеством самых жутких способов!
Давайте вернемся к варианту с шаблонным интерфейсом:
template <class T>
class ISummator {
public:
virtual ~Summator() = 0;
virtual T Sum(T a, T b) const = 0;
virtual T Zero() const = 0;
}
Это отличный способ, но у него, как мы уже обсуждали, множество недостатков. Начиная от виртуальности, заканчивая невозможностью никаких изменений в сигнатурах методов. Но все эти недостатки мы можем исправить. Добавив дополнительный уровень индирекции и вспомнив одно замечательное свойство шаблонов: тип-параметр шаблона может быть неполным (incomplete) типом. И он может оставаться таковым до тех пор, пока к нему не обратятся как к полному типу (применят sizeof, попробуют объявить переменную, заглянуть во вложенные типы и т.д.). На этом свойстве работает Curious Recurring Template Pattern (CRTP).
Попробуем рассмотреть его на примере нашего сумматора.
Начнется все с базового класса.
template <class Impl> // Impl --- будет конечным типом, имплементирующим интерфейс
class ISummator {
public:
// потребуем, чтоб Impl предоставлял как минимум константный (!а может и статический!)
// метод Sum принимающий какие-то два аргумента.
// используем perfect forwarding для возвращаемого значения (dectype(auto))
// и аргументов (T&&, std::forward<T>) чтобы все передавалось и возвращалось как есть:
// копии -- копиями, ссылки -- ссылками (сохранить rvalue/lvalue и прочие *value категории)
template <class T1, class T2>
decltype(auto) Sum(T1&& a, T2&& b) const {
return AsImpl()->Sum(std::forward<T1>(a), std::forward<T2>(b));
}
// аналогично потребуем, чтоб Impl предоставил как минимум константный метод Zero, не принимащий ничего
decltype(auto) Zero() const {
return AsImpl()->Zero();
}
private:
// не разрешаем никому, кроме имплементирующего типа, конструировать базовый класс
friend Impl;
ISummator() = default;
// мы не используем виртуальный деструктор
// так что разрушать объект тоже запрещаем,
// чтоб никто не делал delete через указатель
// на этот класс
~ISummator() = default;
const Impl* AsImpl() const {
// Будем требовать, чтоб Impl был нашим наследником
// иначе static_cast просто не скомпилируется
// причем мы можем здесь обращаться с Impl как с полным типом.
// Потому что методы шаблонов компилируются только тогда, когда к ним будут обращаться вне шаблонов
return static_cast<const Impl*>(this);
}
};
А теперь имплементируем наш интерфейс
// наследуемся от базового класса, указывая параметром шаблона самих себя!
// Внутри шаблона в этот момент DefaultSummator является неполным типом (конечно, мы его только начали описывать!)
// Однако никаких ошибок не возникает -- мы не трогаем методы ISummator, они не компилируеются
// и мы не обращаемся к нашему пока еще не полному типу!
class DefaultSummator : public ISummator<DefaultSummator> {
public:
static float Zero() {
return 0;
}
static float Sum(float a, float b) {
return a + b;
}
};
Заметим, что мы не используем виртуальные методы и виртуальный деструктор.
Используем теперь его в функции accumulate:
template<class T, class Summator>
auto accumulate(const std::vector<T>& arr, const ISummator<Summator>& op) {
auto sum = op.Zero();
for (auto x : arr) sum = op.Sum(sum, x);
return sum;
}
И попробуем скормить ей что-то неправильное. Получили не очень длинную, но все же неприятную ошибку компиляции. Неприятную от того, что она изнутри функции accumulate. Конечно, у нас потеряна зависимость сумматора от типа T, поэтому никак пораньше мы уловить ошибку не смогли. Но мы можем это исправить -- подкорректировав интерфейс. Потребуем, чтоб сумматор предоставлял тип, с которым он работает, и налодим ограничение, что он должен совпадать (или быть совместимым) с T.
Наивный способ запросить у имплементации тип выглядит довольно логично и красиво:
template <class Impl>
class ISummator {
public:
using value_type = typename Impl::value_type;
....
};
Увы, он не работает. Мы обратились к Impl в поисках вложенных типов -- а значит, Impl должен быть полным типом. Таковым он в момент инстанциирования шаблона не является. Стандартная ситуация для C++: если очевидное и красивое решение не работает, то сработает неочевидное и уродливое!
Сделаем метод, внутри которого Impl уже будет полным типом, а значит, из него можно будет доставать все что угодно.
// заведем себе оберточку, с помощью которой
// будем доставать тип.
// в C++20 для подобных махинаций есть std::type_identity<T>,
// но будь у нас С++20, мы бы всем этим не занимались
template <class T>
struct TypeWrapper {
using type = T;
};
template <class Impl> // Impl --- будет конечным типом, имплементирующим интерфейс
class ISummator {
public:
// в возвращаемом значении мы не можем указать ничего, кроме auto
// иначе мы полезем внутрь Impl пока он еще incomplete
static constexpr auto ValueType() {
// Impl уже complete. Требуем, чтоб он предоставлял тип value_type
// мы не можем просто вернуть value_type{} -- у него может не быть конструктора по умолчанию
// можно вернуть std::declval<value_type>()
// но тогда этот метод не будет компилироваться вне невычисляемого контекста (decltype, sizeof)
// поэтому нам и нужна обертка
return TypeWrapper<typename Impl::value_type>{};
}
};
Добавим в DefaultSummator
тип, с которым он будет работать. И наконец-то напишем восхитительную по своей красоте проверку для функции accumulate
, что этот тип совпадает с типом элементов вектора.
template <class T, class Summator>
auto accumulate(const std::vector<T>& arr, const ISummator<Summator>& op) ->
std::enable_if_t<std::is_same_v<T, // если T совпадает с типом,
// который мы вытащили из обертки, предоставленной методом ValueType,
typename decltype(op.ValueType())::type>,
T> // то accumulate вернет T
{
...
}
Опять подсунем какую-нибудь ерунду и полюбуемся на плоды наших трудов. Любоваться будем с помощью clang, а не gcc. Потому что clang уродливый enable_if в логе ошибок превращает в красивое сообщение.
Теперь у вас есть еще один вариант, как превозмогать отсутствие концептов. Описанный способ крайне эффективен: не используются виртуальные методы, все можно отлично инлайнить, применять empty-base optimization. Типы параметров можно жестко не фиксировать. Минусы:
- Наследование -- для сторонних классов с таким же интерфейсом, но не наследованных от него, пиши обертку.
- Много бойлерплейта при описании интерфейса
- При описании интерфейса легко ошибиться и обратиться к имплементации, пока она еще incomplete. О чем вы не узнаете, пока не начнете писать имплементацию.