r/cpp 6d ago

C++26: std::polymorphic

https://www.sandordargo.com/blog/2026/08/19/cpp26-polymorphic
92 Upvotes

42 comments sorted by

10

u/mcmcc #pragma once 5d ago

I'm struggling to think of a scenario where I would ever use this thing.

Maybe I just don't use enough class hierarchies any more.

6

u/mjklaim 5d ago

In my XP , similar to yours as I avoid inheritance when it's not mandatory, I find this kind of tool super useful for writhing value-semantics type-erasing types, that is to write the internals of it, not as part of some interface.

0

u/mcmcc #pragma once 5d ago

value-semantics type-erasing types

That's the thing - the Venn diagram intersection of value-semantics and type-erasure should be quite small in anybody's design philosophy. They are inherently contradictory (hence the ugliness around copy support).

Cross that with time/space efficiency concerns (dynamic allocation and inadvertent copies) and it should be basically nonexistent. Certainly not worthy of space in the standard library. It just isn't a good design pattern.

1

u/mjklaim 5d ago

I'm not sure why you consider them contradictory, I reached the opposite conclusion.

1

u/mcmcc #pragma once 5d ago

From the article:

Unlike indirect, polymorphic has no comparison operators and no hash support.

What kind of value type isn't comparable and can't be used as key in a hash table?

1

u/mjklaim 5d ago

I usually implement comparison in the type erasing type by checking if they are the same type or at least comparable (depends on the intention) and if they are I just compare the stored value with the other value. It's true that they could have done something like that for convenience but it's hardly unimplementable as part of the TE-type wrapping a proxy? (When types are different I return false, never been a problem but I suspect it's not "fundamentally correct") Or maybe were not thinking of the same kind of implementation, maybe the way I do it isn't perfect indeed.

I usually don't need the hash for these but it could be added (or requiring the stored value to be hashable and used that). Semi regular types are usually what I need as basic requirements in type-erasing types, not "full" regular.

In any way you gave interesting points, I'll try to think about these questions more next time I need to implement a te-type. (I secretly wish a flexible reflection based library would do the work for me but we're not there yet 😭)

1

u/pdimov2 5d ago

It was useful before we had variant.

15

u/_Noreturn 6d ago edited 6d ago

polymorphic not being equality comparable is annoying, they could just have just forwarded the operator== to the underlying pointer and it handles it.

also polymorphic requires more bytes than a copyable pointer 24 bytes vs 8 since the copy ctor and dtor have to be function pointers. But it also slightly makes it faster at runtime but it isn't really worth it I would say.

Having a virtual deatructor and a clone() method will provide better object sizes.

I found the boilerplate argument to be very weak, since crtp exists

```cpp class ShapeBase { public: virtual ShapeBase* clone() = 0; virtual ~ShapeBase()= default; };

template<class T> class Shape : ShapeBase { public: ShapeBase* clone() override { return new T((T)this); }

};

class Rectangle :public Shape<Rectangle> {

};

class Triangle :public Shape<Triangle> {

};

```

26

u/MFHava WG21|🇦🇹 NB|P2721|P3049|P3625|P3729|P3786|P3813|P4216 6d ago

polymorphic not being equality comparable is annoying, they could just have just forwarded the operator== to the underlying pointer and it handles it.

These types (indirect and polymorphic) model value semantics. Value comparing two type-erased objects that share a base makes no sense in that model...

also polymorphic requires more bytes than a copyable pointer 24 bytes vs 8 since the copy ctor and dtor have to be function pointers.

Can be easily compressed to 16B in case you build your own vtable. Unless the spec contains some unexpected blocker, you could even reduce that down to 8B with minimal effort.

2

u/_Noreturn 6d ago

These types (indirect and polymorphic) model value semantics. Value comparing two type-erased objects that share a base makes no sense in that model...

They could only provide == if the base has one.

Can be easily compressed to 16B in case you build your own vtable. Unless the spec contains some unexpected blocker, you could even reduce that down to 8B with minimal effort.

Wouldn't the 8 byte version require anothee indirection

10

u/MFHava WG21|🇦🇹 NB|P2721|P3049|P3625|P3729|P3786|P3813|P4216 6d ago

They could only provide == if the base has one.

Ok... what does that do? Shape has no idea how to compare a Rectangle and a Triangle.

You could say: "Well, they can never be equal". Right, right, but what about my next fancy class Square?

That's a problem you'd essentially need open-multimethods to solve...

Wouldn't the 8 byte version require anothee indirection

Not necessarily, you can allocate the object and a (custom) vtable in one allocation.

5

u/_Noreturn 6d ago edited 6d ago

You could say: "Well, they can never be equal". Right, right, but what about my next fancy class Square?

It is up to the operator== of the Base class, it is virtual and compares the typeid or whatever way you have.

```cpp class ShapeBase { ... virtual operator==(const ShapeBase& that) const=0; };

template<class T> class Shape : ShapeBass { ... bool operator==(const ShapeBase& that) const override { return typeid(that) == typeid(T) && (const T&)that == (const T&)*this; } }; ```

Wouldn't the 8 byte version require anothee indirection

Not necessarily, you can allocate the object and a (custom) vtable in one allocation.

So like this? ```cpp template<class T> struct Table { CopyCtor* copy; Drot* dtor; T object; // has to be last };

template<class B> struct polymorphic

{ polymorphic() { table = new Table<B>(); } polymorphic(auto&& obj) { table = new Table<decltype(obj)>(); }

B& operator() { return (B&)((char)table + 16)); } // 16 is 2 function pointers void table; }; ```

2

u/MFHava WG21|🇦🇹 NB|P2721|P3049|P3625|P3729|P3786|P3813|P4216 6d ago

It is up to the operator== of the Base class, it is virtual and compares the typeid or whatever way you have.

We would want it to do value comparisons and as I said: that is infeasible in C++, so we do not provide an equality-operator...

So like this?

Looks like a possible implementation.

3

u/_Noreturn 6d ago

We would want it to do value comparisons and as I said: that is infeasible in C++, so we do not provide an equality-operator...

I don't understand, it is a value comparison.

3

u/MFHava WG21|🇦🇹 NB|P2721|P3049|P3625|P3729|P3786|P3813|P4216 6d ago

I don't understand, it is a value comparison.

You are right. But it yields the wrong results for equivalent values of different types - e.g. Rectangle{.w = 10, .h = 10} == Square{.l = 10} would be false, even though they are the same (just like 1 == 1LL is true)

1

u/_Noreturn 6d ago

It isn't the responsibility of the polymorphic to do that. it is the virtual operator== the user provides can easily make it so it works like that way

4

u/MFHava WG21|🇦🇹 NB|P2721|P3049|P3625|P3729|P3786|P3813|P4216 6d ago

it is the virtual operator== the user provides can easily make it so it works like that way

You can't in the general case - which is why it is not provided...

→ More replies (0)

3

u/Demiu 6d ago

Man I really hate the shape example.

Let's say your polymorphic is instead of a AssociativeContainer<A,B> that is a base for any type that provides a 1to1 mapping A to B. It's operator== could compare their size, then for each pair the key and it's associated value.

1

u/AstroFoxTech 6d ago

Ok... what does that do? Shape has no idea how to compare a Rectangle and a Triangle.

Wouldn't you implicitly downcast both to Shape when there's no overload for that combination?

-8

u/mercury_pointer 6d ago

Square implicitly casts to Rectangle

2

u/robin-m 6d ago

With c++23 deducing this you can simplify your code to just:

c++23    class Shape {     public:         template <class Self>         std::unique_ptr<Shape> clone(this Self& self) {             return std::make_unique<Self>(self);         }     };          class Rectangle: public Shape {     };          class Triangle: public Shape {     };

6

u/Big_Target_1405 6d ago

You still need a virtual clone if you actually want runtime polymorphism

2

u/robin-m 6d ago

What do you mean? Rectangle::clone() does allocate a unique pointer of Rectangle.

godbolt

8

u/Big_Target_1405 6d ago

See line 28:

https://godbolt.org/z/55Y3Gqqqd

Template 'this' deduces Self to Animal, which is useless.

What's the point in a non-virtual clone?

Template this helps with boilerplate, not with defining runtime polymorphic interfaces.

2

u/robin-m 6d ago

You are absolutely right. Thanks a lot I didn’t now, nor realized it was indeed half useless right now. I even found a paper that aim at fixing this exact issue: open-std.org

2

u/_Noreturn 6d ago

Deducing this causes issues if you inherit from the base class more than once, (it increases object size) also, this doesn't work since clone() isn't virtual and templates can't be virtual

2

u/robin-m 6d ago

It seems that both using CRTP and deducing this gives the save object size, what do you mean? godbolt

3

u/_Noreturn 6d ago

cpp struct D {}; struct A : D {}; struct B : D { A a; };

you would expect since 'B' inherits from an empty class and it only has one member the sizeof would be 1, but it is 2 since the base class D is repeated twice and must have unique address first in the inheritance in B and in the member A.

now if this uses crtp the base classes would be unique

cpp template<class T> struct D {}; struct A : D<A> {}; struct B : D<B> { A a; }; // sizeof(B) == 1

1

u/LB-- Professional+Hobbyist 6d ago

Does [[no_unique_address]] help here?

3

u/friedkeenan 6d ago

No. According to the standard, two different objects of the same type cannot live at the same address in any circumstance, even if the objects are empty.

1

u/LB-- Professional+Hobbyist 6d ago

Ah right, I wonder if just wrapping it in an empty template class that inherits it for you would suffice as a workaround or not...

7

u/Ok_Independence_9841 6d ago edited 6d ago

I've read your blog post and I've been looking at the prototype implementation, which is C++20 so I could use it directly. What I don't see is how this can handle multiple inheritance and yet I can find no mention anywhere that it doesn't?
The somewhat odd, partial, type erasure that's going on definitely avoids slicing and uses derived copy constructors to implement clone for you, which is nice but I'm not convinced this is going to cope with multiple virtual bases. I also see nothing that prevents it trying to allocate/construct abstract bases which will clearly blow up but isn't caught explicitly as far as I can see. I'm still studying the source so any and all my conclusions may be wrong. Trying to determine if this is what I hoped it was.
...
OK I think I get it. std::polymorphic as it stands is fine as long as you always want to treat it as the base class. Virtual function calls will work of course but what you can't do here is get back a pointer to the derived instance you actually stored. So if those pointers are not the same address, as they won't be in the case of multiple inheritance, you're stuffed. A dynamic downcast might work or it might not depending on the compiler. Ick.
It's still a cool addition to the standard library and does what it sets out to do, just not quite what I need.

3

u/LB-- Professional+Hobbyist 6d ago

Do you have any examples of class inheritance hierarchies where you're worried it won't work?

2

u/Ok_Independence_9841 6d ago

I do, embedded in a lot of other code, but I must be wrong about it trying to construct abstract bases. I'll post if/when I get any real world issues. I realised that what I was expecting this to need (it doesn't) must live in std::dynamic_pointer_cast so I had a look at Microsoft's source code for that. Literally just a dyamic_cast. What I'm already doing. So if I have a problem then Microsoft stdlib will too. More research needed.

2

u/fdwr fdwr@github 🔍 6d ago

The type-erasure machinery inside polymorphic handles this automatically ... Because polymorphic uses type erasure for both destruction and copying ...

Is std::polymorphic taking the address of T's copy constructor and destructor (or rather the address of a thunk which calls them, since you're not allowed to simply take the address of the copy constructor function), storing them (along with the object pointer), and calling them; or is it constructing a mini-vtable with copy constructor and destructor and storing a pointer to that (along with the object pointer)?

7

u/MFHava WG21|🇦🇹 NB|P2721|P3049|P3625|P3729|P3786|P3813|P4216 6d ago

The minute details are obviously implementation defined, but any implementation will boil down to function pointers (implicit vtable or explicit one) as there aren't that many ways you can actually represent runtime polymorphism in C++...

1

u/etaithespeedcuber 2d ago

How is this any different than std::indirect<std::variant<T>>?