skypjack
skypjack

Reputation: 50550

Type erasure and allocators: what's the expected behavior?

I asked the same on codereview, but they kindly noted that this question is better suited to SO.

Consider the following code:

#include<vector>
#include<memory>

template<typename T>
struct S final {
    struct B {
        virtual void push_back(T&& v) = 0;
        virtual ~B() { }
    };

    template<class Allocator>
    struct D final: public B {
        D(Allocator alloc): vec{alloc} { }
        void push_back(T&& v) override { vec.push_back(v); }
        std::vector<T, Allocator> vec;
    };

    S(): S{std::allocator<T>{}} { } 

    template<class Allocator>
    S(Allocator alloc): ptr{new D<Allocator>{alloc}} { }

    ~S() { delete ptr; }

    void push_back(T&& v) { ptr->push_back(std::move(v)); }

    B* ptr;
};

int main() {
    int x = 42;
    S<int> s1{};
    S<double> s2{std::allocator<double>{}}; 
    s1.push_back(42);
    s2.push_back(x);
}

It's a minimal example for the purpose of the question.
The idea is to type-erase something that accepts a custom allocator (in this case, a std::vector), so as to bend the definition of the container (that has the type of the allocator as part of its type) to something similar to the one of std::function (that has not the type of the allocator as part of its type, but still accepts an allocator during the construction).

The code above compiles, but I have doubts about the fact that this class works as it is expected to do.
In other terms, whenever an user of the class provides its own allocator, it is used as an argument for the new std::vector, the type of which is erased, but it is not used to allocate the instance of D pointed to by ptr.

Is this a valid/logical design, or the allocator should be used consistently for each allocation? I mean, is that something that can be found also in the STL or some other major library, or it's something that doesn't make much sense?

Upvotes: 2

Views: 305

Answers (2)

Jonathan Wakely
Jonathan Wakely

Reputation: 171303

There's no right answer, the design is reasonable, but it would also be reasonable to use the user-supplied allocator for the creation of the derived object. To do that you'd need to do the destruction and deallocation in the type-erased context, so the allocator can be used:

template<typename T>
struct S final {
    struct B {
        // ...
        virtual void destroy() = 0;
    protected:
        virtual ~B() { }
    };

    template<class Allocator>
    struct D final: public B {
        // ...
        void destroy() override {
            using A2 = std::allocator_traits<Allocator>::rebind_alloc<D>;
            A2 a{vec.get_allocator()};
            this->~D();
            a2.deallocate(this, 1);
        }
    };

    S(): S{std::allocator<T>{}} { } 

    template<class Allocator>
      S(Allocator alloc): ptr{nullptr} {
          using DA = D<Allocator>;
          using AT = std::allocator_traits<Allocator>;
          static_assert(std::is_same<typename AT::pointer, typename AT::value_type*>::value, "Allocator doesn't use fancy pointers");

          using A2 = AT::rebind_alloc<DA>;
          A2 a2{alloc};
          auto p = a2.allocate(1);
          try {
              ptr = ::new((void*)p) DA{alloc};
          } catch (...) {
              a2.deallocate(p);
              throw;
          }
      }

    ~S() { ptr->destroy(); }

    // ...
};

(This code asserts that Allocator::pointer is Allocator::value_type*, to support allocators where that's not true you would need to use pointer_traits to convert between pointer types, which is left as an exercise for the reader.)

Upvotes: 1

Richard Hodges
Richard Hodges

Reputation: 69892

Yes, it's a valid design IF:

You want the memory allocation strategy to be user-defined, but want the interface to the class to be polymorphic. This would be reasonable if for example, some of your objects were being generated by a messaging protocol. In any one message, there may be many objects which could all reasonably be allocated from the same memory chunk (owned by the message) for performance reasons.

However:

  1. Obviously you'll want to either implement ptr in terms of a smart pointer, or very carefully write all your copy/move constructors/operators.

  2. You'll need to manage very carefully the copying (and certainly moving!) of objects from one type-erased container to another. It might well be invalid to allow a move of an object allocated with allocator A into a container managed by allocator B, for example. This kind of thing becomes difficult to reason about very quickly. There will need to be runtime check, perhaps throw a std::logic_error if this is violated (or maybe degrade to a copy?), and so on.

Upvotes: 1

Related Questions