tldr: Use a variant or type erasure, depending on context.
What you are asking for in C++ would be described roughly as a value type or a type with value semantics. You want a type that is copyable, and copying just "does the right thing" (copies do not share ownership). But at the same time you want polymorphism. You want to hold a variety of types that satisfy the same interface. So... a polymorphic value type.
Value types are easier to work with, so they will make a more pleasant interface. But, they may actually perform worse, and they are more complex to implement. Therefore, as with everything, discretion and judgment come into play. But we can still talk about the "best practice" for implementing them.
Let's add an interface method so we can illustrate some of the relative merits below:
struct Base {
virtual ~Base() = default;
virtual auto name() const -> std::string = 0;
};
struct Derivative1: Base {
auto name() const -> std::string override {
return "Derivative1";
}
};
struct Derivative2: Base {
auto name() const -> std::string override {
return "Derivative2";
}
};
There are two common approaches: variants and type erasure. These are the best options we have in C++.
Variants
As you imply, variants are the best option when the set of types is finite and closed. Other developers are not expected to add to the set with their own types.
using BaseLike = std::variant<Derivative1, Derivative2>;
struct Host {
std::vector<BaseLike> derivativeList;
};
There's a downside to using the variant directly: BaseLike
doesn't act like a Base
. You can copy it, but it doesn't implement the interface. Any use of it requires visitation.
So you would wrap it with a small wrapper:
class BaseLike: public Base {
public:
BaseLike(Derivative1&& d1) : data(std::move(d1)) {}
BaseLike(Derivative2&& d2) : data(std::move(d2)) {}
auto name() const -> std::string override {
return std::visit([](auto&& d) { return d.name(); }, data);
}
private:
std::variant<Derivative1, Derivative2> data;
};
struct Host {
std::vector<BaseLike> derivativeList;
};
Now you have a list in which you can put both Derivative1
and Derivative2
and treat a reference to an element as you would any Base&
.
What's interesting now is that Base
is not providing much value. By virtue of the abstract method, you know that all derived classes correctly implement it. However, in this scenario, we know all the derived classes, and if they fail to implement the method, the visitation will fail to compile. So, Base
is actually not providing any value.
struct Derivative1 {
auto name() const -> std::string {
return "Derivative1";
}
};
struct Derivative2 {
auto name() const -> std::string {
return "Derivative2";
}
};
If we need to talk about the interface we can do so by defining a concept:
template <typename T>
concept base_like = std::copyable<T> && requires(const T& t) {
{ t.name() } -> std::same_as<std::string>;
};
static_assert(base_like<Derivative1>);
static_assert(base_like<Derivative2>);
static_assert(base_like<BaseLike>);
In the end, this option looks like: https://godbolt.org/z/7YW9fPv6Y
Type Erasure
Suppose instead we have an open set of types.
The classical and simplest approach is to traffic in pointers or references to a common base class. If you also want ownership, put it in a unique_ptr
. (shared_ptr
is not a good fit.) Then, you have to implement copy operations, so put the unique_ptr
inside a wrapper type and define copy operations. The classical approach is to define a method as part of the base class interface clone()
which every derived class overrides to copy itself. The unique_ptr
wrapper can call that method when it needs to copy.
That's a valid approach, although it has some tradeoffs. Requiring a base class is intrusive, and may be painful if you simultaneously want to satisfy multiple interfaces. std::vector<T>
and std::set<T>
do not share a common base class but both are iterable. Additionally, the clone()
method is pure boilerplate.
Type erasure takes this one step more and removes the need for a common base class.
In this approach, you still define a base class, but for you, not your user:
struct Base {
virtual ~Base() = default;
virtual auto clone() const -> std::unique_ptr<Base> = 0;
virtual auto name() const -> std::string = 0;
};
And you define an implementation that acts as a type-specific delegator. Again, this is for you, not your user:
template <typename T>
struct Impl: Base {
T t;
Impl(T &&t) : t(std::move(t)) {}
auto clone() const -> std::unique_ptr<Base> override {
return std::make_unique<Impl>(*this);
}
auto name() const -> std::string override {
return t.name();
}
};
And then you can define the type-erased type that the user interacts with:
class BaseLike
{
public:
template <typename B>
BaseLike(B &&b)
requires((!std::is_same_v<std::decay_t<B>, BaseLike>) &&
base_like<std::decay_t<B>>)
: base(std::make_unique<detail::Impl<std::decay_t<B>>>(std::move(b))) {}
BaseLike(const BaseLike& other) : base(other.base->clone()) {}
BaseLike& operator=(const BaseLike& other) {
if (this != &other) {
base = other.base->clone();
}
return *this;
}
BaseLike(BaseLike&&) = default;
BaseLike& operator=(BaseLike&&) = default;
auto name() const -> std::string {
return base->name();
}
private:
std::unique_ptr<Base> base;
};
In the end, this option looks like: https://godbolt.org/z/P3zT9nb5o