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


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

    => 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)