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
  4. Remove an indirect call from Concept

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