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:
- It is all value-semantics (owning), and
- 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