I have some serialization code to persist an object into a DB. This works by mapping an enum value, representing the column, to a member of the class. The type information is preserved this way, so I know how to write the blob.
// maps each field to a column based on the DbColumns enum - returns a reference to the field that represents the column
template<DbColumns Column>
auto& GetColumn()
{
using enum DbColumns;
if constexpr(Column == timestamp) return _timestamp;
if constexpr(Column == id) return _id;
}
I am hoping that I can also use this to deserialize the blob back to the object - I wouldn't need to manually write a constructor that takes N columns and have to check and write each underlying type (there could be 10+ columns). In the deserialization process, I know what column I'm reading at the time, and thus its type. So I can just call GetColumn() and assign it to the new value. The problem with this is that the object must be default constructed, and then each field assigned. This ends up default-initializing every member, only to immediately overwrite them with a value.
Using a templated constructor and an index sequence, I can get partially the way there, but I can only call GetColumn() in the body of the ctor, which means each field still needs to be default-initialized and then immediately overwritten anyway. I was hoping that doing it this way would allow the compiler to see that the default-initialized value is never read from, and could thus skip it entirely, but that doesn't appear to be the case (see example below).
// Ideal style ctor - this is basically copy/paste for all DbRecord types, independent of number of fields,
// all types are inferred by return type of GetColumn<>(), no changes needed if columns are added/removed.
// But this requires all fields to be default-initialized and then immediately overwritten.
template<size_t... Is, typename... Args>
DbRecord(std::index_sequence<Is...>, Args&&... args)
{
// fold expression to call GetColumn() on each argument, assigning each column
((GetColumn<static_cast<DbColumns>(Is)>() = std::move(args)), ...);
}
Is there something else I can do here to 'generate' a ctor with an initializer list? In this Compiler Explorer simplified example, you can see the assembly created with this approach vs a 'perfect' ctor with an initializer list: https://godbolt.org/z/9EGY93qz9 - clang generates 2x the assembly (I'm not saying that the code will execute 2x instructions - I think a lot of this is for the "if the old string is not empty, destroy it" check, which would be skipped in this case as it's impossible for the string to be non-empty).
A different approach is to make all DbRecord types store all non-primitive members as a std::optional. This leaves the default-initialized state cheaper, and overwriting isn't as expensive. But it does waste a small amount of space, is still more expensive than the ideal ctor, and requires all DbRecord types to know and use std::optional for some column types. Am I going to have to wait for reflection to do this the most efficient way?
You can do something with advanced variadic macros (e.g. from boost.preprocessor
). Below is a rough sketch of a possible solution.
The first step is to define a macro
CONSTRUCT_MEMBERS(CLS, FUNC, ARG, ...)
An invocation like CONSTRUCT_MEMBERS(MyClass, MyFunc, MyArg, a, b)
would expand to
a(MyFunc<&MyClass::a>(MyArg)), b(MyFunc<&MyClass::b>(MyArg))
Next, define a compile-time mapping between pointers-to-members of MyClass
and the column enumerators, such that getColumn<&MyClass::a>()
is a consteval function returning DbColumn::a
.
At this point you are basically done. MyFunc
template can now get to the database (passed ee.g. as MyArg) with the column enumerator and return a value to initialize the member.
You can use a similar technique to serialize your objects.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With