【问题标题】:Allocator propagation policies in your new modern C++ containers新的现代 C++ 容器中的分配器传播策略
【发布时间】:2019-07-09 06:23:36
【问题描述】:

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

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

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

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

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

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

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

如果我今天要编写一个 new 容器和/或 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).

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

【问题讨论】:

  • 你有一个类的集合,比如 MyFoo、ThisFoo、ThatOtherFoo,每个类都实现了一种 Foo。每个类的对象的行为都被编码在类中。 作为一个整体的类的属性被编码在相关的特征类中,这是 FooTraits 模板的一个特化。您可以在整个标准库中看到这一点。
  • "这似乎违背了泛型编程和当前的 C++ 哲学。" 以何种方式? Traits 类在整个 C++ 中使用,并且在 C++98 出现之前就已经使用。
  • @NicolBolas,是的,但是特征通常不会告诉其他类如何复制元素以及复制或移动“真正”的含义。 “复制复制”给使用分配器的所有案例带来了很大的负担,因为它必须考虑所有案例。 (我不反对特质,我反对特质,这些特质充其量与语义是多余的,最坏的情况是与 C++ 可以允许您指定的语义相矛盾)。我认为这一定是一个历史故障。
  • @alfC: ""Propagate on copy" 给使用分配器的所有情况带来了很大的负担,因为它必须考虑所有情况。" 它只会让容器实现的负担,它是可能需要复制分配器的代码子集。即便如此,它也只适用于副本assignment
  • @NicolBolas,是的,仅适用于我的意思的容器类(我碰巧正在实现)。它也适用于移动和交换。第二个问题,如果我忽略这些特征,我的容器会与近期预期的标准分配器一起使用吗? (参见最后一部分的代码,其中我假设分配器具有正常语义,例如它们本身是指针(句柄)到堆youtube.com/watch?v=0MdSJsCTRkY

标签: c++11 move-semantics allocator copy-assignment move-assignment-operator


【解决方案1】:

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

  • 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 必须做出一个艰难的决定:“就我的 而言,我正在采用 pusher 的元素值;我是否也应该采用他的分配器?”

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

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

那么,propagate_on_container_move_assignment (POCMA) 和 POCCA 和 POCS 是否都应该是 container 类型的属性?我们是否应该让std::vector&lt;int&gt; 混杂地采用分配器,而std::stickyvector&lt;int&gt; 总是坚持使用它构建的分配器?嗯,大概吧。

C++17 有点假装我们确实这样做了,通过提供类似于std::pmr::vector&lt;int&gt; 的类型定义,看起来与std::stickyvector&lt;int&gt; 非常相似;但在后台std::pmr::vector&lt;int&gt; 只是std::vector&lt;int, std::pmr::polymorphic_allocator&lt;int&gt;&gt; 的类型定义,并且仍然通过咨询std::allocator_traits&lt;std::pmr::polymorphic_allocator&lt;int&gt;&gt; 来弄清楚该怎么做。

【讨论】:

  • 这是我觉得奇怪的一点“在这一点上,约翰尼必须做出一个艰难的决定”......“约翰尼在 C++11 及更高版本中做出决定的方式是咨询 allocator_traits>::propagate_on_container_move_assignment 并按照它说的去做”。那么,约翰尼在什么意义上做出决定。根据您自己的描述,该决定已由 A 的(特征)定义。此外,如果决定是由 A 做出的,那么它可以是 A 的正常语义的一部分。
  • 另外,您如何看待默认设置?默认情况下,传播特征是错误的。如果它们默认为 true,我想我什至不会费心去问这些。
  • “Johnny 在什么意义上做出决定” — Johnny 是决定询问allocator_traits 的人。约翰尼不必那样做。 Johnny 这样做是因为他想成为一个优秀的“allocator-aware STL 容器”。 Johnny 成为可感知分配器的 STL 容器的人生目标与A完全无关A 将继续成为 A,无论 Johnny 做什么。 A 甚至不知道约翰尼的存在。 A 专注于自己的事业,追求它的人生目标,即成为一个优秀的“价值语义类型”。
  • 您是否暗示通过决定不遵循(或忽略)建议,那么容器将成为不良分配器感知容器?此外,IMO 作为一种良好的价值语义类型意味着不要试图将复制或移动的真正含义强加给他人。也许我将传播与复制或移动混淆了。 (在 C++ 中,传播对我来说意义不大)。也许传播是一个超出语义正常定义的概念。
  • 我查看了您的幻灯片,关键在幻灯片 22“分配器必须是‘仅复制’类型”。你有充分的理由不喜欢这个要求。这会搞砸一切,因为它会强制颠覆语言语义。唯一的后置条件应该是 v.clear() 有效(移动的分配器 必须 能够“删除 null”),但不一定在 v.push_back(4) 中成功,它像往常一样可以抛出bad_alloc。我的看法是:移动的分配器可以bad_alloc(仅仅是因为它没有内存资源)。那将有更弱和更好的要求。对于分配器
【解决方案2】:

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

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

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

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

因此是特质。

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

...

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

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

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

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

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


历史回顾也会有所帮助。

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

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

这曾一度被基于概念的版本所包含,但一旦从 C++0x 中删除,它又回到了特征类 with a new name and a more simplified interface (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) {}

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

【讨论】:

  • 我认为问题不在于特征做什么,而在于为什么它们必须是一个独立于分配器的类。
  • @n.m.:他在问为什么他们必须与操作本身分开。也就是说,为什么你不只是在你的复制赋值运算符中实现非复制传播,而不是让每个容器都这样做。它们实际上并没有与分配器分开。您可以将它们定义为分配器的成员别名就好了。
  • @n.m.:但是 OP 并没有问为什么它是一个特征类;他问为什么你需要指定行为at all,而不是仅仅在复制操作符中对其进行编码。就像他说的那样,“为什么这些信息不能成为自定义分配器类型本身的语义的正常实现的一部分?”看看他的例子。这与您在哪里提出问题无关;这是关于问问题根本
  • 呃,这就是我的意思。显然,这与标志的物理位置无关。
  • @alfC:嗯,这件事与 scoped 分配器(让容器的容器共享要使用的实际分配器类型的能力)比多态分配器更相关.如果作用域分配器要工作,你真的不能让某人过来并将某个内部容器的分配器设置为不同的值,即使分配器本身理论上可以设置为不同的值价值。
猜你喜欢
  • 1970-01-01
  • 2011-02-25
  • 1970-01-01
  • 2022-09-29
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-01-25
相关资源
最近更新 更多