411 REFERENCE · Wikibooks
More C++ Idioms/Fake Vtable
Reference material by Wikibooks contributors. This retains its source identity and is not 411 news reporting.
Intent
To try to avoid dynamic allocation and avoid inheritance and intrusion in polymorphism.
Motivation
Someone might want non-intrusive polymorphism in C++.
Solution and Sample Code
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
std::function on certain ABIs (you may have to set an ABI flag)
std::move_only_function
std::function_ref
std::any
Related Idioms
Type Erasure
Polymorphic Value Types
References