3

在容器中具有这些特征的原因是什么(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或者至少可以调用一些最后的释放,所以分配器不能无效。(我个人认为这是一个弱论点。)

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

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

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

(此外,如果is_always_equal是真的,可以让operator==分配器返回std::true_type信号。)

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

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

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

在我看来,只要移动的分配器可以“解除分配”一个空指针状态(这意味着在这种特殊情况下基本上什么都不做),那么它应该没问题,如果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).

并且,如果移出容器由于移出分配器失去对堆的访问权而无法调整大小(例如,因为没有特定资源的默认分配器),那么,太糟糕了,那么操作将抛出(因为任何调整大小可以扔)。

4

2 回答 2

7

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

  • 分配器是堆的句柄。它是一种值语义类型,就像指针或 anintstring. 当你复制一个分配器时,你会得到一个它的值的副本。副本比较相等。这适用于分配器,就像它适用于指针或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不得不做出一个艰难的决定:“就我的价值pusher而言,我正在采用'元素'的价值;我是否也应该采用他的分配器?”

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

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

那么,propagate_on_container_move_assignment(POCMA)和 POCCA 和 POCS 都应该是容器类型的属性吗?我们是否应该让std::vector<int>which 混杂地采用分配器,并且std::stickyvector<int>始终坚持使用它构建的分配器?我们可能会。

C++17 假装我们确实是这样做的,通过让 typedefstd::pmr::vector<int>看起来非常类似于std::stickyvector<int>; 但在引擎盖std::pmr::vector<int>下只是一个 typedefstd::vector<int, std::pmr::polymorphic_allocator<int>>并且仍然通过咨询来弄清楚要做什么std::allocator_traits<std::pmr::polymorphic_allocator<int>>

于 2019-02-24T05:22:40.397 回答
6

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

概念/命名要求“ CopyAssignable ”不仅仅意味着将左值分配给与该左值相同类型的对象的能力。它还具有语义意义:目标对象的值应与原始对象的值等价。如果你的类型提供了一个复制赋值操作符,那么期望这个操作符复制对象。标准库中几乎所有允许复制分配的东西都需要这个。

如果您给标准库一个类型,它要求它是 CopyAssignable,并且它有一个不遵守该概念/命名要求的语义含义的复制赋值运算符,则会导致未定义的行为。

分配器具有某种“值”。并复制分配器复制该“值”。在复制/移动/交换上传播的问题基本上是在问这个问题:分配器的值是容器值的一部分吗?这个问题只会在处理容器的范围内提出。在处理一般的分配器时,这个问题是没有实际意义的。分配器有一个值,复制它会复制该值。但这相对于先前分配的存储意味着什么是一个完全独立的问题。

于是有了这个特质。

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

...

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

违反AllocatorAwareContainer规则的容器不是分配器感知容器,您不能合理地将分配器传递给遵循标准库分配器模型的分配器。同样,违反分配器规则的分配器不能合理地分配给 AllocatorAwareContainer,因为该模型要求分配器实际上是分配器。这包括句法和语义规则。

如果您不提供propagate_on_*属性值,false则将使用默认值 。这意味着它不会尝试传播您的分配器,因此您不会违反分配器可复制/移动分配或可交换的需求。但是,这也意味着您的分配器的复制/移动/交换行为将永远不会被使用,因此您为这些操作提供什么语义并不重要。此外,如果没有传播,如果两个分配器不相等,则意味着线性时间移动/交换。

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

基本上,完成这项工作的唯一方法是生活在你自己的世界中,在那里你只将你的“容器”与“分配器”一起使用,并且永远不要尝试将任何标准库等价物与“容器/分配器”一起使用。


历史回顾也会有所帮助。

出于历史目的,这些propagate_on_*功能在 C++11 之前有一段曲折的历史,但它从未像您建议的那样出现。

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

这曾一度被基于概念的版本所包含,但一旦从 C++0x 中删除,它就会以新名称和更简化的接口 (PDF)回到特征类(是的,你的接口)现在使用的是简单版。不客气;))。

因此,在所有情况下,都明确认识到需要区分复制/移动/交换的存在和这些操作相对于容器的含义。


并且处理这些情况是可怕的代码。

但事实并非如此。在 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) {}

连同用于移动和交换的版本。然后根据传播行为调用该函数来执行复制/移动/交换或不执行复制/移动/交换。

于 2019-02-21T06:17:26.047 回答