In my exploration of the C++26 series, I have so far covered how to set up your C++26 build environment on Ubuntu in this previous post. Then I explored another feature called template for, covered a simple use of reflection with a basic example, and progressed to JSON serialisation using reflection. In this post, I am going to explain another extremely powerful C++26 feature called annotations and how they can make our lives easier when combined with reflection.
To start with let’s take a look at the following hypothetical MotorConfig class:
class MotorConfig
{
public:
MotorConfig(int rpm, double maxTemperature, std::string deviceName);
[[nodiscard]]
int rpm() const noexcept
{
return m_rpm;
}
[[nodiscard]]
double max_temperature() const noexcept
{
return m_maxTemperature;
}
[[nodiscard]]
const std::string& device_name() const noexcept
{
return m_deviceName;
}
private:
int m_rpm;
double m_maxTemperature;
std::string m_deviceName;
};This class has three private members, m_rpm, m_maxTemperature and m_deviceName, which are initialized from the values passed to the constructor when an object is created. Suppose you do not want an instance of the class to be created if one of its numeric values lies outside the permitted range, and you want the validation to occur during construction. What options do you have? You could add checks like the following to the constructor:
MotorConfig::MotorConfig(int rpm,
double maxTemperature,
std::string deviceName)
: m_rpm{rpm},
m_maxTemperature{maxTemperature},
m_deviceName{std::move(deviceName)}
{
if (m_rpm < 0 || m_rpm > 10000)
{
throw std::out_of_range(
"m_rpm is outside the permitted range [0, 10000]");
}
if (m_maxTemperature < -40.0 || m_maxTemperature > 150.0)
{
throw std::out_of_range(
"m_maxTemperature is outside the permitted range [-40, 150]");
}
if (m_deviceName.empty())
{
throw std::invalid_argument(
"m_deviceName is required");
}
}This works, but what happens if another member that also requires validation is added to the class? You must manually add another validation check to the constructor. This is easy to forget and can leave the new member unvalidated, and hence this approach is error prone. In C++26, annotations can be combined with reflection to automate this validation process.
Use Annotation
C++26 introduces the concept of an annotation. The general syntax of annotation is:
[[=constant-expression]]
declarationThis associates the result of the constant expression with the declaration. Reflection code can later retrieve a reflection of that result at compile time and, when required, extract the stored value. We will look at that shortly. An example of an annotation is:
[[=42]]
int value;The = immediately inside [[...]] identifies this as an annotation.
We can also define more customised annotation using user defined data types.
User-Defined Type for the Annotation
You can also use a user-defined type as an annotation value for a declaration, as follows:
[[=AnnotationType{arguments...}]]
declarationFor our example, we can define two types, Range and Required, as follows and use them in annotations later:
struct Range
{
double minimum;
double maximum;
};
struct Required
{
};Once these types are defined, we can rewrite the MotorConfig class using them to create annotation values:
class MotorConfig
{
public:
MotorConfig(int rpm, double maxTemperature, std::string deviceName);
[[nodiscard]]
int rpm() const noexcept
{
return m_rpm;
}
[[nodiscard]]
double max_temperature() const noexcept
{
return m_maxTemperature;
}
[[nodiscard]]
const std::string& device_name() const noexcept
{
return m_deviceName;
}
private:
[[= Range{0.0, 10000.0}]]
int m_rpm;
[[= Range{-40.0, 150.0}]]
double m_maxTemperature;
[[= Required{}]]
std::string m_deviceName;
};Let’s take a closer look at the annotations attached to the data members:
[[= Range{0.0, 10000.0}]]
int m_rpm;
[[= Range{-40.0, 150.0}]]
double m_maxTemperature;
[[= Required{}]]
std::string m_deviceName;The annotation on m_rpm creates a Range value with a minimum of 0.0 and a maximum of 10000.0. Similarly, the annotation on m_maxTemperature specifies a permitted range from -40.0 to 150.0. The Required{} annotation marks m_deviceName for required-value validation.
At this point, however, it is only information associated with the member declaration. It does not enforce the range by itself. Code must later retrieve the annotation through reflection and enforce the corresponding validation rule.
Now reflect it
Once our annotations are in place, as shown in the code above, we can leverage reflection to extract and use them in our code.
First, we will write a loop that will go over all the non-static members of the MotorConfig class:
template for (constexpr std::meta::info member :
std::define_static_array(
std::meta::nonstatic_data_members_of(^^T,
std::meta::access_context::unchecked())))I have discussed template for in detail in this article. Briefly, ^^T represents the reflection of the type T, which is MotorConfig in our case. std::meta::nonstatic_data_members_of returns a vector of reflections of all the non-static data members. But as the vector uses dynamic storage, it cannot be used as the range of template for. To use it with template for, we need to copy it into a static array, which we achieve using std::define_static_array.
Then, in each iteration, we retrieve the annotations attached to the current member:
constexpr auto ranges =
std::define_static_array(std::meta::annotations_of_with_type(
member, ^^Range));The function std::meta::annotations_of_with_type returns a vector of reflections of all the annotations of type Range. std::define_static_array copies those reflections into an array with static storage and returns a std::span referring to that array. A vector normally stores its elements in dynamically allocated memory, so its contents cannot be stored directly in the constexpr variable ranges.
Single vs Multiple Annotation
Note that, in this example, each member has at most one annotation of a given type:
[[= Range{0.0, 10000.0}]]
int m_rpm;But it is possible for a member to have multiple annotations of the same type:
[[=Range{0.0, 10000.0}, =Range{1000.0, 8000.0}]]
int m_rpm;And you can express it in any of the following ways:
[[=Range{0.0, 3000.0},
=Range{7000.0, 10000.0}]]
int m_rpm;Or separately as the following:
[[=Range{0.0, 3000.0}]]
[[=Range{7000.0, 10000.0}]]
int m_rpm;So, in the following expression, ranges is a span referring to the static array of reflections of the annotations:
constexpr auto ranges =
std::define_static_array(std::meta::annotations_of_with_type(
member, ^^Range));In our case we have only one Range annotation, and its reflection is available at ranges[0]. To extract the corresponding Range value, we can do the following:
constexpr Range range = std::meta::extract<Range>(ranges[0]);The Splice Expression
We can now access the current value of each member that has a Range annotation and compare it with its permitted range. The member value can be accessed using the following splice expression:
template for (constexpr std::meta::info member : std::define_static_array(
std::meta::nonstatic_data_members_of(^^T,
std::meta::access_context::unchecked())))
{
...
...
constexpr auto ranges =
std::define_static_array(std::meta::annotations_of_with_type(member, ^^Range));
if constexpr (!ranges.empty())
{
...
const auto& value = object.[:member:];[:member:] is a C++26 splice expression. It converts the reflection back into the entity represented by that reflection. So in the first iteration object.[:member:] will give us the value of object.m_rpm and now we can do a comparison like the following:
if (value < range.minimum || value > range.maximum)
{
throw std::out_of_range(std::string{name}
+ " is outside the permitted range ["
+ std::to_string(range.minimum) + ", "
+ std::to_string(range.maximum) + "]");
}In the next iteration the object.[:member:] will give us the value of object.m_maxTemperature and so on. The following code snippet which does it all:
template <typename T>
void validate_or_throw(const T& object)
{
template for (constexpr std::meta::info member : std::define_static_array(
std::meta::nonstatic_data_members_of(^^T,
std::meta::access_context::unchecked())))
{
constexpr auto ranges =
std::define_static_array(std::meta::annotations_of_with_type(
member, ^^Range));
if constexpr (!ranges.empty())
{
constexpr Range range = std::meta::extract<Range>(ranges[0]);
const auto& value = object.[:member:];
if (value < range.minimum || value > range.maximum)
{
throw std::out_of_range(std::string{name}
+ " is outside the permitted range ["
+ std::to_string(range.minimum) + ", "
+ std::to_string(range.maximum) + "]");
}
}The handling of the Required annotation is very similar and can be implemented somewhat like the following:
constexpr auto required =
std::define_static_array(std::meta::annotations_of_with_type(member, ^^Required));
if constexpr (!required.empty())
{
const auto& value = object.[:member:];
if constexpr (requires { value.empty(); })
{
if (value.empty())
{
throw std::invalid_argument(std::string{name} +
" is required");
}
}
else
{
static_assert(
requires { value.empty(); },
"Required annotation can only be applied to a type "
"that provides empty()");
}
}Validate at Construction
The full listing of the validation code is as follows:
template <typename T>
void validate_or_throw(const T& object)
{
template for (constexpr std::meta::info member :
std::define_static_array(
std::meta::nonstatic_data_members_of(^^T,
std::meta::access_context::unchecked())))
{
constexpr std::string_view name =
std::meta::identifier_of(member);
std::cout << "Validating member: " << name << '\n';
constexpr auto ranges =
std::define_static_array(
std::meta::annotations_of_with_type(member, ^^Range));
constexpr auto range_count = ranges.size();
if constexpr (!ranges.empty())
{
constexpr Range range = std::meta::extract<Range>(ranges[0]);
const auto& value = object.[:member:];
if (value < range.minimum || value > range.maximum)
{
throw std::out_of_range(std::string{name}
+ " is outside the permitted range ["
+ std::to_string(range.minimum) + ", "
+ std::to_string(range.maximum) + "]");
}
}
constexpr auto required =
std::define_static_array(
std::meta::annotations_of_with_type(member, ^^Required));
if constexpr (!required.empty())
{
const auto& value = object.[:member:];
if constexpr (requires { value.empty(); })
{
if (value.empty())
{
throw std::invalid_argument(std::string{name}
+ " is required");
}
}
else
{
static_assert(
requires { value.empty(); },
"Required annotation can only be applied to a type "
"that provides empty()");
}
}
}
}The full source code is available in this Github link. To compile the code do the following in the command line:
g++ -std=c++26 \
-freflection \
-Wall \
-Wextra \
-Wpedantic \
reflection_validation.cpp \
-o reflection_validation
./reflection_validation
Validating member: m_rpm
Found 1 range annotations for member: m_rpm
Construction failed: m_rpm is outside the permitted range [0, 10000]
Validating member: m_rpm
Found 1 range annotations for member: m_rpm
Validating member: m_maxTemperature
Found 1 range annotations for member: m_maxTemperature
Validating member: m_deviceName
Found 0 range annotations for member: m_deviceName
Valid configuration created.
RPM: 5000
Maximum temperature: 85
Device name: motor-controller-1To enable validation right at the inception we can call the validation function template validate_or_throw in the constructor of the MotorConfig class as follows:
MotorConfig::MotorConfig(int rpm,
double maxTemperature,
std::string deviceName)
: m_rpm{rpm},
m_maxTemperature{maxTemperature},
m_deviceName{std::move(deviceName)}
{
validate_or_throw(*this);
}This will ensure that the constructor of MotorConfig throws if values outside the permitted range are passed to it. All the hassle of manually checking the passed parameter values is gone!
C++26 Series
Follow my C++26 series as I explore the language’s new features through practical examples.
- 01 Compile Your First C++26 Program with GCC 16.1
-
02
C++26: What Is
template for? - 03 C++26: What Is Reflection and How Do You Use It?
- 04 C++26 Reflection: Simplifying JSON Serialization
- 05 C++26 Reflection Annotations: Automated Member Validation
- 06 C++26 Contracts: What Do They Add Beyond Manual Checks and Assertions?
Discover more from Tech For Talk
Subscribe to get the latest posts sent to your email.
3 Comments