std::vector pop_back() 实现

Posted

技术标签:

【中文标题】std::vector pop_back() 实现【英文标题】:std::vector pop_back() implementation 【发布时间】:2020-03-24 14:58:47 【问题描述】:

我刚刚开始使用我的数据结构,并且正在实现某种数组列表 (~=std::vector)。我想知道如何实现 pop_back() 以使其具有恒定的复杂性?它必须将大小减小 1 并删除最后一个元素。现在我在某处看到它基本上将大小减小了 1 并通过一些迭代器/指针破坏了最后一个元素,但是为什么我们不能以相同的方式实现 pop_front() 并将指向第一个元素的指针重定向到下一个元素?

template <typename T>
ArrayList<T>& ArrayList<T>::pop_back()

    --size_;
    auto p = new T[size_];
    std::copy(elements_,elements_+size_,p);
    delete [] elements_;   //elements_ being my pointer
    elements_ = p;
    return *this;

【问题讨论】:

您的问题是关于pop_back() 还是pop_front() 对于pop_back(),您实际上不必删除/销毁最后一个元素。您可以只减少指向数组最后一个元素的指针或减少 size 成员(取决于您如何实现它)。为了pop_front(),您仍然需要减小大小,但是现在您必须将元素移到前面,或者增加前面的指针。再次取决于你的实施。关键是您不必特意删除元素。停止使用该元素。 FWIW,几个月前我读到一篇文章,关于像 vector 这样的数据结构,它从中间增长,也支持快速前端操作,但这个名字让我忘记了。在这种情况下,您可以以大致相同的方式实现pop_front @LLSv2.0 pop_back() 当然必须销毁最后一个元素。如果该元素具有析构函数,则要求析构函数运行并且元素的生命周期结束。 @chris 是std::deque。它不像向量那样连续,但具有随机访问和前后快速插入/删除的功能。 【参考方案1】:

pop_back() 通常不是这样实现的。虽然它是一个实现细节,但通常您的列表/向量/动态数组会跟踪两种大小:一个是列表的实际大小,另一个是容量。容量是底层分配内存的大小。现在 pop_back() 只是将大小减小 1 并在最后一个元素上运行析构函数(不要混淆破坏,即调用 ~T() 方法和 delete 运算符)。它不会重新定位任何东西。容量保持不变。整个操作不依赖于列表的大小(与您的实现不同)。

请注意,您不能以简单的方式对 pop_front() 做同样的事情。您必须跟踪列表的开头和结尾以及底层内存(并且取决于方法:存储大小和容量或在运行时计算它们)。这需要更多内存和可能更多的 cpu 操作。而且这样的结构也变得很奇怪。你知道容量,你知道大小,但你实际上不知道在调整大小之前你可以做多少 push_back() (你只知道这个数字受“容量减去大小”的限制,但它可以更小)。除非您将此信息添加到您的结构中,否则它会再次占用内存或 CPU。

旁注:如果您打算采用原始析构函数,则根本不要使用delete[] 运算符。删除操作几乎是“调用析构函数+释放内存”。因此,如果您手动破坏,那么对delete[] 的额外调用将​​导致未定义的行为。除非您实际分配char 内存(不管T)并使用placement new(这还需要一些手动大小和偏移量计算)。这是实现此类向量的好方法,尽管需要格外小心(手动内存跟踪是 b***h)。

【讨论】:

在 OPs 实现中,在最后一个元素上显式调用 ~T() 将导致问题,当它们最终 delete[] 底层内存并且在所有这些元素上再次调用 ~T() 时,如果 T是一个类类型,而不是 int 什么的。 @JohnFilleau 我不打算这么建议。我已经更新了答案。 “而且这种结构也变得很奇怪。你知道容量,你知道大小,但实际上你不知道在调整大小之前你可以做多少 push_back()。”你能详细说明一下吗?据我了解,pop_front() 也可以通过销毁该元素并保留分配的内存来轻松工作,以防我们执行 push_front()。此外,如果我们有一个指向列表开头的指针,那么获取第一个和最后一个元素不会像取消引用指针和/或指针+大小那样简单吗? 我也有容量作为我的班级成员之一,用于在大小达到它时分配新的(2* 当前)内存块,所以让我想知道 currentSize - currentCapacity 不会给我们关于我们可以有多少次回击的信息? @ZlatanRadovanovic 如果您从两侧移动指针而不是没有。考虑容量 5。您执行 2 个 push_back() 和 2 个 pop_front()。现在您的大小为 0。但开始移动到索引 2。这意味着您只能在调整大小之前执行 3 push_back()。即使容量大小为 5。【参考方案2】:

您在实现具有 O(1) 复杂性的 pop_back() 时遇到困难的原因是,通过使用 new[]delete[],您将包含的对象的 生命周期 与您的对象的存储。我的意思是,当您使用new T[n]; 创建一个原始动态分配的数组时,会发生两件事:1)分配存储和2)在该存储中构造对象。相反,delete[] 将首先销毁所有对象(调用它们的析构函数)并然后将底层内存释放回系统。

C++ 确实提供了分别处理存储和生命周期的方法。这里涉及的主要内容是原始存储、放置new、指针强制转换和令人头疼的问题。

在您的pop_back() 函数中,您似乎希望能够仅销毁数组中的一个对象 而不会销毁所有其他对象或释放它们的存储空间。不幸的是,使用new[]delete[] 是不可能的。 std::vector 和其他一些容器解决此问题的方式是使用较低级别的语言功能。通常,这是通过分配一个连续的原始内存区域(键入为unsigned charstd::byte,或使用std::aligned_storage 之类的帮助程序),进行大量的簿记和安全检查来完成的和额外的工作,然后使用placement new 在该原始内存中构造一个对象。要访问该对象,您将计算原始内存(数组)的偏移量,并使用reinterpret_cast 来生成指向您放置在那里的对象的指针。这也需要显式调用对象的析构函数。在这个低级别工作,实际上对象生命周期的每一个细节都掌握在您手中,而且非常繁琐且容易出错。我不建议这样做。但这是可能的,它允许 std::vector::pop_back() 以 O(1) 复杂度实现(并且不会使先前元素的迭代器无效)。

【讨论】:

你不用reinterpret_cast 原始内存来获取对象指针。放置new 已经返回了一个正确的指向创建对象的有效指针。 管理原始内存实际上并不难。基本上所有额外的工作都是为了确保内存对齐并且在编译时安全完成。例如,您可以查看std::aligned_storage。但是,您必须对the rule of 3/5/0 更加小心。 @FrançoisAndrieux 是的,但是假设vector 的实现只存储了一个指向std::bytestd::aligned_storage 数组的指针,你将如何获得指向第i 个元素的指针那个数组? 我同意一旦你知道自己在做什么,这并不难。如果刚刚学习new[]delete[] 的人试图通过反复试验来做这种事情,那将是一场灾难。 否;澄清一下,当我做这种事情时,我的代码是 asserts 和 static_asserts 的 70%,因为这种低级控制量让我偏执。【参考方案3】:

当您在标准向量上pop_back 时,您实际上并没有释放相关的内存。只有最后一个元素被销毁,size 减少但capacity 没有。没有其他元素被复制或移动。这很难用自定义容器复制,因为您不能delete 或破坏数组的单个元素。

因此,std::vector&lt;T&gt; 实际上并没有使用T 的数组。它分配原始未初始化内存(类似于std::aligned_storage)并执行placement new 以根据需要在该分配中创建元素。放置newnew 的一个版本,它不分配,而是给出了一个指针,它应该在其中构造对象。这意味着对象的生命周期与它的分配没有直接关联,并且唯一地允许您单独销毁元素,而无需通过调用它的析构函数来释放它的底层内存。在内部,它看起来像 pop_back() back().~T(); --size;

【讨论】:

为什么我不能通过显式调用它的析构函数来删除数组的最后一个元素?与您在答案末尾写的内容类似? 如果你这样做了,当你最终delete[] 数组在容器的生命周期结束或增长时,它将再次调用该析构函数。我写的内容只有在您的元素使用新位置时才有效。【参考方案4】:

我想知道如何实现 pop_back() 以使其具有恒定的复杂性?它必须将大小减小 1 并删除最后一个元素。

没错。这就是它所要做的。不管你总共有多少元素。

这就是恒定复杂性的定义。

现在我在某处看到它基本上将大小减小 1 并通过一些迭代器/指针销毁最后一个元素

没错。内存仍然被分配,但存在于其中的对象经历了逻辑破坏。

但是为什么我们不能以同样的方式实现 pop_front() 并将指向第一个元素的指针重定向到下一个元素?

你可以!这就是std::deque 的工作原理,这就是为什么std::deque 有一个具有恒定复杂性的pop_front()

但是,你不能在维护其他廉价操作的同时做到这一点,因为它必然会在几次之后导致内存碎片。内存是按块分配的,向量需要按连续的块分配。想象一下,如果你 pop_front()'d 它,向量只是“忽略”了第一个元素——现在这样做五百次。你有未使用的内存永远坐在那里。不好!如果你现在想添加到前面怎么办?最终,除非您想最终使用无限内存,否则您必须将内存分成不同的块,但这会破坏保证的连续性。

其他容器旨在满足您的需求,但需要权衡取舍。特别是,std::deque 不能保证您连续存储。这就是它如何防止一直留下大量未使用的内存。

【讨论】:

以上是关于std::vector pop_back() 实现的主要内容,如果未能解决你的问题,请参考以下文章

std::vector 向下调整大小

std::stack 是不是公开迭代器?

如何使我的 std::vector 实现更快? [复制]

std::vector 插入是如何实现的? C++

std::vector 调整大小算法

C++ STL应用与实现2: 如何使用std::vector