Code simplification with if constexpr in C ++ 17

Original author: Bartlomiej Filipek
  • Transfer
  • Tutorial

Several new features in C ++ 17 allow you to write more compact and clear code. This is especially important with template meta-programming, the result of which often looks creepy ...


For example, if you want to express if which is computed at compile time, you will be forced to write code using SFINAE (for example enable_if ) or static dispatch (tag dispatching). These expressions are hard to understand, and they look like magic to developers unfamiliar with advanced meta-programming patterns.


Fortunately, with the advent of C ++ 17 we get if constexpr . Now most of the SFINAE and static dispatching techniques are no longer needed, and the code is reduced, becoming like "normal" if .


This article demonstrates several uses if constexpr .


Introduction


Static if in form is a if constexpr useful feature introduced in C ++ 17. Recently, a meeting was published on the Meeting C ++ site about how the author of the Jens article simplified the code using if constexpr : How if constexpr simplifies your code in C ++ 17 .


I found a couple of additional examples that can demonstrate how the new feature works.


  • Comparison of numbers
  • Factor Variable Factories

I hope these examples help you understand the static if from C ++ 17.
But first, I'd like to brush up on the basics enable_if .


Why do I need if at compile time?


Having heard about this for the first time, you might ask why you need static if and these complex template expressions ... Wouldn’t normal if work?


Consider an example:


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

This function can serve as a simple tool for displaying a textual representation of objects. Since it to_string does not accept a type parameter std::string , we can check this and simply return t if t - string. It sounds simple ... But let's try to compile this code:


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

We will get something similar to this:


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 gives true for the type used (string), and we can just return t without conversion ... but what went wrong?


The main reason for this is that the compiler tried to parse both conditional branches and found an error in the case else . It cannot reject the “wrong” code in our particular case of specifying a template.


For this we need a static if one that will “exclude” the code and compile only the block that matches the condition.


std :: enable_if


One way to write static if in C ++ 11/14 is to use enable_if (and enable_if_v starting with C ++ 14). It has a rather strange syntax:


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

enable_if infers type T if condition B is true. Otherwise, according to SFINAE, a partial function overload is removed from the available function overloads.


We can rewrite our simple example like this:


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

It's not easy, is it?


I used enable_if to separate the case when the type is a string ... But the exact same effect can be achieved by simply overloading the function, avoiding use enable_if .


Next, we will simplify this code using if constexpr C ++ 17. After that, we can quickly rewrite our function str .


First use - comparison of numbers


Let's start with a simple example: a function close_enough that works with two numbers. If the numbers are not floating point (for example, when we have two integers int ), we can simply compare them. For floating point numbers, it's best to use some small epsilon value.


I found this example in Practical Modern C ++ Teaser , a fantastic introduction to the possibilities of modern C ++ from Patrice Roy . He kindly allowed me to include his example.


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

As you can see, it is used here enable_if . This is very similar to our function str . The code checks to see if the type of input numbers satisfies the condition is_floating_point . Then the compiler can remove one of their function overloads.


Now let's see how this is done in 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;
}

This is just one function that basically looks like a normal function. With almost "normal if . "


if constexpr It is computed at compile time and then the code of one of the expression branches is skipped.


It uses a bit more features of C ++ 17. Do you see which ones?


Using the second - a factory with a variable number of parameters


Scott Myrs, Chapter 18 of The Effective Use of C ++, describes a method called makeInvestment :


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

This is the factory method that creates the descendants of the class Investment , and the main advantage in it is the support of a different number of parameters!


For example, below are the types of heirs:


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

The example from the book is too idealized and not working - it works as long as the constructors of your classes accept the same number and the same types of input arguments.
Scott Myres comments in corrections and additions to his book "Effective Use of C ++" like this:


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

For example, if you have two classes, the constructor of one takes two arguments and the other three, then this code will not compile:


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

If you write make(bond, 1, 2, 3) , then the expression under else will not be compiled, so there is no suitable constructor for Stock(1, 2, 3) ! For this to work, we need something similar to static if - to compile it only when it meets the condition, otherwise discard it.


Here is the code that could work:


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

As we see, “magic” happens inside a function constructArgs .


The rationale behind the idea is to return unique_ptr<Type> when a type is Type constructed from a given set of attributes, or nullptr otherwise.


Before C ++ 17


In this case, we would use std::enable_if this:


// до 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

In C ++ 17 a little easier, a new helper has appeared:


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

So we can make the code a little shorter ... However, the use enable_if is still terrible and difficult. What about C ++ 17?


FROM if constexpr


Updated Version:


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

We can even extend the functionality by logging actions using the convolution of the 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;
}

Cool ... isn't it?


The whole complex syntax of expressions has enable_if gone away; we don’t even need a function overload. We can write expressive code in just one function.


Depending on the result of the calculation of the expression condition, if constexpr only one block of code will be compiled. In our case, if an object can be constructed from a given set of attributes, then we compile the call make_unique . If not, then return nullptr (and make_unique not even compile).


Conclusion


Compile-time conditional expressions are a great feature that greatly simplifies the use of templates. In addition, the code becomes clearer than using pre-existing solutions: static dispatching (tag dispatching) or enable_if (SFINAE). Now you can express your intentions "similar" to the code in runtime.


This article has only looked at simple expressions, and I encourage you to explore more broadly the applicability of the new features.


Going back to our function example str : can you now rewrite it using if constexpr?