Type Traits: Compile-Time Type Analysis
Learn how to use type traits to perform compile-time type analysis, enable conditional compilation, and enforce type requirements in templates.
Type traits are a feature of C++ that allows us to perform compile-time analysis of the types we use in our code. In this lesson, we'll introduce type traits, and then cover examples of common use cases, including:
- Template type safety and documentation: ensuring that template functions are only called with the expected types, thereby preventing runtime errors, and improving the developer experience
- Compile-Time
if
statements: enabling or disabling lines of code based on the properties of a type, or other compile-time factors - Template metaprogramming: writing code to change how templates are generated and used at compile time
- Conditional type selection: changing the data types we use based on boolean expressions
- Custom types - how our own user-defined types can interact with any of these systems
Using Type Traits
The C++ standard library includes a large collection of type traits, and related functions, within the <type_traits>
header file
#include <type_traits>
Type traits are within the std
namespace, and almost all of them are struct templates. They accept the type we want to investigate as a template parameter and make the result of that investigation available through a static value
field.
For example, the std::is_arithmetic
type trait tells us if a type is numeric:
#include <type_traits>
#include <iostream>
int main() {
if (std::is_arithmetic<int>::value) {
std::cout << "int is arithmetic";
}
if (!std::is_arithmetic<std::string>::value) {
std::cout << "\nbut std::string isn't";
}
}
int is arithmetic
but std::string isn't
From C++17 onwards, we have a more concise way to access the value
field of standard library traits. We can instead append _v
to the class name or, less commonly, we can use the ()
operator:
#include <type_traits>
#include <iostream>
int main() {
if (std::is_arithmetic_v<double>) {
std::cout << "double is arithmetic\\n";
}
if (std::is_arithmetic<std::int32_t>()) {
std::cout << "int32_t is also arithmetic";
}
}
double is arithmetic
int32_t is also arithmetic
Type Traits with Templates
More usefully, we'd use type traits in a situation where we don't necessarily know what type we're dealing with. This means type traits are most commonly used when working with templates:
#include <type_traits>
#include <iostream>
template <typename T>
void Function(T Param) {
if (std::is_arithmetic_v<T>) {
std::cout << Param << " is arithmetic\n";
} else {
std::cout << Param << " is not arithmetic\n";
}
}
int main() {
Function(42);
Function("Hello World");
}
42 is arithmetic
Hello World is not arithmetic
Using if constexpr
Statements
The logic we implement with type traits is evaluated at compile time. For example, a statement like std::is_arithmetic_v<T>
will be determined at compile time, and the result of that expression will then be available at run time.
For example, imagine we have the following code:
#include <type_traits>
#include <iostream>
template <typename T>
void LogDouble(T Param) {
if (std::is_arithmetic_v<T>) {
std::cout << "Double: " << Param * 2;
}
}
int main() {
LogDouble(42);
LogDouble(std::string("Hello World"));
}
After our templates are instantiated and our type trait evaluated, we can imagine we have two functions, one instantiated when T
is an int
, and one instantiated when T
is a std::string
:
void LogDouble(int Param) {
if (true) {
std::cout << "Double: " << Param * 2;
}
}
void LogDouble(std::string Param) {
if (false) {
std::cout << "Double: " << Param * 2;
}
}
This is a problem because std::string
does not implement the *
operator, so we get a compilation error even though that statement was within an if (false)
block:
error: binary '*': 'T' does not define this operator or a conversion to a type acceptable to the predefined operator
To address this, C++17 introduced if constexpr
statements. These are evaluated at compile time and, if the expression we pass to if constexpr
evaluates to false
, the block of code is removed from our function entirely.
Let's apply it to our previous example:
#include <iostream>
#include <type_traits>
template <typename T>
void LogDouble(T Param) {
if constexpr (std::is_arithmetic_v<T>) {
std::cout << "Double: " << Param * 2;
}
}
int main() {
LogDouble(42);
LogDouble(std::string("Hello World"));
}
Now, we can imagine our instantiated functions look like this:
void LogDouble(int Param) {
std::cout << "Double: " << Param * 2;
}
void LogDouble(std::string Param) {}
There's no longer any compiler error here, so our program runs successfully and outputs:
Double: 84
More Standard Library Examples
Below, we show more examples of if constexpr
, and some more useful standard library type traits:
std::is_pointer
lets us determine if a type is a pointerstd::is_class
lets us determine if a type is a class or struct, excluding enums and unions (which we cover later in the course)std::is_same
lets us determine if a type is the same as another typestd::is_base_of
lets us determine if a type is the same as another type, or derived from that type through inheritance
#include <iostream>
#include <type_traits>
class Actor {};
class Monster : Actor {};
template <typename T>
void Function(T Param) {
if constexpr (std::is_pointer_v<T>) {
std::cout << "\nType is a pointer";
}
if constexpr (std::is_class_v<T>) {
std::cout << "\nType is a class";
}
if constexpr (std::is_same_v<Actor, T>) {
std::cout << "\nType is an Actor";
}
if constexpr (
std::is_base_of_v<Actor, T>) {
std::cout << "\nType is derived from Actor";
}
}
int main() {
std::cout << "&x: ";
int x{5};
Function(&x);
std::cout << "\n\nMyActor: ";
Actor MyActor;
Function(MyActor);
std::cout << "\n\nMyMonster: ";
Monster MyMonster;
Function(MyMonster);
}
&x:
Type is a pointer
MyActor:
Type is a class
Type is an Actor
Type is derived from Actor
MyMonster:
Type is a class
Type is derived from Actor
For a list of all traits that are available within the standard library, there are many references we can use like cppreference.com
Using static_assert()
One of the main uses for type traits is to ensure a provided type meets the expectations of our template. For example, let's imagine we created this simple template function.
template <typename T1, typename T2>
auto Average(T1 x, T2 y) {
return (x + y) / 2;
}
This function allows us to pass two numeric values, and get their average. Because the types are templated, we can pass any type to this function.
However, that's a bit too permissive. Our function is intended to work with numbers, but that is not made explicit. What if a different, unexpected type is passed to this function? One of two things can happen.
In the best-case scenario, the type will not support one of the operators we're using in the function, such as +
. This will result in a compiler error, although for someone not familiar with our template, it doesn't describe the core problem:
template <typename T1, typename T2>
auto Average(T1 x, T2 y) {
return (x + y) / 2;
}
int main() {
Average("Hello", "World");
}
error C2110: '+': cannot add two pointers
In the worst case, the type we pass does implement all the required operators, but not in the way that our template is assuming. Types are free to implement operators like +
and /
in any way they want - they don't need to represent addition and division.
As such, our template will accept those objects without any compilation error, resulting in unexpected behaviour at run time.
Type traits allow us to be more explicit about the requirements of our types. The most basic example of this is simply using a static_assert()
to ensure the type meets the expected criteria:
#include <type_traits>
template <typename T1, typename T2>
auto Average(T1 x, T2 y) {
static_assert(std::is_arithmetic_v<T1>
&& std::is_arithmetic_v<T2>,
"Average: Arguments must be numeric");
return (x + y) / 2;
}
int main() {
Average("Hello", "World");
}
error: static_assert failed: 'Average: Arguments must be numeric'
This has three benefits:
- It implements the desired requirements. Now, the compiler will ensure that the provided type is a number - not just that it implements the
+
and/
operators. - It documents our requirements. Anyone looking at our
Average
function will be able to see what the requirements are. Some IDEs will also be able to better understand the requirements of this function, and provide immediate feedback as we're writing the code - If someone tries to use our template with a type that doesn't meet the requirements, we get to provide a custom, contextual error message.
Comparing Types
One of the main use cases we have for type traits is to determine if some template typename T
matches a specific type. Below, our template has a different implementation depending on whether it was instantiated with an int
or not:
#include <iostream>
#include <type_traits>
template <typename T>
void Print(T&& x) {
if constexpr (std::is_same_v<T, int>) {
std::cout << x << " is an int\n";
} else {
std::cout << x << " is not an int\n";
}
}
int main() {
Print(1);
}
1 is an int
The comparisons carried out by these type traits are often more strict than we need. For example, if our type is a const int
, the non-int code path will be used, because a const int
is not the same as an int
.
Similar distinctions are made between value categories and reference types, further disrupting our intended design:
#include <type_traits>
#include <iostream>
template <typename T>
void Print(T&& x) {
if constexpr (std::is_same_v<T, int>) {
std::cout << x << " is an int\n";
} else {
std::cout << x << " is not an int\n";
}
}
int main() {
Print(1);
int y{2};
Print(y);
const int x{3};
Print(x);
}
1 is an int
2 is not an int
3 is not an int
Often, the logic we're trying to implement doesn't care whether our types are constant, or whether they're references. To implement these agnostic comparisons, the standard library includes further traits, such as std::remove_const
and std::remove_reference
.
We pass a type to these traits, and they return a type that is possibly different. The returned type will be the same as the type we provide but with any const or reference aspect removed.
The new type is available as the ::type
static member, or by appending _t
to the struct name:
#include <iostream>
#include <type_traits>
int main() {
if constexpr (std::is_same_v<int,
std::remove_const<const int>::type>) {
std::cout << "Those are the same";
}
if constexpr (std::is_same_v<int,
std::remove_reference_t<int&>>) {
std::cout << "\nThose are the same too";
}
}
Those are the same
Those are the same too
We can remove both const
and references at once using std::remove_cvref
:
#include <iostream>
#include <type_traits>
int main() {
if constexpr (std::is_same_v<int,
std::remove_cvref_t<const int&>>) {
std::cout << "Those are the same";
}
}
Those are the same
Let's apply this to fix the problem we had with our template. We create a type alias BaseType
using the type returned from std::remove_cvref_t
:
#include <iostream>
#include <type_traits>
template <typename T>
void Print(T&& x) {
using BaseType = std::remove_cvref_t<T>;
if constexpr (std::is_same_v<BaseType, int>) {
std::cout << x << " is an int\n";
} else {
std::cout << x << " is not an int\n";
}
}
int main() {
Print(1);
int y{2};
Print(y);
const int x{3};
Print(x);
}
1 is an int
2 is an int
3 is an int
Note that std::remove_cvref
and similar type traits are not modifying our function parameter. For example, if x
is const
, it continues to be const
in our previous example.
Type traits like std::remove_cvref
simply receive a type as an argument, and return a type that does not have those characteristics.
Creating our own Type Traits
As usual, we're not restricted to just the type traits that come with the standard library. We can pull traits in from third-party libraries or create our own. Let's imagine we have a template function that receives objects that may or may not have a Render
function.
We'd like to write an is_renderable
trait to help us out:
template <typename T>
void Render(T Param) {
if constexpr (is_renderable<T>::value) {
Param.Render();
} else {
std::cout << "\nNot Renderable";
}
}
Standard library-type traits that answer questions like this are struct templates that accept a type as a template argument, and make the boolean result available as a value
static member. We can follow the same convention. We'd initially want our type trait's value
to be false
, meaning any arbitrary object does not satisfy our type trait by default:
template <typename T>
struct is_renderable {
static const bool value{false};
};
Now, for any type we create that does satisfy the requirement, we can specialize the type trait template to make the value true
. In the following code, is_renderable<T>::value
will be true
if T
is Fish
, and false
for any other type:
#include <iostream>
// General Template
template <typename T>
struct is_renderable {
static const bool value{false};
};
class Fish {
public:
void Render() {
std::cout << "Fish: ><((((`>";
}
};
// Specialized Template
template<>
struct is_renderable<Fish> {
static const bool value{true};
};
void Render(T Param) {/*...*/}
int main() {
Fish MyFish;
Render(MyFish); // Renderable
Render(42); // Not Renderable
}
Fish: ><((((`>
Not Renderable
Template Specialization
A practical guide to template specialization in C++ covering full and partial specialization, and the scenarios where they're useful
Using std::false_type
and std::true_type
The standard library's <type_traits>
header includes a pair of utilities that make type traits slightly faster to implement. Rather than defining our value
static members, we can just inherit them from a base struct.
Our struct can inherit from std::false_type
in the general case (when we want value
to be false
) and std::true_type
when specializing the type trait to set value
to true
.
// General Template
template <typename>
struct MyTrait : std::false_type {};
// Specialized Template
template <>
struct MyTrait<SomeType> : std::true_type {};
Below, we've updated our previous is_renderable
example to use this technique:
#include <iostream>
#include <type_traits>
// General Template
template <typename>
struct is_renderable : std::false_type {};
class Fish {/*...*/};
// Specialized Template
template <>
struct is_renderable<Fish> : std::true_type {};
void Render(T Param) {/*...*/}
int main() {/*...*/}
Fish: ><((((`>
Not Renderable
Implementing the _v
and _t
Pattern
As we've seen, the standard library's type traits make their results available through variables suffixed with _v
(for values) and _t
(for types). For example, std::is_integral_v<T>
is equivalent to std::is_integral<T>::value
.
We can implement the same API for our own type traits, using a variable template that retrieves the correct static member from our struct:
#include <iostream>
#include <type_traits>
struct is_renderable {/*...*/};
template <typename T>
constexpr bool is_renderable_v{
is_renderable<T>::value};
class Fish {/*...*/};
struct is_renderable<Fish> {/*...*/};
template <typename T>
void Render(T Param) {
if constexpr (is_renderable_v<T>) {
Param.Render();
} else {
std::cout << "\nNot Renderable";
}
}
int main() {/*...*/}
Fish: ><((((`>
Not Renderable
Summary
In this lesson, we introduced type traits in C++ and explored their common use cases. We learned how to use type traits from the <type_traits>
header to perform compile-time analysis of types. Key takeaways:
- Type traits allow compile-time analysis of types
if constexpr
enables conditional compilation based on type traitsstatic_assert
can enforce type requirements in templates- Custom type traits can be created using struct templates and specialization
- Type traits improve template safety, documentation, and error messages
Using SFINAE to Control Overload Resolution
Learn how SFINAE allows templates to remove themselves from overload resolution based on their arguments, and apply it in practical examples.