Manually translated from: 基于 C++26 静态反射实现零样板代码、零额外开销的分派表.
As compile-time reflection is finally adopted to C++26 standard, developers are enabled to fetch and operate on the metadata of the C++ program itself. Metadata of various C++ entities (variables, functions, classes, templates, namespaces, etc.) can be obtained from reflection API in C++26 standard library, the most frequently used of which is getting an entity’s name, type, list of members or list of base classes. Reflection is incredibly helpful to support more elegant and more efficient C++ code by utilizing the metadata properly. In this blog we dive into dispatch table, which is a common pattern in real-world C++ code, demonstrating how the great power and expressiveness of C++26 reflection helps with eliminating boilerplate code and runtime overhead that are inevitable in pre-C++26 dispatch table implementation, and then, how the API design of our reflection-based dispatch table can be further improved step-by-step.
The contents shown by this blog has full implementation in my rbox library. Source code is available in GitHub.
How It Works Finally
We start from the following example to demonstrate the expressiveness of C++26 reflection. All the constexpr evaluations of building the dispatch_table are encapsulated into the API macro RBOX_FUNCTION_FIXED_MAP. The first parameter ops is the namespace whose members are to be traversed. The second parameter "do_*" is the identifier pattern which the members of namespace ops are expected to follow. Each function in namespace ops is added to the dispatch table if and only if its identifier matches the pattern "do_*". The dispatch_table returned is an associative array whose value type is auto-deduced as int (*)(int, int) and the internal data structure is selected adaptively according to the input (typically a hash table, or a linear table as fallback when the number of entries is few).
More examples are provided in the examples of rbox library.
1 | namespace ops { |
Background: Drawbacks of Traditional Dispatch Tables
Before static reflection being introduced to C++, dispatch tables are usually implemented by the following 3 approaches:
- if-else chain: The most brute-force approach that is acceptable only when the number of entries is fairly small. When the number of entries goes larger, such code can be a disaster of maintenance, as developers will probably suffer from hidden bugs from wrongly inserted/removed entries or typo in keys.
- X-macros: As shown in the example below. X-macros perform for-each operation during code preprocessing. They are much cleaner and more maintainable than lengthy if-else chains, yet boilerplate code like the
#defineand#undefpair is still required. Furthermore, the expressiveness of X-macros drops sharply when the relation of entries grows more complex. Though it works fine in the following example where there are only two sets (OP_BIT_FUNCSas subset ofOP_FUNCS), it is still facing challenge as more and more subsets may be involved in the future, forcing the developer to either create scattered X-macro partitions or create duplicated entries in multiple X-macros. - Static or global containers: As shown in the example below. Dispatch table entries are inserted during runtime to a container like
std::unordered_map, which is initialized as a static or global instance. Developers can benefit from lower time complexity for each query with proper data structure, yet the overhead of dispatch table initialization is delayed to runtime, and in some cases developers may encounter trouble with static or global objects (initialization order, error handling during initialization, etc.).
A brand new approach is provided by C++26 reflection. The entry list can be obtained via reflection APIs like std::meta::members_of(), thus manual work of listing all the entries in if-else chains or X-macros are no more needed. The underlying data structure of dispatch table can be selected & constructed in compile-time with flexible constexpr evaluations assisted by reflection mechanism.
1 |
|
Prior Knowledge: C++26 Compile-time Reflection & Data Promotion
For the sake of brevity, this blog is neither a comprehensive tutorial of C++26 reflection nor attempting to cover every of its details. You can refer to the draft of C++26 reflection (a.k.a. the p2996 paper) and other blogs in C++ community for more information.
The C++26 standard library provides std::define_static_array() which allows us to define an array of constants during constexpr evaluation, promoting all the values in the input range to static storage. It is powerful but not powerful enough for our dispatch table implementation. rbox::to_static_storage() is an extension which supports promoting data of more complicated types like nested ranges. You can read my previous blog for the rationale of data promotion and the details of rbox::to_static_storage(): Extension to std::define_static_array() - More Powerful Compile-time Data Promotion.
Diving into Implementation
In the following contents we will finish the implementation of dispatch tables in an incremental manner. The full implementation will be decomposed to three steps:
- Basic functionality;
- Adaptive data structure;
- Auto-deducing the target type.
Step 1: Basic Functionality
We start with implementing the following dispatch table API which is a simplified version: ns is the namespace whose members are to be traversed. prefix specifies the identifier pattern which the filtered members shall follow. The target type T of filtered members (which can be either an object type or a function type) should be provided explicitly by the user as a template parameter. All the members in namespace ops that match the given identifier pattern and type will be added to the dispatch table. This simplified API returns a linear list of key-value pairs, where keys are sorted by lexicographical order so that binary search is supported. The linear list is promoted to static storage via rbox::to_static_storage() and this API returns the promotion result of type Span<Pair<StringView, const T*>>.
Note: std::span and std::string_view from C++ standard library are not available in this case. They are not structural (the same constraint to non-type template parameters, namely NTTP) due to their private data members. Span and StringView are their respective replacements whose data members are exposed as public for the sake of structuralness. My previous blog has provided a detailed analysis on why structuralness is required during data promotion.
1 | template <class T> |
This simplified version can be implemented easily with the help of C++26 standard library of reflection. Given a reflection of a class or namespace, std::meta::members_of() returns a list of its members. For each member in the returned list:
std::meta::type_of(member)obtains its type;std::meta::is_same_type(A, B)tests whetherAandBare exactly the same type (the same semantics asstd::is_samesince C++11);std::meta::has_identifier(member)tests whethermemberis not an anonymous member;std::meta::identifier_of(member)obtains its identifier. A compile-time exception of typestd::meta::exceptionwill be thrown ifmemberis anonymous;- When the exact target type
Tofmemberis known,std::meta::extract<T*>(member)obtains a pointer to the variable or functionmemberreflects. A compile-time exception of typestd::meta::exceptionwill be thrown on target type mismatch.
Important notes:
- Argument-dependent lookup (ADL) is useful for less typing (11 characters
std::meta::per invocation) and cleaner code. All the C++ standard library components in namespacestd::metawith at least onestd::meta::infoargument can be found via ADL. std::meta::members_of(), as well as other reflection API for member list query, returns the direct members of the given class or namespace. Members in base classes, nested classes, parent or child namespaces are not covered, and additional recursive work is required to cover these non-direct members. In this blog parent or child namespaces are not in our consideration.std::meta::members_of(), as well as other reflection API for member list query, takes another argumentctxof typestd::meta::access_contextbesides the reflection of class or namespace.ctxaffects access control of each member. For class members,protectedandprivatemembers are added to the returned list only if they are accessible in given access context. For namespace members, the effects ofctxare negligible.
1 | template <class T> |
Step 2: Adaptive Data Structure
In the previous section, we have implemented a simplified dispatch table whose data structure is fixed as sorted linear list, enabling binary search with O(log n) complexity. In this section we will proceed towards the ability of adaptive data structure. The first problem we will meet is which return type should make_dispatch_table() take when multiple data structures coexist as candidates. The following pseudocode is a skeleton with three candidate data structures, neither of which can be selected as the return type alone. Combining all of them with a union or std::variant is a bad idea that ruins all the expected advantages of dispatch tables. Not only the additional runtime overhead is a headache, using such a dispatch table also requires lots of boilerplate code for each invocation. if constexpr is not available in this case either as the arguments ns and prefix are not constant expressions.
Though the full definition of constant expressions (see cppreference for details) is quite complicated and verbose, constant expressions can be interpreted informally as those which can be evaluated in compile-time and then used as template parameters, size of C-style arrays,
casevalue in switch statements,constexprvariable initializer, etc.In the following code pattern:
- The function arguments
nsandprefixare not constant expressions (even though they are arguments of aconstevalfunction);- The template parameter
Tcan be used in constant expressions. In step 3 where we will implement auto-deduction of the target type,Twill become a variable of typestd::meta::infoevaluated insidemake_dispatch_table()function body, in which caseTis no more a constant expression.
1 | template <class T> |
C++26 provides a solution for this problem. std::meta::reflect_constant() takes any value of structural type as argument and returns a std::meta::info reflecting its value, which is useful during constexpr evaluation when we need to unify the type of various values to std::meta::info. In this case we wrap the returned instance of each data structure with std::meta::reflect_constant(). Then the make_dispatch_table() API returns std::meta::info reflecting the instance of the selected data structure.
Generally, there are two approaches to extract the reflected value from a std::meta::info:
- Using splice specifiers: Given
Rwhich is a constant expression of typestd::meta::info,[: R :]is evaluated to its reflected value; - Using
std::meta::extract(): Given the exact value typeTand the reflectionr,std::meta::extract<T>(r)is evaluated tor‘s reflected value. This approach does not requirerto be a constant expression but as a costTmust be known.
The first approach fits in this case where we can extract the dispatch table instance by applying splice specifier to the std::meta::info returned by make_dispatch_table(). A wrapper macro MAKE_DISPATCH_TABLE() acts as a friendlier API design for C++ developers unfamiliar with C++26 reflection syntax ^^ and [: :].
p.s. Aesthetics is another minor concern as I dislike the weird syntax [: :] appearing everywhere :(
1 | template <class T> |
Step 3: Auto-deduced Target Type
The mighty power of C++26 reflection is still not fully exploited. A demanding user of our dispatch table API still complains that the target type T has to be typed manually, which is indeed a burden considering the fact that typical call signatures are fairly long in real-world codebase. C++26 has helped us to obtain the member list of namespace ns and build the underlying data structure in an adaptive manner. Can it be even more helpful such that the target type T can be auto-deduced from the filtered member list? The answer is fortunately yes, but more tricks are necessary for this goal. In the following code pattern we attempt to get rid of template <class T>. Reflection of the target type rT is obtained from the filtered members via std::meta::info. Type consistency check is performed to ensure all the filtered members share exactly the same type. Then we face another bottleneck of implementation: rT is no more a constant expression, as a result both splicing specifier and std::meta::info fails to work.
1 | // No more explicitly specified T |
Here is the useful trick: Reflection of a type or a value can be promoted to template parameter via std::meta::substitute() and std::meta::extract() combined, where it becomes a constant expression. The trick can be generalized as the following code pattern. make_something() is expected to return an instance of type SomethingWith<T> where T is deduced from the argument entity and its reflection rT, which is not a constant expression, is accessible inside the function body of make_something(). A helper function make_something_impl<T>() taking T as template parameter is needed which builds and returns the SomethingWith<T> instance. The helper function template is instantiated via std::meta::substitute() inside make_something() function body, then the corresponding function pointer is extracted by std::meta::extract(). Since the helper function template has fixed call_signature = std::meta::info(std::meta::info) (the result type is unified as std::meta::info with the help of std::meta::reflect_constant), std::meta::extract<call_signature>() works in this case and the instantiated helper function can be invoked by make_something() via the extracted function pointer.
The substitute + extract pattern serves as a practical bridge on the huge gap between non-constant expressions during consteval evaluation and constant expressions as template parameters, allowing us to go back and forth, benefiting from both the power of constant expressions and the flexibility & compile-time performance (compared to traditional template metaprogramming) of consteval functions.
1 | template <class T> |
Now we finish implementing auto-deduction of the target type with the substitute + extract pattern shown above. A helper function template is needed for each data structure which builds its instance. The following code contains one of them as example. The deduced target type will be promoted as template <class T> of the helper function template make_binary_search_dispatch_table(). Reflection of the deduced type rT will be promoted to the template parameter with std::meta::substitute, then the instantiated helper function will be extracted by std::meta::extract<call_signature>() and immediately invoked. As a result of our work till now, the MAKE_DISPATCH_TABLE() API is finally minimized after eliminating the explicit target type T, only ns and prefix are necessary.
1 | template <class T> |
Conclusion & Discussion: Reflection as Game Changer in C++ Code Design
We have managed to implement a dispatch table API in a step-by-step manner, zero boilerplate & zero runtime overhead based on the great power of C++26 reflection. Further extensions to its functionality and more use cases (class member lookup, recursive traversal of base classes, multiple target types, etc.) are omitted in this blog due to space limitations. See the rbox implementation for their details.
In the end of this blog, let us think deeper about compile-time reflection. Considering the fact that identifiers of C++ entities are able to be obtained by std::meta::identifier_of() and then participate in constexpr evaluations, enabling more string parsing and dispatching work in compile-time, C++ identifiers themselves get the potential to play an active role in code design models. Similarly, classes and namespaces are ready for a new role as “grids” or “drawers”, partitioning the traversal range of std::meta::members_of() etc. The power of reflection will not be limited to elaborating existing code patterns (like a more fancy dispatch table). It is a game changer on how we think and how we design new C++ code.
The code pattern below is a brief showcase of reflection-based C++ code design. namespace events represents the full set of events. Event keys are encoded into the names of handler functions and key registry is done automatically. The design is elegant, self-explaining, free from boilerplates and much more maintainable than traditional code patterns. The only risk is compile-time overhead bloating due to extensive constexpr evaluations diffused to multiple irrelevant translation units via #include chains, which can be prevented by careful design of source file structure.
1 | /* |