Unconstrained Dynamic Types using Void Pointers and std::any
Learn how to use void pointers and std::any
to implement unconstrained dynamic types, and understand when they should be used
In this lesson, we'll cover the two main techniques for implementing dynamic types in C++. A variable with a dynamic type can store different types of data throughout its life. For example, it might be initialized as an int
, have a float
assigned to it later, and then finally changed to store a Player
or some other custom type.
However, we should remember that C++ is designed as a statically typed language. The use of dynamic types, particularly unconstrained dynamic types, should be quite rare.
- If we don't know the type of data we are working with, but it can be deduced at compile time, templates tend to be the tool we should prefer.
- If we need dynamic types at run time, we'll often be able to narrow it down to a range of possibilities. In this case, we should generally prefer specifying those possibilities, using the techniques we covered in the previous lesson - such as
std::variant
.
Use Cases
There are some use cases where neither of these options apply, and we need to create an unconstrained container. Some examples include:
- Arbitrary user data: such as creating a file manager, where we have no way of predicting what type of data the users will be providing
- Developer tools: for performance reasons, the tools that support programming in general are often written in C++. Examples include our IDE, and interpreters that implement other languages such as Python and JavaScript. We have no way to know in advance what types will be created, and many of those programming languages are designed around a dynamic type system anyway
- Message passing: components that coordinate other systems are often written in C++. An example of this is a messaging component that facilitates communication between other systems. Our component may not need to operate on these messages in any way, so attempting to restrict or coerce them into a known type is unnecessary.
To deal with these scenarios, we have void pointers, or containers that are based on void pointers. An example of such a container is the standard library's std::any
, introduced in C++17. We'll cover both void pointers and std::any
in this lesson.
Void Pointers
The main way C++ represents a pointer to potentially any type of data is a void pointer. The syntax looks similar to any other pointer - the type it's pointing to is simply void
:
void* Ptr;
We assign a memory address to such a pointer in the usual way. We can assign a memory address for any data type to a void*
.
Below, our Ptr
points first at an int
, and then gets reassigned to point to a float
:
int main(){
int SomeInt{42};
void* Ptr{&SomeInt};
float SomeFloat{9.8f};
Ptr = &SomeFloat;
}
Null Void Pointers
Similar to any other pointer type, void pointers can be initialized or updated to nullptr
. This is used to represent the absence of a value, which can be tested for by treating the pointer as a boolean:
#include <iostream>
int main(){
void* Ptr{nullptr};
if (!Ptr) { std::cout << "Empty"; }
}
Empty
Accessing Void Pointer Values
We cannot directly dereference a void pointer. To access the value it is pointing at, we need to first cast the void pointer to match the underlying type:
#include <iostream>
int main(){
int SomeInt{42};
void* Ptr{&SomeInt};
int* IntPtr{static_cast<int*>(Ptr)};
std::cout << "Data: " << *IntPtr;
}
Data: 42
Type Safety: The Problem with Void Pointers
Similar to unions, which we introduced in the previous lesson, void pointers offer very little type safety.
It is on us to keep track of what type our void*
is pointing at. In a larger program, that bookkeeping can be complicated.
And most importantly, if we get it wrong, the compiler won't tell us. Our program will simply have a bug that could have serious implications:
#include <iostream>
template <typename T>
void Log(void* Data){
T* Ptr{static_cast<T*>(Data)};
std::cout << "Data: " << *Ptr << '\n';
}
int main(){
int SomeInt{42};
void* Ptr{&SomeInt};
Log<int>(Ptr);
float SomeFloat{3.14};
Ptr = &SomeFloat;
Log<int>(Ptr);
}
Data: 42
Data: 1078523331
Because of this, using void pointers directly is quite dangerous. It's generally recommended to wrap the concept in an intermediate container that provides some additional type safety.
In C++17, the standard library introduced an implementation of this concept: std::any
Creating std::any
Containers
The std::any
type is available within the <any>
header and can be constructed in the usual ways.
Below, we create a container that is initially storing a type of int
, and a value of 42
:
#include <any>
int main(){
std::any Data{42};
}
Using std::make_any()
We also have access to the std::make_any()
function. It receives the type we want to construct as a template argument, and a list of function arguments to forward to that type's constructor. It then returns a std::any
initialized with that object.
#include <any>
struct Vec3 {
float x;
float y;
float z;
};
int main(){
auto Data{std::make_any<Vec3>(1.f, 2.f, 3.f)};
}
The reason for this function is similar to the rationale behind the smart pointer generators like std::make_unique
and std::make_shared
.
It mitigates some potential memory leaks with constructors that can throw exceptions. Compilers can additionally make small optimizations if the std::any
and the object it contains can be constructed at the same time.
Updating std::any
Values
There are three main ways we can update the value contained within a std::any
Assignment using =
The simple assignment operator, =
, works as we'd expect. Naturally, the type we're assigning doesn't need to match the current type the std::any
is holding:
#include <any>
int main(){
std::any Data { 42 };
Data = 3.14f;
}
Constructing in place using emplace()
When we want to simultaneously construct and assign an object to the std::any
, we should use the emplace()
function. This has the same rationale as the emplace()
method in other container types.
Specifically, for non-trivial types, constructing an object in place is more efficient than constructing it outside of the container and then moving it in.
The std::any
's emplace()
method accepts the type we want to construct as a template argument, and the parameters to forward to that type's constructor as function arguments:
#include <any>
struct Vec3 {
float x;
float y;
float z;
};
int main(){
std::any Data{42};
Data.emplace<Vec3>(1.f, 2.f, 3.f);
}
Deleting values using reset()
We can destroy the object held by a std::any
using the reset()
method:
#include <iostream>
#include <any>
struct Vec3 {
float x;
float y;
float z;
~Vec3(){ std::cout << "Destructor"; }
};
int main(){
auto Data{std::make_any<Vec3>(1.f, 2.f, 3.f)};
Data.reset();
std::cout << "\nBye!";
}
Destructor
Bye!
Accessing std::any
Values
Similar to void pointers, we need to do some additional work before we can access values stored in a std::any
Using has_value()
std::any
can be initialized without a value, or we can delete their value using the reset()
method. The has_value()
method returns a boolean, letting us check if the container is currently holding a value:
#include <iostream>
#include <any>
int main(){
std::any Data{42};
if (Data.has_value()) {
std::cout << "I have a value";
}
Data.reset();
if (!Data.has_value()) {
std::cout << "\nbut not any more";
}
}
I have a value
but not any more
Using std::any_cast()
To access the value stored in a std::any
, we pass it to the std::any_cast()
function, passing the type we expect to receive as a template argument:
#include <iostream>
#include <any>
int main(){
std::any Data{42};
std::cout << "Value: "
<< std::any_cast<int>(Data);
}
Value: 42
The std::bad_any_cast
Exception Type
Unlike void pointers, std::any
provides type safety at this point. If the type we provide as a template argument is not the type the std::any
is currently storing, we get an exception.
Specifically, it's a std::bad_any_cast
exception, which we can detect and react to in the usual way:
#include <iostream>
#include <any>
int main(){
std::any Data{42};
try { std::any_cast<float>(Data); }
catch (const std::bad_any_cast& e) {
std::cout << "That wasn't a float!";
}
}
That wasn't a float!
Run Time Type Checking
We can get the type currently being stored by a std::any
using the type()
method.
This will be returned as a std::type_info
. We can use the typeid()
operator to compare this to other types:
#include <iostream>
#include <any>
int main(){
std::any Data{42};
if (Data.type() == typeid(int)) {
std::cout << "We have an int over here";
}
}
We have an int over here
If the container is empty, the object returned by type()
will be equal to typeid(void)
:
#include <any>
#include <iostream>
int main(){
std::any Data;
if (Data.type() == typeid(void)) {
std::cout << "It's empty";
}
}
It's empty
We covered std::type_info
and the typeid()
operator in detail in a dedicated lesson earlier in the course:
Run Time Type Information (RTTI) and typeid()
Learn to identify and react to object types at runtime using RTTI, dynamic casting and the typeid()
operator
Summary
In this lesson, we introduced two ways of managing fully dynamic types in C++: void pointers and std::any
. The std::any
container provides a safer, standardized alternative to void*
, but unconstrained dynamic types should still be used sparingly
Here are the key takeaways:
- C++ is statically typed, but sometimes unconstrained dynamic types are needed,
- Void pointers (
void*
) allow pointing to data of any type, but provide no type safety std::any
, introduced in C++17, is a type-safe container for single values of any typestd::any
can be created by directly specifying a value, or usingstd::make_any()
- Values in
std::any
can be updated by assignment, emplaced withemplace<SomeType>()
, or reset withreset()
- Accessing values requires
std::any_cast<SomeType>()
, which throwsstd::bad_any_cast
if the types mismatch - The type stored in a
std::any
can be checked at runtime using thetype()
method and compared to another type using thetypeid()
operator
Tuples and std::tuple
A guide to tuples and the std::tuple
container, allowing us to store objects of different types.