3
\$\begingroup\$

Sometimes I use std::unique_ptr<BaseClass> when all I really want is polymorphism. For fun, I made this container that skips the heap allocation. The downside is that it needs to know all possible derived types it will store in order to know how much space to reserve.

Any feedback is appreciated, but I'm especially interested in how this implementation enables/prevents compiler optimizations you would otherwise have with virtual types.

Edit: I am also intersted in supporting constexpr access, but right now the reinterpret_cast prevents constexpr dereference.

#include <algorithm>
#include <type_traits>

template <typename B, size_t Size, size_t Align>
struct PolyUnionSize {
    template <typename D>
    explicit PolyUnionSize(D&& d) noexcept (std::is_nothrow_copy_constructible_v<D>) {
        new(&storage) D(std::forward<D>(d));
    }

    template <typename D>
    PolyUnionSize& operator=(D&& d) noexcept(std::is_nothrow_assignable_v<B, D>) {
        this->~PolyUnionSize();
        *(new(&storage) D) = std::forward<D>(d);
        return *this;
    }

    ~PolyUnionSize() noexcept {
        (*this)->~B();
    }

    B const& operator*() const noexcept {
        return *reinterpret_cast<B const*>(&storage);
    }

    B& operator*() noexcept {
        return *reinterpret_cast<B*>(&storage);
    }

    B const* operator->() const noexcept {
        return reinterpret_cast<B const*>(&storage);
    }

    B* operator->() noexcept {
        return reinterpret_cast<B*>(&storage);
    }

private:
    typename std::aligned_storage<Size, Align>::type storage;
};

constexpr auto max(std::initializer_list<std::size_t> const& t) noexcept {
    return *std::max_element(std::begin(t), std::end(t));
}

template <typename B, typename ... Ds>
struct PolyUnion : public PolyUnionSize<B, max({sizeof(Ds)... }), max({alignof(Ds)... })> {
    using PUSize = PolyUnionSize<B, max({sizeof(Ds)... }), max({alignof(Ds)... })>;

    template <typename D>
    explicit PolyUnion(D&& d) noexcept(std::is_nothrow_copy_constructible_v<D>)
        : PUSize(std::forward<D>(d))
    {
        static_assert((std::is_base_of_v<B, Ds> && ...));
        AssertDinDs<D>();
    }

    template <typename D>
    PolyUnion& operator=(D&& d) noexcept(std::is_nothrow_assignable_v<B, D>) {
        AssertDinDs<D>();
        PUSize::operator=(std::forward<D>(d));
        return *this;
    }

private:
    template <typename D>
    constexpr void AssertDinDs() const& noexcept{
        static_assert((std::is_same_v<D, Ds> || ...));
    }
};

Sample usage:

struct Base {
    virtual int Foo() const { return 1; }
};

struct A : Base {
    int Foo() const final { return 2; }
};

struct B : Base {
    int Foo() const final { return 3; }
};

int main() {
    PolyUnion<Base, A, B> pu(A{});
    pu = B{};
    return pu->Foo();
}

Compiles with -std=c++17 or -std=c++2a

\$\endgroup\$
3
\$\begingroup\$
  1. I suggest not codifying the derived types suggested in the type. Only note the ultimate base, size and alignment. Thus you are open to later change.

    Add a templated alias to get the proper type from base plus candidates.

  2. Assure that no over-sized object is ever assigned in PolyUnionSize, probably best using SFINAE. No need to defer to the user.

  3. Don't assume constructing the new object will never fail.

  4. PolyUnionSize::PolyUnionSize<class D>(D&& d) and PolyUnionSize::operator=<class D>(D&& d) use perfect forwarding. You don't account for that by using std::decay_t where needed, nor heed it when computing noexcept.

  5. The implicit copy-/move- ctor / assignment are only appropriate for trivial types. And in that case, why override the dtor?

  6. As casts should be used sparingly, consider delegating between op* and op->.

\$\endgroup\$
  • \$\begingroup\$ Thanks for your response. I think I already do 1? 2 is a good idea. I thought for a while about 3, but I don't see how it's possible (isn't this the reason std::variant can be valueless by exception?). I don't know how to write 4 but it sounds like a good idea. For 5, I could add a move ctor, but I think the copy ctor is enough for now. And I write a dtor because the storage won't call the dtor automatically. re 6, fair point could refactor. \$\endgroup\$ – sudo rm -rf slash Jul 11 at 16:21
  • \$\begingroup\$ @sudorm-rfslash No, you have a base which does most of point 1. As the ultimate base must have a polymorphic dtor (check with std::has_virtual_destructor, you could make a short sortie into implementation-details, and take advantage of every known implementation putting a non-null virtual pointer first for an identifying marker. Simply disable the implicit functions. As an aside, consider swallowing the overhead and starting with a pointer to an array of helper-functions for a fuller experience and better conformance. \$\endgroup\$ – Deduplicator Jul 11 at 16:38
1
\$\begingroup\$

We're in C++17, so we can use std::aligned_storage_t:

typename std::aligned_storage_t<Size, Align> storage = {};

(I added the initializer to pacify g++ -Weffc++; I also added virtual ~Base() = default; for the same reason).

One thing that breaks is that I can't assign a B to the object unless the base is explicitly listed as one of the Ds.... Perhaps that's intentional; it's certainly worth noting in the documentation if it is (I note that the posted code has no documentary comments - that really should be fixed).

I get a long cascade of errors if I instantiate with only the base type and empty Ds...:

PolyUnion<Base> foo(Base{});

We can reduce that greatly by constraining with concepts, or just make this degenerate case be valid, by including the base type in the max() call:

template <typename B, typename ... Ds>
struct PolyUnion
    : public PolyUnionSize<B,
                           max({sizeof(B), sizeof(Ds)... }),
                           max({alignof(B), alignof(Ds)... })>
{
    using PUSize = PolyUnionSize<B,
                                 max({sizeof(B), sizeof(Ds)... }),
                                 max({alignof(B), alignof(Ds)... })>;

I tried adding a static_assert() to force non-empty Ds..., but that didn't reduce the log spam very much, and I was unable to use std::enable_if to SFNIAE the template out for empty Ds. I did get a useful, short error message by specializing the template for that case, though:

template <typename B>
class PolyUnion<B>;     // deliberately incomplete
\$\endgroup\$
  • \$\begingroup\$ I was not sure exactly what kind of Base classes I want to accept, so I did not make any design choices about that. My original goal was to get virtual function calls to work. However this implementation doesn't work with non virtual overloads (you'll always call the base class version -- not sure if there's any way around that without adding additional overhead). Therefore I ignore (but do not disallow) creating a Base object. You're right that this should be documented in a comment. \$\endgroup\$ – sudo rm -rf slash Jul 11 at 21:27
  • \$\begingroup\$ I think your definition of "doesn't work with non virtual overloads" is actually "works exactly the same as a reference or (raw/smart) pointer to the base class" - i.e. exactly what a programmer expects. I don't see a problem there. :-) \$\endgroup\$ – Toby Speight Jul 12 at 7:39
  • 1
    \$\begingroup\$ yes, but it's not the same as a union/variant. if you store pu = Derived then call pu->novirtualfunc, it's not very obvious from the call site that you're using the base class reference \$\endgroup\$ – sudo rm -rf slash Jul 12 at 7:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.