Reflection and code generation in C ++
The following describes implementation details and a demo project. Who cares - welcome to cat.
Requirements
There is an opinion that correctly posed problem conditions practically solve this problem. That is why, before the start of implementation, the necessary requirements and restrictions were collected:
- The minimum functionality. The user must have access to the types of objects and their inheritance. An object type must provide information about members, its attributes, and class methods. It also requires functionality to change the values of class members and method calls through reflection.
- Easy to use. The use of reflection should not burden the syntax with any difficulties.
- Performance. Working with reflection should not significantly affect the performance of the application.
Syntax
Following the simplicity requirement, a fairly simple syntax for defining an object supporting reflection was developed: it is necessary that the class be inherited from IObject , contain the macro IOBJECT (* class name *) in the body ; and class members were indicated with the necessary attributes through comments.
Example of a class with reflection support:
class MyClass: public IObject
{
IOBJECT(MyClass);
float mSomeFloat; // @SERIALIZABLE
int mSomeInteger; // @SOME_ATTRIBUTE
MyClass* mSomeObject;
void Func(int abc) { ... }
};
Examples of using reflection:
// Объявим экземпляр класса
MyClass sample;
// Получаем тип класса
Type& myClassType = MyClass::type;
// Получаем тип класса из объекта
Type& myClassTypeToo = sampe.GetType();
// Создать экземпляр класса MyClass
IObject* newSample = myClassType.CreateSample();
// Получение члена класса
FieldInfo* fieldInfo = myClassType.Field("mSomeFloat");
// Получение значения члена класса
float fieldValue = fieldInfo->GetValue(&sampe);
// Присваивание значения члену класса
fieldInfo->SetValue(&sample, 36.6f);
// Получим функцию из типа
FunctionInfo* funcInfo = myClassType.GetFunction("Func");
// Вызовем функцию
funcInfo->Invoke(&sample, 123);
Implementation
As you probably already guessed, everything works simply: the user declares classes that support reflection, and a separate utility generates code that initializes the types in the system. In this article I will describe the principle of operation, with minimal inclusion of the source code, since its presence will inflate the article. But the article comes with a demo project with all the source code, which you can see and test.
So, more about the structure of reflection.
Object type
Each class has a type description that contains the class name, inheritance information, members and methods of the class. Using the type, you can instantiate the class. Also in the class type there is a rather interesting functionality: you can get a pointer to a class member by its path and vice versa, get the path to a class member by its pointer. To understand what the last two functions do, just look at an example of use:
class A;
class B;
class MyClass: public IObject
{
IOBJECT(MyClass);
A* aObject = new A();
};
class A: public IObject
{
IOBJECT(A);
B* bObject = new B();
};
class B: public IObject
{
IOBJECT(A);
int value = 777;
};
MyClass sample;
// Получаем путь к члену класса:
string path = sample.GetType().GetFieldPath(&sample->aObject->bObject->value);
// Получаем указатель на член класса:
int* ptr = sample.GetType().GetFieldPtr("aObject/bObject/value");
Why is this needed? The closest example is animation. Suppose there is an animation that animates the value parameter from the example. But how to save such an animation to a file? This is where these functions are needed. We save the animation keys with the path to the animated parameter “aObject / bObject / value”, and when loading from the file along this path we find the variable we need. This approach allows you to animate absolutely any members of any objects and save / load to a file.
However, there is a small drawback that needs to be considered. Searching for a pointer to a class member along the way is fast and linear, but the reverse process is completely non-linear and can take a lot of time. The algorithm has to “comb through” all the members of the class, their members, and so on, until it encounters the pointer we need.
Class members
Each member of the class is defined using the FieldInfo type , which contains the type of member, its name and a list of attributes. Also, the type of a class member allows you to get the value of a class member for a specific instance and vice versa, assign a value. Type, name and attributes are filled with the generated code. Assigning and getting a value works through address arithmetic. At the type initialization stage, the offset in bytes relative to the beginning of the memory for the object is calculated, then, when assigning or receiving a value, this offset is added to the address of the object.
Class functions
Each function of the class corresponds to an object with the FunctionInfo interface , which stores the type of the return value, the name of the function, and a list of parameters. Why exactly an interface and how to call a function from this interface? To call a function, we need a pointer to it. Moreover, the pointer to the class function should be of this kind:
_res_type(_class_type::*mFunctionPtr)(_args ... args);
To store such pointers, classes are defined, template classes are defined:
template
class ISpecFunctionInfo: public FunctionInfo
{
public:
virtual _res_type Invoke(void* object, _args ... args) const = 0;
};
template
class SpecFunctionInfo: public ISpecFunctionInfo<_res_type, _args ...> { ... };
template
class SpecConstFunctionInfo: public ISpecFunctionInfo<_res_type, _args ...> { ... };
Template magic is scary, but it works quite simply. At the type initialization stage, an object of the SpecFunctionInfo or SpecConstFunctionInfo class is created for each function, depending on the constancy of the function and placed in the type of the Type object . Then, when we need to call a function from the FunctionInfo interface , we statically convert the interface to ISpecFunctionInfo and call the virtual function Invoke on it , an overridden version of which performs the function at the saved address.
Attributes
The attribute function is to add meta information for class members. Using attributes, you can specify the serializable members, the type of serialization, display in the editor, animation parameters, and so on. Attributes are inherited from the IAttribute interface and added to class members at the type initialization stage. The user only needs to indicate the necessary attributes through comments on class members.
Reflection module
So, we have the whole type structure, it is necessary to store all this somewhere. The singleton Reflection class handles this simple task . With it, you can get the type by name, create an instance of the type, or get a list of all registered types.
Code Generation
Why code generation? There are reflection solutions using tricky macros, prescribing serialization functions, and the like. But such approaches have one common minus - in fact, you have to manually describe the types. There is no way to get rid of this in reflection, so it’s easiest to entrust this routine to a separate routine that works before compilation and generates a separate source file with type initialization.
In a good way, the compiler must of course do this, because it has all the information about classes, members, and methods. But the solution should not be dependent on the compiler, in the end it’s stupid to impose your specific compiler on the developer, especially for 2D games with a bunch of target platforms.
That is why code generation is a separate utility. Unfortunately, the utility in my project cannot boast of elegance and absolute stability, but on my rather rather big project it works fine. In the future, it will probably be rewritten and supplemented, since it has at least one significant minus - it is written in C #, which requires a Windows platform, or the presence of Mono. This is the path of least resistance, and I chose it because at the stage of the demo version I need as much functionality as possible in the project. Nevertheless, in this article I will describe the stages of her work and what difficulties I encountered.
The utility is divided into two stages:
- Parsing project sources
- Generation of the final source with type initialization
Parsing project sources
Here my approach is different from compilers.
- Each header file is parsed separately, without processing the inclusion of other header files. Simply put, all #include are ignored.
- A list of header files is constructed, in which there is a list of namespaces, in which a list of classes, and so on.
- The namespaces of all header files are merged and inheritance binding occurs.
- Specialization of template classes: if somewhere in the code there is a specialization of a template, a class with such specialization is created and registered.
At the output, we get all namespaces, classes, and class descriptions.
Generation of the final source with type initialization
This stage includes the generation of the following parts:
- Declaring Static Class Types
- Declaring type initialization functions: registering members, adding attributes depending on comments, registering functions and their parameters
- Initialization function of all types and inheritance binding
In the end, we get the source file with a function that initializes all types of applications. The user only needs to call it when the application starts.
Weaknesses and Future Improvements
- Of course, the code generation utility is far from perfect and will most likely be rewritten in C ++, which will reduce working time and multiplatform.
- The runtime of the utility, although small (about seven seconds on my laptop), but it is still noticeable.
- You still need to manually write the macro IOBJECT () in the body of the class. It would be nice to have it added automatically.
- The static member of the type class is public and can be changed inadvertently. You can monitor this, but no one is safe from mistakes.
- The designation of attributes through comments is fraught with typos and is not as convenient as in languages where reflection is supported. So far, I have not found an acceptable solution.
Link to the demo project
github.com
PS: Comments and suggestions for improvement are welcome!