How to annotate in C++26 (1)
Table of Contents
This post shows how to use different kinds of annotations in C++. Starting with a simple [[=annotation]], it moves to a more powerful [[=derive(Annotation)]] syntax, and ends up discussing the merits of using stateful annotations, like [[=derive(Annotation(WithSomeValue))]].
Introduction #
If the ability to obtain and manipulate reflections at compile time is what forms the skeleton of C++26 reflection, then annotations are the muscles that make it spring to life. Simply put, annotations are like non-type template parameters with the distinction that they can be attached to almost any entity in a program to provide additional information about it. This information can then be consumed by compile-time programs to make decisions about what to do with the annotated entities.
For instance, we could create an annotation serializable that could then be used to mark data members of a class that should be serialized:
struct LotsOfData
{
[[=serializable]] int a;
[[=serializable]] double b;
std::string c;
};The metaprogram that consumes this annotation could be written as a single function template that takes an object of any type1 and serializes its annotated members:
template<typename T>
requires has_serializable_members<T>
void serialize(const T& obj, std::ostream& os);What can be used as an annotation? #
First of all, annotations are not types but values. Only values of types that are structural can be used as annotations—this is the same restriction that applies to non-type template parameters. The list of conditions a type must satisfy to be structural is long. Besides pointers, lvalue references, and integral and floating-point types, an annotation can also be:
- a closure type object of a lambda expression that does not capture anything, or
- an object of a class type, with the following properties2:
- all its non-static data members and base classes are
public, - it has a
constexprtrivial destructor, and aconstexprconstructor, - all its non-static data members are either scalar or reference types, or they are of structural class types, and
- all its base classes also (recursively) have these properties.
- all its non-static data members and base classes are
So, the following types are possibly usable as annotations:
struct Annotation
{
int a;
double b;
};
struct DerivedAnnotation : Annotation
{
const char * str;
};
struct AnotherAnnotation
{
constexpr AnotherAnnotation(int a, double b, std::string_view str)
: z{a}
, derived{ 42, 0.0, str.data() } // don't mind the potentially missing null terminator
{}
int z;
DerivedAnnotation derived;
};And those are a no-go:
struct NotStructural
{
int a;
std::string_view b; // std::string_view is not a structural type
};
class AnotherNotStructural
{
int a;
std::string b; // private members and std::string is not a structural type
};As a curiosity,
[[=42]]is a valid annotation, becauseintis a structural type.
Creating simple annotations #
Once we have defined such a type, we can use it to annotate program entities:
struct serializable
{};
struct Record
{
[[=serializable()]] int id;
[[=serializable()]] std::string name;
Record * next;
};This approach, where we create ad-hoc annotation objects with [[=annotation()]], looks (arguably) ugly, so we usually prefer to create a global constexpr variable of the annotation type and use it instead:
struct serializable_t {};
constexpr serializable_t serializable{};
struct Record
{
[[=serializable]] int id;
[[=serializable]] std::string name;
Record * next;
};Composable annotations with derive(...) #
But let’s go a step further and try to implement the Rust-inspired #[derive(...)] syntax to create a more polished way to annotate types. This will require a change in the API, which will evolve into:
struct [[=derive(Serializable)]] Record
{
[[=serialize]]
int id;
[[=serialize]]
std::string name;
Record * next;
};Generally, this is better than the previous style—with a single annotation we directly signal that the type is serializable, without the need to look at its data members. We still keep an annotation (changed to [[=serialize]]) to opt in data members for serialization. Implementing the helpers for the new API requires just a few lines of code.
derive(Serializable) syntax rather than derive<Serializable>, which is more typical in the C++ world. Both are fine, but the former is more consistent with its Rust heritage.template <typename T>
struct derive : T {};
struct Serializable_t {};
constexpr Serializable_t Serializable{};
struct serialize_t {};
constexpr serialize_t serialize{};The class template derive inherits from the type T of the object used to construct it. It’s a pretty neat trick because the object passed to derive is used to initialize the base class subobject of type T. This allows for the possibility of having stateful annotations, like [[=derive(Serializable(ToJSON))]].
Perhaps let’s do just this, and—using the same pattern—allow the Serializable annotation to take a parameter that specifies the serialization format:
template <typename T>
struct derive : T {};
struct ToString_t {};
constexpr ToString_t ToString{};
template <typename T>
struct Serializable : T {};
struct serialize_t {};
constexpr serialize_t serialize{};
struct [[=derive( Serializable(ToString) )]] Record
{
[[=serialize]]
int id;
[[=serialize]]
std::string name;
Record * next;
};The final touch is to make the derive template support multiple annotations, so we can write [[=derive(Serializable, Cloneable)]]. This is easily achieved by making derive a variadic template:
template <typename...From>
struct derive : From... {};
template <typename...From>
derive(From...) -> derive<From...>;If you just experienced a worrying C++17 flashback with std::visit and the trick with overloaded<Ts...> in the main role, you are on the right track. derive in the snippet above is a class template that inherits from all the types passed to it. The deduction guide is needed to help the compiler figure out the template parameters from the constructor arguments. There are other ways to tell the compiler what types it should infer3, but the deduction guide has the advantage of preserving the aggregate status of derive.
More stateful annotations #
We are almost ready to start implementing the serialization functionality for Serializable types, but first let’s add a little twist to the mix. What if we wanted the members to be serialized in an order different from the one they are declared in? Perhaps like this:
struct [[=derive( Serializable(ToString) )]] Record
{
[[=serialize]]
int id;
[[=serialize(with_order(1))]]
std::string name;
Record * next;
};In this case, we opted to serialize name before id by using (once again) a stateful annotation, with_order(1). To enable this functionality, we need to modify the serialize_t annotation. Now, it’s defined as a stateless type, and its usage is routed through a constexpr variable:
struct serialize_t {};
constexpr serialize_t serialize{};To make it stateful, we add a single int data member:
struct serialize_t
{
int order = -1;
};The remaining issue is how to pass the value of order to the serialize annotation. Notice that the syntax used in the example above allows for both [[=serialize]] and [[=serialize(with_order(1))]]. The second form is certainly not a constructor call. Instead, we can add a function call operator to the serialize_t type:
struct with_order
{
int order;
};
struct serialize_t
{
int order = -1;
constexpr serialize_t operator()(with_order ord) const
{
return serialize_t{ord.order};
}
};
constexpr serialize_t serialize{};And just like this, we have created a set of annotations that can be used to mark types and their members for serialization. The next step is to implement the actual serialization.
Of almost any type, because the example constrains
Tto have members annotated with the=serializableannotation through thehas_serializable_membersconcept. ↩︎This list is a condensed and simplified version of what the standard says about structural types. For the normative definition see the structural types and the literal types sections of the C++ standard draft. ↩︎
For instance, we could use aggregate initialization with braced initializers:
[[=derive{Serializable, Cloneable}]]. ↩︎