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

问题描述 投票:0回答:2

在容器中具有这些特征的原因是什么(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_equal,实际上可以使分配器分配什么也不做。

(此外,如果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).
c++11 move-semantics allocator copy-assignment move-assignment-operator
2个回答
1
投票

Nicol Bolas的答案非常准确。我会这样说:

  • 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<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>滥用分配器,而std::stickyvector<int>总是坚持使用分配器吗?我们可能会。

C ++ 17类型假装我们确实这样做,通过给std::pmr::vector<int>类似于std::stickyvector<int>的typedef;但在引擎盖下,std::pmr::vector<int>只是std::vector<int, std::pmr::polymorphic_allocator<int>>的一个typedef,并且仍然通过咨询std::allocator_traits<std::pmr::polymorphic_allocator<int>>找出该做什么。


2
投票

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

概念/命名要求“CopyAssignable”不仅仅意味着能够将左值分配给与该左值相同类型的对象。它也具有语义含义:目标对象的值应与原始对象的值相等。如果您的类型提供了复制赋值运算符,则期望此运算符复制该对象。标准库中允许复制分配的几乎所有内容都需要这样。

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

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

因此特点。

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

...

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

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

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

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

基本上,使这项工作的唯一方法是生活在你自己的宇宙中,你只使用你的“容器”和“allocators”,并且永远不要尝试在你的“容器/分配器”中使用任何标准的库等价物。


历史回顾也可能有所帮助。

出于历史目的,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) {}

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

© www.soinside.com 2019 - 2024. All rights reserved.