This is the summary page for jv::BoundedPoly. To see the reference, go there: reference.html.

Abstract

BoundedPoly<Storage, Base, Mover = …​> is a type abstractor. It stores any no-throw movable polymorphic derived class of Base that fits into size and alignment of Storage. It provides semantics similar to std::unique_ptr<Base> and std::variant<…​>:

  • The memory is owned by the instance (like both, and unlike std::reference_wrapper<Base>).

  • The type can change over time (like both).

  • The actual type of the stored value is not known (like unique_ptr).

  • The set of accepted types is not closed (line unique_ptr and unline variant).

  • It is allocated on the stack (like variant).

  • It cannot be empty, even when exceptions are thrown (like variant, but greater guarantee).

This allows to use both polymorphic-semantics and stack-allocation (and so, cache-friendlyness).

Examples

Shape

IShape
struct IShape {
    virtual ~IShape() noexcept {}
    virtual auto get_area() const noexcept -> float = 0;
    virtual void move_to(void*) && noexcept = 0;
};

We first create an interface named IShape. It provides a get_area() method which returns the area of the shape. It also provides a move_to method to move itself to an uninitialized storage.

ShapeMaxSize
constexpr std::size_t ShapeMaxSize = 16;

We choose an arbitrary size to store shapes.

Shape
using Shape = jv::BoundedPolyVM<std::aligned_storage_t<ShapeMaxSize>, IShape,
                                &IShape::move_to>;

We define Shape as an alias of BoundedPolyVM<Storage, Base, Method>, which is an alias to BoundedPoly<Storage,Base,VirtualMover<Base, Method>>. VM (Virtual Mover) means that we used a virtual method in order to make move operations.

So BoundedPoy<Storage,Base,Mover> is instantiated with the following template parameters:

  • Storage = std::aligned_storage_t<ShapeMaxSize> : it can only stores shapes with sizeof(_) <= ShapeMaxSize.

  • Base = IShape : the polymorphic type abstracted

  • Mover = VirtualMover<IShape, &IShape::move_to> : we specify that to perform a move operation on IShape, we must call its virtual method move_to.

Circle
struct Circle : IShape {
    float radius = 1;

    Circle(float r) noexcept : radius(r) {}

    auto get_area() const noexcept -> float override {
        return PI * radius * radius;
    }

    void move_to(void* dst) && noexcept override {
        new (dst) Circle(std::move(*this));
    }
};

We define Circle an implementation of IShape whose area is defined by PI * radiusĀ².

Rectangle
struct Rectangle : IShape {
    float width, height;

    Rectangle(float w, float h) noexcept : width(w), height(h) {}

    auto get_area() const noexcept -> float override { return width * height; }

    void move_to(void* dst) && noexcept override {
        new (dst) Rectangle(std::move(*this));
    }
};

We define Rectangle an implementation of IShape whose are is defined by width * height.

RectangleEx
struct RectangleEx : Rectangle {
    RectangleEx(float w, float h) noexcept : Rectangle(w, h) {}

    float angle = 0;
    std::uint32_t color = 0xFFFFFFFF;

    void move_to(void* dst) && noexcept override {
        new (dst) RectangleEx(std::move(*this));
    }
};

// RectangleEx is too large to be stored
static_assert(sizeof(RectangleEx) > ShapeMaxSize);

RectangleEx is an improvement of Rectangle. But its added members make its size too big to fit in ShapeMaxSize bytes. So it cannot be handled by Shape.

main
int main() {

    Shape shape = Circle{1.414}; (1)
    std::cout << shape->get_area() << " (expected: about PI*2 = 6.28)\n";
    dynamic_cast<Circle&>(shape.get()).radius = 10; (2)
    std::cout << shape->get_area() << " (expected: about PI*100 = 314)\n";

    shape.emplace<Rectangle>(3, 4); (3)
    std::cout << shape->get_area() << " (expected: 3*4 = 12)\n";
    dynamic_cast<Rectangle&>(shape.get()).height *= 2; (4)
    std::cout << shape->get_area() << " (expected: 3*8 = 24)\n";

    std::cout << "Can handle RectangleEx ? " << std::boolalpha
              << Shape::can_handle_v<RectangleEx> << " (expected: false)\n";

    return 0;
}
1 Construct-moving a Circle of radius 1.414 (about sqrt(2)).
2 dynamic_cast does not throw: it is really a Circle that is stored.
3 Emplacing in place a Rectangle. This is allowed because Rectangle is nothrow-constructible.
4 dynamic_cast does not throw: it is really a Rectangle that is stored.

Complete source


#include <iostream>

#include <jv/bounded-poly.hpp>

constexpr float PI = 3.14159265359f;

struct IShape {
    virtual ~IShape() noexcept {}
    virtual auto get_area() const noexcept -> float = 0;
    virtual void move_to(void*) && noexcept = 0;
};

constexpr std::size_t ShapeMaxSize = 16;

using Shape = jv::BoundedPolyVM<std::aligned_storage_t<ShapeMaxSize>, IShape,
                                &IShape::move_to>;
// to specify the Shape type, we just needed to set a max size for storage, and
// to say which method must be called to move shapes.

struct Circle : IShape {
    float radius = 1;

    Circle(float r) noexcept : radius(r) {}

    auto get_area() const noexcept -> float override {
        return PI * radius * radius;
    }

    void move_to(void* dst) && noexcept override {
        new (dst) Circle(std::move(*this));
    }
};

struct Rectangle : IShape {
    float width, height;

    Rectangle(float w, float h) noexcept : width(w), height(h) {}

    auto get_area() const noexcept -> float override { return width * height; }

    void move_to(void* dst) && noexcept override {
        new (dst) Rectangle(std::move(*this));
    }
};

struct RectangleEx : Rectangle {
    RectangleEx(float w, float h) noexcept : Rectangle(w, h) {}

    float angle = 0;
    std::uint32_t color = 0xFFFFFFFF;

    void move_to(void* dst) && noexcept override {
        new (dst) RectangleEx(std::move(*this));
    }
};

// RectangleEx is too large to be stored
static_assert(sizeof(RectangleEx) > ShapeMaxSize);

int main() {

    Shape shape = Circle{1.414}; (1)
    std::cout << shape->get_area() << " (expected: about PI*2 = 6.28)\n";
    dynamic_cast<Circle&>(shape.get()).radius = 10; (2)
    std::cout << shape->get_area() << " (expected: about PI*100 = 314)\n";

    shape.emplace<Rectangle>(3, 4); (3)
    std::cout << shape->get_area() << " (expected: 3*4 = 12)\n";
    dynamic_cast<Rectangle&>(shape.get()).height *= 2; (4)
    std::cout << shape->get_area() << " (expected: 3*8 = 24)\n";

    std::cout << "Can handle RectangleEx ? " << std::boolalpha
              << Shape::can_handle_v<RectangleEx> << " (expected: false)\n";

    return 0;
}

Benchmark

The benchmark compares the performances of std::variant, std::unique_ptr and jv::utils::PolyStorage.
The program generates a sequence of operations (Addition, Substraction and ExclusiveXor) to be applied on an int.
The sequence is stored in a std::vector with space reserved outside of the time measurements.
The type stored in std::vector is either a std::variant, a std::unique_ptr or a jv::utils::PolyStorage.
The operation is applied using the visitor pattern for std::variant and polymorphic dispatch using unique_ptr and PolyStorage.

We measure 2 things: time of constructing the sequence of operations, and time of evaluating the sequence of operations. These results are taken after compiling bounded-poly.cpp, variant.cpp and unique-ptr.cpp with g -std=c17 -O2. Each result is taken as the minimum of three tries. The sequence contains 100'000'000 operations.

Benchmark results
Which Construction timing Evaluation timing

std::unique_ptr

3.2718 seconds

0.2755 seconds

std::variant

1.2204 seconds

0.1900 seconds

jv::utils::BoundedPoly

1.4158 seconds

0.1846 seconds

We see that unique_ptr is the worse alternative both for construction and evaluation. This was expected, because at construction, it needs heap allocation, and at evaluation, it is not cache-friendly.

We see that variant and BoundedPoly have similar performances.

Comparaisons

Syntax (CAUTION: this is highly opiniated)
  • I do prefer the calling syntax of polymorphism (as with unique_ptr and BoundedPoly), instead of std::visit and lambdas (as with variant).

  • unique_ptr and variant have minimal bloat code in the class definition. For BoundedPoly, we needed to define a move_to operation for each derived class, which is indeed bloat code. It is not needed, by using UniversalMover instead of VirtualMover, but this adds space cost (one function pointer per BoundedPoly).

Customization
  • unique_ptr is the most customizable, because it can stores any type derived from Base.

  • variant is the less customizable, because the types it accepts cannot be customized by a 3rd party.

  • BoundedPoly is a good compromise, types being bounded only by their size and alignment.

Storage
  • unique_ptr is heap allocated, so it is slow both to allocate and to access.

  • variant and BoundedPoly are stack allocated, si it is cache-friendly and does not require system calls for construction.