Annotations in C++26 static reflection
Table of Contents
C++26 added static reflection to the already big bag of compile-time features. One of the most powerful tools that made it into the language is the ability to use custom annotations and to inspect them at compile time. Here, we will see how to define and use annotations, and how to pass various arguments to them that can be extracted through reflection.
Introduction #
The main focus of the proposal introducing reflection to C++26, P2996, is inspection of programs. No matter how appealing inspecting programs may sound, being able to generate code based on inspection results sounds even better. One of the tools that aids code generation is the ability to decorate various entities in a program with custom annotations. Proposed in P3394, annotations allow for this:
struct
[[=streamable]] Data
{
int id;
std::string text;
};Here [[=streamable]] annotates the Data type. Annotations borrow the syntax from C++ attributes, with the distinction that they always start with the character =. Crucially, annotations are values, not types. This follows the spirit of C++26 reflection, which is heavily value-oriented, as opposed to traditional type-oriented metaprogramming. This means that streamable in the example above must be an existing object. So let’s create it to make the snippet above compile:
struct streamable_t {};
constexpr streamable_t streamable{};
struct
[[=streamable]] Data
{
int id;
std::string text;
};Extracting information about annotations #
It should compile just fine, but doesn’t do much besides this.
Let’s extend the program to finally make use of reflection and create an overloaded stream output operator at compile time for any type that’s annotated with [[=streamable]].
#include <iostream>
#include <meta>
struct streamable_t {};
constexpr streamable_t streamable{};
template <typename T>
concept Streamable = !annotations_of_with_type(^^T, ^^streamable_t).empty();
template <Streamable T>
std::ostream& operator<<(std::ostream& out, T const& obj)
{
out << "Yes, it streams!\n";
return out;
}
struct
[[=streamable]] Data
{
int id;
std::string text;
};
int main()
{
Data data{42, "Hello"};
std::cout << data;
}We introduced an overloaded operator<< function template.
Soon, it will be able to enumerate over the members of obj and send them to the output stream out.
But for now we must constrain it, so it will only be selected for types that have the [[=streamable]] annotation. For this we use the Streamable concept that uses reflection to check the annotations of the type passed to it. The reflection part comes in the form of a metafunction std::meta::annotations_of_with_type.
Metafunctions from the meta namespace operate on an opaque type std::meta::info that represents a reflection. To get a reflection, we use the reflection operator ^^. It takes some entity (e.g., a type or a variable name) and produces its reflection as an object of type std::meta::info. Conceptually, it’s something like this:
auto operator^^(auto&& entity) -> std::meta::info;All the std::meta functions are ADL-enabled, which means that we do not need to spell out their fully qualified names in most situations.
We used this fact when defining the Streamable concept.
In it, the function annotations_of_with_type takes two reflections as its parameters: the reflection of some entity and the reflection of a type.
It then returns a vector with the reflections (std::vector<std::meta::info>) of all annotations with this type that are applied to the entity.
Because it’s just a vector, we can check if it’s not empty and be done with implementing the Streamable concept. What’s left is implementing the operator itself.
Reflection-based metaprogramming #
We’ll start small and just print the name of the type to the output stream. To get the name, we’ll use yet another metafunction, std::meta::identifier_of. It takes a reflection and returns the name of the entity as a string:
template <Streamable T>
std::ostream& operator<<(std::ostream& out, T const& obj)
{
auto sep = "\n ";
out << identifier_of(^^T) << "\n{";
// print the members here
return out << "\n}";
}Once again, we relied on the ADL lookup to call identifier_of without the std::meta:: prefix.
With the name of the type printed, we can move on to the members. These can be obtained with the nonstatic_data_members_of metafunction:
auto members = nonstatic_data_members_of(^^T,
std::meta::access_context::unchecked());The second parameter specifies the access level.
Here we use std::meta::access_context::unchecked(), which basically means that we ignore any access specifiers and get all the members. Yes, including those that are private or protected.
This might sound like going around the access-control rules, but reflection was deliberately designed with this ability in mind. How else would we be able to write a generic debug-info dumper?
Whenever you see the unchecked access context, you should be extra careful. unchecked screams at you that something dangerous is being cooked up, so you need to pay attention.
If we wanted to respect the rules and only get the members we have access to, we could have used std::meta::access_context::current() instead.
You might think that, with the members in hand, what’s left is just iterating over them and printing their names and values.
Unfortunately, this is far from what’s waiting ahead.
members is a vector of reflections (yes, a std::vector<std::meta::info>) and as such it can only exist at compile time.
It simply cannot pass the barrier between compile time and runtime. This means that we cannot just iterate over it and print the members. Instead, we need to:
- Promote the vector to static storage, so it can be used in a
constexprcontext. - Use templates to expand the promoted vector at compile time and generate printing code for each member.
For the first part, we can use the std::define_static_array function, proposed with its friends in P3491.
It takes a compile-time sequence and auto-magically turns it into a static storage sequence (or std::span to be more precise) usable at runtime.
So we’ll do:
auto members = std::define_static_array(
nonstatic_data_members_of(^^T, std::meta::access_context::unchecked()));Expansion statements to the rescue #
Now, we could use standard sequence expansion tricks, possibly heavily leaning on std::integer_sequence to print all the members.
But this would be against the spirit of modern C++ metaprogramming. It’s time to bring out the big guns, the expansion statements. Simply put, it’s a compile-time sequence expansion in the form of a for-loop. Given this:
template for (constexpr auto& obj : some_sequence)
{
call_some_function(obj.do_something());
}The compiler will (roughly speaking) expand it into:
{
constexpr auto& obj0 = some_sequence[0];
call_some_function(obj0.do_something());
}
{
constexpr auto& obj1 = some_sequence[1];
call_some_function(obj1.do_something());
}
// and so on for all the elements in some_sequence
This is precisely what we need! Equipped with the right tools, we can finally write:
template <Streamable T>
std::ostream& operator<<(std::ostream& out, T const& obj)
{
auto sep = "\n ";
out << identifier_of(^^T) << "\n{";
template for (constexpr auto member : std::define_static_array(
nonstatic_data_members_of(^^T, std::meta::access_context::unchecked())))
{
if constexpr (has_identifier(member))
{
out << sep << identifier_of(member) << ": " << obj.[: member :];
sep = ",\n ";
}
}
return out << "\n}";
}The most interesting among the added lines is the one with the splice expression, obj.[: member :]. Splicing is used to turn a reflection back into what it represents. In our case, member is a reflection of a non-static data member, so splicing it brings back the member, which can be directly used to access the corresponding subobject of obj. What makes splicing even more interesting is that it totally doesn’t care about the access level of the member. Remember that we already took care of the access when we obtained the members with the unchecked access context. Splicing is perfectly oblivious to access-control rules and will happily access private and protected members (as long as you have their reflections, of course).
As a side note, we guard against compile-time errors that could be triggered by members with no identifiers (e.g., bit fields with no names) with the use of if constexpr. The beauty of this approach is that the check is performed at compile time and nothing at all will be generated for members that do not have identifiers.
And that’s all! In a few lines of code, we have implemented a fully generic stream output operator using reflection. See it working on Compiler Explorer.