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

в 12:27, , рубрики: c++, if constexpr

Несколько новых возможностей C++17 позволяют написать более компактный и ясный код. Это особенно важно при шаблонном мета-программировании, результат которого часто выглядит жутко…

Например если вы хотите выразить if, который вычисляется во время компиляции, вы будете вынуждены написать код используя приём SFINAE (например enable_if) или статическую диспетчеризацию (tag dispatching). Такие выражения тяжело понять, и они выглядят как магия для разработчиков, незнакомых с продвинутыми шаблонами мета-программирования.

К счастью, с появлением C++17 мы получаем if constexpr. Теперь большинство приёмов SFINAE и статической диспетчеризации отпадает, и код уменьшается, становится похожим на "обычный" if.

Эта статься демонстрирует несколько приёмов использования if constexpr.

Введение

Статический if в форме if constexpr полезная возможность, появившаяся в C++17. Недавно на сайте Meeting C++ была опубликована статься о том, как автор статьи Jens упростил код, используя if constexpr: Как if constexpr упрощает ваш код в C++17.

Я нашёл пару дополнительных примеров, которые могут продемонстрировать, как работает новая возможность.

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

Я надеюсь, что эти примеры помогут вам понять статический if из C++17.
Но для начала я бы хотел освежить основы enable_if.

Для чего нужен if во время компиляции?

Услышав об этом в первый раз, возможно вы спросите, зачем нужен статический if и эти сложные шаблонные выражения… Разве нормальный if не будет работать?

Рассмотрим пример:

template <typename T>
std::string str(T t) {
  if (std::is_same_v<T, std::string>) // строка или преобразуемый в строку
    return t;
  else
    return std::to_string(t);
}

Эта функция может служить простым инструментом для вывода текстового представления объектов. Так как to_string не принимает параметр типа std::string, мы можем проверить это и просто вернуть t если t — string. Звучит просто… Но давайте попробуем скомпилировать этот код:

// код, который вызывает нашу функцию
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_same даёт true для используемого типа (string), и мы можем просто вернуть t без преобразований… но что пошло не так?

Главная причина в этом: компилятор попытался разобрать обе условные ветви и нашёл ошибку в случае else. Он не может отбросить "неправильный" кода в нашем частном случае конкретизации шаблона.

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

std::enable_if

Один из способов написать статический if в C++11/14 — использовать enable_ifenable_if_v начиная с C++14). Он имеет достаточно странный синтаксис::

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

enable_if выводит тип T, если условие B истинно. Иначе, согласно SFINAE, частичная перегрузка функции удаляется из доступных перегрузок фунции.

Мы можем переписать наш простой пример так:

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

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

Это не просто, не так ли?

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

Далее мы упростим подобный код с помощью if constexpr из C++17. После этого мы сможем быстро переписать нашу функцию str.

Использование первое — сравнение чисел

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

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

Версия для C++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 вычисляется во время компиляции и затем пропускается код одной из ветвей выражения.

Здесь используются чуть больше возможностей C++17. Вы видите, какие?

Использование второе — фабрика с переменным количеством параметров

В главе 18 книги "Эффективное использование С++" Скотта Майрса описывается метод, названный 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 { }
};

Пример из книги слишком идеализированный и не рабочий — он работает, пока конструкторы ваших классов принимают одинаковое число и одинаковые типы входных аргументов.
Скотт Майрес комментирует в исправлениях и дополнениях к его книге "Эффективное использование С++" так:

Интерфейс 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)! Чтобы это заработало, нам нужно что-то похожее на static if — компилировать это только тогда, когда это удовлетворяет условию, иначе отбросить.

Вот код, который мог бы работать:

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 в противном случае.

До 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 позволяет быстро проверить, будет ли данный тип конструироваться из заданного списка аргументов. // @cppreference.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;
}

Мы можем даже расширить функциональность логироваием действий, используя свёртку выражения:

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 ушёл прочь; нам даже не нужна перегрузка функции. Мы можем написать выразительный код всего лишь в одной функции.

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

Заключение

Условные выражения времени компиляции — замечательная возможность, которая сильно упрощает использование шаблонов. Кроме того, код становится яснее, чем при использовании существовавших ранее решений: статической диспетчеризации (tag dispatching) или enable_if (SFINAE). Сейчас вы можете выразить свои намерения "похоже" на код в рантайме.

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

Возвращаясь назад к нашему примеру функции str: можете ли вы сейчас переписать её используя if constexpr? :)

Автор: sergio_nsk

Источник

Поделиться

* - обязательные к заполнению поля