DEV Community

Cover image for Polymorphic Experiments
Matthew Faithfull
Matthew Faithfull

Posted on

Polymorphic Experiments

Sandor Dargo recently wrote an excellent blog post on a new C++26 standard library feature std::polymorphic.

I felt when I read this as if the std::polymorphic authors must be pulling some language trick that's recently become possible or exploiting a loophole. It seemed like something would break if I tortured it. So I thought I'd investigate.

It turns out that implementing std::polymorphic doesn't require a C++26 language base. C++20 is sufficient and there's even a C++14 port. So I copied Johnathan B Coe's single source C++20 header and did some experiments.

Read Sandor's article for what polymorphic does and how to use it. Here I'm going to push the boat out and see if/when it breaks down.

Note: I'm using a C++20 implementation, not one from C++26 and I'm using it on MSVC with /std:c++20 set. The behaviour of the actual C++26 standard library is in no way guaranteed to be identical.

I had the following concerns:

  • Will polymorphic cope with classes which have multiple bases? (multiple inheritance)

  • Will it work when used directly with a multiple inheritance base rather than a specific derived concrete class?

  • Will it work even if that base is abstract, given that you can't instantiate abstract classes?

I'll need an inheritance monster to really test that. What follows is hopefully just enough so that you can see what is going on.

class Item
{
public:
    virtual ~Item() {};

    template< typename U > constexpr bool Is()
    {
        U* test = dynamic_cast<U*>(this);
        return test != nullptr;
    }        

    template< typename U > U* As()
    {
        U* as = dynamic_cast<U*>(this);
        return as;
    }
};
Enter fullscreen mode Exit fullscreen mode

Item is the common base from which everything in the main hierarchy derives
We use virtual inheritance of course so that there's only one Item instance for each final instantiation. As it's stateless that's not too much of an issue although it does matter for the intermediate bases that have state.

class Compound; //forward decl

class Contained : public virtual Item
{
//...//handles having a parent
};

class Compound : public virtual Item
{
//...//handles having a vector of Contained items
};

class Scene;

class Renderer
{
//...//Out of hierarchy class to do rendering
};

class Renderable : public virtual Item
{
//...//Something that can be rendered
    virtual void Render(Renderer* renderer)
    {
    //...
    }        
};

class Scene : public Compound, public virtual Renderable
{
//...//A Compound that is Renderable
};

class Arrangeable : public virtual Item
{
//...//Handles interaction with layout algorithms
};

//base class for Widget
class Widget : public Compound, 
    public virtual Contained, 
    public virtual Arrangeable, 
    public virtual Renderable
{
public:
    Widget() = default;
    virtual ~Widget() = default;
};
Enter fullscreen mode Exit fullscreen mode

Given what polymorphic is intended for and what Sandor showed I was pretty confident this first test case would work. Creating a polymorphic< T > of a concrete, derived class Widget and then treating it as one or other of its base classes.

polymorphic<Widget> widget;

std::vector<Renderable*> renderables;
renderables.emplace_back(widget.operator->());

Renderer renderer;

for(auto renderable : renderables)
{
    renderable->Render(&renderer);
}

std::vector<Arrangeable> arrangables;

arrangables.push_back(*widget);

for(auto arrangeable : arrangables)
{
    arrangeable.ComputeRequirement();
}
Enter fullscreen mode Exit fullscreen mode

It does exactly as you'd expect, despite the slightly awkward syntax. widget.operator->() returns a Widget* which happily decays automatically to its Renderable* base. Calling through it confirms all is well.
Copying with push_back works without breaking the std::vector and I can get a std::vector of Arrangeable objects (as long as Arrangeable isn't abstract which would break the vector anyway.)

That is the easy part. What I'd really like is to have one or more polymorphic<Item> instances, probably in a homogenous container, and make calls through them.
This is the case that makes std::polymorphic valuable. You can treat a polymorphic<Item> as a value, just like an int or struct. Shunt it around in standard containers and pass it as an ordinary function parameter while it remains polymorphic when you call through it.

This works:

Widget* exampleWdiget = new Widget();
polymorphic<Item> item(std::move(*exampleWdiget));
Enter fullscreen mode Exit fullscreen mode

As does:

polymorphic<Item> item(Widget{});
Enter fullscreen mode Exit fullscreen mode

and it's safe due to the requires constrained template constructors and clever copying machinery in polymorphic.

However whether this works or not depends:

polymorphic<Item> item;
Enter fullscreen mode Exit fullscreen mode

What if we make Item abstract?

class Item
{
public:
    virtual ~Item() {};
//...    
    virtual void make_base_abstract() = 0;
};
Enter fullscreen mode Exit fullscreen mode

In that case

polymorphic<Item> item;
Enter fullscreen mode Exit fullscreen mode

will not compile. I get a static_assert with a reasonably useful error message telling me I can't instantiate an abstract class.
What then of the common case of abstract bases in class hierarchies?
Well it turns out, remarkably, this still works:

polymorphic<Item> item(Widget{});
Enter fullscreen mode Exit fullscreen mode

Even when Item is abstract.
Internally no Item is ever instantiated as we supply an in place constructed Widget instance so the abstract instantiation problem is dodged.

We must of course add implementations to prevent the classes we do instantiate from being abstract:

virtual void make_base_abstract(){ };
Enter fullscreen mode Exit fullscreen mode

goes into Arrangeable and Widget.

The following move assignment case also compiles and executes correctly.

Widget* exampleWdiget = new Widget();
std::vector<Renderable*> renderables;

polymorphic<Item> item(std::move(*exampleWdiget));

renderables.emplace_back(item->As<Renderable>());
for(auto renderable : renderables)
{
    renderable->Render(&renderer);
}
Enter fullscreen mode Exit fullscreen mode

The only thing polymorphic doesn't do for us it to upcast/downcast (I never know which it is) from Item* to the derived Renderable*.
Item itself handles that with the As< T >() function which is just wrapping a dynamic_cast.

YAGNI ?

It may seem like the use case above is esoteric (It was meant to be) and you might think you'll never need polymorphic but I suspect that a lot of use cases for the ever popular std::variant in current C++ codebases are actually candidates for std::polymorphic and you might get better memory usage and or better performance as a result. You'll need to experiment with a real C++26 standard library implementation to confirm.

Consider something like a JSON object model where you use std::variant to model the different possible types of a JSON value:

using JSONVariant = std::variant< 
std::nullptr_t, 
bool, 
int, 
double, 
std::string, 
std::vector<JSONVariant> >;
Enter fullscreen mode Exit fullscreen mode

This could be replaced with

polymorphic<ValueModel> valueModel;
Enter fullscreen mode Exit fullscreen mode

where ValueModel is a shared base class

enum class ValueType
{
    _false,
    _null,
    _true,
    number,
    string,
    array,
    object
};

class ValueModel
{
public:        
    virtual ~ValueModel() = default;
    virtual ValueType GetType()
    {
        return ValueType::_null;
    }
};
Enter fullscreen mode Exit fullscreen mode

Definitely more code, as you also have to derive classes for each type of ValueModel but perhaps enough of a smaller memory footprint, for large models, to justify the extra code. You'd have to measure it on your platform to be sure.

Conclusion

std::polymorphic looks to be an excellent addition to the C++26 standard library and with a few minutes work you can use it now with C++20.
Just take care with construction if you have an abstract base.

Top comments (0)