Pull to refresh
1001.88
OTUS
Цифровые навыки от ведущих экспертов

Упрощаем код с помощью if constexpr и концептов C++17/C++20

Reading time14 min
Views17K
Original author: cppstories.com

До C++17 у нас было несколько довольно неэлегантных способов написать static if (if, который работает во время компиляции). Например, мы можем использовать статическую диспетчеризацию или SFINAE. К счастью, ситуация изменилась к лучшему, ведь теперь мы можем воспользоваться для этого if constexpr  и концептами C++20!

Ну что ж, давайте разберемся, как мы можем использовать это в качестве замены std::enable_if кода!

  • Обновление от апреля 2021 г.: изменения, связанные с C++20 — концепты.

  • Обновление от августа 2022 г.: дополнительные примеры if constexpr (четвертый).

Введение  

If во время компиляции в форме if constexpr — это замечательная фича, которая была добавлена в C++17. Этот функционал может помочь нам значительно улучшить читаемость кода с большими нагромождениями шаблонов.

Кроме того, C++20 принес нам концепты (сoncepts)! Это еще один шаг на пути к достижению почти “органичного” кода времени компиляции.

На эту статью меня вдохновил пост на @Meeting C++ с очень похожим названием. Я нашел еще четыре примера, которые наглядно продемонстрировать в действии этот новый функционал:

  • Сравнение чисел.

  • (Новинка!) Вычисление среднего значения в контейнере.

  • Фабрики с переменным числом аргументов.

  • Примеры реального кода из продакшена.

Но для начала, чтобы у нас было больше контекста, я все-таки хотел бы пройтись по некоторым базовым сведениям о enable_if.

Для чего может понадобиться if во время компиляции?    

Начнем с примера, в котором мы пытаемся преобразовать некоторый ввод в строку:

#include <string>
#include <iostream>

template <typename T>
std::string str(T t) {
    return std::to_string(t);
}

std::string str(const std::string& s) {
    return s;
}

std::string str(const char* s) {
    return s;
}

std::string str(bool b) {
    return b ? "true" : "false";
}

int main() {
    std::cout << str("hello") << '\n';
    std::cout << str(std::string{"hi!"}) << '\n';
    std::cout << str(42) << '\n';
    std::cout << str(42.2) << '\n';
    std::cout << str(true) << '\n';
}

Вы можете посмотреть этот код в Compiler Explorer.

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

Сработает ли здесь “заурядный” if?

Вот тестовый код:

template <typename T>
std::string str(T t) {
    if (std::is_convertible_v<T, std::string>)
        return t;
    else if (std::is_same_v<T, bool>)
        return t ? "true" : "false";
    else
        return std::to_string(t);
}

Звучит достаточно просто… но давайте попробуем скомпилировать этот код:

// код, вызывающий нашу функцию
auto t = str("10"s);

В результате мы должны получить что-то вроде этого:

In instantiation of 'std::__cxx11::string str(T) [with T = 
std::__cxx11::basic_string<char>; std::__cxx11::string =
 std::__cxx11::basic_string<char>]':
required from here
error: no matching function for call to 
'to_string(std::__cxx11::basic_string<char>&)'
    return std::to_string(t);

is_convertible  возвращает true  для используемого нами типа (std::string), так что мы можем просто вернуть t  без какого-либо преобразования… так что же не так?

Вот в чем дело:

Компилятор скомпилировал все ветки и нашел ошибку в else. Он не может отбросить “неправильный” код для этого частного случая конкретизации шаблона.

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

std::enable_if   

Один из способов, которым можно реализовать статический if в C++11/14 — это использовать enable_if.

enable_ifenable_if_v C++14). У него довольно странный синтаксис:

template< bool B, class T = void >  
struct enable_if;

enable_if выводит тип T, если входное условие B истинно. В противном случае это SFINAE, и конкретная перегрузка функции удаляется из набора перегрузок. Это означает, что в случае false компилятор “отбрасывает” код — это как раз то, что нам нужно.

Мы можем переписать наш пример следующим образом:

template <typename T>
enable_if_t<is_convertible_v<T, string>, string> strOld(T t) {
    return t;
}

template <typename T>
enable_if_t<!is_convertible_v<T, string>, string> strOld(T t) {
    return to_string(t);
}
// префикс std:: был опущен

Не так легко… верно? Кроме того, эта версия выглядит намного сложнее, чем отдельные функции и обычная перегрузка функций, которые были у нас в самом начале.

Вот почему нам нужен if constexpr из C++17, который может помочь в таких случаях.

Почитав эту статью, вы сами сможете быстро переписать нашу функцию str (или найти решение в конце).

Чтобы лучше разобраться с новыми фичами, давайте рассмотрим несколько базовых примеров:

Пример 1 — сравнение чисел    

Давайте начнем с самого простого примера: функция close_enough, которая работает с двумя числами. Если числа не являются числами с плавающей запятой (например, когда мы получаем два int), мы можем сравнить их напрямую. Для чисел с плавающей запятой лучше будет использовать некоторую достаточно маленькую величину, с которой мы будем сравнивать их разницу abs < epsilon.

Я нашел этот код в Practical Modern C++ Teaser — фантастическом пошаговом руководстве по современным фичам C++, написанном Патрисом Роем (Patrice Roy). Он был очень любезен и позволил мне включить этот пример в свою статью.

Версия С++11/14:

template <class T> constexpr T absolute(T arg) {
   return arg < 0 ? -arg : arg;
}

template <class T> 
constexpr enable_if_t<is_floating_point<T>::value, bool> 
close_enough(T a, T b) {
   return absolute(a - b) < static_cast<T>(0.000001);
}
template <class T>
constexpr enable_if_t<!is_floating_point<T>::value, bool> 
close_enough(T a, T b) {
   return a == b;
}

Как видите, здесь используется enable_if. Эта функция очень похожа на нашу функцию str. Код проверяет тип входного числа — is_floating_point. Затем компилятор может удалить одну из перегрузок функции.

А теперь давайте посмотрим на версию C++17:

template <class T> constexpr T absolute(T arg) {
   return arg < 0 ? -arg : arg;
}

template <class T>
constexpr auto precision_threshold = T(0.000001);

template <class T> constexpr bool close_enough(T a, T b) {
   if constexpr (is_floating_point_v<T>) // << !!
      return absolute(a - b) < precision_threshold<T>;
   else
      return a == b;
}

Вау… все заключено в одной функции, которая к тому же выглядит почти как обычная функция.

С почти “заурядным” if :)

В конструкции if constexpr constexpr выражение вычисляется во время компиляции, после чего код в одной из ветвей отбрасывается.

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

Именно поэтому следующий код генерирует ошибку компилятора:

template <class T> constexpr bool close_enough(T a, T b) {
   if constexpr (is_floating_point_v<T>) 
      return absolute(a - b) < precision_threshold<T>;
   else
      return aaaa == bxxxx; // ошибка компилятора - синтаксис!
}

close_enough(10.04f, 20.f);

Кстати, заметили ли вы какие-нибудь другие фичи C++17, которые здесь использовались?

Вы можете посмотреть этот код в @Compiler Explorer

Добавление концептов C++20   

Но подождите… на дворе уже 2021 год, так почему бы нам не воспользоваться концептами? :)

До C++20 мы могли рассматривать шаблонные параметры как что-то вроде void* в обычной функции. Если вы хотели ограничить такой параметр, вам приходилось использовать различные методы, описанные в этой статье. Но вместе с концептами мы получили естественный способ ограничить эти параметры.

Взгляните на следующий фрагмент кода: 

template <typename T>
requires std::is_floating_point_v<T>
constexpr bool close_enough20(T a, T b) {
   return absolute(a - b) < precision_threshold<T>;
}
constexpr bool close_enough20(auto a, auto b) {
   return a == b;
}

Как видите, версия C++20 вернулась обратно к двум функциям. Но теперь код намного читабельнее, чем с enable_if. С помощью концептов мы можем легко выразить наши требования к шаблонным параметрам:

requires std::is_floating_point_v<T>

is_floating_point_v является свойством типа (из библиотеки <type_traits>), а оператор requires, как вы можете видеть, вычисляет булевы константные выражения.

Вторая функция использует новый обобщенный синтаксис функции, в котором мы можем опустить template<> и написать:

constexpr bool close_enough20(auto a, auto b) { }

Такой синтаксис мы получили с обобщенными (generic) лямбда-выражениями. Это не прямая трансляция нашего C++11/14 кода, поскольку он соответствует следующей сигнатуре:

template <typename T, typename U>
constexpr bool close_enough20(T a, U b) { }

Вдобавок, C++20 предлагает нам краткий синтаксис для концептов, который основан на auto с ограничениями (constrained auto):

constexpr bool close_enough20(std::floating_point auto a,
                              std::floating_point auto b) {
   return absolute(a - b) < precision_threshold<std::common_type_t<decltype(a), decltype(b)>>;
}
constexpr bool close_enough20(std::integral auto a, std::integral auto b) {
   return a == b;
}

В качестве альтернативы, мы также можем использовать имя концепта вместо typename без оператора requires:

template <std::is_floating_point T>
constexpr bool close_enough20(T a, T b) {
   return absolute(a - b) < precision_threshold<T)>;
}

В этом случае мы также переключились с is_floating_point_v на концепт floating_point определенный под заголовком <concepts>.

Вы можете посмотреть этот код в @Compiler Explorer

Хорошо, а как насчет других вариантов использования?

Пример 2 — вычисление среднего значения 

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

Вот, что мы хотели бы видеть:

std::vector ints { 1, 2, 3, 4, 5};
std::cout << Average(ints) << '\n';

Наша функция должна:

  • Принимать числа с плавающей запятой или целочисленные типы.

  • Возвращать double.

В C++20 для этого мы можем использовать диапазоны (ranges), но в целях этой статьи давайте рассмотрим другие способы реализовать такую функцию.

Вот возможная версия с концептами:

template <typename T> 
requires std::is_integral_v<T> || std::is_floating_point_v<T>
constexpr double Average(const std::vector<T>& vec) {
    const double sum = std::accumulate(vec.begin(), vec.end(), 0.0);        
    return sum / static_cast<double>(vec.size());
}

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

У нас нет предопределенного концепта, объединяющего типы с плавающей запятой и целочисленные, поэтому мы можем попробовать написать свой собственный:

template <typename T> 
concept numeric = std::is_integral_v<T> || std::is_floating_point_v<T>;

И использовать его следующим образом:

template <typename T> 
requires numeric<T>
constexpr double Average2(std::vector<T> const &vec) {
    const double sum = std::accumulate(vec.begin(), vec.end(), 0.0);        
    return sum / static_cast<double>(vec.size());
}

Мы также можем сделать этот код достаточно лаконичным:

constexpr double Average3(std::vector<numeric auto> const &vec) {
    const double sum = std::accumulate(vec.begin(), vec.end(), 0.0);        
    return sum / static_cast<double>(vec.size());
}

Так этот код будет выглядеть с enable_if C++14:

template <typename T> 
std::enable_if_t<std::is_integral_v<T> || std::is_floating_point_v<T>, double>
Average4(std::vector<T> const &vec) {
    const double sum = std::accumulate(vec.begin(), vec.end(), 0.0);        
    return sum / static_cast<double>(vec.size());
}

Вы можете посмотреть этот код в @Compiler Explorer

Пример 3 — фабрика с переменным количеством аргументов

В параграфе 18 книги “Эффективное использование C++” Скотт Майерс описал функцию makeInvestment:

template<typename... Ts> 
std::unique_ptr<Investment> 
makeInvestment(Ts&&... params);

Это фабричный метод, который создает классы, наследуемые от Investment, и его главное преимущество заключается в том, что он поддерживает переменное количество аргументов!

Вот несколько предлагаемых типов для примера:

class Investment {
public:
    virtual ~Investment() { }

    virtual void calcRisk() = 0;
};

class Stock : public Investment {
public:
    explicit Stock(const std::string&) { }

    void calcRisk() override { }
};

class Bond : public Investment {
public:
    explicit Bond(const std::string&, const std::string&, int) { }

    void calcRisk() override { }
};

class RealEstate : public Investment {
public:
    explicit RealEstate(const std::string&, double, int) { }

    void calcRisk() override { }
};

Код из книги был слишком идеалистичным и практически нерабочим. Он работал только в тех случаях, когда все ваши классы имели одинаковое количество и типы входных параметров:

Скотт Майерс: История изменений и список исправлений для книги “Эффективное использование C++”:

Интерфейс makeInvestment нереалистичен, потому что он подразумевает, что все производные типы объектов могут быть созданы из одних и тех же типов аргументов. Это особенно бросается в глаза в коде примера реализации, где наши аргументы передаются всем конструкторам производных классов с помощью прямой передачи (perfect-forwarding).

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

// псевдокод:
Bond(int, int, int) { }
Stock(double, double) { }
make(args...)
{
  if (bond)
     new Bond(args...);
  else if (stock)
     new Stock(args...)
}

Если вы напишете make(bond, 1, 2, 3), то блок else не будет скомпилирован — так как нет доступного Stock(1, 2, 3)! Чтобы этот код работал, нам нужно что-то вроде статического if, который будет отбрасывать во время компиляции части кода, которые не соответствуют условию.

Несколько статей назад вместе с одним из моих читателей мы придумали работающее решение (подробнее вы можете прочитать в Nice C++ Factory Implementation 2).

Вот код, который будет работать:

template <typename... Ts> 
unique_ptr<Investment> 
makeInvestment(const string &name, Ts&&... params)
{
    unique_ptr<Investment> pInv;

    if (name == "Stock")
        pInv = constructArgs<Stock, Ts...>(forward<Ts>(params)...);
    else if (name == "Bond")
        pInv = constructArgs<Bond, Ts...>(forward<Ts>(params)...);
    else if (name == "RealEstate")
        pInv = constructArgs<RealEstate, Ts...>(forward<Ts>(params)...);

    // далее вызываем дополнительные методы для инициализации pInv...

    return pInv;
}

Как видите, вся “магия” происходит внутри функции constructArgs .

Основная идея заключается в том, чтобы возвращать unique_ptr<Type>, когда у нас есть Type, сконструированный из заданного набора атрибутов, или nullptr в противном случае.

До С++17  

В моем предыдущем решении (до C++17) мы использовали std::enable_if, и это выглядело так:

// до C++17
template <typename Concrete, typename... Ts>
enable_if_t<is_constructible<Concrete, Ts...>::value, unique_ptr<Concrete>>
constructArgsOld(Ts&&... params)
{
    return std::make_unique<Concrete>(forward<Ts>(params)...);
}

template <typename Concrete, typename... Ts>
enable_if_t<!is_constructible<Concrete, Ts...>::value, unique_ptr<Concrete> >
constructArgsOld(...)
{
    return nullptr;
}

std::is_constructible (смотрите c++ reference.com) — позволяет нам быстро проверить, можно ли использовать список аргументов для создания данного типа.

В C++17 есть хелпер:

is_constructible_v = is_constructible<T, Args...>::value;

Так мы могли бы сделать код немного лаконичнее…

Тем не менее, использование enable_if выглядит неэлегантно и чересчур сложно. Как насчет C++17 версии?

С if constexpr   

Вот обновленная версия:

template <typename Concrete, typename... Ts>
unique_ptr<Concrete> constructArgs(Ts&&... params)
{  
  if constexpr (is_constructible_v<Concrete, Ts...>)
      return make_unique<Concrete>(forward<Ts>(params)...);
   else
       return nullptr;
}

Очень лаконично!

Мы можем даже добавить сюда логирование, используя свертку (fold expression):

template <typename Concrete, typename... Ts>
std::unique_ptr<Concrete> constructArgs(Ts&&... params)
{ 
    cout << __func__ << ": ";
    // свертка:
    ((cout << params << ", "), ...);
    cout << "\n";

    if constexpr (std::is_constructible_v<Concrete, Ts...>)
        return make_unique<Concrete>(forward<Ts>(params)...);
    else
       return nullptr;
}

Неплохо… не так ли? :)

Вся сложность синтаксиса enable_if канула в лету; нам даже не нужна перегрузка функции для else. Теперь мы можем заключить весь этот выразительный код в одной функции.

if constexpr вычисляет условие, в результате чего будет скомпилирован только один блок. В нашем случае, если тип может быть сконструирован из заданного набора атрибутов, мы скомпилируем make_unique. Если нет, то вернем nullptrmake_unique даже не будет создана).

C++20   

Мы можем легко заменить enable_if концептами:

// C++20:
template <typename Concrete, typename... Ts>
requires std::is_constructible_v<Concrete, Ts...>
std::unique_ptr<Concrete> constructArgs20(Ts&&... params) {
    return std::make_unique<Concrete>(std::forward<Ts>(params)...);
}

template <typename Concrete, typename... Ts>
std::unique_ptr<Concrete> constructArgs20(...) {
    return nullptr;
}

Но я не уверен, что этот вариант лучше. Я думаю, что в этом случае, if constexpr выглядит намного лаконичнее и проще для понимания.

Вы можете посмотреть этот код в @Compiler Explorer

Пример 4 — реальные проекты     

if constexpr годится не только для экспериментальных демок — он уже нашел применение в продакшене.

Если мы посмотрим на опенсорсную реализацию STL от команды MSVC, мы можем найти несколько случаев, где if constexpr пришелся очень кстати.

Журнал изменений можно посмотреть здесь: https://github.com/microsoft/STL/wiki/Changelog

Вот некоторые из улучшений:

  • Теперь вместо статической диспетчеризации используется if constexpr в: get<I>() и get<T>() для pair (#2756)

  • Вместо статической диспетчеризации, перегрузок или специализаций используется if constexpr в таких алгоритмах, как is_permutation(), sample(), rethrow_if_nested() и default_searcher (#2219), общих механизмах <map> и <set> (#2287) и паре других мест.

  • Используется if constexpr вместо статической диспетчеризации в оптимизации в find() (#2380), basic_string(first, last) (#2480)

  • Улучшена реализация вектора, также для упрощения кода был задействован if constexpr (#1771)

Давайте посмотрим на улучшения для std::pair:

Untag dispatch get for pair by frederick-vs-ja · Pull Request #2756 · microsoft/STL

До C++17 код выглядел следующим образом:

template <class _Ret, class _Pair>
constexpr _Ret _Pair_get(_Pair& _Pr, integral_constant<size_t, 0>) noexcept {
    // получаем ссылку на элемент 0 в паре _Pr
    return _Pr.first;
}

template <class _Ret, class _Pair>
constexpr _Ret _Pair_get(_Pair& _Pr, integral_constant<size_t, 1>) noexcept {
    // получаем ссылку на элемент 1 в паре _Pr
    return _Pr.second;
}

template <size_t _Idx, class _Ty1, class _Ty2>
_NODISCARD constexpr tuple_element_t<_Idx, pair<_Ty1, _Ty2>>& 
    get(pair<_Ty1, _Ty2>& _Pr) noexcept {
    // получаем ссылку на элемент по адресу _Idx в паре _Pr
    using _Rtype = tuple_element_t<_Idx, pair<_Ty1, _Ty2>>&;
    return _Pair_get<_Rtype>(_Pr, integral_constant<size_t, _Idx>{});
}

И после изменения:

template <size_t _Idx, class _Ty1, class _Ty2>
_NODISCARD constexpr tuple_element_t<_Idx, pair<_Ty1, _Ty2>>& get(pair<_Ty1, _Ty2>& _Pr) noexcept {
    // получить ссылку на элемент по адресу _Idx в паре _Pr
    if constexpr (_Idx == 0) {
        return _Pr.first;
    } else {
        return _Pr.second;
    }
}

Теперь это одна функция, и ее намного легче читать! Больше нет никакой необходимости использовать статическую диспетчеризацию и хелпер integer_constant.

В другой библиотеке, на этот раз связанной с SIMD-типами и вычислениями (популярная реализация от Agner Fog), вы можете найти множество примеров использования if constexpr:

https://github.com/vectorclass/version2/blob/master/instrset.h

Одним из ярких примеров является функция маски:

// zero_mask: возвращает компактную битовую маску для обнуления с использованием маски AVX512.
// Параметр a является ссылкой на массив constexpr int индексов перестановок
template <int N>
constexpr auto zero_mask(int const (&a)[N]) {
    uint64_t mask = 0;
    int i = 0;

    for (i = 0; i < N; i++) {
        if (a[i] >= 0) mask |= uint64_t(1) << i;
    }
    if constexpr      (N <= 8 ) return uint8_t(mask);
    else if constexpr (N <= 16) return uint16_t(mask);
    else if constexpr (N <= 32) return uint32_t(mask);
    else return mask;
}

Без if constexpr код был бы намного длиннее и потенциально дублировался бы.

Заключение

if во время компиляции — замечательная фича, значительно упрощающая шаблонный код. Более того, она намного выразительнее и элегантнее, чем предыдущие решения: статическая диспетчеризация и enable_if (SFINAE). Теперь мы можем легко выразить свои намерения наподобии с рантайм-кодом.

Мы также переработали код примеров с нововведениями C++20! Как видите, благодаря концептам код стал еще более читабельным, ведь теперь мы можем выразить требования к своим типам “естественным” образом. Мы также получили несколько сокращений синтаксиса и несколько способов сообщить о наших ограничениях.

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

Возвращаясь назад…

И возвращаясь к нашему примеру со str:

Можете ли вы теперь переписать str (из начала этой статьи), используя if constexpr? :) Попробуйте, а затем взгляните на мое простое решение в @CE.

Еще больше

Вы можете найти больше примеров и вариантов использования if constexpr в моей книге о C++17: C++17 в деталях на @Leanpub или печатную версию на @Amazon.


Статья подготовлена в преддверии старта курса "C++ Developer. Professional". Всех желающих приглашаем посмотреть запись открытого урока «Умные указатели», на котором разобрали, что такое умные указатели и зачем они нужны; а также провели обзор умных указателей, входящих в stl: unique_ptr, shared_ptr, weak_ptr. Посмотреть можно по ссылке.

Tags:
Hubs:
Total votes 12: ↑10 and ↓2+9
Comments34

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS