Yet another reflection system for C++.
C++ already has several excellent reflection systems so why build a new one? To put it simply, Reflect does things differently:
- It reflects functions
- It uses a macro-based type description DSL (domain-specific language)
We'll dig into these features and see exactly why it makes Reflect extra special but first, here's a few more buzzword to pad out the description:
- C++11
- Extensible
- Non-intrusive
- Lazy type loading
- Run-time reflection
- Self-contained (no external tool required)
- Imbued with dark template voodoo-magic
Before we get started, note that reflection systems are usually meant as a mean to an end. In other words, they provide basic tools that can be used by library writers to construct libraries for their users. As an example, Reflect includes a configuration library which uses a json file to create, fill and link a bunch of objects at runtime. This is then used in the cubes and tubes example to build a simple pipeline framework from a json file.
It all started as an experiment to see if it was possible to reflect any given function and make a call to it in a type-safe manner. After having made the required sacrifice to the proper template voodoo gods, Reflect was born.
Here's a small taste of function reflection:
struct Foo
{
int bar(int a, int b) const { return a + b; }
int baz;
};
reflectType(Foo)
{
reflectPlumbing();
reflectField(baz);
reflectFn(bar);
}
Value foo = type("Foo")->construct();
int sum = foo.call<int>("bar", 1, foo.call<Value>("bar", 10, 100));
assert(sum == 111);
As a C++ programmer, those last 3 lines of code might feel a little... off. It's not something we're used to being able to do and, to make things even more weird, those lines are also completely type-safe which means that Reflect will raise an error if it detects an attempt at making an invalid function call.
To get a better idea of what's going on, let's ask Reflect to dump what it knows
about the Foo
class.
std::cerr << type("Foo")->print() << std::endl;
type Foo
{
bar:
int(Foo const&, int, int)
baz:
traits: [ field ]
int&(Foo&)
int const&(Foo const&)
void(Foo&, int)
operator=:
Foo&(Foo&, Foo const&)
Foo&(Foo&, Foo&&)
Foo:
Foo()
Foo(Foo const&)
Foo(Foo&&)
}
Well this is interesting; there are only functions here even for our baz
field
which was just a plain int
in the original type. Not only that but Reflect
also keeps track of the reference-ness and the const-ness of each function
parameter. In other words, whenever a reflected object is manipulated, it is
done through function calls which are checked for type, reference-ness and
const-ness at runtime. The rules emulated are relatively similar to what you
would expect from a regular function call which means that the proper hand-off
method (copy, move or reference) is used for the given argument-parameter pair.
What about that traits thing? Traits in Reflect are tags that can be associated
with types and functions. They allow library writers to define concepts which
can be used to create generic utilities. As an example, the field
trait
indicates that either a getter or setter function is available in the overload
pool. This can then used be used by utilities like json
and
path
to list the members of an object and navigate
it completely generically.
The list of features supported by Reflect is actually quite a bit longer then what I can fit here but you can find a brief overview in this example. For those who are curious to know what makes Reflect tick, head over to value_function.h and enjoy the templates.
The sad reality of C++ is that we have no built-in type introspection mechanism which means that any reflection system will need to figure out a way to extract a type's make up. Historically, reflection systems have used external tools to process either a DSL file or the original source code.
Reflect takes another approach: it uses macros to define a DSL which is compiled alongside the reflected type. The advantage is that we re-use all the existing machinery of the tool-chains that is already familiar to the user. As a bonus, this allows the DSL to use C++'s template-based introspection mechanisms to simplify our DSL down to its bare essentials.
The result, as we've seen in the example of the previous section, is pretty
clean and simple. It's also easily extensible because the macros use the same
simple interface that is available to the user of the library. As an example,
let's enhance the reflectField
macro so that it also adds a text description
for the field:
#define reflectFieldDesc(field, desc) \
do { \
reflectField(field); \
type_->add(#field "_desc", [] { return std::string(desc); }); \
} while(false)
Now let's modify our reflection of Foo
to make use of our new macro:
reflectType(Foo)
{
reflectPlumbing();
reflectFieldDesc(baz, "well, that was easy");
reflectFn(bar);
}
auto desc = type<Foo>()->call<std::string>("baz_desc");
assert(desc == "well, that was easy");
The key take-away here is that, if you can represent an extension as a function, then Reflect supports it with no modifications to the core. Handy.
While Reflect is pretty nice overall, it does have one major downside:
It makes compilers cry.
In other words, the build time for reflecting types can be quite high. Because of this, there's been a considerable amount of effort dedicated to optimizing for compilation times using a suite of benchmark tests. Note that this is an ongoing area of development and there are known compilation bottlenecks which still need to be resolved (eg. reflection of templated classes).
While compilation times can be high when reflecting types, Reflect allows for the separation of the declaration of the reflection from its definition. This means that definitions, which are expensive, will only need to be compiled once while the declaration, which are cheap, can be used many times at no additional cost. It also helps if the definitions are spread out over multiple compilation units to parallelize the compilation.
The requirements for Reflect are a C++11 compliant compiler and any version of
boost that has boost-test
. Here's a rundown of known compilers that can
compile Reflect:
- gcc v4.7+
- clang v??
- mvcc v??
Reflect uses cmake
as its build system which should be available in your
distribution's package manager or at the
cmake website. Once that's dealt with, building is
straightforward:
cmake .
make -kj8
make test
This will produce the following libraries in the lib
folder which you'll want
to link with:
libreflect.so
libreflect_std.so
libreflect_reflect.so
libreflect_primitives.so
The headers for Reflect are in the src
folder and organized into one main header
and 2 sub-folders. Here's what a typical include set might look like.
#include "reflect.h"
#include "dsl/all.h"
#include "types/primitives.h"
#include "types/std/string.h"
The reflect.h
header includes the core library, the dsl
folder contains the
macros for the DSL and the types
folder contains the default reflection for
commonly used types.
That's it!