Skip to content

C++ Runtime Polymorphism

Published: at 09:44 AM

Table of contents

Open Table of contents

Background

Conduct a comparative research on the C++ runtime polymorphism libraries boost-ext/te, folly/Poly, and microsoft/proxy from the perspectives of 1. Performance 2. Functionality 3. Ease of use 4. Other.

Objective:

To achieve better code flexibility, performance, and composability. Possible Use Cases:

Explanation:

The purpose of these libraries is to implement runtime polymorphism in a non-intrusive manner. It eliminates the need to write functions with the same function names and parameters as virtual functions, and there is no need to modify those classes to inherit from others.

Predefinitions

#include <iostream>
// Implementations (No base class)
class Rectangle {
 public:
  Rectangle(double width = 1.0, double height = 1.0)
      : width_(width), height_(height) {}
  void Draw(std::ostream& out) const {
    out << "{Rectangle: width = " << width_ << ", height = " << height_ << "}";
  }
  void SetWidth(double width) { width_ = width; }
  void SetHeight(double height) { height_ = height; }
  double Area() const { return width_ * height_; }

 private:
  double width_;
  double height_;
};
class Circle {
 public:
  Circle(double radius = 1.0) : radius_(radius) {}
  void Draw(std::ostream& out) const {
    out << "{Circle: radius = " << radius_ << "}";
  }
  void SetRadius(double radius) { radius_ = radius; }
  double Area() const { return 3.14 * radius_ * radius_; }

 private:
  double radius_;
};
class Point {
 public:
  void Draw(std::ostream& out) const { out << "{Point}"; }
  constexpr double Area() const { return 0; }
};

microsoft/proxy

// Abstraction
struct Draw : pro::dispatch<void(std::ostream &)> {
  template <class T>
  void operator()(const T &self, std::ostream &out) {
    self.Draw(out);
  }
};
struct Area : pro::dispatch<double()> {
  template <class T>
  double operator()(const T &self) {
    return self.Area();
  }
};

then, using pro::facade:

struct DrawableFacade : pro::facade<Draw, Area> {};

then, we can use facade to write generic code:

// Client - Consumer
std::string PrintDrawableToString(pro::proxy<DrawableFacade> p) {
  std::stringstream result;
  result << "shape = ";
  p.invoke<Draw>(result);                     // Polymorphic call
  result << ", area = " << p.invoke<Area>();  // Polymorphic call
  return std::move(result).str();
}  // Client - Producer
pro::proxy<DrawableFacade> CreateRectangleAsDrawable(int width, int height) {
  Rectangle rect;
  rect.SetWidth(width);
  rect.SetHeight(height);
  return pro::make_proxy<DrawableFacade>(
      rect);  // No heap allocation is expected
}

then, conveniently in our code, we can do:

int main() {
  auto rec = CreateRectangleAsDrawable(10, 20);
  std::cout << PrintDrawableToString(std::move(rec)) << std::endl;
  return 0;
}

Moreover, the proxy library supports lifecycle management in combination with smart pointers, and the uniqueness of this lifecycle management approach in proxy is unparalleled. While some libraries claim to support various lifecycle management models, they do not allow for the customization of each object as proxy does.

therefore, we can use a factory generator:

pro::proxy<DrawableFacade> MakeDrawableFromCommand(const std::string& s) {
  std::vector<std::string> parsed = ParseCommand(s);
  if (!parsed.empty()) {
    if (parsed[0u] == "Rectangle") {
      if (parsed.size() == 3u) {
        static std::pmr::unsynchronized_pool_resource rectangle_memory_pool;
        std::pmr::polymorphic_allocator<> alloc{&rectangle_memory_pool};
        auto deleter = [alloc](Rectangle* ptr) mutable {
          alloc.delete_object<Rectangle>(ptr);
        };
        Rectangle* instance = alloc.new_object<Rectangle>();
        std::unique_ptr<Rectangle, decltype(deleter)> p{instance, deleter};
        p->SetWidth(std::stod(parsed[1u]));
        p->SetHeight(std::stod(parsed[2u]));
        return p;  //implicit conversion occurs
      }
    } else if (parsed[0u] == "Circle") {
      if (parsed.size() == 2u) {
        Circle circle;
        circle.SetRadius(std::stod(parsed[1u]));
        return pro::make_proxy<DrawableFacade>(circle);  //Small Buffer Optimization could happen
      }
    } else if (parsed[0u] == "Point") {
      if (parsed.size() == 1u) {
        static Point instance;  // global singleton
        return &instance;
      }
    }
  }
  throw std::runtime_error{"Invalid command"};
}

and then, we can:

int main() {
  pro::proxy<DrawableFacade> p = MakeDrawableFromCommand("Rectangle 2 3");
  std::string s = PrintDrawableToString(std::move(p));
  puts(
      s.c_str());  // prints: "shape = {Rectangle: width = 2.00000, height  // = 3.00000}, area = 6.00000"
  p = MakeDrawableFromCommand("Circle 1");
  s = PrintDrawableToString(std::move(p));
  puts(
      s.c_str());  // prints: "shape = {Circle: radius = 1.00000}, area  // = 3.14159"
  p = MakeDrawableFromCommand("Point");
  s = PrintDrawableToString(std::move(p));
  puts(s.c_str());  // prints: "shape = {Point}, area = 0.00000"
  try {
    p = MakeDrawableFromCommand("Triangle 2 3");
  } catch (const std::runtime_error& e) {
    puts(e.what());  // prints: "Invalid command"
  }
  return 0;
}

boost-ext/te

first, we define:

namespace te = boost::te;  // Define interface of something which is drawable
struct Drawable {
  void draw(std::ostream& out) const {
    te ::call([](auto const& self, auto& out) { self.Draw(out); }, *this, out);
  }
  double area() const {
    double res = 0.0;
    te ::call([](auto const& self, double& res) { res = self.Area(); }, *this,
              res);
    return res;
  }
};

then, we can:

// Client - Consumer
std::string PrintDrawableToString(te ::poly<Drawable> const& drawable) {
  std::stringstream result;
  result << "shape = ";
  drawable.draw(result);                     // Polymorphic call
  result << ", area = " << drawable.area();  // Polymorphic call
  return std::move(result).str();
}
int main() {  // naive tests:
  std::cout << PrintDrawableToString(Rectangle{}) << std::endl;
  std::cout << PrintDrawableToString(Circle{}) << std::endl;
}

Note that this library cannot use factory functions like the proxy library mentioned above, as it does not support other lifecycle management models and cannot be wrapped in unique_ptr or shared_ptr. Doing so would result in the corresponding Draw and Area() functions becoming inaccessible. This library can only use the default lifecycle: a te::poly<Drawable> is created and then destroyed upon exiting its scope.

folly/Poly

first we define:

// This example is an adaptation of one found in Louis Dionne's dyno library.
#include <folly/Poly.h>
#include <iostream>
#include "Shapes.h"
#include "utils.h"
struct IDrawable {  // Define the interface of
                    // something that can be
                    // drawn:
  template <class Base>
  struct Interface : Base {
    void Draw(std::ostream& out) const { folly::poly_call<0>(*this, out); }
  };  // Define how concrete types can fulfill that interface (in C++17):
  template <class T>
  using Members = folly::PolyMembers<&T ::Draw>;
};  // Define an object that can hold anything that can be drawn:
using drawable = folly::Poly<IDrawable>;

then, we can:

void f(drawable const& d) { d.Draw(std::cout); }
int main() {
  f(Rectangle{});  // prints Square
  f(Circle{});     // prints Circle
}

Performance Comparisons

library/time(ns)0th50th75th90th95th99th99.999th100th
proxy379399409429439499245205406061
te380430450470480810289762444824
folly360370380400410790248242353563
Native C++ (Non-Polymorphic)360370380400410430154920281170
Native C++ (Polymorphism with Inheritance)359379379409419429150878298877

All three libraries have similar performance. All three libraries are Native C++ (Non-Polymorphic) > Native C++ (Polymorphism with Inheritance) > polymorphic libraries, but none are much faster. Based on averages, it’s roughly folly: 387 < 391 < 396; te: 435 < 439 < 451; proxy: 412 < 420 < 421. (nanoseconds)

For accuracy, these are the results after poisoning the L1 cache before each run, all from calling a Draw() and then an Area() into a stringstream. Also, all system soft interrupts were bound to core 0 using taskset on the test machine, and these tests were done on core 1. (Rocky Linux 9.2, AMD 7950X, same configuration as the live trading machine we use for quantitative trading)

Functionality

The proxy library supports lifecycle management in conjunction with smart pointers. proxy is unique in this lifecycle management highlight. Some libraries claim to support various lifecycle management models, but do not allow per-object customization like proxy.

Ease of use:

As seen in the code, all three libraries require about the same amount of code to implement the desired functionality

Flexibility

te and proxy are single header files and can be easily integrated into existing projects, Poly is not single file and requires the installation of the folly library, which also relies on boost, glog, etc., which is more cumbersome to install.

technological realization

The underlying principle is similar

runtime polymorphism

References