How to use type traits?

Sandor Dargo - Apr 14 '21 - - Dev Community

As a spin-off of the concepts series, I delved into the world of type traits and last week we started to discuss what type traits are and how they are implemented.

As I prefer to keep my articles somewhere between 5 and 10 minutes of reading time, I decided to stop right there. With the basic understanding of type traits, now it's time to see how to use them. We are going to see how they can set conditions for compiling different template specializations and then how they can alter types.

Conditional compilation

As we've already mentioned we can use type traits to disallow the usage of templates with certain types based on their characteristics. Just to emphasize, this has no runtime costs, all the checks (and errors) happen at compile-time.

Let's see a basic example.

Let's say that we want to write a function called addSigned(T a, T b) where we only add unsigned number thus we are sure that the result is bigger than any of the inputs (we ignore overflow errors).

If we write a simple template, the problem is we can still call it with unsigned numbers.

#include <iostream>
#include <type_traits>

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


int main() {
    int a = 5;
    int b = -6;
    auto s = addUnsigned(a, b);
    if (s < a || s < b) {
        std::cout << "Oh, oh! The sum is smaller than one of the inputs!\n";
    } else {
        std::cout << "OK! The sum is larger than any of the inputs!s\n";
    }
}
/*
Oh, oh! The sum is smaller than one of the inputs!
*/
Enter fullscreen mode Exit fullscreen mode

Type traits can help us solve this issue in different ways.

static_assert

We can simply statically assert that T is an unsigned type.

template <typename T>
T addUnsigned(T a, T b) {
    static_assert(std::is_unsigned<T>::value, "T must be unsigned!" );
    return a + b;
}
Enter fullscreen mode Exit fullscreen mode

It's worth reminding ourselves, that when used in a boolean context, we can't simply use std::is_unsigned<T> as it's already a type that is not boolean - it inherits from std::integral_constant - but we need its value static member constant that is a bool. Since C++17 we can use std::is_unsigned_v<T> directly.

So static_assert takes the compile-time boolean as a first parameter and an error message as the second parameter.

Then if we use it with some other types, we'll get the - hopefully - nice error message from the compiler.

main.cpp: In instantiation of 'T addUnsigned(T, T) [with T = int]':
main.cpp:14:30:   required from here
main.cpp:6:40: error: static assertion failed: T must be unsigned, but it's
    6 |     static_assert(std::is_unsigned<T>::value, "T must be unsigned, but it's");
      |                     
Enter fullscreen mode Exit fullscreen mode

If you think that the error message is not good enough, just write a better one as it's taken from your static_assert.

std::enable_if

Now let's say that we want to support different additions and we want to use the same function signature T add(T a, T b). We can use the std::enable_if metafunction from the <type_traits> header.

#include <iostream>
#include <type_traits>

template <typename T, typename std::enable_if<std::is_unsigned<T>::value, T>::type* = nullptr>
T add(T a, T b) {
    std::cout << "add called with unsigned numbers\n";
    return a + b;
}

template <typename T, typename std::enable_if<std::is_signed<T>::value, T>::type* = nullptr>
T add(T a, T b) {
    std::cout << "add called with signed numbers\n";
    return a + b;
}

int main() {
    add(5U, 6U);
    add(5, 6);
    add(5, -6);
    // add(5U, -6); // error: no matching function for call to 'add(unsigned int, int)'
}
/*
add called with unsigned numbers
add called with signed numbers
add called with signed numbers
*/
Enter fullscreen mode Exit fullscreen mode

We can see that we were able to define two functions with the same signature, while only the template parameter list is different. There we used enable_if to express that one or the other function should be called in case the is_signed or is_unsigned trait is evaluated to true.

In case, std::enable_if receives true as its first argument, then it will have an internal type that is taken from the second argument. If its first argument evaluates to false, then it doesn't have an internal type and the substitution fails. In order not to end up with a compilation error we default these types to nullptr.

I know this is still a bit vague, but this part that is often referred to as SFINAE deserves its own article. Something we are going to cover in detail in the coming weeks.

if constexpr

Since C++17, there is a third way, as we have if constexpr at our hands. With if constepxr we can evaluate conditions at compile time and we can discard branches from the compilation. With if constexpr you can significantly simplify obscure metaprogramming constructs.

Let's see how we can use it to use it to cut down our previous example:

#include <iostream>
#include <type_traits>

template <typename T>
T add(T a, T b) {
    if constexpr (std::is_signed<T>::value) {
        std::cout << "add called with signed numbers\n";
        return a + b;
    }
    if constexpr (std::is_unsigned<T>::value) {
        std::cout << "add called with unsigned numbers\n";
        return a + b;
    }
    static_assert(std::is_signed<T>::value || std::is_unsigned<T>::value, "T must be either signed or unsingned!");
}


int main() {
    add(5U, 6U);
    add(5, 6);
    add(5, -6);
    // add(5U, -6); // error: no matching function for call to 'add(unsigned int, int)'
    // add("a", "b"); // error: static assertion failed: T must be either signed or unsingned!
}
/*
add called with unsigned numbers
add called with signed numbers
add called with signed numbers
*/
Enter fullscreen mode Exit fullscreen mode

With if constexpr we can evaluate conditions at compile-time and as such we can make compile-time decisions based on the type traits. I'm sure I'm not alone considering it much simpler to read than enable_if

Could we make it simpler? Yes and that's true for all the previous examples. Since C++17 there is a shortcut I already referred to, you don't have to access value in a type_trait, there are metafunctions to return the value directly. They are called the same way as the corresponding type traits, but appended with _v:

#include <iostream>
#include <type_traits>

template <typename T>
T add(T a, T b) {
    if constexpr (std::is_signed_v<T>) {
        std::cout << "add called with signed numbers\n";
        return a + b;
    }
    if constexpr (std::is_unsigned_v<T>) {
        std::cout << "add called with unsigned numbers\n";
        return a + b;
    }
    static_assert(std::is_signed_v<T> || std::is_unsigned_v<T>, "T must be either signed or unsingned!");
}


int main() {
    add(5U, 6U);
    add(5, 6);
    add(5, -6);
    // add(5U, -6); // error: no matching function for call to 'add(unsigned int, int)'
    // add("a", "b"); // error: static assertion failed: T must be either signed or unsingned!
}
/*
add called with unsigned numbers
add called with signed numbers
add called with signed numbers
*/
Enter fullscreen mode Exit fullscreen mode

Altering types

Now let's have a look at how type traits can alter types. There are templates shipped in the <type_traits> header that can

  • add or remove const and/or volatile specifiers from a given type
  • add or remove reference or pointer from a given type
  • make a type signed or unsigned
  • remove dimensions from an array
  • etc. (including enable_if, that we already saw briefly)

Let's see three examples.

Adding/removing const specifier

With std::add_const/std::remove_const you can add/remove the topmost const of a type:

#include <iostream>
#include <type_traits>

int main() {
    using Integer = int;

    std::cout << "Integer is " << (std::is_same<int, Integer>::value
        ? "int" : "not an int") << '\n';
    std::cout << "The result of std::add_const<Integer> is " << (std::is_same<const int, std::add_const<Integer>::type>::value
        ? "const int" : "not const int") << '\n';
    std::cout << "The result of std::add_const<Integer> is " << (std::is_same<int, std::add_const<Integer>::type>::value
        ? "a simple int" : "not a simple int") << '\n';        

    using ConstInteger = const int;

    std::cout << "ConstInteger is " << (std::is_same<const int, ConstInteger>::value
        ? "const int" : "not a const int") << '\n';
    std::cout << "The result of std::remove_const<ConstInteger> is " << (std::is_same<int, std::remove_const<ConstInteger>::type>::value
        ? "int" : "not an int") << '\n';
}
/*
Integer is int
The result of std::add_const<Integer> is const int
The result of std::add_const<Integer> is not a simple int
ConstInteger is const int
The result of std::remove_const<ConstInteger> is int
*/
Enter fullscreen mode Exit fullscreen mode

When you make comparisons, make sure that you access the type nested member. Since C++17 you can directly get the type by using std::add_const_t instead of std::add_const<T>::type to keep things shorter and more readable.

But how can this be useful? The above example already sparks an answer. If you want to compare two types regardless of their qualifiers, first you can remove the const qualifiers and make the comparison with std::is_same only after. Without calling std::remove_const, you might compare T with const T which are different, but after calling it, you'd compare T with T.

Following the same logic, you can find a use case for removing references or pointers as well.

Turning an unsigned number into a signed one

You can use type traits to turn a signed type into an unsigned one or the other way around.

#include <iostream>
#include <type_traits>

int main() {

    std::cout << "Making signed to unsigned " << (std::is_same<unsigned int, std::make_unsigned_t<int>>::value
        ? "worked" : "did not work") << '\n';
    std::cout << "Making unsigned to signed " << (std::is_same<int, std::make_signed_t<unsigned int>>::value
        ? "worked" : "did not work") << '\n';
}
/*
Making signed to unsigned worked
Making unsigned to signed worked
*/
Enter fullscreen mode Exit fullscreen mode

As you can see, we used the _t-style helper functions to get back directly the modified type.

std::conditional to choose between two types at compile time

With std::conditional you can choose between two types based on a compile time condition. You can imagine it as the compile-time ternary operator though probably it's a bit more difficult to read.

#include <iostream>
#include <type_traits>
#include <typeinfo>

int main() 
{
    typedef std::conditional<true, int, double>::type Type1;
    typedef std::conditional<false, int, double>::type Type2;
    typedef std::conditional<sizeof(int) >= sizeof(double), int, double>::type Type3;

    std::cout << typeid(Type1).name() << '\n';
    std::cout << typeid(Type2).name() << '\n';
    std::cout << typeid(Type3).name() << '\n';
}
/*
i
d
d
*/
Enter fullscreen mode Exit fullscreen mode

You can find examples where based the condition is the size of the passed in type. There might be cases, where you want to choose a type based on that for example to have better padding, to fit more the memory layout. How to make a decision based on the size? It's very simple, just use the sizeof operator:

#include <iostream>
#include <type_traits>
#include <typeinfo>

class SmallSize{};
class BigSize{};

template <class T>
using ContainerType =
typename std::conditional<sizeof(T) == 1, SmallSize, BigSize>::type;

int main()
{
    ContainerType<bool> b;
    std::cout << typeid(b).name() << '\n';

    ContainerType<int> i;
    std::cout << typeid(i).name() << '\n';
}
/*
9SmallSize
7BigSize
*/
Enter fullscreen mode Exit fullscreen mode

Conclusion

Today we had a look into how to use type traits for conditional compilation and how to use them to alter types. We mentioned SFINAE as well, which will be the topic in a couple of weeks.

Stay tuned!

Connect deeper

If you liked this article, please

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .