std::vector::push_back的代价成功或无效

Cost of std::vector::push_back either succeeding or having no effect?

本文关键字:代价 成功 无效 back vector push std      更新时间:2023-10-16

如果我理解正确的话,在复制或移动期间抛出异常的情况下,std::vector::insert不保证std::vector s的提交或回滚(std::list s的原因很明显),因为检查异常的成本很高。我最近看到push_back确实保证在最后成功插入或什么都不发生。

我的问题如下。假设在push_pack期间,向量必须调整大小(重新分配)。在这种情况下,必须通过复制或移动语义将所有元素复制到新容器中。假设移动构造函数不保证noexcept,那么std::vector将使用复制语义。因此,为了保证push_pack的上述行为,std::vector必须检查复制是否成功,如果没有,则通过交换初始向量进行回滚。这就是正在发生的事情吗?如果是这样,这不是很昂贵吗?或者,因为重新分配很少发生,所以可以说平摊代价很低?

在c++ 98/03中,我们(显然)没有移动语义,只有复制语义。而在c++ 98/03中,push_back有很强的保障。c++ 11的一个强烈动机是不破坏依赖于这种强保证的现有代码。

c++ 11的规则是:
  1. 如果is_nothrow_move_constructible<T>::value为真,移动,否则
  2. 如果is_copy_constructible<T>::value为真,复制,否则
  3. 如果is_move_constructible<T>::value为真,移动,否则
  4. 代码格式错误

如果我们在1或2的情况下,我们有强保证。如果是第三种情况,我们只有基本的保证。因为在c++ 98/03中,我们总是在情形2中,我们有向后兼容性。

Case 2不需要昂贵的维护强保证。一个是分配新的缓冲区,最好使用RAII设备,比如第二个向量。复制到其中,只有当所有这些都成功时,才能将*this与RAII设备交换。这是最便宜的做事方式,不管你是否想要强有力的保证,你都可以免费得到它。

情形1维护强保证的成本也很低。我所知道的最好的方法是首先复制/移动新元素到新分配的中间。如果成功,则从旧缓冲区中移动元素并交换。

比你想要的更多的细节

libc++用相同的算法完成了这三种情况。为此,使用了两个工具:

  1. std::move_if_noexcept
  2. 类似vector的非标准容器,其中数据是连续的,但可以从分配缓冲区开始的非零偏移处开始。libc++把这个东西叫做split_buffer

假设在重新分配的情况下(非重新分配的情况是微不足道的),split_buffer是通过引用这个vector的分配器来构建的,其容量是这个vector的两倍,并且其起始位置设置为this->size()(尽管split_buffer仍然是empty())。

然后将新元素复制或移动(取决于我们谈论的是哪个push_back重载)到split_buffer。如果失败,split_buffer析构函数撤销分配。如果成功,那么split_buffer现在有size() == 1,并且split_buffer在其第一个元素之前正好有this->size()个元素的空间。

接下来,元素以倒序从this移动/复制split_buffermove_if_noexcept用于此,它的返回类型为T const&T&&,这正是我们需要的,正如上面3个案例所指定的那样。在每次成功移动/复制时,split_buffer都在执行push_front。如果成功,split_buffer现在有size() == this->size()+1,并且它的第一个元素从它分配的缓冲区开始的偏移量为零。如果任何移动/复制失败,split_buffer的析构函数将销毁split_buffer中的所有内容并释放缓冲区。

接下来split_bufferthis交换它们的数据缓冲区。这是noexcept的操作。

最后,split_buffer销毁它的所有元素,并释放它的数据缓冲区。

不需要try-catch。没有额外的费用。一切都按照c++ 11的规定工作(如上所述)。

这就是正在发生的事情,它可能是昂贵的。我们决定,push_back的强保证比move语义的性能更重要。

重新分配是昂贵的,是的。这就是为什么重新分配的代价是通过多次调用std::vector::push_back来平摊的。

最常见的是通过将旧大小乘以某个常数因子(例如1.5或2)来分配新的块大小,例如1,2,4,8,16,32,…

重新分配时,std::vector分配新块,复制元素,销毁旧元素并释放旧块。如果失败(抛出异常),我们可以简单地中止复制过程,并开始销毁复制的元素——所以最坏情况下的代价是2*(n-1)+d,其中d是释放的代价(我们复制n-1个元素,当复制第n个元素时,抛出异常,因此我们销毁n-1个元素,并释放新块)

注意,以上适用于移动不为noexcept的情况。如果移动是noexcept,唯一可能的故障点是分配新的内存块(因为析构函数要求是noexcept),重新分配过程更简单、更快。

您可以通过使用不同的容器(比如不存在问题的std::deque)或事先使用std::vector::reserve(这需要知道对元素计数的估计)来减少重新分配对性能的影响。