Skip to content

Instantly share code, notes, and snippets.

@Nekrolm
Created December 19, 2019 16:10
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Nekrolm/2fa9ee35fbfb27bc93c82c612805edfb to your computer and use it in GitHub Desktop.
Save Nekrolm/2fa9ee35fbfb27bc93c82c612805edfb to your computer and use it in GitHub Desktop.
cpp14_17_traits part1

C++14/17 type_traits (пока нет concepts)

Подходит к концу 2019 год. Уже скоро C++20 станет доступен во всей своей красе. Но совсем не скоро он плотно войдет в мир промышленной разработки (сюда еще даже C++14 не везде дошел).

В С++20 появятся концепции/типажи (concepts) -- долгожданная горсть синтаксического сахара, призванная дать разработчикам возможность писать шаблоны (templates), накладывая ограничения на их параметры так, чтоб потом не было мучительно больно.

Зачем вообще накладывать ограничения?

Допустим, у нас есть такой простенький шаблон

template<typename T>
T sum(T a, T b) { return a + b; }

Зачем в нем навешивать какие-то ограничения? Ведь и так понятно, что над типом T должна быть определена операция сложения. А если не определена, код просто не скомпилируется. Коплилятор сам знает, где и какие ограничения. Так зачем писать больше кода, если можно писать меньше? Действительно, в таких тривиальных примерах писать больше нет никакого смысла. Если мы подставим неправильный тип, компилятор выдаст две строчки ошибки, и мы сразу все поймем... Или нет? В примере по ссылке, конечно, сразу все ясно, ведь первой строчкой указана причина наших неудач, однако, сразу за этой строчкой идет сотня других. И именно эту сотню вы скорее всего увидите первой в своем прекрасном терминале/логе IDE, если у вас не выключена автопрокрутка, конечно.

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

Компилятор, АСТАНАВИСЬ!

Допустим у нас есть некий способ проверки, что типы удовлетворяют некому условию. В случае выше, будем считать, что это некоторая (мета)функция is_addable<T1, T2>, которая проверяет, можно ли сложить a и b типов T1 и T2 соответственно. Посмотрим, что мы можем с ней сделать.

SFINAE работает до тех пор, пока подстановка фейлиться в заголовке функции (класса). Если компилятор смог расставить аргументы и вывести типы, он останавливается и идет внутрь тела функции/класса. Дальше SFINAE для этого шаблона не работает. Отсюда происходят два способа, как можно поставить органичение на используемые типы.

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

1. Проверка в теле функции

static_assert:

Начиная с C++11 в языке есть static_assert для проверок на этапе компиляции. Попробуем с ним.

template<typename T>
T sum(T a, T b) {
    static_assert(is_addable<T, T>::value, "can't apply +");
    return a + b; 
}

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

if constexpt:

В C++17 появилась дополнительная возможность условной компиляции, похожая на использование директив #if #else #endif.

template <int x>
void foo() {
   if constexpr (x == 5) {
        std::cout << "Hello!\n";
   }else {
        std::cout << "World!\n";
   }
}

В зависимости от того, какое значение у x (которое должно быть известно на этапе компиляции), будет компилироваться только одна из веток if constexpr. (но есть нюансы). Также, в отличие от #if деректив препроцессора, содержимое обеих веток должно соответствовать синтаксису C++, обращаться к существующим именам и т.д.

С использованием if constexpr становится значительно лучше:

template <class T>
struct always_false : std::false_type {}; 

template<typename T>
T sum(T a, T b) {
    if constexpr (is_addable<T, T>::value) { 
        return a + b; 
    }else{
        static_assert(always_false<T>::value, "+ is not supported");
    }
}

Правда, нам понадобилась вспомогательная метафункция always_false. Мы не можем написать в static_assert просто false. Иначе мы будем получать ошибку компиляции даже если наш шаблон ни разу не инстанциировался. Поэтому нам нужен false, зависящий от параметра шаблона.

Строк с отчетом об ошибке стало значительно меньше, правда появились предупреждения, что по одной из веток отсутствует return. Что не очень хорошо. Нужно что-то вернуть. Но что? Мы не можем просто написать return T{}. У типа может не быть конструктора по умолчанию, и компилятор заботливо накидает нам дополнительных ненужных строк в отчете об ошибке. Можем использовать std::declval. Чтобы убрать предупреждение... Но его можно корректно использовать только в невычисляемом контексте. Конечно, в нашем случае в этой ветке кода всегда будет происходить ошибка компиляции, потому можно делать что угодно... Кажется, пытаясь решить одну проблему, мы придумали себе множество других...

2. Проверка в заголовке функции

Второй вариант позволяет в принципе выкинуть из рассмотрения шаблон sum<T>(T, T), если тип не будет удовлетворять условию. И тут у нас также есть несколько вариантов, как это сделать.

Механизм частичной специализации (partial specialization)

Если вы открывали ссылки из примеров выше, вы видели как реализована метафункция is_addable:

template <class T1, class T2, class = void>
struct is_addable : std::false_type {};

template <class T1, class T2>
struct is_addable <T1, T2, 
                    decltype(void(std::declval<T1>() + std::declval<T2>()))> : std::true_type {};

Довольно жутко, но ничего страшного тут нет! Функция is_addable объявляется как шаблон с тремя типами-параметрами. Причем третий параметр устанавливается по умолчанию в void (можно любой другой тип, можно даже вместо типа-параметра использовать значение-параметр). Значения двух произвольных типов вообще говоря не обязаны поддерживать сложение. Поэтому общая (general) версия шаблон реализуется просто с помощью наследования от false_type;

template <class T1, class T2, class = void>
struct is_addable : std::false_type {};

Далее идет частичная специализация нашей метафункции. Частичная, потому что специализированный шаблон продолжает зависеть от параметров (при полной специализации список параметров в угловых скобках после ключевого слова template становится пустым). Но теперь шаблон зависит от двух параметров вместо трех. Третий параметр зафиксирован и равен... а чему он равен?

Рассмотрим эту штуку поближе:

decltype(void(std::declval<T1>() + std::declval<T2>()))

decltype -- получить тип выражения в скобках. Дальше видно, что последним действием идет приведение к void. Значит, результат всегда должен быть void, делов-то! Да? А вот и нет. Приведение к void -- самая последняя операция, которая будет производиться внутри decltype. А это значит, что у компилятора тут еще много возможностей, чтобы упереться в ошибку... А если произойдет ошибка... SFINAE! Специализированная версия шаблона будет выброшена и останется только общая версия. Это замечательно, но зачем нам это надо? Давайте посмотрим на то, что именно приводится к void:

std::declval<T1>() + std::declval<T2>()

Это как раз попытка применить сложение к объектам наших переданных типов.

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

Остается лишь два вопроса:

  1. У нас std::declval. Но тут его можно вполне законно применять и ничего с нами никто не сделает. Потому что внутри скобок decltype как раз невычисляемый контекст.
  2. Зачем было приведение к void? Тут основная хитрость. У нашего шаблона не два параметра, а три. Так как в общей версии шаблона мы в качестве третьего параметра по умолчанию указали void, чтобы наша метафункция правильно работала при указании только первых двух параметров, мы в специализации должны выставить третий параметр также в тип void. Никто не запрещает использовать любой другой тип или значение. Главное, чтоб оно совпало с выставленным по умолчанию.

Возвращаемcя к нашему примеру с функцией sum. Мы могли бы использовать механизам частичной специализации и тут... но не можем, потому что он не работает с функциями. А только с классами и структурами. Попробуем по-другому.

enable_if дополнительным параметром функции

Мы не можем засунуть проверку в аргументы шаблона, как мы это сделали при реализации is_addable. Но мы можем засунуть проверку в аргументы функции! А почему бы и нет? Это все еще не тело, так что будет работать. Для этого нам понадобится какая-нибудь хитрость, которая приведет к ошибке, если условие не выполнится. В качестве такой хитрости воспользуемся std::enable_if_t<bool Cond, typename T = void>. Если Cond истинно, возвращается переданный тип T. А если ложно... то ничего не возвращается. Ошибка.

Попробуем:

template<typename T>
T sum(T a, T b, std::enable_if_t<is_addable<T,T>::value>* = nullptr) {
        return a + b;
}

Мы добавили третий параметр (со заначением по-умолчанию). Имеющий тип void*, если сложение поддерживается. И приводащий к ошибке, если нет. Получилось более мерзко, чем было с if constexpr, но лучше, чем в самом начале.

Мы можем это немного исправить, сделав свой собственный enable_if, который дополнительно будет выдавать сообщение. Меньше строк не станет, но в отчете обо ошибке появится static_assert по которому обычно проще искать, где произошла ошибка.

template <class T>
struct always_false : std::false_type {};

template <bool condition, class T>
struct enable_if;

template <class T>
struct enable_if<true, T> {
    using type = T;
};

template <class T>
struct enable_if<false, T> {
    static_assert(always_false<T>::value, "requirements are not satisfied");
};

template <bool condition, class T = void>
using enable_if_t = typename enable_if<condition, T>::type;
enable_if в возвращаемом типе

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

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

Число строк в отчете об ошибке не поменялось, но выглядеть это стало чуть лучше. И нет лишних параметров.

Мы можем еще переделать enable_if (и саму нашу метафункцию), чтоб получить чуть более читаемый синтаксис.

  1. C++14 позволяет нам объявлять шаблонные переменные. И, чтобы не писать везде ::value, переделаем нашу метафункцию следующим образом.
template <class T1, class T2, class = void>
struct is_addable_impl : std::false_type {};

template <class T1, class T2>
struct is_addable_impl <T1, T2, 
                    decltype(void(std::declval<T1>() + std::declval<T2>()))> : std::true_type {};

template <class T1, class T2>
constexpr auto is_addable = is_addable_impl<T1, T2>::value; 
  1. Добавим небольшую обертку
template <class T>
struct Result {
    template <bool Condition>
    using Requires = typename enable_if<Condition, T>::type;
};

Позволяющую нам писать такие прекрасные вещи

template<typename T>
auto sum(T a, T b) -> typename Result<T>::template Requires<is_addable<T,T>> {
    return a + b;
}
  1. Поскольку красота этой конструкции не вызывает никаких сомнений, добавим два простеньких макроса:
#define RESULT typename Result
#define REQUIRES template Requires

И мы восхитительны!

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

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

конец первой части

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