USCiLab / cereal

A C++11 library for serialization
BSD 3-Clause "New" or "Revised" License
4.22k stars 758 forks source link

Proposal: Serializable macro #294

Open davjs opened 8 years ago

davjs commented 8 years ago

Manually written serialization code is error prone because it requires replicating information. Especially when the goal is to serialize every member of a struct/class. Whenever the members are changed the serialization method(s) need to as well. More modern languages has solutions for this using reflection. C# uses a Serializable attribute, Java let's you extend/implement a XSerializable and dynamic languages usually work out of the box.

I started looking into the possibilities of simulating reflection in C++ and stumbled over this SO answer. http://stackoverflow.com/questions/11031062/c-preprocessor-avoid-code-repetition-of-member-variable-list/11744832#11744832 It's a macro designed to look like member variable declarations that generates a "reflection table" in addition to the members. After a while i realized we could do something similar but much more lightweight. A macro that just generates the serialization function in place.

The following:

    struct Car{
        SERIALIZABLE
        (
            (std::string) name,
            (int) age,
            (bool) isBest
        )
    };

Would then expand to:

    struct Car{
        std::string name;
        int age;
        bool isBest;
        template <class Archive>
        void serialize( Archive & ar ) const {
          ar( ::cereal::make_nvp("name",name),
              ::cereal::make_nvp("age",age),
              ::cereal::make_nvp("isBest",isBest));
        }
    };

Benefits with this approach

Downsides

The implementation can be found in here: https://github.com/davidkron/cereal/blob/master/include/cereal/serializable.hpp

AzothAmmo commented 8 years ago

I like the idea of this as an optional way of using cereal. We can't have boost be a requirement however, as cereal needs to be self contained.

If can't be shed as a requirement, this could potentially fit in better with the idea of 'modules' for cereal (see the next milestone) which would be self-contained additions to the library.

Annoying cases users might eventually request of a feature like this:

Devacor commented 8 years ago

Something interesting and semi-related (just considering the syntax you've got proposed matches identically), Boost::Hana introduces the ability to define structs with metadata in a similar way: http://www.boost.org/doc/libs/1_61_0/libs/hana/doc/html/index.html

There's a fantastic talk as well, I highly suggest watching it: https://channel9.msdn.com/Events/CPP/CppCon-2015/CPPConD04V003

I'm not sure if it's super applicable, but if you're going to introduce a macro anyway, it might be nice to look at what BOOST_HANA_DEFINE_STRUCT does and consider exposing your members to a reflection interface instead of just a bare call to archive which is way less useful imho.

// 1. Give introspection capabilities to 'Person'
struct Person {
  BOOST_HANA_DEFINE_STRUCT(Person,
    (std::string, name),
    (int, age)
  );
};
// 2. Write a generic serializer (bear with std::ostream for the example)
auto serialize = [](std::ostream& os, auto const& object) {
  hana::for_each(hana::members(object), [&](auto member) {
    os << member << std::endl;
  });
};
// 3. Use it
Person john{"John", 30};
serialize(std::cout, john);
// output:
// John
// 30

As it stands, I'm happily using cereal because it's very flexible and I use load_and_construct and some other customizations which let me add/extract information from an archive to reconstitute things in their context. I couldn't benefit from such a macro except in the most trivial cases of my "Point" class (x, y, z) and a few other tiny edge cases... But perhaps I could benefit from added reflection which handles serialization, but which also allows me to hook up something like chaiscript.

Right now I have a bunch of code that looks like this:

    class TexturePoint{
    public:
        TexturePoint(PointPrecision a_textureX, PointPrecision a_textureY):textureX(a_textureX), textureY(a_textureY){}
        TexturePoint():textureX(0.0),textureY(0.0){}
        ~TexturePoint(){}

        TexturePoint& operator=(const TexturePoint& a_other);
        TexturePoint& operator=(const DrawPoint& a_other);

        bool operator==(const TexturePoint& a_other) const{
            return equals(textureX, a_other.textureX) && equals(textureY, a_other.textureY);
        }
        bool operator!=(const TexturePoint& a_other) const{
            return !(*this == a_other);
        }

        template <class Archive>
        void serialize(Archive & archive){
            archive(CEREAL_NVP(textureX), CEREAL_NVP(textureY));
        }

        PointPrecision textureX, textureY;

        static chaiscript::ChaiScript& hook(chaiscript::ChaiScript &a_script) {
            a_script.add(chaiscript::user_type<TexturePoint>(), "TexturePoint");
            a_script.add(chaiscript::constructor<TexturePoint()>(), "TexturePoint");
            a_script.add(chaiscript::constructor<TexturePoint(PointPrecision, PointPrecision)>(), "TexturePoint");
            a_script.add(chaiscript::fun(&TexturePoint::textureX), "textureX");
            a_script.add(chaiscript::fun(&TexturePoint::textureY), "textureY");
            a_script.add(chaiscript::fun(&TexturePoint::operator==), "==");
            a_script.add(chaiscript::fun(&TexturePoint::operator!=), "!=");
            return a_script;
        }
    };

Note the base definition of: textureX, textureY and then the chaiscript hook binding textureX, textureY, and finally cereal archive(textureX, textureY). While no bit of code in this is complex, this is the simplest example of a serializable and scriptable object that exists in my code. I made the decision to make everything in my scene graph save-able, load-able, and script-able. I've thought about how reflection could vastly help, but it really isn't quite there in C++ yet. I just saw the hana video though and am pretty impressed.

volcoma commented 8 years ago

I think i have a better solution for this. I have the following macro

#include <Serialization/cereal/cereal.hpp>

#define SERIALIZABLE(T) \
public:\
friend class cereal::access;\
template<typename Archive> friend void CEREAL_SAVE_FUNCTION_NAME(Archive & ar, T const &);\
template<typename Archive> friend void CEREAL_LOAD_FUNCTION_NAME(Archive & ar, T &);

usage is the following: In your normal header

class MyClass
{
    SERIALIZABLE(MyClass)
...
};

then in a COMPLETELY seperate file that you can change without the need to recompile the original class header every time you need to change something, thus saving a lot of compile time and achieving non extrusive serialization. You also dont't break the existing interface of the class this way

template<typename Archive> inline \
void CEREAL_SAVE_FUNCTION_NAME(Archive & ar, TransformComponent const & obj)
{
    ar(
        cereal::make_nvp("base_type", cereal::base_class<Component>(&obj)),
        cereal::make_nvp("hierarchy_level", obj.mHierarchyLevel),
        cereal::make_nvp("local_transform", obj.mLocalTransform),
        cereal::make_nvp("children", obj.mChildren)
        );

}

template<typename Archive> inline \
void CEREAL_LOAD_FUNCTION_NAME(Archive & ar, TransformComponent & obj)
{
    ar(
        cereal::make_nvp("base_type", cereal::base_class<Component>(&obj)),
        cereal::make_nvp("hierarchy_level", obj.mHierarchyLevel),
        cereal::make_nvp("local_transform", obj.mLocalTransform),
        cereal::make_nvp("children", obj.mChildren)
        );

}

#include <Serialization/Archives.hpp>
CEREAL_REGISTER_TYPE(TransformComponent);