Type traits and if constexpr

A type trait is a compile-time question about a type: is it an integer, is it a pointer, what is it without its reference? The standard library ships a set of them in <type_traits>, and a template can branch on the answer without any run-time cost.

The branching tool is if constexpr: the untaken branch is discarded, so it does not have to compile for the type at hand. This lesson uses traits to choose an implementation, writes one trait of its own, and shows the error that appears when the branch is a plain if.

The standard traits

std::is_integral_v<T> is a bool known at compile time, std::is_same_v<A, B> compares two types and std::remove_reference_t<T> is T without its reference. The _v and _t suffixes are the short forms added in C++14 and C++17.

The program asks three questions about int, double and int& and prints the answers. The remove_reference line shows the type it produces through sizeof, which is the size of an int.

Traits are the vocabulary of generic code: a library decides between a fast path and a general path from them, and the compiler removes the path that cannot apply. Lesson 039 showed how they are built.

#include <iostream> #include <type_traits> int main() { std::cout << std::is_integral_v<int> << ' ' << std::is_integral_v<double> << '\n'; std::cout << std::is_same_v<int, int> << ' ' << std::is_same_v<int, long> << '\n'; std::cout << sizeof(std::remove_reference_t<int&>) << '\n'; }

g++ -std=c++23 -Wall -Wextra traits.cpp -o traits
./traits

1 0
1 0
4

if constexpr

if constexpr (condition) evaluates the condition at compile time and keeps only the branch that applies. The other branch is not instantiated, so code that only makes sense for some types can sit next to code for the others.

The function below returns the value itself for arithmetic types and returns 0 for anything else. The program calls it with an int and with a std::string, and both compile because the unused branch is discarded.

A plain if would compile both branches for every type, which is the mistake in the last section. if constexpr is the C++17 tool that makes a single function template cover types with different capabilities.

#include <iostream> #include <string> #include <type_traits> template <typename T> auto number_or_zero(const T& value) { if constexpr (std::is_arithmetic_v<T>) { return value; } else { return 0; } } int main() { std::cout << number_or_zero(7) << '\n'; std::cout << number_or_zero(std::string{"seven"}) << '\n'; }

g++ -std=c++23 -Wall -Wextra if_constexpr.cpp -o if_constexpr
./if_constexpr

7
0

A trait of your own

A custom trait answers a question the standard library does not: the program below asks whether a type is small enough to pass by value. The trait is a struct with a bool value computed from sizeof, and the function branches on it.

The program prints the answer for int and for a struct with three doubles. The trait is a compile-time constant, so the branch is decided before the program runs.

A trait costs nothing at run time and can carry more than a bool: a type, a size or a function. The standard library's std::tuple_size and std::iterator_traits are the same shape with a type inside.

#include <iostream> struct point3 { double x; double y; double z; }; template <typename T> struct is_small { static constexpr bool value = sizeof(T) <= sizeof(void*); }; template <typename T> constexpr bool is_small_v = is_small<T>::value; template <typename T> void pass(const T& value) { if constexpr (is_small_v<T>) { std::cout << "small, " << sizeof(value) << " bytes\n"; } else { std::cout << "large, " << sizeof(value) << " bytes\n"; } } int main() { pass(1); pass(point3{1.0, 2.0, 3.0}); }

g++ -std=c++23 -Wall -Wextra custom.cpp -o custom
./custom

small, 4 bytes
large, 24 bytes

enable_if and SFINAE

Before if constexpr and concepts, a library chose between overloads with std::enable_if. The trait makes an overload disappear from the candidate set when its condition is false, which is substitution failure, and the other overload is then chosen.

The program has two functions called describe: one enabled only for integral types and one only for floating point types. The call picks the right one from the argument type, and neither body mentions the other.

enable_if is still common in older code and in libraries that support C++11. Lesson 043 shows the modern replacement, which reads better and reports errors that point at the call.

#include <iostream> #include <type_traits> template <typename T, typename std::enable_if_t<std::is_integral_v<T>, int> = 0> void describe(T) { std::cout << "integral\n"; } template <typename T, typename std::enable_if_t<std::is_floating_point_v<T>, int> = 0> void describe(T) { std::cout << "floating point\n"; } int main() { describe(3); describe(2.5); }

g++ -std=c++23 -Wall -Wextra enable_if.cpp -o enable_if
./enable_if

integral
floating point

A plain if compiles both branches

Without constexpr, both branches of an if are compiled for every type. The program's to_number() calls value.size() in one branch and returns the value in the other, and the branch that calls size() is compiled even when T is int.

The message says that int has no member named size, and it points into the branch that should never run for an int. The fix is if constexpr, which discards the branch before it is type-checked.

The same rule applies to every operation that only some types support: begin(), push_back(), operator[], a nested type. A template that mentions them has to guard them with if constexpr or a concept.

#include <string> #include <type_traits> template <typename T> auto to_number(const T& value) { if (std::is_arithmetic_v<T>) { return value; } else { return value.size(); } } int main() { return to_number(3); }

g++ -std=c++23 -Wall -Wextra plain_if.cpp -o plain_if

plain_if.cpp: In instantiation of 'auto to_number(const T&) [with T = int]':
plain_if.cpp:16:21: required from here
16 | return to_number(3);
| ~~~~~~~~~^~~
plain_if.cpp:10:22: error: request for member 'size' in 'value', which is of non-class type 'const int'
10 | return value.size();
| ~~~~~~^~~~

Exercises

  1. Add a branch to number_or_zero for types with a size() member and call it with a std::string.
  2. Write a trait is_pointer that reuses std::is_pointer and add a static_assert for int and for int*.
  3. Replace the enable_if in the fourth program with two constrained overloads using std::integral and std::floating_point.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What is a type trait?

    Answer

    A compile-time answer to a question about a type

    The trait is a template whose value or type is computed while the program is compiled.

  2. What does if constexpr do with the branch it does not take?

    Answer

    It discards it, so it does not have to compile

    The discarded statement is not instantiated, which is what lets one template handle unrelated types.

  3. What does std::is_integral<int>::value give?

    Answer

    true

    The trait answers whether the type is an integer type, and the answer is a compile-time bool.

  4. What is std::enable_if used for?

    Answer

    Removing an overload from the candidate set with SFINAE

    The condition makes the substitution fail quietly, so the overload disappears instead of causing an error.

  5. What do the three trait lines print?

    Answer

    1 0, 1 0, and 4

    The program prints two trait answers twice and the size of a type.

  6. What does this program print? #include <iostream> #include <type_traits> template <typename T> int size_of(const T& value) { if constexpr (std::is_integral_v<T>) return static_cast<int>(value); else return 0; } int main() { std::cout << size_of(7) << ' ' << size_of(std::string{"abc"}); }

    Answer

    7 0

    The integral branch returns the value, and the discarded branch returns zero for the string.

  7. What does this program print? #include <iostream> #include <type_traits> struct large { char data[24]; }; template <typename T> void show() { if constexpr (sizeof(T) <= 4) std::cout << "small, " << sizeof(T) << " bytes\n"; else std::cout << "large, " << sizeof(T) << " bytes\n"; } int main() { show<int>(); show<large>(); }

    Answer

    small, 4 bytes / large, 24 bytes

    The trait compares the size with four, and the two types fall on different sides.

  8. What does this program print? #include <iostream> #include <type_traits> template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0> void show(T) { std::cout << "integral\n"; } template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0> void show(T) { std::cout << "floating point\n"; } int main() { show(1); show(1.5); }

    Answer

    integral / floating point

    enable_if removes the wrong overload from each call, so the two calls select different functions.

  9. What does a plain if in a template report for the int case?

    Answer

    request for member 'size' in 'value', which is of non-class type 'const int'

    Both branches are compiled, so the size() call on an int must still type-check.

  10. Why does if constexpr fix the plain-if problem?

    Answer

    The false branch is not instantiated for the type at hand

    The discarded branch never reaches the compiler for that instantiation.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container. The programs whose section shows a compiler error were compiled until that error and were not run.

Article author: Arthur Isaev