新的现代 C++ 容器中的分配器传播策略

Allocator propagation policies in your new modern C++ containers

容器中具有这些特性的原因是什么(https://en.cppreference.com/w/cpp/memory/allocator_traits)

propagate_on_container_copy_assignment  Alloc::propagate_on_container_copy_assignment if present, otherwise std::false_type
propagate_on_container_move_assignment  Alloc::propagate_on_container_move_assignment if present, otherwise std::false_type
propagate_on_container_swap             Alloc::propagate_on_container_swap if present, otherwise std::false_type
is_always_equal(since C++17)            Alloc::is_always_equal if present, otherwise std::is_empty<Alloc>::type

我了解容器实现在其分配和交换实现中将以一种或另一种方式运行。 (并且处理这些案例是可怕的代码。) 我也明白,有时可能需要将移出容器保持在 resizeble 的状态,或者至少可以调用一些最后的释放,因此分配器不能保持无效。 (我个人认为这是一个薄弱的论点。)

但问题是,为什么该信息不能已经成为自定义分配器类型本身的正常实现和语义的一部分?

我的意思是,容器复制分配可以尝试复制分配源分配器,如果语法复制分配没有真正复制,那么,好吧,就像说你的容器 doesn 't propagate_on_container_copy_assignment.

以同样的方式而不是使用 is_always_equal 实际上可以使分配器赋值什么也不做。

(此外,如果 is_always_equal 为真,可以为分配器 operator== return std::true_type 发出信号。)

在我看来,这些特征似乎试图覆盖可以通过普通 C++ 方式赋予自定义分配器的语义。 这似乎与泛型编程和当前的 C++ 哲学背道而驰。

唯一的原因,我认为这对于实现与“旧”容器的某种向后兼容性很有用。

如果我今天要写一个 new 容器 and/or 一个 new 非平凡分配器,我可以依靠分配器的语义而忘记这些特征吗?

在我看来,只要移出的分配器可以“解除分配”一个空指针状态(这意味着在这种特殊情况下主要是什么也不做),那么它应该没问题,如果 resize 抛出,这也很好(有效),它只是意味着分配器无法再访问其堆。


编辑: 实际上,我可以这样简单地编写容器吗?并将复杂性委托给自定义分配器的语义?:

templata<class Allocator>
struct my_container{
  Allocator alloc_;
  ...
  my_container& operator=(my_container const& other){
    alloc_ = other.alloc_; // if allocator is_always_equal equal this is ok, if allocator shouldn't propagate on copy, Alloc::operator=(Alloc const&) simply shouldn't do anything in the first place
    ... handle copy...
    return *this;
  }
  my_container& operator=(my_container&& other){
    alloc_ = std::move(other.alloc_); // if allocator shouldn't propagate on move then Alloc::operator=(Alloc&&) simply shouldn't do anything.
    ... handle move...
    return *this;
  }
  void swap(my_container& other){
     using std::swap;
     swap(alloc, other.alloc); //again, we assume that this does the correct thing (including not actually swapping anything if that is the desired criteria. (that would be the case equivalent to `propagate_on_container_swap==std::false_type`)
     ... handle swap...
  }
}

我认为对分配器的唯一真正要求是,移出的分配器应该能够做到这一点。

my_allocator a2(std::move(a1));
a1.deallocate(nullptr, 0); // should ok, so moved-from container is destructed (without exception)
a1.allocate(n); // well defined behavior, (including possibly throwing bad_alloc).

并且,如果移出容器无法调整大小,因为移出分配器无法访问堆(例如,因为没有针对特定资源的默认分配器),好吧,太糟糕了,那么该操作将抛出(因为任何调整大小都可能抛出)。

I mean, container copy-assignment can try copy-assign the source allocator, and if that syntactic copy assign doesn't really copy, then, well, it is like saying that your container doesn't propagate_on_container_copy_assignment.

concept/named 要求“CopyAssignable”的含义不仅仅是将左值分配给与该左值类型相同的对象的能力。它还具有语义含义:预期目标对象的值与原始对象的值相等。如果您的类型提供了复制赋值运算符,则期望该运算符复制对象。标准库中几乎所有允许复制分配的东西都需要这个。

如果你给标准库一个类型,它需要 CopyAssignable,并且它有一个复制赋值运算符,它不遵守 concept/named 要求的语义,会导致未定义的行为。

分配器有某种 "value"。并复制分配器复制 "value"。在 copy/move/swap 上传播的问题基本上是在问这个问题:分配器的值是容器值的一部分吗?这个问题只在处理容器的范围内提出;在处理一般的分配器时,这个问题没有实际意义。分配器有一个值,复制它会复制那个值。但这相对于先前分配的存储意味着什么是一个完全不同的问题。

因此特征。

If I were to write a new container and/or an new non-trivial allocator today, can I rely on the sematics of the allocator and forget about these traits?

...

Can I write the containers simply this way? and delegate the complexity to the semantics of the custom allocators?:

不能将违反 AllocatorAwareContainer is not an allocator aware container, and you cannot reasonably pass allocators to it that follow the standard library allocator model. Similarly, an allocator that violates the rules of an Allocator 规则的容器合理地分配给 AllocatorAwareContainer,因为该模型要求分配器实际上是一个分配器。这包括语法和 语义 规则。

如果您不为 propagate_on_* 属性提供值,则将使用默认值 false。这意味着它不会尝试传播您的分配器,因此您不会 运行 与分配器 copy/move-assignable 或可交换的需要相冲突。然而,这也意味着您的分配器的 copy/move/swap 行为将永远不会被使用,因此您为这些操作赋予什么语义并不重要。此外,如果没有传播,如果两个分配器不相等,则意味着线性时间 move/swap.

但是,仍然不允许 AllocatorAwareContainer 忽略 这些属性,因为根据定义,它必须实现它们才能承担该角色。如果分配器定义了复制赋值运算符,但使其复制时传播为假(这是完全有效的代码),则您不能在复制分配容器时调用分配器的复制赋值运算符.

基本上,完成这项工作的唯一方法是生活在你自己的宇宙中,在那里你只将你的 "containers" 与你的 "allocators" 一起使用,并且永远不要尝试将任何标准库等价物与你的 "containers/allocators".


A historical review can also be helpful.

出于历史原因,propagate_on_* 功能在 C++11 中有一段曲折的历史,但它从未 如您所建议的那样出现。

我能找到的关于这个主题的最早论文是 N2525 (PDF): Allocator-specific Swap and Move Behavior。该机制的主要目的是允许某些 classes 的有状态迭代器能够进行恒定时间的移动和交换操作。

这曾一度被基于概念的版本所包含,但一旦从 C++0x 中删除,它又回到了特性 class with a new name and a more simplified interface (PDF)(是的,接口您现在使用的是简单版本。不客气;))。

因此,在所有情况下,都明确认识到需要区分 copy/move/swap 的存在与这些操作相对于容器的含义。


and that handling of these case is horrible code.

但事实并非如此。在 C++17 中,您只需使用 if constexpr。在旧版本中,你必须依赖 SFINAE,但这意味着编写这样的函数:

template<typename Alloc>
std::enable_if_t<std::allocator_traits<Alloc>::propagate_on_container_copy_assignment::value> copy_assign_allocator(Alloc &dst, const Alloc &src)
{
  dst = src;
}

template<typename Alloc>
std::enable_if_t<!std::allocator_traits<Alloc>::propagate_on_container_copy_assignment::value> copy_assign_allocator(Alloc &dst, const Alloc &src) {}

以及用于移动和交换的版本。然后根据传播行为调用该函数来执行 copy/move/swap 或不执行 copy/move/swap。

尼可波拉斯的回答非常准确。我会这样说:

  • An allocator is a handle to a heap. 它是一个值语义类型,就像指针或 intstring 一样。当你复制一个分配器时,你会得到它的值的副本。副本比较相等。这适用于分配器,就像它适用于指针或 ints 或 strings.

  • 你可以用分配器做的一件事是使用纯值语义将它传递给不同的算法和数据结构。 STL 在这个部门没有太多,但它确实有,例如。 allocate_shared.

  • 您可以使用分配器做的另一件事 是将其分配给 STL 容器。您在容器的构造期间将分配器提供给容器。在其生命周期的某些时刻,容器会遇到其他分配器,它必须做出选择。


A<int> originalAlloc = ...;
std::vector<int, A<int>> johnny(originalAlloc);

A<int> strangeAlloc = ...;
std::vector<int, A<int>> pusher(strangeAlloc);

// pssst kid wanna try my allocator? it'll make you feel good
johnny = std::move(pusher);

此时,johnny不得不做出一个艰难的决定:"I'm adopting pusher's elements' values as far as my value is concerned; should I also adopt his allocator?"

在 C++11 及更高版本中,johnny 做出决定的方式是参考 allocator_traits<A<int>>::propagate_on_container_move_assignment 并按照它说的去做:如果它说 true 那么我们将采用 strangeAlloc,如果它显示 false,我们将坚持我们的原则并坚持使用我们原来的分配器。坚持使用我们原来的分配器确实意味着我们可能不得不做一些额外的工作来复制所有 pusher 的元素(我们不能只是窃取他的数据指针,因为它指向与 strangeAlloc,而不是与 originalAlloc).

关联的堆

重点是,决定坚持使用当前的分配器还是采用新的分配器是一个只有在 容器 的上下文中才有意义的决定。这就是为什么特征 propagate_on_container_move_assignment (POCMA) 以及 POCCA 和 POCS 在名称中都有 "container" 的原因。这是关于 container 赋值期间发生的事情,而不是 allocator 赋值期间发生的事情。分配器分配遵循值语义,因为分配器是值语义类型。期间。

那么,propagate_on_container_move_assignment(POCMA)和POCCA、POCS应该都是容器类型的属性吗?我们是否应该有 std::vector<int> 混杂地采用分配器,而 std::stickyvector<int> 始终坚持使用其构造的分配器?嗯,大概吧。

C++17 假装我们确实 那样做,通过给出类似于 std::pmr::vector<int> 的类型定义,看起来与 std::stickyvector<int> 非常相似;但在引擎盖下 std::pmr::vector<int> 只是 std::vector<int, std::pmr::polymorphic_allocator<int>> 的类型定义,并且仍然通过咨询 std::allocator_traits<std::pmr::polymorphic_allocator<int>>.

来弄清楚该怎么做