c++oop

How to mix polymorphism and templates


I have a base class, let's call it Node for the sake of argument, that will have an arbitrary number of leaf implementations. Let's just restrict it to two for now: NodeA and NodeB. They require different arguments to construct.

I want to create a node, and I have a factory class for this purpose. The factory has a template function

template<typename NodeType, typename...Args>
Node * createNode(Args&&... args) 
{
    auto const node = new NodeType(std::forward(args)...);
    /* other stuff*/
    return node;
}

This allows me to create new node types without having to worry about adding extra create functions to the factory

auto const nodeA = factory->createNode<NodeA>("foo", 10);
auto const nodeB = factory->createNode<NodeB>("bar", "wibble", 100.0f);

But here's the twist: the actual type returned will not just depend on the NodeType parameter, but also the factory type.

class NodeA1 : public NodeA {/*stuff*/};

class Factory1 : public Factory
{
    /* C++ won't allow me to virtualise (or overload?) createNode*/
    template<typename NodeType, typename...Args>
    Node * createNode(Args&&...args)
    {
        /* If NodeType is A, I want to return new NodeA1() */
        ???
    }
};

I don't want to have an interface like

class Factory
{
    virtual Node * createNodeA(/*A args*/) = 0;
    virtual Node * createNodeB(/*B args*/) = 0;
};

because that scales badly (every factory type is affected by argument changes and has to be extended to create new types). I fear I may inevitably have to do this, but wondered if there was a cleaner way of implementing this design? Or indeed a better design!


Solution

  • The typical approach is using type-erasure, the amount of type-erasure that you use depends on what needs to be extensible and what is concrete.

    since createNode is templated, it cannot be virtual, the best it can do is to convert its arguments into a type-erased object that is later passed into a virtual function, let's call that createNode_impl.

    Each factory will use this type-erased object to resolve the type needed (NodeA, NodeB, etc..) then construct its own version of it (NodeA1, etc..)

    #include <iostream>
    #include <string_view>
    #include <memory>
    
    struct Node {
        Node() = default;
        Node(Node&&) = default;
        Node(const Node&) = default;
        Node& operator=(Node&&) = default;
        Node& operator=(const Node&) = default;
        virtual ~Node() = default;
    
        virtual void foo() = 0;
    };
    
    struct ConstructionArgs
    {
        // you need a vtable to dynamic_cast
        virtual std::string_view name() = 0;
    };
    
    
    struct NodeA : public Node
    {
        NodeA(std::string s) : data{ std::move(s) } {}
        std::string data;
    };
    
    template <typename T>
    struct SpecificConstructionArgs;
    
    template <>
    struct SpecificConstructionArgs<NodeA>: public ConstructionArgs
    {
        std::string_view name() override
        {
            return "NodeA";
        }
        std::string data = "some_data";
    };
    
    struct AbstractFactory
    {
        template <typename T, typename...Args>
        std::unique_ptr<Node> createNode(Args&&...args)
        {
            auto arg = SpecificConstructionArgs<T>{ std::forward<Args>(args)... };
            return createNode_impl(arg);
        }
        virtual std::unique_ptr<Node> createNode_impl(ConstructionArgs& args) = 0;
        virtual ~AbstractFactory() = default;
    };
    
    struct NodeA1 : public NodeA
    {
        using NodeA::NodeA;
        virtual void foo() { std::cout << "A1"; }
    };
    
    struct Factory1 : public AbstractFactory
    {
        std::unique_ptr<Node> createNode_impl(ConstructionArgs& args) override
        {
            if (auto* obj = dynamic_cast<SpecificConstructionArgs<NodeA>*>(&args))
            {
                return std::make_unique<NodeA1>( std::move(obj->data) );
            }
            return nullptr;
        }
    };
    
    int main()
    {
        Factory1 f;
        auto obj = f.createNode<NodeA>();
        obj->foo();
    }
    

    goldbolt Demo

    An alternative to dynamic_cast that doesn't use RTTI is to use std::any with a pointer (but also doesn't work across dll boundaries) or std::variant of all the argument types (will work across dll boundaries but beware of ABI compatibility) or add an enum to the ConstructArgs object.