An Introduction to Reflection in C++

阎修杰
2023-12-01

 

Stop me if you’ve heard this one before. You are working on a messaging middleware, a game engine, a UI library, or any other large software project that has to deal with an ever-growing, ever-changing number of objects. These objects have many different qualities but can be grouped by their functionality: they can be sent across the network or collided with or rendered.

Because you are a good programmer who believes in the DRY principle, you want to write the “action” code that does the stuff on these objects without repetition, and plug in specific Message types or Renderable types into your generic pipeline at the appropriate places. It would be really nice to compose objects hierarchally: for example, if I had a widget class composed of several different renderable Rectangles, I want to be able to automatically generate the rendering code for my widget based on the existing rendering logic for its constituent shapes.

In C++, this is harder than it sounds. You start by using inheritance and a parent type that provides a unified interface for the “action” code. But you quickly run into issues with interface composibility and slicing, and if latency or code size are critical to your application, virtual functions will give you a performance hit.

You hear that using an external code generation tool (like Protobuf for message encoding, or the QT Meta-Object compiler for UI widgets) is a common practice for these kinds of projects. This approach may work for certain use cases but feels wrong for a few reasons: brittle, inextensible, intrusive, and redundant.

Because you’re in touch with the latest and greatest features of C++, you hear about this thing called Concepts that will help the shortcomings of inheritance for providing a generic interface to related types. But even if you’re working with a compiler that implements concepts, introducing a “Serialiazable” or “Renderable” concept doesn’t solve some of your fundamental problems. When you want to compose objects hierarchically, you still don’t have a good way of automatically detecting when any of the members of your type also conform to that Concept.

This is not a new problem. See this excerpt from The Art of Unix Programming by Eric S. Raymond, circa 2003, that describes this issue as it manifested itself in the fetchmailconf program:

The author considered writing a glue layer that would explicitly know about the structure of all three classes and use that knowledge to grovel through the initializer creating matching objects, but rejected that idea because new class members were likely to be added over time as the configuration language grew new features. If the object-creation code were written in the obvious way, it would be fragile and tend to fall out of synchronization when the class definitions changed. Again, a recipe for endless bugs.

The better way would be data-driven programming — code that would analyze the shape and members of the initializer, query the class definitions themselves about their members, and then impedance-match the two sets.

Lisp programmers call this introspection; in object-oriented languages it’s called metaclass hacking and is generally considered fearsomely esoteric, deep black magic.

There’s another name for this arcane art which we’ll use in this article: reflection. You probably think there’s already far too much black magic in C++. But I want to convince you that we need reflection in the language today, especially compile-time introspection, and that it’s not a fearsome, esoteric mystery, but a useful tool that can make your codebase cleaner and more effective.

The story so far

Before we dive too far in, let’s define some terms. As we know from my previous blog post, names are frustratingly hard to pin down, so let’s not quibble too much over definitions so that we can get to the real content.

Introspection is the ability to inspect a type and retrieve its various qualities. You might want to introspect an object’s data members, member functions, inheritance hierarchy, etc. And you might want to introspect different things at compile time and runtime.

Metaobjects are the result of introspection on a type: a handle containing the metadata you requested from introspection. If the reflection implementation is good, this metaobject handle should be lightweight or zero-cost at runtime.

Reification is a fancy word for “making something a first-class citizen”, or “making something concrete”. We will use it to mean mapping from the reflected representation of objects (metaobjects) to concrete types or identifiers.

Actually, there are a few ways you can achieve reflection in the language today, whether you’re using C++98 or C++14. These methods have their advantages and disadvantages, but the fundamental issue overall is that we have no standardized language-level reflection facilities that solve the use case described in the introduction.

RTTI

Run-time type information/identification is a controversial C++ feature, commonly reviled by performance maniacs and zero-overhead zealots. If you’ve ever used dynamic_casttypeid, or type_info, you were using RTTI.

RTTI is C++’s built-in mechanism for matching relationships between types at runtime. That relationship can be equality or a relationship via inheritance. Therefore it implements a kind of limited runtime introspection. C++ RTTI requires generating extra runtime state for every class which uses using vtables (virtual function tables), the array of pointers used to dispatch virtual functions to their runtime-determined implementations. Compiling with -fnortti is a common optimization to disable generating this extra state, which can save on code size as well as compilation times (since the compiler has less work to do). But it also disables the “idiomatic” C++ way of achieving runtime polymorphism.

A bit of a sidebar: why is RTTI idiomatic C++ when there are better alternatives? LLVM implements an alternative to RTTI which works on classes without vtables, but requires a little extra boilerplate (see theLLVM Programmer’s Manual). And, if you squint at the extra boilerplate used in this technique, you can imagine formalizing this with a generic library and even adding more ideas like dynamic concepts (sometimes called interfaces in other languages). In fact, Louis Dionne is working on a experimental librarythat does just that.

As a reflection mechanism, RTTI doesn’t have many advantages besides the fact that it’s standardized and available in any of the three major compilers (Clang, gcc, MSVC). It is not as powerful as the other reflection mechanisms we are about describe and doesn’t fit the problem statement we made above. Still, the general problem of mapping from runtime information to compile-time types is important for many interesting applications.

Macros

We love and hate C++ because the language allows us to do almost anything. Macros are the most flexible tool in the arsenal of C++’s foot-guns.

There’s a lot you can do with macros that you could instead do with templates, which grants you type safety, better error handling, and often better optimizations than macros. However, reflection can’t be achieved with templates alone and must leverage macros (until C++17, as we’ll see in the next section).

The Boost C++ libraries offer a few utility libraries for template metaprogramming, including MPL, Fusion, and Hana. Fusion and Hana both offer macros that can adapt a user-defined POD-type into an introspectible structure which tuple-like access. I’ll focus on Hana because I’m more familiar with the library and it’s a newer library. However, the macro offered by Fusion has the same interface.

The Hana documentation gives a full overview of these macros. You can either define an introspectible struct with BOOST_HANA_DEFINE_STRUCT:

struct Person {
  BOOST_HANA_DEFINE_STRUCT(Person, (std::string, name), (std::string, last_name), (int, age) ); }; 

Or, if you’d like to introspect a type defined by another library, you can use BOOST_HANA_ADAPT_STRUCT:

BOOST_HANA_ADAPT_STRUCT(Person, name, last_name, age); 

After doing this, you can access the struct in a generic fashion. For example, you can iterate over the fields of the struct using hana::for_each:

Person jackie{"Jackie", "Kay", 24}; hana::for_each(jackie, [](auto pair) { std::cout << hana::to<char const*>(hana::first(pair)) << ": " << hana::second(pair) << std::endl; }); // name: Jackie // last_name: Kay // age: 24 

The basic idea behind the magic is that the macro generates generic set and get functions for each member. It “stringizes” the field name given to the macro so it can be used as a constexpr string key, and uses the identifier name and type to get the member pointer for each field. Then it adapts the struct to a tuple of compile-time string key, member pointer accessor pairs.

Though that sounds fairly straightforward, the code is quite formidable and verbose. That’s because there’s a separate macro case for adapting a struct of specific sizes. For example, all structs with 1 member map to a particular macro, all structs with 2 members map to the next macro, etc.

The macro is actually generated from a less than 200-line Ruby template. You might not know Ruby, but you can get a taste of the pattern by looking at the template, which can be expanded for a maximum of n=62 (line breaks are my own):

#define BOOST_HANA_DEFINE_STRUCT(...) \
    BOOST_HANA_DEFINE_STRUCT_IMPL(BOOST_HANA_PP_NARG(__VA_ARGS__), __VA_ARGS__)

#define BOOST_HANA_DEFINE_STRUCT_IMPL(N, ...) \
    BOOST_HANA_PP_CONCAT(BOOST_HANA_DEFINE_STRUCT_IMPL_, N)(__VA_ARGS__)

<% (0..MAX_NUMBER_OF_MEMBERS).each do |n| %> #define BOOST_HANA_DEFINE_STRUCT_IMPL_<%= n+1 %>(TYPE <%= (1..n).map { |i| ", m#{i}" }.join %>) \ <%= (1..n).map { |i| "BOOST_HANA_PP_DROP_BACK m#{i} BOOST_HANA_PP_BACK m#{i};" }.join(' ') %> \ \ struct hana_accessors_impl { \ static constexpr auto apply() { \ struct member_names { \ static constexpr auto get() { \ return ::boost::hana::make_tuple( \ <%= (1..n).map { |i| \ "BOOST_HANA_PP_STRINGIZE(BOOST_HANA_PP_BACK m#{i})" }.join(', ') %> \ ); \ } \ }; \ return ::boost::hana::make_tuple( \ <%= (1..n).map { |i| "::boost::hana::make_pair( \ ::boost::hana::struct_detail::prepare_member_name<#{i-1}, member_names>(), \ ::boost::hana::struct_detail::member_ptr< \ decltype(&TYPE::BOOST_HANA_PP_BACK m#{i}), \ &TYPE::BOOST_HANA_PP_BACK m#{i}>{})" }.join(', ') %> \ ); \ } \ } \ 

Why did the authors of these libraries write these macros in the first place? Bragging rights, of course!

Actually, tuple-like access for structs is incredibly useful for generic programming. It allows you to perform operations on every member of a type, or a subset of members conforming to a particular concept, without having to know the specific names of each member. Again, this is a feature that you can’t get with inheritance, templates, and concepts alone.

The major disadvantage of these macros is that to reflect on a type you either need to define it withDEFINE_STRUCT, or to refer to it and all of its introspectible members in the correct order withADAPT_STRUCT. Though the solution requires less logical and code redundancy than code generation techniques, this last bit of hand-hacking wouldn’t be required if we had language-level reflection.

magic_get

The current pinnacle of POD introspection in C++14 is a library called magic_get by Antony Polukhin. Unlike Fusion or Hana’s approach, magic_get doesn’t require calling a DEFINE_STRUCT or ADAPT_STRUCTmacro for each struct we want to introspect. Instead, you can put an object of any POD type into themagic_get utility functions and access it like a tuple. For example:

Person author{"Jackie", "Kay", 24}; std::cout << "People are made up of " << boost::pfr::tuple_size<Person>::value << " things.\n" std::cout << boost::pfr::get<0>(author) << " has a lot to say about reflection\n"! // People are made up of 3 things. // Jackie has a lot to say about reflection! 

Another exciting thing about magic_get is that it provides a C++17 implementation that uses no macros, only templates and the new structured bindings feature.

Let’s take a peek under the hood to see how Antony achieves this:

template <class T> constexpr auto as_tuple_impl(T&& /*val*/, size_t_<0>) noexcept { return sequence_tuple::tuple<>{}; } template <class T> constexpr auto as_tuple_impl(T&& val, size_t_<1>) noexcept { auto& [a] = std::forward<T>(val); return ::boost::pfr::detail::make_tuple_of_references(a); } template <class T> constexpr auto as_tuple_impl(T&& val, size_t_<2>) noexcept { auto& [a,b] = std::forward<T>(val); return ::boost::pfr::detail::make_tuple_of_references(a,b); } 

These specializations of as_tuple_impl repeat this same pattern for a total of 101 specializations (some liberty was taken with line breaks):

template <class T> constexpr auto as_tuple_impl(T&& val, size_t_<100>) noexcept { auto& [ a,b,c,d,e,f,g,h,j,k,l,m,n,p,q,r,s,t,u,v,w,x,y,z,A,B,C,D,E,F,G,H,J,K,L,M,N, P,Q,R,S,T,U,V,W,X,Y,Z, aa,ab,ac,ad,ae,af,ag,ah,aj,ak,al,am,an,ap,aq,ar,as,at,au,av,aw,ax,ay,az, aA,aB,aC,aD,aE,aF,aG,aH,aJ,aK,aL,aM,aN,aP,aQ,aR,aS,aT,aU,aV,aW,aX,aY,aZ, ba,bb,bc,bd ] = std::forward<T>(val); return ::boost::pfr::detail::make_tuple_of_references( 
 类似资料:

相关阅读

相关文章

相关问答