C++ type erasure: a std::polymorphic implementation

, ,

We’re back on type erasure today, with a simple polymorphic implementation. C++26 is going to introduce two new types:

  1. std::polymorphic: a wrapper above a polymorphic dynamically allocated object with value-like semantics
  2. std::indirect: a wrapper above a dynamically allocated object, also with value-like semantics

The only drawback of std::polymorphic It relies on an interface with virtual functions. But thanks to that, you continue to manipulate value, with its copy or move :).

std::polymorphic usage

The goal of this article is to write the code that allows us to write:

struct Drawable {
    // No need for real virtual destructor
    virtual void draw(std::ostream &stream) const = 0;
};

struct Circle final : Drawable {
    Circle(int radius) : radius{radius} {}

    void draw(std::ostream &stream) const override {
        stream << "Draw circle of " << radius << " radius" << std::endl;
    }

    int radius;
};

struct Rectangle final : Drawable {
    Rectangle(int width, int height) : width{width}, height(height) {}

    void draw(std::ostream &stream) const override {
        stream << "Draw rectangle of (" << width << "," << height << ")" << std::endl;
    }

    int width, height;
};

int main() {
    polymorphic<Drawable> circle1 = Circle{10};
    polymorphic<Drawable> circle2 = circle1;

    polymorphic<Drawable> rectangle1 = Rectangle{800, 600};
    polymorphic<Drawable> rectangle_valueless = Rectangle{800, 600};
    polymorphic<Drawable> rectangle2 = std::move(rectangle_valueless);

    circle1->draw(std::cout);
    circle2->draw(std::cout);

    rectangle1->draw(std::cout);
    rectangle2->draw(std::cout);

    std::cout << std::boolalpha << rectangle_valueless.valueless_after_move() << std::endl;
}

As you can see, we can copy the objects, leading to a true copy; we can move the objects, leading the object to be in valueless_after_move state. Here is the godbolt link if you want it.

std::polymorphic implementation

We are going to implement our own simple std::polymorphic. To do so, we are going to do it in this order:

  1. The structure
  2. The constructors
  3. operator=
  4. The accessors
  5. The valueless_after_move function.

The structure

As you can see, our polymorphic implementation takes the interface as a template parameter. Since we talked about type erasure, we can also follow the same approach as usual

  • A polymorphic Concept structure
  • A templated Model<T> structure which inherits from Concept.
  • A std::unique_ptr<Concept> which manages the erased type.
template<typename Interface>
class polymorphic {
    struct Concept {
        virtual ~Concept() = default;
    };

    template<typename Object>
    struct Model : Concept {
        Model(Object value) : m_value{std::move(value)}{}

    private:
        Object m_value;
    };

    std::unique_ptr<Concept> m_object;
};

Which kind of behavior do we need to implement?

  1. We need to access the underlying type through Interface. We will expose a get_address function returning a Interface*.
  2. We need to be able to copy the object. We will then expose a clone function.

It looks like that:

struct Concept {
    virtual ~Concept() = default;

    virtual Interface* get_address() = 0;
    virtual std::unique_ptr<Concept> clone() const = 0;
};

template<typename Object>
struct Model : Concept {
    Model(Object value) : m_value{std::move(value)}{}

    Interface* get_address() override {
        return std::addressof(m_value);
    }     
   
    std::unique_ptr<Concept> clone() const override {
        return std::make_unique<Model<Object>>(m_value);
    }

private:
    Object m_value;
};

Now that we have the basics, let’s handle the different constructors.

Constructors

We have a different kind of constructor:

  • Constructor with the value as an argument
  • Copy constructor
  • Move constructor

As we can see, in our implementation, I didn’t put a default constructor. Then, except if we move the object, our polymorphic will never be null.

The implementation is straightforward; we call the clone() function for the copy constructor and make_unique<Model<T>> for the main constructor:

template<typename Object>
polymorphic(Object value) noexcept
    : m_object{std::make_unique<Model<Object>>(std::move(value))} {
        static_assert(std::is_base_of_v<Interface, Object>);
}

polymorphic(polymorphic&&) noexcept = default;

polymorphic(const polymorphic &object)
    : m_object{object.m_object->clone()}{}

As we can see, the implementation is straightforward. The move constructor calls the move constructor from unique_ptr and that’s over.

operator=

The operator= functions are the same:

polymorphic &operator=(polymorphic&&) noexcept = default;
    
polymorphic& operator=(const polymorphic& object) {
    m_object = object->clone();
    return *this;
}

There is nothing to say here

Accessors functions

The accessor functions are also straightforward; we need to call the get_address functions.

const Interface &operator*() const {
    return *m_object->get_address();
}

Interface& operator*() {
    return *m_object->get_address();
}

const Interface *operator->() const {
    return m_object->get_address();
}

Interface *operator->() {
    return m_object->get_address();
}

valueless_after_move

Another straightforward implementation:

bool valueless_after_move() const {
    return m_object == nullptr;
}

Conclusion

Our implementation is finished, but there are some things to do to improve it:

  1. Adding more constructors, like in_place ones.
  2. Manage custom allocator
  3. Adding small object optimization

We can also enhance the safety of the object by checking with the std::is_final_v trait. If the object is final, to be sure we are not losing some information, and propose eventually a tag not_final to allow the user to use types that are not flagged as final.

It was a short article, but I hope you liked it.

references

  1. cpp-reference
  2. std::polymorphic proposal
  3. Compiler explorer

Comments

Leave a Reply