Search code examples
c++allocatorboilerplatec++pmr

Authoring a container to work with both c++11 and pmr allocators


How do I correctly create a container that works with both, C++11 and C++17 polymorphic allocators? Here's what I have so far (as a generic boilerplate template):

Explanation: I've included two fields, res_ which shows how dynamic memory is managed directly from the container, whereas field vec_ is used to demonstrate how the allocator propagates downwards. I've taken a lot from Pablo Halpern's talk Allocators: The Good Parts but he mainly talks about pmr allocators, not the c++11 ones.

Demo

#include <cstdio>
#include <vector>
#include <memory>
#include <memory_resource>


template <typename T, typename Allocator = std::allocator<T>>
struct MyContainer {

    auto get_allocator() const -> Allocator {
        return vec_.get_allocator();
    }

    MyContainer(Allocator allocator = {})
        : vec_{ allocator }
    {}

    MyContainer(T val, Allocator allocator = {})
        : MyContainer(allocator)
    {
        res_ = std::allocator_traits<Allocator>::allocate(allocator, sizeof(T));
        std::allocator_traits<Allocator>::construct(allocator, res_, std::move(val));
    }

    ~MyContainer() {
        Allocator allocator = get_allocator();
        std::allocator_traits<Allocator>::destroy(allocator, std::addressof(res_));
        std::allocator_traits<Allocator>::deallocate(allocator, res_, sizeof(T));
        res_ = nullptr;
    }

    MyContainer(const MyContainer& other, Allocator allocator = {})
        : MyContainer(allocator)
    {
        operator=(other);
    }

    MyContainer(MyContainer&& other) noexcept
        : MyContainer(other.get_allocator())
    {
        operator=(std::move(other));
    }

    MyContainer(MyContainer&& other, Allocator allocator = {})
        : MyContainer(allocator)
    {
        operator=(std::move(other));
    }

    auto operator=(MyContainer&& other) -> MyContainer& {
        if (other.get_allocator() == get_allocator()) {
            std::swap(*this, other);
        } else {
            operator=(other); // Copy assign
        }
    }

    auto operator=(const MyContainer& other) -> MyContainer& {
        if (other != this) {
            std::allocator_traits<Allocator>::construct(get_allocator(), std::addressof(vec_), vec_);
            std::allocator_traits<Allocator>::construct(get_allocator(), std::addressof(res_), other);
        }
        return *this;
    }
    
private:
    std::vector<T, Allocator> vec_; // Propagation
    T* res_ = nullptr;
};

int main() {
    MyContainer<std::string, std::pmr::polymorphic_allocator<std::byte>> ctr1 = std::string{"Hello World!"};

    MyContainer<double> ctr2 = 2.5;
}

However even this doesn't work as planned, as vector expects its value type to match that of the allocator:

<source>:67:31:   required from 'struct MyContainer<std::__cxx11::basic_string<char>, std::pmr::polymorphic_allocator<std::byte> >'
<source>:72:74:   required from here
/opt/compiler-explorer/gcc-13.1.0/include/c++/13.1.0/bits/stl_vector.h:438:64: error: static assertion failed: std::vector must have the same value_type as its allocator
  438 |       static_assert(is_same<typename _Alloc::value_type, _Tp>::value,
      | 

What else am I missing? Should I maybe propagate differently based on allocator's propagation traits (is this required for generic containers)?


Solution

  • tl;dr

    • All standard library containers must be given an allocator with a value_type that is the same as the value_type of the container; otherwise it would be ill-formed.
      So in this case one would need to either use a std::pmr::polymorphic_allocator<std::string> for MyContainer, or rebind the allocator type before passing it to std::vector, e.g.:
      // option 1
      MyContainer<std::string, std::pmr::polymorphic_allocator<std::string>> ctr1 = /* ... */;
      
      // option 2
      template <class T, class Allocator = std::allocator<T>>
      struct MyContainer {
          // ...
      private:
          std::vector<T, typename std::allocator_traits<Allocator>::template rebind_alloc<T>> vec_;
      };
      
      MyContainer<std::string, std::pmr::polymorphic_allocator<std::byte>> ctr1 = /* ... */;
      
    • This is not a problem in the video you linked, because it defines a user-defined container, so it does not matter that it is not an allocator-aware container.
    • Implementing a container class that can handle both std::polymorphic_allocator and std::allocator is comparatively easy - both satisfy the named requirement Allocator, so the special sauce needed in this case is just to do nothing special - implement them as a bog-standard allocator (basically use std::allocator_traits<Alloc> for all interactions with the allocator)
      • This does include manually checking the propagation preferences of the allocator (implement them exactly as described in the table "Influence on container operations" on the Allocator requirements page)

    1. Why the given code example is ill-formed

    All containers that are allocator-aware containers must have an allocator with a value_type that is the same as the value_type of the container.

    This is mandated in the standard by: (emphasis mine)

    24.2.2.5 Allocator-aware containers (4)
    (3) In this subclause,
    (3.1) - X denotes an allocator-aware container class with a value_type of T using an allocator of type A,
    [...] A type X meets the allocator-aware container requirements if X meets the container requirements and the following types, statements, and expressions are well-formed and have the specified semantics.

    typename X::allocator_type

    • (4) Result: A
    • (5) Mandates: allocator_type​::​value_type is the same as X​::​value_type.

    So the following statement must always be true for an allocator-aware container:

    static_assert(
        std::same_as<
            Container::value_type,
            Container::allocator_type::value_type
        >
    );
    

    Note that all containers defined in the standard library (except std::array) are mandated to be allocator-aware. (see 24.2.2.5 (1) Allocator-aware containers)


    Note that in your example that statement will not be satisfied:

    // Hypothetical, won't compile
    using Container = std::vector<std::string, std::pmr::polymorphic_allocator<std::byte>>;
    
    // will be std::string
    using ContainerValueType = Container::value_type;
    // will be std::byte (std::pmr::polymorphic_allocator<std::byte>::value_type)
    using AllocatorValueType = Container::allocator_type::value_type;
    
    // would fail
    static_assert(std::same_as<ContainerValueType, AllocatorValueType>);
    
    • So this version of std::vector would not be an allocator-aware container (because it doesn't fulfill this requirement)
    • But the standard mandates that std::vector must be an allocator-aware container

    => This is ill-formed due to contradiction in the standard.

    Note that this also matches the error message you got from gcc:

    error: static assertion failed: std::vector must have the same value_type as its allocator
    

    2. Why it's not a problem in the linked video

    The Youtube Video you linked in the comments (CppCon 2017: Pablo Halpern “Allocators: The Good Parts”) is about a user-defined container class that does not utilize any standard library containers.

    There are no rules that the standard imposes for user-defined container types, so one can basically do whatever one wants there.

    Here's a small transcript of the class the talk is about:

    template<class Tp>
    class slist {
    public:
      using value_type = Tp;
      using reference = value_type&;
      // ...
      // non-template use of polymorphic_allocator
      using allocator_type = std::pmr::polymorphic_allocator<std::byte>;
    
      // Constructors
      // Every constructor has an variant taking an allocator
      slist(allocator_type a = {});
      slist(const slist& other, allocator_type a = {});
      slist(slist&& other);
      slist(slist&& other, allocator_type a = {});
    
      // ...
    };
    

    Note that the allocator_type is hardcoded to std::pmr::polymorphic_allocator<std::byte>, so allocator_type::value_type will generally not match slist::value_type (except the case where both are std::byte);

    So this container does not satisfy the requirements of an allocator-aware container most of the time.
    But there's also no requirement for it to do so.
    => well-formed

    Note: It would be ill-formed if one would pass e.g. an slist<> to a function that mandates that its parameter must be an allocator-aware container. - But as long as one avoids that there's no issue with defining almost-conforming containers.


    3. How to write a container that works with any allocator

    Note that std::pmr::polymorphic_allocator satisfies the named requirement Allocator, exactly like std::allocator does.
    (All allocators that are intended to be used with standard containers must satisfy that requirement)

    So the trick to support both is just to do nothing special - treat the std::pmr::polymorphic_allocator like any other allocator, since it's just that. (use std::allocator_traits<Alloc> for basically everything)

    Note that this also means that you should respect the std::allocator_traits<Allocator>::propagate_on_ container_copy / container_move_assignment / container_swap values.
    Which for polymorphic_allocator means that the allocator should not propagate when copying / moving / swapping the container.
    Because doing so can lead to surprising lifetime issues - see for example this answer.

    (Of course those should always be respected, not only just for polymorphic_allocators)