Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save thecppzoo/58496f06dc9126900ecf9a7246a81549 to your computer and use it in GitHub Desktop.
Save thecppzoo/58496f06dc9126900ecf9a7246a81549 to your computer and use it in GitHub Desktop.

Goals

The main aim of AnyContainer is to solve the need for subtyping without the problems of subclassing, i.e., not forcing user types to inherit from base classes, allowing value semantics, and having the option of the performance of normal C++ code rather being forced to the inefficient dynamic dispatch through a virtual table.

We think the goals have been achieved: the performance and object code size are optimal as far as we know, and it is possible to have general solutions for runtime polymorphism, including infinite refinement.

Comparison to folly::Poly

First let us see a comparison to another type-erasure framework.

Eric Niebler authored Facebook's folly::Poly, a framework for type erasure. Niebler compares his work to Louis Dionne's Dyno, this document is an early draft of how we can compare zoo::AnyContainer to them.

In the document describing Poly, this example (adapted from Dyno) is mentioned:

    // This example is an adaptation of one found in Louis Dionne's dyno library.
    #include <folly/Poly.h>
    #include <iostream>

    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>;

    struct Square {
      void draw(std::ostream& out) const { out << "Square\n"; }
    };

    struct Circle {
      void draw(std::ostream& out) const { out << "Circle\n"; }
    };

    void f(drawable const& d) {
      d.draw(std::cout);
    }

    int main() {
      f(Square{}); // prints Square
      f(Circle{}); // prints Circle
    }

Using the Generic Policy mechanism to build policies for AnyContainer, that example looks identical from the definition of drawable, in zoo the affordance of drawing would look like this, and would be used like this:

// This is the drawable affordance
struct IDrawable {
    // This affordance is implemented by putting a function pointer
    // "drawBehavior" in the vtable
    struct VTableEntry {
        void (*drawBehavior)(const void *, std::ostream &);
    };

    // To replicate Poly's behavior, throw an exception
    constexpr static inline VTableEntry Default = {
        [](const void *, std::ostream &){
            throw std::runtime_error("Drawing nothing");
        }
    };

    // if not an empty container, do the following
    template<typename ConcreteValueManager>
    constexpr static inline VTableEntry Operation = {
        [](const void *pConcreteValueManager, std::ostream &out) {
            auto pValue =
                // The behaviors in the vtable use the concrete value manager
                // but receive the value manager as a void * for generality
                static_cast<const ConcreteValueManager *>(
                    pConcreteValueManager
                )
                // the concrete value managers have an accessor "value" for the
                // address of the object they manage
                ->value();
            pValue->draw(out);
        }
    };

    // nothing needed on a per-instance, just the vtable
    template<typename>
    struct Mixin {};

    // What the user will be able to do:
    template<typename SpecificAnyContainer>
    struct UserAffordance {
        void draw(std::ostream &out) const {
            // CRTP downcast
            auto myself = static_cast<const SpecificAnyContainer *>(this);
            // Access the internal manager
            auto manager = myself->container();
            // The managers must be able to access the vtable entry of this
            // affordance by using "vTable<VTableEntry>"
            auto vTable = manager->template vTable<IDrawable>();
            // the dynamic dispatch
            vTable->drawBehavior(manager, out);
        }
    };
};

using MoveOnlyDrawablePolicyWith3PointersOfSpace =
    zoo::Policy<
        // The local buffer will be big enough to fit void *[3] and aligned
        // as a void *[3] is aligned
        void *[3],
        // stock affordances to destroy and move, the user could write their
        // own!, including affordances that do nothing!
        zoo::Destroy, zoo::Move,
        // Just tack in the user affordance
        IDrawable
    >;

using drawable = zoo::AnyContainer<MoveOnlyDrawablePolicyWith3PointersOfSpace>;

The rest is identical.

The compiler explorer tells us this works as expected: https://godbolt.org/z/3TtfHH

Now, the ostream library is quite intricate, let us simplify that to be able to compare both implementations easier, let us "fake" std::ostream like this:

namespace OTHER {
    struct ostream;
    template<typename T>
    ostream &operator<<(ostream &, const T &);
    ostream *pCout;
    auto &cout = *pCout;
    struct runtime_error: std::runtime_error {
        using std::runtime_error::runtime_error;
    };
}
#define std OTHER

I preprocessed folly/Poly.h to use the compiler explorer with it, the scripts are available, but since folly configures itself tightly to a platform, some differences may arise in your installation, however I think this link is representative:

https://godbolt.org/z/toyDnA

This is AnyContainer with the generic policy:

https://godbolt.org/z/JNh6Gm

The first thing you should notice is that the assembler output, not taking into account empty lines and labels, is about a whole order of magnitude smaller.

You can see there are very fundamental differences between zoo::AnyContainer and other libraries, in particular folly::Poly, one of them is folly::Poly follows the pattern of providing base classes for user code to refine, we do exactly the opposite, our infrastructure takes base classes from user code. That's the reason for my confidence that this framework can do pretty much anything any other can and then some, its extensibility is bounded only by the imagination of the users, and its attitude is to, whatever little it does, it will be optimal, so the users have confidence that if something the framework does is useful to them, they can rely it will be done optimally; and also does not want to impose policies on users, even the most fundamental mechanisms can be changed by the user, for example, if the user does not like the stock affordance of destruction, moving, very well, they may change them to their liking.

This is very fruitful, for example, here there is no dychotomy between move-only and copyable, since for a set of affordances the copyable AnyContainer "knows" how to do anything the move-only does, they should be compatible at the most fundamental level; in this case, at the level of reference compatibility!:

static_assert(!std::is_copy_constructible_v<drawable>);
using CopyableDrawablePolicy =
    // This allows the synthesis of something akin to inheritance
    zoo::DerivedVTablePolicy<
        drawable, // A type-erased container that will be the base
        zoo::Copy // with the old affordances plus this one
    >;
using copyable_drawable = zoo::AnyContainer<CopyableDrawablePolicy>;
static_assert(std::is_copy_constructible_v<copyable_drawable>);

// a copyable_drawable can be used when a move-only is wanted:
void takesMoveOnly(drawable &);

void forwardToMoveOnly(copyable_drawable &cd) {
    takesMoveOnly(cd); // no conversion!
}

See https://godbolt.org/z/5r9QV2 for live code.

That was easy, what about comparison with the language features for runtime polymorphism?

What about comparing runtime polymorphism through this framework to the C++ intrinsic features of inheritance and virtual overrides?

We should create an interface:

struct IDrawable {
    virtual ~IDrawable() {}
    virtual void draw(std::ostream &) const = 0;

    template<typename>
    bool holds() const noexcept;
};

We would like to draw and query if the implementation is an integer, for example.

Now, we need implementations. Because primitive types such as int can not inherit, we need a wrapper, let's called TDrawable:

template<typename T>
struct TDrawable: IDrawable {
    template<typename... As>
    TDrawable(As &&...as): member_(std::forward<As>(as)...) {}

    void draw(std::ostream &out) const override {
        out << member_;
    }

    T member_;
};

Now, we can implement holds() by checking if the runtime type of the IDrawable is the implementation of the wrapper we expect:

template<typename T>
bool IDrawable::holds() const noexcept {
    return typeid(*this) == typeid(TDrawable<T>);
}

And just a deduction guide to not have to indicate explicitly the type:

template<typename A>
TDrawable(A a) -> TDrawable<A>;

We will use this fragment for the comparison:

void drive(std::ostream &out, IDrawable &id) {
    id.draw(out);
}

IDrawable *create(int a) { return new TDrawable(a); }

IDrawable *create(double d) { return new TDrawable(d); }

bool drawableHoldsInteger(const IDrawable &d) noexcept {
    return d.holds<int>();
}

By the way, in zoo, the TDrawable is exactly the same thing we had before, drawable, also, the stock destructor affordance, zoo::Destroy, uses function pointer comparison to provide the holds() introspection capability.

So, here is the code, side by side: https://godbolt.org/z/P-ABPA

We are actually being unfair to zoo::AnyContainer with this comparison, because of these reasons:

  1. zoo will totally let you create local variables and members that are runtime-polymorphic, inheritance + virtual overrides will force you to, will have the drawbacks of:
    1. Use referential semantics: either a pointer or a reference
    2. Allocate on the heap
    3. Manage the lifetime of the dynamic object
    4. Brings in the crapola of RTTI use it or not
    5. Won't let you bind the actual functions to be called, you will always have to use dynamic dispatch
  2. With inheritance + virtual overrides the frequent way to change the value is by deallocating (also destroying) it and reallocating (including construction), the deallocation/allocation pair may be avoided if the local buffer is suitable
  3. For any operation, zoo is either nearly identical to inheritance + virtual overrides or better, look for example drive: it is shorter because zoo lets you implement the affordance in a way such that the order of the parameters is optimal, drawableHoldsInt is much shorter because it uses an inherently cheaper introspection mechanism than what is available with const std::type_info &, these are zoo freedoms with respect to the fundamental operations that because inheritance + virtual overrides have absolutely nothing comparable we can't compare!
  4. For subtyping relationships that are not what I call monophyletic, inheritance + virtual overrides becomes an unsolvable class hierarchy problem really quickly, whereas zoo "whatever" will let you express para- and poly-phyletic subtyping relationships naturally <-- this is the killer feature!: my tweets explaining this https://twitter.com/thecppzoo/status/1222287048490401793

Even being "unfair" to zoo we see a pretty big difference: the performance of zoo is equal or better, the assembler is half as big!

So, I really mean that this runtime-polymorphism framework is more efficient than the intrinsic language mechanism of inheritance + virtual overrides, the advantages become larger and larger the more features you use because they compound with each other, and it has more expressive/modeling power for important subtyping relationships!...

Acknowledgements

I would like to thank Eric Niebler and Louis Dionne for the open source work they have done over the years, studying their work is how I learned the programming techniques that made this possible.

Also, I am profoundly grateful to my employer Snap, Inc., the maker of Snapchat for having allowed me to "upstream" improvements and the experience of having exposed earlier versions of this framework to the huge challenge of running at the scale of hundreds of millions of active daily users, platforms from Android, iOS, Windows, Mac OSX

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment