This is a single-header, zero extrnal dependencies, and pretty easy-to-use type-erasing runtime polymorphism library. Just drop the some.hpp header in your project and you're all set!
create polymorphic objects with value semantics and RAII memory ownership on the fly.
If "type-erasing runtime polymorphism" sounds scary, let's just say it's similar to the Rust's Traits or Go's interfaces, so in a way it's C++ traits implementation.
In that it's somewhat similar to Dyno, Folly::Poly or AnyAny. However some was built with different trade-offs in mind and trying to steamline the user experience as much as possible without introducing any DSL-like descriptions and/or macros.
I agree, a good example is worth a thousand words! How about we write something like a slightly simplified std::function but indeed it should have SBO and would also be nice to have a move_only_function! Oh, and maybe something like a std::function_ref? And ideally in under 30 lines of code for the implementation ;) Sounds unreal? Try it (on godbolt)!
implementing a (slightly simplified) std::function-like type
and also let's inspect the assembly output
some<[Trait], [config]>some<Trait&>// non-owning polymorphic view using poly_viewsome<Trait const&>// const non-owning polymorphic view using poly_viewfsome<[Trait], [config]>// fat-pointer version of some<>, optimised for speed
+ extras
poly_view<[const] Trait>some_ptr<[const] Trait>(used in fsome<>)
To define a polymorphic interface that has .draw(ostream) -> void and .area() -> float here's all you have to do:
struct Shape : vx::trait {
virtual void draw(std::ostream&) const = 0;
virtual float area() const;
};
template <typename T>
struct vx::impl<Shape, T> final : impl_for<Shape, T> {
using impl_for<Shape, T>::impl_for;
void draw(std::ostream& out) const override { vx::poly {this}->draw(out); }
float area() const override { return vx::poly {this}->area(); }
};See below for explanation and more examples
Here is a very simple example of how you can use some<> even without Traits (about them in a second)
// without a trait, some can be used as std::any, but with a bunch of extra tricks up its sleeve (configurable SBO, configurable copy and move, ...)
vx::some<> anything = 1;
assert(( vx::some_cast<int>(anything) == 1 ));
vx::some_cast<int&>(anything) = 7;
assert(( vx::some_cast<int>(anything) == 7 ));
anything = std::string{"hi"};
std::cout << vx::some_cast<std::string const&>(anything);
// and same for `fsome`
vx::fsome<> anything = 1;
assert(( vx::some_cast<int>(anything) == 1 ));
vx::some_cast<int&>(anything) = 7;
assert(( vx::some_cast<int>(anything) == 7 ));
anything = std::string{"hi"};
std::cout << vx::some_cast<std::string const&>(anything);But indeed the main raison d'etre of some is runtime polymorphism, so let us dive right into it!
Here is another simple example:
Let's say we have a simple struct Square that has a method void draw(std::ostream&):
struct Square { // notice there is no inheritance
//... may have some members
void draw(std::ostream& out) const { out << "[ ]\n"; } // a plain non-virtual function
};And for now let's just say that we already have a trait Shape that defines a method void draw(std::ostream&)
Type-erasing the Square is as easy as this:
vx::some<Shape> obj = Square{};
obj->draw(std::cout);Yes, it's that easy. Note, however, when accessing the polymorphic methods we should use the arrow -> syntax.
Now, let's see how to define such a trait:
struct Shape : vx::trait {
virtual void draw(std::ostream&) const = 0;
};
/// describe the implementation
template <typename T>
// +---- your trait's name
// __|__ ,_______,-- this thing handles most of the complexities so that it works with some, fsome, and views
struct vx::impl<Shape, T> final : impl_for<Shape, T> {
using impl_for<Shape, T>::impl_for; // <--- it's advisable to pull in the T's ctors like so, the impl_for<...> already wraps them
/// Now just write it as you would if Square was derived from some IShape that had a virtual function draw to override
void draw(std::ostream& out) const override {
vx::poly {this}->draw(out);
// -------- ------ - poly {this} is a helper that gets the actual type of T from the impl<>
}
};or, if you really don't like the vx::poly {this}
template <typename T>
// +---- your trait's name
// __|__ ,_______,-- this class handles most of the complexities so that it works with some, fsome, and views
struct vx::impl<Shape, T> final : impl_for<Shape, T> {
using impl_for<Shape, T>::impl_for; // <--- it's advisable to pull in the T's ctors like so, the impl_for<...> already wraps them
using impl_for<Shape, T>::self;
/// Now just write it as you would if Square was derived from some IShape that had a virtual function draw to override
void draw(std::ostream& out) const override {
self().draw(out);
// ------ - same as (*poly {this})
}
};Not too bad, is it? There still is some boilerplate, but it is only written once per trait and as a result you have all the benefits for free for any objects that happen to satisfy this interface.
Meanwhile your original objects can still have a nice layout, no vptr embedded in them, they can stay trivially_copyable and so on.
Now, to a more realistic example: Let's store some shapes in a std::vector and see how they compare with the standard approach.
classic implementation
struct IShape {
virtual ~IShape() = default;
virtual void draw(std::ostream&) const = 0;
virtual void bump() noexcept = 0;
};
struct VSquare : public IShape {
int side_ = 0;
void draw(std::ostream& out) const override { out << "[ ]\n"; }
void bump() noexcept override { side_ += 1; }
};
struct VCircle : public IShape {
int radius_ = 0;
void draw(std::ostream& out) const override { out << "( )\n"; }
void bump() noexcept override { radius_ += 1; }
};
A polymorphic approach
struct Square { // no inheritance
int side_ = 0;
// plain functions
void draw(std::ostream& out) const { out << "[ ]\n"; }
void bump() noexcept { side_ += 1; }
};
struct Circle {
int radius_ = 0;
void draw(std::ostream& out) const { out << "( )\n"; }
void bump() noexcept { radius_ += 1; }
};
/// We can define a new Trait anywhere, anytime
struct Shape : vx::trait {
virtual void draw(std::ostream&) const = 0;
virtual void bump() noexcept = 0;
};
/// trait implementation, the only boilerplate you'll have to write (once per trait)
template <typename T>
struct vx::impl<Shape, T> final : impl_for<Shape, T> {
using impl_for<Shape, T>::impl_for; // pull in the ctors
void draw(std::ostream& out) const override {
vx::poly {this}->draw(out);
}
void bump() noexcept override {
vx::poly {this}->bump();
}
};
Let's define some functions that will iterate through the polymorphic elements in a vector
/// _____ NOTE: const here doesn't stop us from invoking bump()
auto iterate_and_call_classic(std::vector<std::unique_ptr<IShape>> const& shapes) { // courtesy of pointer semantics, the pointer is const, whatever it points to - not so much
std::size_t sides = 0;
for (auto && p_shape : shapes) {
p_shape->draw(std::cout);
p_shape->bump();
}
return sides;
}
/// _ a const here will trigger a compile-time error
auto iterate_and_call_some(std::vector<vx::some<Shape>> & shapes) {
std::size_t sides = 0;
for (auto && shape : shapes) {
sides += shape->sides();
shape->bump();
}
return sides;
}
/// +-- the same Trait lets you choose the polymorphic object's representation, `some` or `fsome` with different configurations
/// __|__ ____________ here, the nullability is disabled since we know that we won't need it for a given example
auto iterate_and_call_fsome(std::vector<vx::fsome<Shape, vx::cfg::fsome{.empty_state=false}>> & shapes) {
std::size_t sides = 0;
for (auto & shape : shapes) {
sides += shape->sides(); // calling a method through `some` and `fsome` looks the same as calling it through a pointer
shape->bump();
}
return sides;
}Notice how some and fsome preserve the constness. Both support value semantics, so that when you copy or move one, you copy the polymorphic object, not the pointer to it, and without slicing ever happening!
and let's randomly populate these containers:
static constexpr std::size_t N = 42;
std::vector<std::unique_ptr<IShape>> classic_shapes {};
classic_shapes.reserve(N);
std::vector<vx::some<Shape>> some_shapes {};
some_shapes.reserve(N);
std::vector<vx::fsome<Shape, vx::cfg::fsome{.empty_state=false}>> fsome_shapes {};
fsome_shapes.reserve(N);
std::vector<vx::fsome<Shape, vx::cfg::fsome{.copy=false, .empty_state=false}>> fsome_of_everything {};
std::random_device rd;
std::mt19937 mt(rd());
for (std::size_t i = 0; i < N; ++i) {
if (mt() % 2 == 0) {
classic_shapes.push_back(std::make_unique<VCircle>()); // TRICKY: new VCircle() may leak on vector reallocation
some_shapes.emplace_back(Circle{});
fsome_shapes.emplace_back(Circle{});
fsome_nonsense.emplace_back(VCircle()); // we can even push a VCircle from the classic approach into some of fsome
} else {
classic_shapes.push_back(std::make_unique<VSquare>());
some_shapes.emplace_back(Square{});
fsome_shapes.emplace_back(Square{});
fsome_nonsense.emplace_back(VSquare());
}
}Here's an approximate layout of some and fsome.
+=== impl<Trait, T> ===+ +=== vtable for impl<Trait, T> ===+ .---> ... @draw(...)
+= some<Trait> =+ | Trait [ vptr * ] |*------->| ............................... |*-/
| Trait* |*---->| +-[ impl_for<...> ]-+| | a list of functions ........... |*------> ... @bump(...)
+---------------+ | | T self_ || +---------------------------------+
| +-------------------+|
+----------------------+
+=== impl<Trait, T> ===+ +=== vtable for impl<Trait, T> ===+ .---> ... @draw(...)
+= some<Trait> =+ | Trait [ vptr * ] |*------->| ............................... |*-/
| Trait* |*-+--->| +-[ impl_for<...> ]-+| | a list of functions |*------> ... @bump(...)
+...............+ | |-| T self_c || +---------------------------------+
| SBO buffer: |<-+ | +-------------------+|
| [ impl<...> ] | +----------------------+
+---------------+
^
SBO stores the impl<Trait, T>, if it fits, or doesn't store anything, then the Trait* points to the heap-allocated object.
+------> +=== vtable for impl<Trait, T*> ==+
+== fsome<Trait> =====+ | | ............................... |*------> @draw(...)
|[some_ptr<Trait, T*>]|*-----+ | a list of functions |*------> @bump(...)
|| vptr (*) || +---------------------------------+
|+-------------------+|
|| dptr (*) ||*------> [ heap allocated object T ]
+---------------------+
<=>
^ actually stores this:
+=== impl<Trait, T*> ===+
| Trait [ vptr * ] |
| +== impl_for<...> ==+ |
| | T* self_ | |
| +-------------------+ |
+-----------------------+
namespace cfg {
/// ===== [ SBO storage configuration ] ======
struct SBO {
vx::u16 size;
vx::u16 alignment { alignof(std::max_align_t) };
};
struct some {
SBO sbo {24};
bool copy {true};
bool move {true};
bool empty_state {true};
bool check_empty {VX_HARDENED};
};
struct fsome {
SBO sbo {0};
bool copy {true};
bool move {true};
bool empty_state {true};
bool check_empty {VX_HARDENED};
};
}// namespace cfgAs such, having a sufficiently new compiler you can do this:
vx::some<Trait, {.sbo{32}, .copy{false}}> s {};
vx::fsome<Trait, {.sbo{32}, .copy{false}}> f {};or, with an older compiler
vx::some<Trait, vx::cfg::some{.sbo{32}, .copy{false}}> s {};
vx::fsome<Trait, vx::cfg::fsome{.sbo{32}, .copy{false}}> f {};Benchmarks
I decided to use quick-bench website for convenience, as the results (in theory) would be easy to assess.
A little ranting on why it isn't as easy to assess as I'd hope
At least that was the plan, as it turns out the website clearly has a limit on code size, doesn't appear to support `#include` with github links and I couldn't find a way to create a permalink to the benchmarks I somehow managed to squeeze in there. Due to the awfully low limit on code size, I had to crop the fsome and some into parts and also re-format it in the ugliest way possible, but here we are...All the plots and the related code live in the quick_benchmark_examples folder. Under every plot you'll see here will be a link to the full benchmark code that you can copy and paste into the quick-bench to experiment.
static constexpr std::size_t N = 100'000;
static void iterate_and_call_classic(benchmark::State& state) {
std::vector<std::unique_ptr<IShape>> shapes;
shapes.reserve(N);
std::mt19937 mt{}; // default initialized for all tests
for (std::size_t i = 0; i < N; ++i) {
if (mt() % 2 == 0) {
shapes.push_back(std::make_unique<VCircle>());
} else {
shapes.push_back(std::make_unique<VSquare>());
}
}
// Testing the access times when iterating throught the vector
for (auto _ : state) {
std::size_t sides = 0;
for (auto && p_shape : shapes) {
sides += p_shape->info();
p_shape->bump();
}
benchmark::DoNotOptimize(sides);
}
}
static void iterate_and_call_fsome(benchmark::State& state) {
std::vector<vx::fsome<Shape>> shapes;
shapes.reserve(N);
// std::random_device rd;
std::mt19937 mt {};
for (std::size_t i = 0; i < N; ++i) {
if (mt() % 2 == 0) {
shapes.emplace_back(Circle{});
} else {
shapes.emplace_back(Square{});
}
}
for (auto _ : state) {
std::size_t sides = 0;
for (auto && shape : shapes) {
sides += shape->info();
shape->bump();
}
benchmark::DoNotOptimize(sides);
}
}static constexpr std::size_t N = 1'000'000;
static void iterate_and_call_classic(benchmark::State& state) {
std::vector<std::unique_ptr<IShape>> shapes;
shapes.reserve(N);
std::mt19937 mt{}; // default initialized for all tests
for (std::size_t i = 0; i < N; ++i) {
if (mt() % 2 == 0) {
shapes.push_back(std::make_unique<VCircle>());
} else {
shapes.push_back(std::make_unique<VSquare>());
}
}
// Testing the access times when iterating throught the vector
for (auto _ : state) {
std::size_t sides = 0;
for (auto && p_shape : shapes) {
sides += p_shape->sides();
}
benchmark::DoNotOptimize(sides);
}
}
static void iterate_and_call_some(benchmark::State& state) {
std::vector<vx::some<Shape>> shapes;
shapes.reserve(N);
// std::random_device rd;
std::mt19937 mt {};
for (std::size_t i = 0; i < N; ++i) {
if (mt() % 2 == 0) {
shapes.emplace_back(Circle{});
} else {
shapes.emplace_back(Square{});
}
}
for (auto _ : state) {
std::size_t sides = 0;
for (auto && shape : shapes) {
sides += shape->sides();
}
benchmark::DoNotOptimize(sides);
}
}
static void iterate_and_call_some_no_sbo(benchmark::State& state) {
std::vector<vx::some<Shape, vx::cfg::some{.sbo{0}}>> shapes;
shapes.reserve(N);
// std::random_device rd;
std::mt19937 mt {};
for (std::size_t i = 0; i < N; ++i) {
if (mt() % 2 == 0) {
shapes.emplace_back(Circle{});
} else {
shapes.emplace_back(Square{});
}
}
for (auto _ : state) {
std::size_t sides = 0;
for (auto && shape : shapes) {
sides += shape->sides();
}
benchmark::DoNotOptimize(sides);
}
}/// The code is very similar for some and fsome
static void call_classic(benchmark::State& state) {
std::mt19937 mt{};
std::unique_ptr<IShape> shape {};
unsigned long sides {0};
for (auto _ : state) {
if (mt() % 2 == 0) { shape = std::unique_ptr<IShape>(new VCircle{}); }
else { shape = std::unique_ptr<IShape>(new VSquare{}); }
sides += shape->info();
shape->bump();
}
benchmark::DoNotOptimize(sides);
}
static void call_some_no_sbo(benchmark::State& state) {
using vx::some;
std::mt19937 mt{};
some<Shape, vx::cfg::some{.sbo{0}}> shape {};
unsigned long sides {0};
for (auto _ : state) {
if (mt() % 2 == 0) { shape = Circle{}; }
else { shape = Square{}; }
sides += shape->info();
shape->bump();
}
benchmark::DoNotOptimize(sides);
}
static void call_some(benchmark::State& state) {
using vx::some;
std::mt19937 mt{};
some<Shape> shape {};
unsigned long sides {0};
for (auto _ : state) {
if (mt() % 2 == 0) { shape = Circle{}; }
else { shape = Square{}; }
sides += shape->info();
shape->bump();
}
benchmark::DoNotOptimize(sides);
}
BENCHMARK(call_classic);
BENCHMARK(call_some);
BENCHMARK(call_some_no_sbo);