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:
if
statements: enabling or disabling lines of code based on the properties of a type, or other compile-time factorsType traits rely heavily on aliases and static members that exist on structs and classes. Unlike a regular member variable, a static variable is owned by the class itself, rather than the objects of that class.
In the following example, we have a type SomeStruct
which defines a type alias called type
which alises bool
, and a static member called value
, which is the integer 42
. We can then access those members outside of the class, using the scope resolution operator ::
struct SomeStruct {
using type = bool;
static const int value{42};
};
int main() {
// This will be a bool
SomeStruct::type MyVariableA;
// This will have a value of 42
int MyVariableB = SomeStruct::value;
}
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
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
if constexpr
StatementsThe 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
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
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:
+
and /
operators.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 codeOne 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 previous example uses a forwarding reference, denoted by the double ampersand &&
prepended to a template type. We’re using a forwarding reference here because it can capture both lvalue and rvalue references.
Further nuance isn’t important for this introduction to type traits, but we cover forwarding references in detail later in the course:
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
The "cvref" in std::remove_cvref
is an abbreviation for const
, volatile
and reference.
A volatile
variable is one that can be modified from outside of our program. For example, we may have some external program or hardware device writing to the specific memory location that our variable maps to.
If the compiler sees our code isn’t changing a variable, it might assume the variable isn’t changing. It might therefore implement a performance optimization that avoids reading the memory location at all.
By marking the variable as volotile
, we tell the compiler that the value can change in ways it is not aware of. As such, it should avoid doing those optimizations and always get the latest value from memory.
volatile int SomeInteger{42};
Just as we can remove reference and const
qualifiers from types using std::remove_reference
and std::remove_const
, we can also remove volatile
using std::remove_volatile
:
#include <iostream>
#include <type_traits>
int main() {
if constexpr (std::is_same_v<int,
std::remove_volatile_t<volatile int>>) {
std::cout << "Those are the same";
}
}
Those are the same
And we can remove all three using std::remove_cvref
.
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.
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
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
_v
and _t
PatternAs 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
A common question is whether we can make our custom types interact with standard library type traits in the same way as the built-in fundamental types, such as int
.
For example, we may have created our own numeric type, and would like objects such as std::is_arithmetic_t
to return true
for our type.
This may be possible but is generally not recommended. The C++ specification does not define what should happen when we try this.
It may work in some compilers, but it may not work in others. So, it’s not something we should try to do - we should instead find an alternative way to implement our required behavior.
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:
if constexpr
enables conditional compilation based on type traitsstatic_assert
can enforce type requirements in templatesLearn how to use type traits to perform compile-time type analysis, enable conditional compilation, and enforce type requirements in templates.
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.