C++26: std::polymorphic

Sandor Dargo’s Blog ·

6 min read Original article ↗

In the previous article, we looked at std::indirect — C++26’s answer to the “I need a heap-allocated member with value semantics” problem. indirect<T> always stores exactly a T. But what if you need to store a Circle or a Rectangle through a Shape base class — and copy it without slicing?

That’s std::polymorphic<T>, the other half of P3019R14. It owns a heap-allocated object whose dynamic type may be T or any type derived from T. Copying performs a type-erased deep copy that preserves the dynamic type. No virtual clone() needed.

The clone() tax

To have a copyable collection of polymorphic objects, every derived class needs a virtual clone() method. Let’s see what that looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Shape {
public:
    virtual ~Shape() = default;
    virtual std::unique_ptr<Shape> clone() const = 0;
    virtual double area() const = 0;
};

class Circle : public Shape {
    double radius_;
public:
    explicit Circle(double r) : radius_(r) {}
    std::unique_ptr<Shape> clone() const override {
        return std::make_unique<Circle>(*this);
    }
    double area() const override { return 3.14159 * radius_ * radius_; }
};

class Rectangle : public Shape {
    double w_, h_;
public:
    Rectangle(double w, double h) : w_(w), h_(h) {}
    std::unique_ptr<Shape> clone() const override {
        return std::make_unique<Rectangle>(*this);
    }
    double area() const override { return w_ * h_; }
};

Now you want a Picture that owns a collection of shapes with value semantics:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// full example at https://godbolt.org/z/Evz8b7r8o
class Picture {
    std::vector<std::unique_ptr<Shape>> shapes_;
public:
    Picture(const Picture& other) {
        shapes_.reserve(other.shapes_.size());
        for (const auto& s : other.shapes_)
            shapes_.push_back(s->clone());
    }
    Picture& operator=(const Picture& other) {
        auto tmp = other;          // copy-and-swap
        swap(shapes_, tmp.shapes_);
        return *this;
    }
    // ... move constructor, move assignment, destructor
};

Every new shape has to implement clone(). Forget it once and you get slicing. The Picture class needs all five special member functions written by hand. This is a lot of ceremony for something conceptually simple: “I want a copyable collection of polymorphic objects.”

Dropping the boilerplate with std::polymorphic

std::polymorphic<T> eliminates all of that. The shapes don’t need clone(), Picture doesn’t need any special member functions, and — perhaps surprisingly — Shape doesn’t even need a virtual destructor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <memory>

class Shape {
protected:
    ~Shape() = default;  // no virtual destructor needed!
public:
    virtual double area() const = 0;
};

class Circle : public Shape {
    double radius_;
public:
    explicit Circle(double r) : radius_(r) {}
    double area() const override { return 3.14159 * radius_ * radius_; }
};

class Rectangle : public Shape {
    double w_, h_;
public:
    Rectangle(double w, double h) : w_(w), h_(h) {}
    double area() const override { return w_ * h_; }
};

class Picture {
    std::vector<std::polymorphic<Shape>> shapes_;
public:
    void add_circle(double r) {
        shapes_.emplace_back(std::in_place_type<Circle>, r);
    }
    void add_rectangle(double w, double h) {
        shapes_.emplace_back(std::in_place_type<Rectangle>, w, h);
    }

    double total_area() const {
        double sum = 0;
        for (const auto& s : shapes_)
            sum += s->area();
        return sum;
    }

    size_t size() const { return shapes_.size(); }

    // ALL special member functions are compiler-generated.
    // Copying deep-copies every shape, preserving its dynamic type.
};

No clone(). No Rule of Five. Copying a Picture copies every shape — a Circle is copied as a Circle, a Rectangle as a Rectangle. The type-erasure machinery inside polymorphic handles this automatically. The only requirement is that every stored type must be copy-constructible.

Deep copies just work

1
2
3
4
5
6
7
8
9
10
Picture a;
a.add_circle(5.0);
a.add_rectangle(3.0, 4.0);

Picture b = a;  // deep copies both shapes, preserving their dynamic types
a.add_circle(1.0);

assert(a.size() == 3);  // a has three shapes
assert(b.size() == 2);  // b is unchanged — it's an independent copy
assert(a.total_area() != b.total_area());

With unique_ptr this would require the hand-written copy constructor we saw above.

No virtual destructor needed

Notice that Shape’s destructor is protected and non-virtual in the polymorphic version. Because polymorphic uses type erasure for both destruction and copying, it knows the actual dynamic type and can call the right destructor directly — no vtable dispatch needed. This is a genuine safety improvement: you can’t accidentally delete a Shape* and get undefined behaviour, because nobody ever holds a raw Shape*.

Const propagation

Just like std::indirect, polymorphic propagates const correctly. Accessing through a const polymorphic<Shape>& yields a const Shape&. In the Picture example, total_area() is a const method, and s->area() correctly resolves to the const overload of area(). If Shape had a non-const mutating method, calling it through a const Picture would be a compile error — the same improvement over unique_ptr that we discussed in the previous article.

What polymorphic does not provide

Unlike indirect, polymorphic has no comparison operators and no hash support. The reason is straightforward: the dynamic type is erased. There is no way to forward == or <=> to the actual stored object without requiring virtual comparison methods on the base class, which the committee considered out of scope.

There is also no perfect-forwarded assignment (operator=(U&&)) — again because the type information is erased at runtime.

The valueless state

Like indirect, polymorphic has no null state. A polymorphic<T> always owns an object — except after being moved from, where valueless_after_move() returns true. If you need nullable polymorphic indirection, use std::optional<std::polymorphic<T>>.

Choosing between indirect and polymorphic

Now that we’ve seen both types, here’s a quick summary of when to use which:

ScenarioType
PIMPL, recursive types, large membersstd::indirect<T>
Need comparison/hash on the owned objectstd::indirect<T>
Open set of derived types, polymorphic collectionsstd::polymorphic<T>
Closed set of known typesstd::variant<A, B, C>
Nullable indirectionstd::optional<indirect<T>> or std::optional<polymorphic<T>>
Shared ownershipstd::shared_ptr<T> (as before)

The simplest mental model: indirect is for when you know the exact type but need it on the heap. polymorphic is for when you don’t.

Conclusion

std::polymorphic eliminates the most tedious pattern in object-oriented C++: writing clone() methods and Rule of Five boilerplate just to have a copyable collection of polymorphic objects. The type-erasure approach means no virtual destructor, no virtual clone(), and no hand-written special member functions. Together with std::indirect, it completes the value-semantic indirection story that has been missing from the standard library since smart pointers were introduced.

Connect deeper

If you liked this article, please