Fully Dynamic Types using Void Pointers and std::any

An overview of how to support fully dynamic types in C++, using void pointers and std::any
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
3D Character Concept Art
Ryan McCombe
Ryan McCombe
Posted

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};
}

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 Containers

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

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

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

std::bad_any_cast Exception

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!

Dynamic Type Checking using type()

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

Was this lesson useful?

Edit History

  • First Published

Ryan McCombe
Ryan McCombe
Posted
7a.jpg
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Standard Library Data Structures
7a.jpg
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access

This course includes:

  • 114 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Tuples and std::tuple

A guide to tuples in C++, using the std::tuple container, allowing us to store any number of objects, including different types.
dsg.jpg
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved