Jump to content

More C++ Idioms/Fake Vtable

From Wikibooks, open books for an open world

Intent

[edit | edit source]

To try to avoid dynamic allocation and avoid inheritance and intrusion in polymorphism.

Motivation

[edit | edit source]

Someone might want non-intrusive polymorphism in C++.

Solution and Sample Code

[edit | edit source]

Below is an implementation of a class that can refer to any object whose type satisfies the shape concept, as well as code that uses it:

#include <format>
#include <iostream>
#include <utility>

using point = std::pair<double, double>;

// 4 below types were written without knowledge of the interface
struct circle{
    public:
        explicit constexpr circle(
            point center,
            double radius
        ) noexcept
            : center_(center)
            , radius_(std::max(radius, 0.)){}
        
        constexpr point center() const noexcept{return center_;}

        constexpr double radius() const noexcept{return radius_;}

        friend bool operator==(circle const&, circle const&) = default;
    private:
        point center_{};
        double radius_{};
};

struct ellipse{
    public:
        explicit constexpr ellipse(
            point center,
            double a,
            double b
        ) noexcept
            : center_(center)
            , a_(std::max(a, 0.))
            , b_(std::max(b, 0.)){}
        constexpr ellipse(circle circle) noexcept : ellipse(
            circle.center(),
            circle.radius(),
            circle.radius()
        ){}
        
        constexpr point center() const noexcept{return center_;}

        constexpr double a() const noexcept{return a_;}

        constexpr double b() const noexcept{return b_;}

        friend bool operator==(ellipse const&, ellipse const&) = default;
    private:
        point center_{};
        double a_{};
        double b_{};
};

struct square{
    public:
        explicit constexpr square(
            point center,
            double side_length
        ) noexcept
            : center_(center)
            , side_length_(std::max(side_length, 0.)){}
        
        constexpr point center() const noexcept{return center_;}

        constexpr double side_length() const noexcept{return side_length_;}

        friend bool operator==(square const&, square const&) = default;
    private:
        point center_{};
        double side_length_{};
};

struct rectangle{
    public:
        explicit constexpr rectangle(
            point center,
            double width,
            double height
        ) noexcept
            : center_(center)
            , width_(std::max(width, 0.))
            , height_(std::max(height, 0.)){}
        constexpr rectangle(square square) noexcept : rectangle(
            square.center(),
            square.side_length(),
            square.side_length()
        ){}

        constexpr point center() const noexcept{return center_;}

        constexpr double width() const noexcept{return width_;}

        constexpr double height() const noexcept{return height_;}

        friend bool operator==(rectangle const&, rectangle const&) = default;
    private:
        point center_{};
        double width_{};
        double height_{};
};

template <class T>
concept shape = requires (T const& shape, std::ostream& os){
    {center(shape)} -> std::convertible_to<point>;
    {area(shape)} -> std::convertible_to<double>;
    {bounding_box(shape)} -> std::convertible_to<rectangle>;
    {draw(shape, os)} -> std::convertible_to<void>;
};

class shape_ref{
    public:
        // Take advantage of the fact we still know the actual type by using
        // this opportunity to pass an appropriate vtable
        template <shape T>
        explicit constexpr shape_ref(T const& shape) noexcept
            : shape_ptr_(std::addressof(shape)) // T can overload operator&
            , vtable_(&shape_vtable_for<T>){}
        
        // constexpr cast from void* since C++26
        // Provide a consistent interface with wrapped types
        friend constexpr point center(shape_ref ref){
            return ref.vtable_->do_center(ref.shape_ptr_);
        }

        friend constexpr double area(shape_ref ref){
            return ref.vtable_->do_area(ref.shape_ptr_);
        }

        friend constexpr rectangle bounding_box(shape_ref ref){
            return ref.vtable_->do_bounding_box(ref.shape_ptr_);
        }

        friend constexpr void draw(shape_ref ref, std::ostream& os){
            return ref.vtable_->do_draw(ref.shape_ptr_, os);
        }
    private:
        template <class R, class... Args>
        using function = R(Args...);

        // This is the fake vtable
        struct shape_vtable{
            function<point, void const*>* do_center{};
            function<double, void const*>* do_area{};
            function<rectangle, void const*>* do_bounding_box{};
            function<void, void const*, std::ostream&>* do_draw{};
        };

        // Requires knowledge of actual type pointed to by void const*
        template <shape T>
        static constexpr shape_vtable shape_vtable_for{
            .do_center = +[](void const* shape_ptr) -> point {
                return center(*static_cast<T const*>(shape_ptr));
            },
            .do_area = +[](void const* shape_ptr) -> double {
                return area(*static_cast<T const*>(shape_ptr));
            },
            .do_bounding_box = +[](void const* shape_ptr) -> rectangle {
                return bounding_box(*static_cast<T const*>(shape_ptr));
            },
            .do_draw = +[](void const* shape_ptr, std::ostream& os) -> void {
                return draw(*static_cast<T const*>(shape_ptr), os);
            }
        };

        void const* shape_ptr_{};
        shape_vtable const* vtable_{};
};

// Circles convert to ellipses and squares convert to rectangles, so not all
// function overloads need to be written
constexpr point center(ellipse e) noexcept{
    return e.center();
}
constexpr point center(rectangle r) noexcept{
    return r.center();
}

constexpr double area(ellipse e) noexcept{
    return std::numbers::pi * e.a() * e.b();
}
constexpr double area(rectangle r) noexcept{
    return r.width() * r.height();
}

constexpr square bounding_box(circle c) noexcept{
    return square(c.center(), c.radius());
}
constexpr rectangle bounding_box(ellipse e) noexcept{
    return rectangle(e.center(), e.a(), e.b());
}
constexpr square bounding_box(square s) noexcept{
    return s;
}
constexpr rectangle bounding_box(rectangle r) noexcept{
    return r;
}

void draw(circle c, std::ostream& os){
    auto const [x, y] = c.center();
    os << std::format(
        "circle({{{}, {}}}, {})",
        x, y, c.radius()
    );
}
void draw(ellipse e, std::ostream& os){
    auto const [x, y] = e.center();
    os << std::format(
        "ellipse({{{}, {}}}, {}, {})",
        x, y, e.a(), e.b()
    );
}
void draw(square s, std::ostream& os){
    auto const [x, y] = s.center();
    os << std::format(
        "square({{{}, {}}}, {})",
        x, y, s.side_length()
    );
}
void draw(rectangle r, std::ostream& os){
    auto const [x, y] = r.center();
    os << std::format(
        "rectangle({{{}, {}}}, {}, {})",
        x, y, r.width(), r.height()
    );
}

int main(){
    circle const c({1, 2}, 3);
    ellipse const e({4, 5}, 6, 7);
    square const s({8, 9}, 10);
    rectangle const r({11, 12}, 13, 14);
    // Be careful not to let the shape_ref outlive the referent. However, this
    // restriction is not so bad because Base* pointers are also restricted in
    // the exact same way.
    shape_ref c_ref(c);
    shape_ref e_ref(e);
    shape_ref s_ref(s);
    shape_ref r_ref(r);
    auto const show_values = [](shape auto const& s, std::string_view name){
        std::cout << "----------\n";
        auto const [cx, cy] = center(s);
        rectangle const bb = bounding_box(s);
        auto const [bbcx, bbcy] = bb.center();
        std::cout << std::format(
            "center({}) = {{{}, {}}}\n",
            name, cx, cy
        );
        std::cout << std::format(
            "area({}) = {}\n",
            name, area(s)
        );
        std::cout << std::format(
            "bounding_box({}) = rectangle({{{}, {}}}, {}, {})\n",
            name, bbcx, bbcy, bb.width(), bb.height()
        );
        std::cout << std::format(
            "Drawn by draw({}): ",
            name
        );
        draw(s, std::cout);
        std::cout << "\n----------\n";
    };
    std::cout << "Static function calls:\n";
    show_values(c, "c");
    show_values(e, "e");
    show_values(s, "s");
    show_values(r, "r");
    std::cout << "Dynamic function calls:\n";
    show_values(c_ref, "c_ref");
    show_values(e_ref, "e_ref");
    show_values(s_ref, "s_ref");
    show_values(r_ref, "r_ref");
    // Because the references are of the same type, same-type operations are now
    // possible. (Note that same-type operations are also possible with Base*).
    std::swap(c_ref, s_ref);
    std::swap(e_ref, r_ref);
    std::swap(c_ref, e_ref);
    std::swap(s_ref, r_ref);
}

Output:

Static function calls:
----------
center(c) = {1, 2}
area(c) = 28.274333882308138
bounding_box(c) = rectangle({1, 2}, 3, 3)
Drawn by draw(c): circle({1, 2}, 3)
----------
----------
center(e) = {4, 5}
area(e) = 131.94689145077132
bounding_box(e) = rectangle({4, 5}, 6, 7)
Drawn by draw(e): ellipse({4, 5}, 6, 7)
----------
----------
center(s) = {8, 9}
area(s) = 100
bounding_box(s) = rectangle({8, 9}, 10, 10)
Drawn by draw(s): square({8, 9}, 10)
----------
----------
center(r) = {11, 12}
area(r) = 182
bounding_box(r) = rectangle({11, 12}, 13, 14)
Drawn by draw(r): rectangle({11, 12}, 13, 14)
----------
Dynamic function calls:
----------
center(c_ref) = {1, 2}
area(c_ref) = 28.274333882308138
bounding_box(c_ref) = rectangle({1, 2}, 3, 3)
Drawn by draw(c_ref): circle({1, 2}, 3)
----------
----------
center(e_ref) = {4, 5}
area(e_ref) = 131.94689145077132
bounding_box(e_ref) = rectangle({4, 5}, 6, 7)
Drawn by draw(e_ref): ellipse({4, 5}, 6, 7)
----------
----------
center(s_ref) = {8, 9}
area(s_ref) = 100
bounding_box(s_ref) = rectangle({8, 9}, 10, 10)
Drawn by draw(s_ref): square({8, 9}, 10)
----------
----------
center(r_ref) = {11, 12}
area(r_ref) = 182
bounding_box(r_ref) = rectangle({11, 12}, 13, 14)
Drawn by draw(r_ref): rectangle({11, 12}, 13, 14)
----------

Not how one did not need to change any of the concrete shape classes in order to make them referenceable. They were not designed with a common interface in mind, but one can still use all 4 in a common way. One can also create a value class for storing any shape using this same technique, just with a few slight modifications like:

  • Adding a do_clone method and using a unique_ptr<void, SomeDeleter> for storage where the deleter also stores a vtable
  • Using shared_ptr<void> for storage and constructing one by using make_shared<T> in the constructor

This is a superior way of writing polymorphic code, and it still captures some of the properties that a traditional OO method would use:

  • Circles, ellipses, squares, and rectangles are shapes. (they all implement the shape concept)
  • Circles are ellipses. (implicit conversion from circle to ellipse)
  • Squares are rectangles. (implicit conversion from square to rectangle)

This implementation of polymorphic references is very similar to Rust's implementation of &dyn, both using a data pointer and a vtable pointer.

Known Uses

[edit | edit source]
  • std::function on certain ABIs (you may have to set an ABI flag)
  • std::move_only_function
  • std::function_ref
  • std::any
[edit | edit source]

References

[edit | edit source]