Skip to content

Instantly share code, notes, and snippets.

@Nekrolm
Last active August 25, 2020 21:45
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Nekrolm/3aadfce1d6ba044859d2108a98de9bf8 to your computer and use it in GitHub Desktop.
Save Nekrolm/3aadfce1d6ba044859d2108a98de9bf8 to your computer and use it in GitHub Desktop.
cpp14_traits_part2

C++14/17 type_traits Часть 2.

В предыдущей серии мы пытались сделать нечто, напоминающее проверку концепции C++20 средствами C++14/17.

У нас была функция sum, от типов аргументов которой мы требовали применимости операции +. И выглядело в конечном итоге это так:

template<typename T>
auto sum(T a, T b) -> RESULT<T>::REQUIRES<is_addable<T,T>> {
    return a + b;
}

Для сравнения, в C++20 это будет выглядеть так:

template <class T>
auto sum(T a, T b) requires is_addable<T,T> {
    return a + b;
}

Или так:

template <class T>
requires requires (T a) { a + a; }
auto sum(T a, T b) {
    return a + b;
}

Первый вариант кажется чуть более привлекательным, поскольку во втором варианте дублируется ключевое слово requires и сама операция. Однако, во втором варианте сразу видно, какие операции допустимы над типом и не нужно переходить к объявлению метафункции (или концепта в С++20) is_addable. А если тип должен удовлетворять сразу нескольким требованиями (концептам), описанным в разных файлах -- придется смотреть каждый. Это замечание может казаться совершенно не важным. Да, пока вы точно знаете, что именно делает этот код и чего он хочет от типов.

В этой части мы рассмотрим способ, как можно собирать в одном месте все ограничения и допустимые операции над объектами, типы которых удовлетворяют поставленным ограничениям. И не сойти с ума. И при этом еще и обеспечить себя поддержкой со стороны любимой IDE. Да-да, вы же замечали, что внутри шаблонов с такими прелестями жизни как автодополнение все довольно туго? С приходом concepts C++20, возможно, эта ситуация улучшится, поскольку движки всяких IntelliSense смогут использовать дополнительную информацию о типах, согласно ограничениям. А если вы не пользуетесь подсказками IDE и привыкли все делать руками (или с помощью grep), наверняка, вам тоже было бы удобнее получить всю необходимую информацию, перейдя лишь к объявлению одного класса/структуры.

Но для начала нам нужно немного наловчиться писать метапредикаты для проверки тех или иных свойств.

Универсальный способ написания метапредикатов

До С++11 и появления decltype и std::declval как только люди не извращались, чтобы выполнить проверку наличия тех или иных свойств у типа!

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

Рассмотрим упрощенную общую схему.

  1. Шаг первый: определяем предикат от необходимых N аргументов и результат в общем случае -- result_type (std::false_type или std::true_type)
template <class T1, class T2, ... class TN, class = void>
struct Predicate : result_type {};
  1. Шаг второй: определяем одну или несколько специализаций, в которых и реализуется сама проверка. Если проверка пройдена, возвращается another_result_type (обычно, противоположный к result_type)
template <class T1, class T2, ... class TN>
struct Predicate<T1, T2,...TN,  проверка> : another_result_type {};

Этого достаточно. Основная сложность состоит в том, как правильно написать саму проверку.

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

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

Краткая справка

Пусть у нас есть объект x типа A, неявно приводимый к типу B. И две перегрузки функции f :

  1. type_1 f(A)
  2. type_2 f(B)

Тогда f(x) приведет к вызову первой перегрузки. Всегда, если есть выбор из двух и более перегрузок, вызывается наиболее подходящая. Никто не запрещает, чтобы эти перегрузки были шаблонами. Главное, чтоб они были различными. Таковыми они считаются, если у них расзличаются типы или количество аргументов. Тип возвращаемого значения не учитывается.

Пусть у нас есть две шаблонные перегрузки:

template<class T>
int foo(int* ) { return 0; }

temptate<class T>
int foo(void*) {return 1;}

Тогда код

int x;
foo<любой тип>(&x);

Приведет к вызову первой версии перегрузки. Если бы первой версии не было, была бы вызвана вторая, поскольку int* неявно приводим к void*. Отсюда происодит идея: давайте объявим две перегрузки, зависящих от шаблонных параметров, одна из которых будет исчезать при помощи SFINAE, если какое-то условие не выполнилось

Как это выглядит на практике

Пусть у нас есть функция

void foo(int a, int b, int c = 2, float d = 5) {}

И мы хотим написать метапредикат, проверяющий, что foo можно вызвать с аргументами типов T1, T2, T3, ... Tn, ...; То есть мы хотим некую метафункцию

template<class... T>
constexpr bool is_foo_invokable = ?;

is_foo_invokable<int, int> == true;
is_foo_invokable<int> == false; // недостаточно аргументов
is_foo_invokable<float, int> == true; // float неявно приводим к int и мы можем вызвать foo(1.f, 5)
is_foo_invokable<int,int, int,int, int> == false; // слишком много аргументов
is_foo_invokable<int,int, std::string> == false; // std::string  неявно не приводим к int.

Начнем с объявления типа, зависящего от параметров, приводящего к ошибке (и собственно SFINAE), если условие не выполнено.

template <class... T>
using int_if_invokable = decltype(void(foo(std::declval<T>()...)), int(1));

В скобках decltype у нас вызов оператора запятая. Который, если не перегружен, возвращает свой правый аргумент. Правый аргумент это int. Левый аргумент -- результат попытки вызова foo с аргументами переданных типов. Этот результат приводится к void, чтобы избежать проблем, если foo возвращает пользовательский тип, для которого кто-то перегрузил запятую. Таким образом, если foo можно вызвать с аргументами переданных типов, результатом будет тип int. Иначе -- ошибка.

Далее объявим две перегрузки. Одна проверит частный случай -- foo можно вызвать с переданным набором типов. Она возвращает std::true_type

template <class... T> 
std::true_type check_invoke(int_if_invokable<T...>*){ return {}; }

Эта перегрузка пропадает, если foo нельзя вызвать. Вторая перегрузка есть всегда. Она будет возвращать std::false_type

template <class... T>
std::false_type check_invoke(void*) { return {}; }

Но если мы передадит аргумент типа int*, вторая перегрузка будет обладать меньшим приоритетом. И будет вызываться только если первая перегрузка отброшена из-за ошибки. И теперь сам предикат реализуется посредством вызова одной из перегрузок и получения ее возвращаемого типа.

template <class... T>
constexpr bool is_foo_invokable = decltype(check_invoke<T...>(std::declval<int*>()))::value;

Полная версия тут

Есть ли метод у класса?

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

Здесь и далее мы не будем рассматривать случаи variadic templates. Будем считать, что методы должны принимать фиксированное количество аргументов, никаких аргументов по умолчанию. Максимально просто. Поэтому можно пользоваться упрощенной общей схемой написания предикатов. Ключевое в ней, как уже упоминалось, написать само условие для проверки. Этим и будем заниматься.

В самом простейшем случае, если нас интересует только наличие публичного метода/поля с определенным именем Name в классе/структуре С, проверка выглядит очень просто:

decltype(void(&C::Name))

Пытаемся получить указатель на метод или поле и радуемся.

Работает и в случае переопределения метода в наследниках. Однако, перестает работать, если искомый метод имеет перегрузки. Иногда, конечно, достаточно провеки по имени, если вы гарантируете отсутсвие перегрузок. Но чаще более востребована проверка имени и сигнатуры. Естественно, в таком случае предикат должен принимать хотя бы два аргумента (сам тип класса и тип, описывающий сигнатуру).

Тогда общий шаблон для предиката:

template <class C, typename Signature, class = void>
struct has_method : std::false_type {};

А специализация:

template<class C, class RetType, class... Args>
struct has_method<C, 
                  RetType(Args...), 
                  std::enable_if_t<std::is_same<RetType, 
                                   decltype(std::declval<C>().method(std::declval<Args>()...))>::value>>
                  : std::true_type {};

Она, конечно, выглядит довольно монструозно. Но ничего страшного. Бывает и хуже. Сначала мы пытаемся вызвать метод с нужным нам именем и списком типов аргументов. Это первый этап, на котором может произойти ошибка -- если такого метода нет. Отметим, что имя искомого метода невозможно передать в качестве параметра шаблона. Поэтому частенько подобные шаблоны заварачиваются в макросы, параметризуемые именем искомого метода. И приход concepts С++20 этого не исправит. Ситуация может измениться, когда в языке появится рефлексия. Но это не точно.

Далее тип результата сравнивается с типом, указанным в сигнатуре. При совпадении enable_if_t вернет void. Иначе будет ошибка и SFINAE.

Отметим также, что проверка сигнутуры таким образом осуществляется не строгая, а с точностью до приведения типов. Строгая проверка в данном конкретном случае только для возвращаемого типа. При желании ее можно ослабить, заменив is_same на is_convertible;

Полностью строгую проверку, если сильно нужно, можно также попытаться осуществить, но это требует куда более жутких решений.

Есть ли вложенный тип?

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

Проверка наличия в классе C типа с нужным именем Name в общей схеме выполняется довольно просто

decltype(void(std::declval<typename C::Name>()))

Сразу пытаемся объявить значение искомого типа и привести его к void. Получилось -- значит, есть такой тип. Не получилось -- ну и ладно. SFINAE.

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

Как интерфейсы, но в compile-time

Мы живем в мире недобитого ООП и лучшие практики говорят, надо программировать на интерфейсах. Ну что ж, хорошо.

Допустим у нас есть массив чисел и мы их всех хотим просуммировать. Но складывать мы можем по-разному. Можем плюсиком, можем произведением, можем минимумом, можем еще как угодно... Все зависит от того, какую ерунду мы понимаем здесь и сейчас под знаком +.

Раз у нас так много вараиантов, надо вычленить общий интерфейс. Да будет так.

class ISummator {
public:
	virtual ~Summator() = 0;
	virtual int Sum(int a, int b) const = 0;
	virtual int Zero() const = 0;
}

И функция, выполняющая суммирование, скорее всего будет выглядеть примерно так:

int accumulate(const std::vector<int>& arr, const ISummator& op) {
	int sum = op.Zero();
	for (int x : arr) sum = op.Sum(sum, x);
	return sum;
}

Здесь прекрасно все. Пока нам не понадобился другой тип. Ну ладно, тоже ничего сложного. Мы можем сделать наш абстрактный интерфес шаблонным.

template <class T>
class ISummator {
public:
	virtual ~Summator() = 0;
	virtual T Sum(T a, T b) const = 0;
	virtual T Zero() const = 0;
}

template<class T>
auto accumulate(const std::vector<T>& arr, const ISummator<T>& op) {
	auto sum = op.Zero();
	for (auto x : arr) sum = op.Sum(sum, x);
	return sum;
}

Выглядит так, будто бы нам и concepts никакие не нужны! Вот же, совершенно естественный способ накладывать ограничения на типы! И IDE с ума не сойдет в попытке подсказать, что же такого умеет этот op. Бери да наследуйся от интерфеса, параметризованного нужным типом, и радуйся! Конечно, само собой. Но проблемы как всегда начинаются когда нужно что-то изменить. Что если метод Sum в одной из конктерных реализаций не должен быть константным? Что если типы принимаемых и возвращаемых значений должны различаться, например, потому что суммирование нужно производить с большей точностью? К тому же если вы возьмете какую-нибудь стороннюю библиотеку, в которой есть класс, удовлетворяющий концепции сумматор, но не унаследованный от вашего интерфейса, придется писать обертку над ним. В мире шаблонов C++ предпочитают делать так, чтоб типы "случайно" оказывались удовлетворяющими той или иной концепции. И все работало. А если не удовлетворяет -- ошибка компиляции и досвидания. Да, в шаблонах C++ утиная типизация.

Так что для полного соответсвия духу C++ наш пример должен выглядеть так:

template<class T, class Summator>
auto accumulate(const std::vector<T>& arr, Summator&& op) {
	auto sum = op.Zero();
	for (auto x : arr) sum = op.Sum(sum, x);
	return sum;
}

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

Начнем с того, что потребуем, чтоб каждый тип, удовлетворяющий нашей концепции, предоставлял вложенный тип SumType. И абстрактный сумматор выглядел бы примерно так (все метапредикаты полагаются уже определенными).

template <class T>
struct DefaultSummator {
	static_assert(is_addable<T,T>, "+ not supported");
	using SumType = decltype(std::declval<T>() + std::declval<T>()); 
	inline static SumType Sum(const T& x, const T& y) { return x + y; }
	inline static SumType Zero() { return T{0}; }
};

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

Определим теперь набор предикатов

template <class Summator>
constexpr bool HasSumType = ...;

template <class Summator>
constexpr bool HasZero = ... // есть ли метод Zero, вызываемый без аргументов

template <class Summator, class T1, class T2>
constexpr bool CanSummate = ... // eсть ли метод Sum, которыйможно вызвать с типами T1 и T2

template <class Summator>
constexpr bool CanSummateSumType = ... // может ли метод Sum суммировать аргументы типа Summator::SumType

template <class Summator>
constexpr bool IsSummator = HasSumType<Summator> && HasZero<Summator> && CanSummateSumType<Summator>;

И тогда заголовок нашей функции accumulate становится примерно таким (используем синтаксис, что мы соорудили в прошлой части)

template<class T, class S>
auto accumulate(const std::vector<T>& arr, S&& op) -> RESULT<T>::REQUIRES<IsSummator<S>>

Это уже замечательно. И на этом можно остановиться. Но обещано было, что мы сделаем так, чтоб наша любимая IDE не сходила с ума при попытке автокомплита внутри тела шаблона.

Вспомним итераторы из стандартной библиотеки. У них есть методы, внутренние классы... но указатели тоже работают как итераторы. А они являются примитивными типами, у которых нет ничего, к чему можно было бы добраться через два двоеточия. На этот случай есть структура iterator_traits<T>, проверяющая, что тип T удовлетворяет концепции 'итератор', и предоставляющая доступ ко всем нужным типам. Если T -- полноценный итератор, iterator_traits, грубо говоря, выворачивает его наизнанку, пробрасывая все его внутренности. Если T -- указатель, просто определяет все необходимые типы.

Если для использования сумматора мы будем использовать дополнительную структуру SummatorTraits, мы дадим еще больше свободы тем, кто будет писать свои сумматоры: им будет необязательно имплементировать структуру или класс, предоставляя нужные методы. Они смогут просто сделать отдельную специализацию шаблона SummatorTraits.

Определим ее следующим способом.

template <class S> 
struct SummatorTraits {
    static constexpr bool value = IsSummator<S>; // удовлетворяет ли концепции
    static_assert(IsSummator<S>, "not supported");

	using SumType = typename S::SumType;
	
	template <class T>
	inline static auto Sum(S summator, const SumType& a, const T& b) 
							-> RESULT<SumType>::REQUIRES<CanSummate<S, SumType, T>> {
		return summator.Sum(a, b);
	}
	
	inline static auto Zero(S summator) {
		return summator.Zero();
	}
};

И наша функция accumulate примет окончательный вид

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;
}

И вот уже после двух двоеточий после STraits ваша любимая IDE, возможно, сможет вас порадовать работающим автокомплитом. А вот и полная версия

На этом все. Конечно, здесь приведена не самая оптимальная версия. Опущены многие необходимые ключевые слова, чтоб компилятор все успешно заинлайнил, лишнее выкинул и т.д. Опущены пляски с perfect forwarding. Да и сам предикат в требованиях accumulate не совсем правильный: он не проверяет, что можно суммировать с типом T, поэтому иногда сообщение об ошибке будет немного больше, чем нужно. Но, данная заметка не претендует на идеальный идеал. Потому что в мире C++ идеальный идеал -- чаще всего чудовищный монстр Франкенштейна из шаблонов, макросов и внешнего кодогенератора.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment