Store instantiations of a class template in another class template

Viewed 52

Lets say I have a template like the following

template <typename T>
struct A {};

What would be the best way to store a vector of different instantiations of this template in another class?

I have thought on something like this, but of course, it forces me to declare all the types I wan't to support, which in my particular case, can escalate quickly.

template <typename ...Args>
struct Holder
{

std::tuple<std::vector<Args>...> tup;

template <typename T>
void push_back(T &&t); // Method to insert in the appropiate vector, considering only one match
}

Given that I have push_back method which I use to insert elements into the appropiate vector in the tuple, and that I don't want any other types but the ones inserted using the push_back method, isn't there a better way to define the holder?

Edit: Ideally, I would like to be able to call a function template on the different elements of the tuple.

Generic call

template <typename T>
void foo(T t)

Specialized call for actual instantiations of A

template <typename T>
void foo(A<T> t)
1 Answers

It sounds like the pattern you're looking for is called Type Erasure -- which allows a collection of heterogeneous types to be collected and accessed in some homogeneous way.

In the comments you indicate two important components:

  1. It is all value-semantics (owning), and
  2. you are looking to non-member draw(A) function calls.

To perform type-erasure, we need to have a means of storing the object into some homogeneous type, and you will want some way to access the important functionality. Note that type-erasure often is a one-way process (e.g. once erased, it is difficult to re-extract the original type without polling. Think of how std::function operates).

Here are a few ways you can accomplish this:

Option 1: Use std::any (C++17 and up)

The somewhat modern approach would be to use C++17's std::any type in conjunction with a mechanism to extract the original type out and generate the desired call. The easiest mechanism is to use function pointer, which will point to a static function template to retain the original type. Consider the following (documented) example:

template <typename T>
class A { /* ... */ };

auto draw(const A<int>&) -> void;
auto draw(const A<long>&) -> void;
// ...

// Named 'drawable' since you indicated 'draw(A)' calls
class Drawable
{
private:

    // A handler function to perform the operation.
    // Each pointer will "know" the original T type
    using handler_fn_t = auto (*)(const std::any&) -> void;

    std::any m_storage;
    handler_fn_t m_handler;
   
    // The handler function. This knows the original type bound
    // to the std::any for an easy cast back, and then calls the
    // desired function.
    template <typename T>
    static auto type_stub(const std::any& storage) -> void {
        const auto* p = std::any_cast<T>(&storage);
        
        // Call the free-function named 'draw'
        draw(*p);
    }

public:

    // A constructor that just constructs the object
    // This uses SFINAE to constrain to functions where 'draw(...)' is 
    // well-formed. If you are using C++20, this can be a "requires" clause.
    template <typename T, typename = decltype(draw(std::declval<const T&>()))>
    Drawable(const T& t)
        : m_storage{t}, m_handler{type_stub<T>}
    {
    }

    Drawable(const Drawable&) = default;    

    // The draw function. This just calls the underlying handler
    auto draw() const -> void {
        // Call the function stub
        (*m_handler)(m_storage);
    }
};

Live Example

The above assumes only 1 single function is used, with one specific mutability (const). If more functionality is desired, the function-pointer technique can be modified to provide a "payload" parameter and an "operation" enum to discriminate the value. The "payload" supplies the correct CV-qualified any for a given operation, which saves const_casting:

class Drawable
{
private:
    enum class operation {
        draw,
        get_something,
        set_something,
    };

    // Modified to take an "operation" and "void*" param
    using handler_fn_t = auto (*)(operation, void*) -> void;

    struct draw_payload {
        const std::any& storage;
    };
    struct get_payload {
        const std::any& storage;
        int some_value_to_return;
    };
    struct set_payload {
        std::any& storage;
        int some_value_to_set;
    };

    std::any m_storage;
    handler_fn_t m_handler;
   
    // Modified to take the operation and the payload
    template <typename T>
    static auto type_stub(operation op, 
                          void* payload) -> void 
    {
        const auto* p = std::any_cast<T>(&storage);
        switch (op) {
            case operation::draw: { 
                auto* payload_type = static_cast<draw_payload*>(payload); 
                draw(*std::any_cast<T>(&payload_type->storage));
                break;
            }
            case operation::get_something: { 
                auto* payload_type = static_cast<get_payload*>(payload);
                out_payload->some_value_to_return = p->get_value();
                break;
            }
            case operation::set_something: { 
                auto* payload_type = static_cast<set_payload*>(payload);
                p->set_value(in_payload->some_value_to_set);
                break;
            }
        }
    }

public:
    
    ...

    // The draw function. This just calls the underlying handler
    auto draw() const -> void {
        auto payload = draw_payload{m_storage};
        // Call the function stub
        (*m_handler)(operation::draw, &payload);
    }
    auto get() const -> int {
        auto payload = get_payload{m_storage};
        (*m_handler)(operation::get, &payload);
        return payload.some_value_to_return;
    }
    auto set(int value) -> void {
        auto payload = set_payload{m_storage, value};

        (*m_handler)(operation::set, &payload);
    }
};

Live Example

Overall, this is a very powerful technique.

Option 2: Create a hierarchy via templates

The alternative option is a bit more natural to write, but leverages a virtual hierarchy and heap memory with unique_ptr.

The idea is simply to take a T type in a class, and generate both a desired interface, and an implementation that wraps the T type into something that satisfies that interface.

Using the second example from above, it could also be written:

class Drawable
{
private:

    // The interface we want each 'T' to subscribe to
    struct Interface {
        virtual ~Interface() = default;
        virtual auto draw() const -> void = 0;
        virtual auto get() const -> int = 0;
        virtual auto set(int) -> void = 0;
    };

    // An implementation that satisfies the interface, in terms of T
    template <typename T>
    struct Concrete : Interface {
        explicit Concrete(const T& v) : value{v}{}

        T value;
        auto draw() const -> void override { return ::draw(value); }
        auto get() const -> int override { return value.get_value(); }
        auto set(int v) -> void override { value.set_value(v); }
    };
    
    // Something to hold onto the T type.
    // This doesn't necessarily have to be a unique_ptr; just something
    // that holds onto the interface in some way.
    std::unique_ptr<Interface> m_interface;

public:
  
    // Similar constructor to before; we create a concrete pointer, and let
    // it erase into the Interface
    template <typename T, typename = decltype(draw(std::declval<const T&>()))>
    Drawable(const T& t)
        : m_interface{std::make_unique<Concrete<T>>(t)}
    {
    }

    Drawable(Drawable&&) = default;

    // Just call the basic underlying types
    auto draw() const -> void {
        m_interface->draw();
    }
    auto get() const -> int {
        return m_interface->get();
    }
    auto set(int v) -> void {
        m_interface->set(v);
    }
};

Live Example

As you can see, this approach is more "natural" to write -- but also generally necessitates some form of allocated memory of some kind, which may lead to fragmentation and may be undesirable. This could also be done with a fixed-sized buffer and a pointer, but this would lead to some redundancy as well.


These are just some possible approaches at solving the problem as described

Related