More C++ Idioms/Fake Vtable
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 aunique_ptr<void, SomeDeleter>
for storage where the deleter also stores a vtable - Using
shared_ptr<void>
for storage and constructing one by usingmake_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
toellipse
) - Squares are rectangles. (implicit conversion from
square
torectangle
)
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