How to Compose Allocator-aware Types

Suppose we want to make an allocator-aware type trip_descriptor to describe a booked airline flight.

To build our type, we’ll use the allocator-aware container classes already provided by the standard library. The data members of our class might look something like this

class trip_descriptor {
  public:
    // ...

    int trip_id() const noexcept { return trip_id_; }

    // ... other methods

  private:
    int trip_id_;
    std::pmr::string passenger_name_;
    std::pmr::vector<flight_descriptor> legs_;
};

For the class to be allocator-aware, we need to support specifying a custom allocator in each constructor. Let’s fill those in

class trip_descriptor {
  public:
    using allocator_type = std::pmr::polymorphic_allocator<>;

    trip_descriptor(
          int trip_id,
          std::pmr::string&& passenger_name,
          std::pmr::vector<flight_descriptor>&& legs,
          allocator_type alloc = {})
      : trip_id_{trip_id},
        passenger_name_{std::move(passenger_name), alloc},
        legs_{std::move(legs), alloc}
    {}

    trip_descriptor(const trip_descriptor& other, allocator_type alloc = {})
      : trip_id_{other.trip_id_},
        passenger_name_{other.passenger_name_, alloc},
        legs_{other.legs, alloc}
    {}

    trip_descriptor(trip_descriptor&& other, allocator_type alloc)
      : trip_id_{other.trip_id_},
        passenger_name_{std::move(other.passenger_name_), alloc},
        legs_{std::move(other.legs_), alloc}
    {}

    trip_descriptor(trip_descriptor&& other) noexcept = default;

    trip_descriptor& operator=(const trip_descriptor&) = default;

    trip_descriptor& operator=(trip_descriptor&&) = default;

    // ...

    int trip_id() const noexcept { return trip_id_; }

    // ... other methods

  private:
    int trip_id_;
    std::pmr::string passenger_name_;
    std::pmr::vector<flight_descriptor> legs_;
};

As we can clearly see, making the class allocator-aware leads to quite a bit of additional boilerplate code, and this increases the maintenance burden for the class: If we add an additional data member, we need to go through and modifying all of these constructors.

Is there a way to avoid all the boilerplate? Fortunately, by using C++ structured-binding reflection and an allocator_aware_helper class, we can. Here’s how we can rewrite trip_descriptor to avoid the boilerplace:

struct trip_descriptor_data {
    int trip_id_;
    std::pmr::string passenger_name_;
    std::pmr::vector<flight_descriptor> legs_;
};

class trip_descriptor final : public allocator_aware_helper<trip_descriptor_data> {
  using base = allocator_aware_helper<trip_descriptor_data>;

  public:
    using base::base;

    trip_descriptor(
          int trip_id,
          std::pmr::string&& passenger_name,
          std::pmr::vector<flight_descriptor>&& legs,
          allocator_type alloc = {})
      : base{
          trip_descriptor_data{
            .trip_id_{trip_id},
            .passenger_name_{std::move(passenger_name},
            .legs{std::move(legs)}
          }, alloc
        }
    {}

    // ...

    int trip_id() const noexcept { return trip_id_; }

    // ... other methods

};

Now when we write code like

trip_descriptor trip1;
// fill in trip1

std::pmr::polymorphic_allocator<> alloc = /* a custom allocator */;
trip_descriptor trip2{std::move(trip1), alloc};

trip_descriptor will correctly move construct with the specified allocator by using the inherited constructors from allocator_aware_helper.

How does allocator_aware_helper work? By using a combination of structured bindings and SFINAE, it’s able to decompose trip_descriptor_data into a tuple. It can then access members individually and forward the user-provided allocator if a member is allocator-aware. For example, here’s what allocator_aware_helper’s move constructor looks like

template <class T>
T make_allocator_aware_move_constructor_initializer(
    T &other, std::pmr::polymorphic_allocator<> alloc) noexcept {
  if constexpr (basc::allocator_aware<T>) {
    return T{std::move(other), alloc};
  } else {
    return T{std::move(other)};
  }
}

template <class Data, size_t... Indexes>
class allocator_aware_impl<Data, std::index_sequence<Indexes...>> : protected Data {
 public:
   using allocator_type = std::pmr::polymorphic_allocator<>;

   // ...
   allocator_aware_impl(allocator_aware_impl &&other,
                        allocator_type alloc) noexcept
       : Data{make_allocator_aware_move_constructor_initializer(
             basrf::tuple_convertible_get<Indexes>(static_cast<Data &>(other)),
             alloc)...} {}
  // ...
};

template <class Data>
class allocator_aware_helper
    : public allocator_aware_impl<
          Data, std::make_index_sequence<basrf::tuple_convertible_arity_v<Data>>> {
  // ...
};

Similarly, allocator_aware_helper provides allocator-aware copy construction and default construction.

For the full example, check out the source code here.

Using allocator_aware_helper, our type trip_descriptor need only worry about type-specific constructors, making it much easier to create and maintain.

Stay up to date