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

habr.png

Несколько новых возможностей 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 
std::string str(T t) {
  if (std::is_same_v) // строка или преобразуемый в строку
    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; std::__cxx11::string = std::__cxx11::basic_string]': required from here error: no matching function for call to 'to_string(std::__cxx11::basic_string&)' return std::to_string(t);


is_same даёт true для используемого типа (string), и мы можем просто вернуть t без преобразований…, но что пошло не так?


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


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


std: enable_if

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


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


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


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


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

template 
std::enable_if_t, 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 
constexpr T absolute(T arg) {
  return arg < 0 ? -arg : arg;
}

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

template 
constexpr enable_if_t::value, bool> 
close_enough(T a, T b) {
  return a == b;
}


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


А сейчас посмотрим, как это делается в C++17:


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

template 
constexpr auto precision_threshold = T(0.000001);

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


Это всего одна функция, которая в основном выглядит как нормальная функция. С почти «нормальным» if:)


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


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


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

В главе 18 книги «Эффективное использование С++» Скотта Майрса описывается метод, названный makeInvestment:


template 
std::unique_ptr 
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  
unique_ptr 
makeInvestment(const string &name, Ts&&... params) {
    unique_ptr pInv;

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

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

    return pInv;
}


Как мы видим, «магия» происходит внутри функции constructArgs.


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


До C++17


В этом случае мы использовали бы std::enable_if так:


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

template 
enable_if_t::value, unique_ptr >
constructArgsOld(...) {
    return nullptr;
}


std::is_constructible позволяет быстро проверить, будет ли данный тип конструироваться из заданного списка аргументов. // @cppreference.com

В C++17 немного проще, появился новый помощник:


is_constructible_v = is_constructible::value;


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


С if constexpr


Обновлённая версия:


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


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


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

    if constexpr (std::is_constructible_v)
        return make_unique(forward(params)...);
    else
       return nullptr;
}


Клёво… не так ли? :)


Весь сложный синтаксис выражений с enable_if ушёл прочь; нам даже не нужна перегрузка функции. Мы можем написать выразительный код всего лишь в одной функции.


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


Заключение

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


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


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

© Habrahabr.ru