I have a library that manages a directed acyclic graph (DAG) of generic objects. The structure is somewhat along the lines of
#include <vector>
#include <memory>
class Node
{
// depth first search visitor pattern along the lines of ...
template <typename Visitor>
void accept(Visitor& v) {
v.visit(*this);
for(auto& child: children) {
child->accept(v);
}
}
//...
protected:
virtual ~Node(){}
private:
std::vector<std::weak_ptr<Node>> children;
std::vector<std::shared_ptr<Node>> parents;
};
I now want to allow users of the library to serialize a DAG/Node using their choice of serialization mechanism. That is, I want to leave it up to the user if they want to use boost::serialize, jsoncpp, yaml-cpp or whatever they like.
Currently I am struggling of finding an appropriate design pattern. The best I could think of is the following approach, but it makes me feel a bit uncomfortable:
Since a user might create several classes derived from Node, but Node only knows about its children/parents as pointers to Node, it makes sense to me to add a virtual serialize method to Node that the user of the library could override. But what would that method return? This really depends on the serialization library the user chooses, could be YAML::Node or Json::Value, ... I want the DAG library to be agnostic of this. I can't make a virtual function a template, but I could create another dummy class
class Serialized
{
protected:
virtual ~Serialized(){}
};
using PSerialized = std::unique_ptr<Serialized>;
and have the virtual Node::serialize method return a PSerialized intance. The user of the library could then derive from Serialized and Node and create a serialization visitor:
struct MyYaml : public Serialized
{
YAML::Node node;
};
template <typename T>
PSerialized serialize(T const& t){
MyYaml x;
// ....
}
template <typename T>
struct MyNode: public Node
{
PSerialized serialize() const override
{
MyYaml root;
return ::serialize(value);
// the user could serialize adjacency information here by iterating over parents and/or children, or not, I don't care.
}
T value;
};
struct ToYamlVisitor
{
void visit(Node& node) {
auto s = node.serialize();
auto y = static_cast<ToYaml const*>(s.get());
//...
}
YAML::Node yaml;
};
YAML::Node serialize(MyNode const& node)
{
ToYamlVisitor visitor;
node.accept(visitor);
return visitor.yaml;
}
This will (probably) work, but ...
- it's a bit complicated for the user
- the
Serializedbase class is empty which makes it feel like a design flaw - The user needs to downcast the
Serializedinstance inToYamlVisitor, which also sounds like a design flaw.
The last two points are related because I wouldn't know how to write an interface for Serialized that would not require extensive glue code for the different serialization libraries. Most libraries allow to stream the serialized objects to a file or std::string or std::cout etc. and I don't want to enforce these by creating e.g. a stream operator an interface function for each.
As an alternative, I could remove the Serialized base class and have Node::serialize return an std::any, but I don't think this would really be a much cleaner solution.
Now my question is: Is it a design flaw? What different choice do I have? Is there a design pattern for this kind of situation that I don't know about/couldn't make a connection to my use case?