Type erasure
– mal anders

(Type erasure, take two)


Tobias Hoffmann

C++ User Gruppe Aachen, 2026-06-10

Agenda

  • Type Erasure 101
    • What? Why? Alternatives?
    • void *, dynamic_cast
    • std::any (“type safe void *”)
  • Another Idea
    • Motivation: Tree Visitors
    • std::any revisited
    • Lock-and-Key Principle
    • Example, Implementation idea
    • Opaque, unique_anyptr, shared_anyptr

What is Type Erasure?

  • “Forget” the original compile-time type
  • Only for pointers!
  • Why not (e.g.) int *?
    • Pointer arithmetic: pInt32++ → addr += 4
    • Alignment
    • Just use char *!
    • Or even better: void *
  • How: static_cast<void *>(p) / <T *>
  • Possibly: reinterpret_cast<intptr_t>(p)

Features of void *

  • No dereferencing (*pVoid → compiler error)
  • Consider pVoid += N:
  • pVoid += N * sizeof(*pVoid)
  • Does not work (as per Standard):
    • MSVC: error C2036: 'void *': unknown size
    • CLANG: error: arithmetic on a pointer to void
  • Extensions... (as if sizeof(...) == 1)
    • GCC: warning: pointer of type 'void *' used in arithmetic [-Wpointer-arith]

Why even use Type Erasure?

Examples:
  • someCallback(…, void *userData)
  • std::map<int, …different types…>
  • printf("%s: %d\n", "foo", 5)
  • std::vformat(std::string_view fmt, std::format_args args)
  • std::function<int()> fn = []() { … }

Common alternatives

  • std::variant<std::monostate,Foo,int> (a.k.a. “Tagged Union”)
  • template <class T> …
  • Base + Derived (runtime polymorphism)
    • most extreme: everything derives from Object, IUnknown (outside of C++ type system), …
    • even when original class hierarchies cannot be altered, composition is usually possible

Problems with void *

  • Zero type safety
  • Destructor?
  • Strict aliasing violations possible
    • Different pointer types expected to point to different memory locations
    • Only char *, signed/unsigned char * (and std::byte in C++17)) are safe
  • Code intent unclear to human readers
  • Idea: Use void * as “Base” …

When to (not) use dynamic_cast?

//void *p = ...; // not a complete class type
                 // (and not polymorphic, either)
Base *p = ...; // = nullptr;

if (Foo *pf = dynamic_cast<Foo *>(p)) {
  // ...
}

// or: Foo &f = dynamic_cast<Foo &>(*p);  // → std::bad_cast
  • Pointer value is valid (or nullptr)
  • RTTI is not disabled (-fno-rtti)
    (except for cases already handled by static_cast)
  • Object is of polymorphic type: std::is_polymorphic
    → has at least one virtual function (possibly via base)

std::any (“type safe void *”)

// std::any foo = User{"Alice", 30};  // temporary object
std::any foo = std::make_any<User>("Alice", 30);  // RVO (mandatory)

if (std::string *foo = std::any_cast<std::string>(&foo)) {
  // ...
}

// or: std::string &bar = std::any_cast<std::string &>(foo);
                          // → std::bad_any_cast
  • C++17 [with RTTI] (alternative: boost::any)
  • Value semantics. Copyable. Memory-safe (dtor).
  • std::any ap = std::unique_ptr<…>(…) (unique_ptr is move-only … → C++23)
  • “Interface”: Clone/Copy, Destroy

Another way?

  • Questions so far?
  • Does std::any allow extracting Base for Derived?
  • Interfaces, (Runtime) Duck typing, …
  • IUnknown::QueryInterface, possibly dynamically (not in COM!)
  • Data-oriented design (one pointer per type, separate arrays, …)
  • Entity-Component-System (ECS)

Motivation: Tree Visitors

  • Scenario: Traversing a complex Tree Structure, e.g. Abstract Syntax Tree (AST)
  • Polymorphic node types
    → Visitor pattern
  • Possibly implicit: during parse, only stack "exists"
  • Idea: Build map, vector, …

JSON: The Stack

JSON: The Dilemma

  • Problem: Inner nodes are visited multiple times.
    Information on the way down also needed on the way back up.
  • Solution A: Visitor maintains a parallel, manual stack “synced” with the traversal depth.
  • What is the value_type of the (e.g.) std::stack<T>?

JSON: The second Stack

JSON: The third Stack…?

JSON: Unified Stack!

std::any revisited: The scene

std::any revisited: The key

The Lock-and-Key Principle

  • Key is required to store and retrieve the “contents” to/from the “storage”.
  • Lock allows only exact match (here).
  • But: Key does not have to be the “type”!

Reification

Object instance as key

Opaque: Example (I)

// type-distinct alias, for overloading
struct sequence_t : Opaque {
  sequence_t() = default;
  sequence_t(Opaque &&o) : Opaque(std::move(o)) { }
};
// ... struct alternative_t : Opaque

void AST::Sequence::visit(Visitor &v) {
  sequence_t s;

  v.begin(s);
  for (auto &child : children) {
    v.seq(s);  // (e.g.)
    child.visit(v);
  }
  v.end(s);
}

Opaque: Example (II)

sequence_t::Accessor<MySequence *> access_seq; // = Opaque::…
alternative_t::Accessor<MyAlternative *> access_alt;

void MyVisitor::begin(sequence_t &s) /*override*/ {
  s.set(access_seq, new MySequence);
}

void MyVisitor::seq(sequence_t &s) /*override*/ {
  // assert(s.is(access_seq));
  auto node = s.get(access_seq);
  …
}

void MyVisitor::end(sequence_t &s) /*override*/ {
  std::unique_ptr<MySequence> node(s.release(access_seq));
  …
}

Architecture

  • Principle: Separating Data and Access
  • Storage (Opaque): Holds only an intptr_t and a pointer to a vtable (Accessor_base).
  • Key (Accessor<T>): Only the correct accessor instance can get, set, release the data.
  • Lock: Invalid pointer casting prevented via strict instance-equality checks (access == &_access).
  • Destructor of Opaque uses stored Accessor-pointer for cleanup.

Implementation idea (I)

class Opaque {
public:
  template <typename T, typename Deleter = …std::default_delete<T>…>
  class Accessor;

  // ctor, dtor, move, swap, get, release, set, is, reset …

private:
  class Accessor_base {
    virtual void destroy(intptr_t data) const = 0;
    friend class Opaque;
  };

  intptr_t data = {};
  const Accessor_base *access = {};
};

Implementation idea (II)

template <typename T, typename Deleter>
class Opaque::Accessor<T *,Deleter> : public Opaque::Accessor_base {
public:
  typedef T *value_type;

  value_type operator()(intptr_t data) const {
    return reinterpret_cast<value_type>(data);
  }

private:
  void destroy(intptr_t data) const override {
    del(operator()(data));
  }
  Deleter del;
};

// Accessor<…, nullptr_t> (no deleter); Accessor<int, …>

Implementation idea (III)

template <typename Access>
typename Access::value_type Opaque::get(Access &_access) const {
  if (access != &_access) {
    return {};
  }
  return _access(data);
}

template <typename Access>
void Opaque::set(Access &_access, typename Access::value_type _data) {
  if (access == &_access && _access(data) == _data) {
    return; // unchanged
  } else if (access) {
    access->destroy(data);
  }
  data = reinterpret_cast<intptr_t>(_data);
  access = &_access;
}

Summary/Recap

  • Address of the Accessor is the key, must stay alive (singleton / static) until last Opaque dtor ran.
  • intptr_t (specializations: w/ vs. w/o Deleter).
  • Key usually directly available (same file, …).
  • Bad key: std::bad_cast / nullptr / .is(…)
  • No typeid(…), dynamic_cast, RTTI, … used/needed.
  • Minimal memory requirements (intptr_t + const Accessor_base *) at Opaque.

unique_anyptr

  • PImpl Idiom (Pointer to implementation)
  • Often std::unique_ptr<Impl> with only private / forward declared struct Impl;
  • Works, as long as destructor (= default;) not in header, but in implementation.
  • Why not hide it fully?
  • unique_anyptr impl; (in header / public class)
  • unique_anyptr::Accessor<Impl> access_impl
  • (not on public github yet)

shared_anyptr

  • But what if I need a shared pointer?
  • Don't reinvent the wheel, also wrt. interop!
  • std::shared_ptr<void> to the rescue:
  • template <class Y, class Deleter>
    void reset(Y *p, Deleter d)

    I.e.: Deleter is type erased (but can also be just a function pointer)
  • std::dynamic_pointer_cast(…) will not work (dynamic_cast under the hood!)
  • (github code does not have interop yet)

Links, Questions?