Skip to content
academia.sh

Lesson 04 / 12

Type Traits

Standard type traits, selection at compile time with if constexpr, static_assert, writing your own type trait, and the selection leaving zero queries at run time.

Published Updated

In this lesson

  • Tells the standard type traits (is_integral_v, is_same_v, remove_cvref_t, conditional_t) apart as queries and transformations.
  • Shows that selection with if constexpr does not compile the discarded branch in that instantiation.
  • Builds a custom type trait on true_type and false_type.
  • Explains that a decision made at compile time leaves zero queries at run time, and that constexpr alone does not guarantee this.
Contents

The previous lesson counted how many bodies pack expansion makes the compiler write. When a template has to look at a type and change its behavior, a second question comes up: when is that look taken, and who pays for it? A type trait is a small template that answers a question about a type during compilation. The claim of the lesson is this: when the selection is made at compile time, no query is left at run time. The cost stays in the compile kind and carries a product count beside it.

This distinction answers a different question from M11/K01. K01 asked who makes a decision; this course asks who pays the cost of a mechanism and what it is counted with. K01’s compiler kind named a decision. The compile kind here names a cost, and always carries a product count beside it.

Queries and Transformations

<type_traits> provides two kinds of traits. Queries produce a bool: std::is_integral_v<T> is true if the type is an integer type, and std::is_same_v<A, B> is true if two types are identical. Transformations produce a new type: std::remove_cvref_t<T> strips the const, volatile and reference qualifiers from a type; std::conditional_t<K, A, B> becomes A if the condition is true and B otherwise. The suffixed _v and _t forms are shorthand for writing ::value and ::type. All of them are evaluated at compile time.

// traits.cpp — queries and transformations
#include <cstdio>
#include <string>
#include <type_traits>

int main() {
    std::printf("integral %d %d %d\n", std::is_integral_v<int>,
                std::is_integral_v<double>, std::is_integral_v<bool>);
    std::printf("same %d %d\n", std::is_same_v<int, int>,
                std::is_same_v<int, long>);
    std::printf("strip %d %d\n",
                std::is_same_v<std::remove_cvref_t<const int&>, int>,
                std::is_same_v<std::remove_cvref_t<std::string&&>, std::string>);
    using selected = std::conditional_t<(sizeof(int) >= 2), int, long>;
    std::printf("condition %d\n", std::is_same_v<selected, int>);
    return 0;
}
integral 1 0 1
same 1 0
strip 1 1
condition 1

bool is an integer type, so the third query is true. When const int& is stripped, it becomes int; the reference qualifier (&&) is stripped as well. conditional_t selects a type, not a value: here selected is equal to int. This output is guaranteed by the standard; the result of the first two queries does not change in any implementation.

Selection with if constexpr

The way to use a query result to select a branch is if constexpr. The condition is evaluated at compile time, and the discarded branch is not compiled in that instantiation. An ordinary if, on the other hand, compiles both branches; if you write x.size() in one branch and give int as T, compilation stops even if that branch never runs. if constexpr solves this problem: if T is an integer, the compiler does not look at the .size() branch.

Below, kind_of gives every type a label. kind_tagged does the same job with a run-time parameter, that is, with a decision supplied while running, and counts a query on every call.

// kind.hpp — selection at compile time and at run time
#ifndef KIND_HPP
#define KIND_HPP

#include <string>
#include <type_traits>

inline int queries = 0;
inline int tagged_queries = 0;

template <typename T>
const char* kind_of(T) {
    if constexpr (std::is_integral_v<T>) {
        return "integer";
    } else if constexpr (std::is_floating_point_v<T>) {
        return "floating";
    } else {
        return "other";
    }
}

template <typename T>
const char* kind_tagged(T, int tag) {
    ++tagged_queries;
    if (tag == 0) {
        return "integer";
    }
    if (tag == 1) {
        return "floating";
    }
    return "other";
}

template <typename T>
long length(T x) {
    if constexpr (std::is_same_v<std::remove_cvref_t<T>, std::string>) {
        return static_cast<long>(x.size());
    } else {
        return static_cast<long>(x);
    }
}

#endif
// kind-select.cpp — same result, different query count
#include <cstdio>
#include <string>

#include "kind.hpp"

int main() {
    int same = 0;
    same += std::string(kind_of(1)) == kind_tagged(1, 0);
    same += std::string(kind_of(2L)) == kind_tagged(2L, 0);
    same += std::string(kind_of('a')) == kind_tagged('a', 0);
    same += std::string(kind_of(1.5)) == kind_tagged(1.5, 1);
    same += std::string(kind_of(2.5f)) == kind_tagged(2.5f, 1);
    same += std::string(kind_of(std::string("x"))) == kind_tagged(std::string("x"), 2);
    std::printf("same=%d queries=%d tagged=%d\n", same, queries, tagged_queries);
    std::printf("length %ld %ld\n", length(std::string("abcd")), length(7));
    return 0;
}
same=6 queries=0 tagged=6
length 4 7

MT20. In all six calls the two methods give the same label (same=6). kind_of makes no query at run time (queries=0): which branch is selected has been decided in the compiler, and a single return is left in each instantiation. kind_tagged reads a tag on every call (tagged=6). This row is the if constexpr selection measurement in the core. length also shows why the discarded branch is not compiled: for int there is no x.size() branch, and for string there is no static_cast<long>(x) branch. With a plain if, both would be compile errors.

static_assert and Constraints

static_assert(condition, "message") checks the condition at compile time. If the condition is false, compilation stops and the message appears in the compiler output; if it is true, no code is generated. The failure is described in prose here, because code that does not compile is not written as a block: instantiating a template that contains static_assert(std::is_integral_v<T>) with double stops compilation; compilers usually show, next to the message, where the instantiation was requested from. This way, a wrong use is caught at compile time instead of as an error at run time.

Writing Your Own Type Trait

The standard traits are built on std::true_type and std::false_type. Both have a ::value member. To write your own trait, the primary template derives from false_type, and the partial specialization for the type you are interested in derives from true_type.

// custom-trait.cpp — a custom type trait
#include <cstdio>
#include <string>
#include <type_traits>
#include <vector>

template <typename T>
struct is_vector : std::false_type {};

template <typename U>
struct is_vector<std::vector<U>> : std::true_type {};

template <typename T>
inline constexpr bool is_vector_v = is_vector<T>::value;

static_assert(is_vector_v<std::vector<int>>, "a vector is a vector");
static_assert(!is_vector_v<int>, "an int is not a vector");

int main() {
    std::printf("is_vector %d %d %d\n", is_vector_v<std::vector<double>>,
                is_vector_v<std::string>, is_vector_v<int>);
    return 0;
}
is_vector 1 0 0

The partial specialization from the Template Specialization lesson is put to work here: if the std::vector<U> pattern matches, true_type is chosen, and if none matches, the primary template remains. As seen in that lesson, a specialization has to be visible before the first use. The two static_asserts generate no code in this file; if the program compiled, both of them passed.

Relations Between Types

Some queries look not at a single type but at the relation between two types. std::is_base_of_v<B, D> is true if D derives from B. std::is_convertible_v<A, B> asks whether a value of A can be implicitly converted to B. std::is_trivially_copyable_v<T> says whether the type can be copied with a memory copy; since a std::string carries ownership inside it, it gives false to this question: its copy is made not byte by byte but by its own copy constructor. std::decay_t<T> performs the type conversion that happens when a value is passed to a function by value: it strips the reference and the const, and turns an array type into a pointer.

// relations.cpp — type relations and decay
#include <cstdio>
#include <string>
#include <type_traits>

struct Base {};
struct Derived : Base {};

int main() {
    std::printf("base %d %d\n", std::is_base_of_v<Base, Derived>,
                std::is_base_of_v<Derived, Base>);
    std::printf("convert %d %d\n", std::is_convertible_v<int, double>,
                std::is_convertible_v<std::string, int>);
    std::printf("copy %d %d\n", std::is_trivially_copyable_v<int>,
                std::is_trivially_copyable_v<std::string>);
    std::printf("decay %d %d\n",
                std::is_same_v<std::decay_t<const int&>, int>,
                std::is_same_v<std::decay_t<int[3]>, int*>);
    return 0;
}
base 1 0
convert 1 0
copy 1 0
decay 1 1

MT21. is_base_of is directional: Derived derives from Base, and the reverse is not true. int converts to double; std::string does not convert to int. int can be copied with a memory copy, while std::string cannot. decay_t<int[3]> reduces the array type to int*. All of these queries are answered at compile time; the lines in the output are the answers that compilation produced, not a duration. None of them leaves a query at run time: for int and std::string the values are already in the compiler’s hands.

Writing Your Own Transformation

Transformations are written with the same pattern. The primary template leaves the type as it is, and partial specializations strip a pattern. The standard std::remove_pointer_t strips one layer of pointer; here we build a recursive version that strips all the layers, together with a _t alias. The ::type member defined with using takes the place of the standard library’s ::type.

// strip.cpp — a custom transformation trait
#include <cstdio>
#include <type_traits>

template <typename T>
struct strip_pointer {
    using type = T;
};

template <typename T>
struct strip_pointer<T*> {
    using type = typename strip_pointer<T>::type;
};

template <typename T>
using strip_pointer_t = typename strip_pointer<T>::type;

static_assert(std::is_same_v<strip_pointer_t<int>, int>);
static_assert(std::is_same_v<strip_pointer_t<int*>, int>);

int main() {
    std::printf("strip %d %d %d\n", std::is_same_v<strip_pointer_t<char**>, char>,
                std::is_same_v<strip_pointer_t<const int*>, const int>,
                std::is_same_v<strip_pointer_t<double>, double>);
    return 0;
}
strip 1 1 1

MT22. The partial specialization strips one layer of pointer and descends to the remaining type by calling itself: char** becomes char in two steps. This recursion lives inside the compiler; it makes the compiler write one instantiation for each layer and leaves no trace at run time. One point to watch is the const int* example: it is not the pointer itself but the type it points to that is const int, so the result stays const int. The constness of the pointer itself is in a different position, and this pattern does not look at it.

Combining Type Traits with Constraints

Type traits can be asked not only one at a time but also together. If we want a function to work only for an unsigned integer type, we check several traits together with static_assert: std::is_integral_v<T> && std::is_unsigned_v<T>. Conditions that can be combined with logical operators can also be written with std::conjunction_v, std::disjunction_v and std::negation_v; this second form short-circuits, that is, it does not instantiate the later traits once the condition is settled.

// combine.cpp — combining traits
#include <cstdio>
#include <type_traits>

template <typename T>
constexpr bool unsigned_integer_v = std::is_integral_v<T> && std::is_unsigned_v<T>;

template <typename T>
constexpr bool non_numeric_v =
    std::conjunction_v<std::negation<std::is_integral<T>>,
                       std::negation<std::is_floating_point<T>>>;

int main() {
    std::printf("unsigned %d %d %d\n", unsigned_integer_v<unsigned>,
                unsigned_integer_v<int>, unsigned_integer_v<double>);
    std::printf("non_numeric %d %d\n", non_numeric_v<int>,
                non_numeric_v<const char*>);
    return 0;
}
unsigned 1 0 0
non_numeric 0 1

MT23. unsigned is both an integer and unsigned, int is signed, and double is not an integer. non_numeric_v is true only for types that are neither integer nor floating point: false for int, true for a pointer. What is worth recording here is that these combinations too are resolved entirely in the compiler; no trace is left at run time, and the cost is again in the compile kind.

The Return Type Is Selected Too

The branches of if constexpr can return values of different types; the discarded branch does not take part in return type deduction. For this reason an auto return type sees a single return in every instantiation. Returning long and double in the same function with an ordinary if is a compile error, because the two returns deduce different types. If we want to write the return type explicitly, std::conditional_t computes a type name.

// return-type.cpp — return type by branch
#include <cstdio>
#include <type_traits>

template <typename T>
auto twice(T x) {
    if constexpr (std::is_integral_v<T>) {
        return x * 2L;
    } else {
        return x * 2.0;
    }
}

template <typename T>
using wide_t = std::conditional_t<std::is_integral_v<T>, long long, double>;

int main() {
    std::printf("twice %d %d\n",
                std::is_same_v<decltype(twice(1)), long>,
                std::is_same_v<decltype(twice(1.5f)), double>);
    std::printf("wide %d %d\n", std::is_same_v<wide_t<short>, long long>,
                std::is_same_v<wide_t<float>, double>);
    std::printf("value %ld %.1f\n", twice(21), twice(1.25f));
    return 0;
}
twice 1 1
wide 1 1
value 42 2.5

MT24. twice(1) selects the integer branch and returns long; twice(1.5f) selects the other branch and returns double. The two return types live in two instantiations of a single function, and they have separate bodies. wide_t moves the same decision into a type alias. The short and float arguments in the second line fall into separate branches as an integer and a floating-point type. Because the selection ends at compile time, it leaves no branch query at run time.

If you want to forbid unsupported types with a static_assert in an else branch, the condition has to depend on T; a condition that does not depend on it may be evaluated when the template is defined, even if that branch is never selected, and may stop compilation. For this reason, in such branches use a type trait that ties the condition to T.

The Bounding Measurement

A decision made at compile time leaves no step at run time, but writing constexpr alone does not guarantee this. A constexpr function means it can be called both at compile time and at run time. The context decides which one it will be: the initial value of a constexpr variable, or a context that requires constant evaluation such as static_assert, moves the evaluation to compile time. std::is_constant_evaluated() tells these two cases apart.

// constant-evaluation.cpp — the same constexpr function, two contexts
#include <cstdio>
#include <type_traits>

inline int steps = 0;

constexpr int sum_of_squares(int n) {
    int t = 0;
    for (int i = 1; i <= n; ++i) {
        if (!std::is_constant_evaluated()) {
            ++steps;
        }
        t += i * i;
    }
    return t;
}

int main() {
    constexpr int a = sum_of_squares(10);
    std::printf("compile  result=%d steps=%d\n", a, steps);

    volatile int n = 10;
    int b = sum_of_squares(n);
    std::printf("run      result=%d steps=%d\n", b, steps);
    return 0;
}
compile  result=385 steps=0
run      result=385 steps=10

MT25. The same function gives the same result; the call assigned to a constexpr variable is evaluated at compile time and does not touch the counter (steps=0). A volatile argument, on the other hand, prevents the compiler from knowing the value; the call is made at run time and ten iterations are counted (steps=10). For this reason constexpr is not a promise but a permission: what moves the cost to compile time is a context that requires constant evaluation. The number is the evidence for the classification; the answer to the measurement question is a kind name, and here that name is the compile kind.

Summary

  • The mechanism measured in this lesson moves the cost to the compile kind: the type query and the selection of the branch end at compile time, and queries=0 is left at run time.
  • Queries (is_integral_v, is_same_v) give a bool; transformations (remove_cvref_t, conditional_t) give a type.
  • if constexpr does not compile the discarded branch in that instantiation; a plain if compiles both branches.
  • A custom type trait is built by having the primary template derive from false_type and the partial specialization from true_type; static_assert stops a wrong use at compile time.
  • constexpr does not guarantee the move: the same function gives steps=0 in constant evaluation and steps=10 at run time.

Next Step

In this lesson the type query pulled the selection to compile time and left zero queries at run time. The next lesson takes the same query one step back: how does the compiler eliminate a candidate template that is not viable without raising an error, and what does overload resolution choose after this elimination?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close