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;
};Now this class has private members m_rpm, m_maxTemperature and m_deviceName that are initialised to certain values at the time of object creation construction via the constructor. Now, lets say you don’t want the class instance to be created with values that lies outside a certain range and you want to make sure that the validation occurs at the time of construction. What are the options you have? You can do something like the below in 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");
}
}Great! but what if another class member is added to the class whose values also needs validation, you have to add the validation code manually in the constructor to take care of the new member. Manual addition is error prone. In C++26, we can utilise feature like annotation and use it with feature like reflection to automate this validation process.
Use Annotation
C++26 comes up with the concept of annotation. The general syntax of annotation is:
[[=constant-expression]]
declarationWhich means you can associate constant expression along with your declaration and later at compile time you can retrieve those values. We will look at that shortly. Example of such annotation will be like the below:
[[=42]]
int value;We can also define more customised annotation using user defined data types.
User-Defined Type for the Annotation
You can also use a used defined type as the annotation for your declaration like the following:
[[=AnnotationType{arguments...}]]
declarationFor example in our example code we can declare annotations of type Range and Required as the following:
struct Range
{
double minimum;
double maximum;
};
struct Required
{
};Once that is declared we can now rewrite the MotorConfig class as the below using user defined type annotation:
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;
};Look closer into the decalration:
[[= Range{0.0, 10000.0}]]
int m_rpm;It indicates that the permitted range for the member variable m_rpm is 0.0 to 10000.0. At this point, however, the annotation is only additional information. It does not enforce the range by itself and can be ignored unless code later on forces it.
Now reflect it
Once your annotations are in place as shown in the above code, we can now 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 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 extract the annotation(s) of each of the members:
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. 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
Remember we have only one annotation in one type:
[[= Range{0.0, 10000.0}]]
int m_rpm;But it is also possible to have multiple annotation in one 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 represents the 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 annotation and reflection of which can be denoted by ranges[0]. To extract the usable range we can do the following:
constexpr Range range = std::meta::extract<Range>(ranges[0]);The Splice Expression
We are getting to a point where we can access the current value of each member and compare it with the 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 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 is the code snippet which does all of these:
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 validation is implemented in the following template function:
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();
std::cout << "Found " << range_count << " range annotations for member: " << name << '\n';
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!
Discover more from Tech For Talk
Subscribe to get the latest posts sent to your email.
Leave a Reply