The first thing I do every time I need to learn a new codebase is to start drawing the UML diagram of its classes, and usually give up soon after I started. The process of doing it manually is certainly useful, but now with reflections I figure it would be fun to try generate it instead.
With C++26 reflections[1] the general consensus is that the magnitude of language change is comparable to what happened with C++11. After my little experiment with it, I would cautiously agree. So how does one go about creating a (Plant)UML diagram at compile time? With something like this.
With the spoilers out of the way let’s dig in the details.
P2996[1] introduces a couple of operators, namely lift ^^ and splice [: :]. The first one “lifts” a type or variable into a “meta” space, and the “splice” one (which imho should have been called “grounding operator”) does the opposite.
The first thing to understand is that regardless of what we apply the lift operator (^^) to, it creates a std::meta::info type. This means that some info objects will be reflections of types, and some will be reflections of values. This creates confusion in my head, as at times one needs to check which kind of info something is, but there are good reasons for this and are well explained in section 2.2 of the paper. With that in mind let’s start coding.
You’ll notice right away there is an ugly, and seemingly avoidable char pointer. Let’s get back to that later. Not much happens in main() besides us calling function template that is just a wrapper for what we actually care about:
Here we see the first interesting thing, something called std::define_static_string[2]. What this does is take what is a compile time std::string and create a string literal, which we can return from a consteval function. If we were to try to return the std::string we would be greeted with the compiler error
:116:24: note: pointer to subobject of heap-allocated object is not a constant expression
/opt/compiler-explorer/clang-bb-p2996-trunk-20250703/bin/../include/c++/v1/__memory/allocator.h:117:13: note: heap allocation performed here
117 | return {allocate(__n), __n};
Which makes sense as we cannot expect to create an object we allocate on the heap at compile time, then have this object exist in the heap also at runtime. This is why we end up with a big fat literal string that holds our final UML diagram, which we can do whatever we want with in main(). Looking at the assembly you’ll see the end result:
.asciz "@startuml \nskinparam linetype ortho \ntogether {\n class \"MyClass\"\n \"MyClass\"*--\"unsigned int\"\n \"MyClass\"*--\"unsigned int\"\n \"MyClass\"*--\"unsigned int\"\n \"MyClass\"*--\"Nested\"\n \"MyClass\"-up-|>\"MyBase\"\n}\ntogether {\n class \"Nested\"\n \"Nested\"*--\"int\"\n \"Nested\"*--\"int\"\n \"Nested\"*--\"MyClass\"\n}\ntogether {\n class \"MyBase\"\n \"MyBase\"*--\"vector>\"\n}\ntogether {\n class \"vector>\"\n}\n@enduml"
Now that we have a magic function that “defines a static string” for us, let’s dig into the actual reflection bits. The first thing to note is what we pass to make_class_graph_impl, which shows the use of the lift operator, ^^U. This creates a std::meta::info object that in this case is a reflection of a type. The Impl function itself, as you may have guessed, is meant to be recursive and takes a second argument that we’ll explain later:
First let’s talk about the new “context” object: the main paper[1] describes it as “a class that represents a namespace, class, or function from which queries pertaining to access rules may be performed[…]“, and it was introduced in [3] as a mean to resolve the “encapsulation” issue. This context comes in 3 flavours:
std::meta::access_context::current(): for accessing the public stuff in the current scope
std::meta::access_context::unprivileged(): for accessing public stuff in the global scope
std::meta::access_context::unchecked(): for accessing anything in the global scope
After that the interesting things are std::meta::nonstatic_data_members_of(head, ctx), and std::meta::info::display_string_of(head). Those are pretty self explanatory, and they do make “metaprogramming” as easy as “programming”!
It gets a bit trickier later, when we have to recurse
Besides the very convenient fact that we can keep track of what we iterate over already simply using an std::vector, we do define a custom function with the terrible name of remove_ptr_cv_type_of.
consteval auto remove_ptr_cv_type_of(std::meta::info r) -> std::meta::info {
return decay(remove_pointer(is_type(r) ? r : type_of(r)));
}
This does what it says on the box, as in our UML we don’t want to distinguish between const/volatile/pointers whathaveyou. The important and interesting bit however is std::meta::is_type(..). This function tells us what kind of info object we have, as they can be reflections of anything, and therefore we need to test whether or not we have a reflection of a type or a value and apply std::meta::type_of only when necessary. This is a bit of a pain, but imho it’s a small price to pay for Reflections.
That is basically it, we just need to try it out plotting our PlantUML result, which gets printed out at runtime in this case.