为什么libc++ std::vector内部保留三个指针,而不是一个指针和两个大小



我正在看std::vector在libc++中的实现,我注意到它内部保持三个指针(一个到开始,一个到结束,一个到分配内存的结束),而不是我本能地做的,即一个指针到开始和两个sizecapacity成员。

这是libc++的<vector>代码(忽略压缩对,我知道它的意思)。

pointer                                    __begin_;
pointer                                    __end_;
__compressed_pair<pointer, allocator_type> __end_cap_;

我注意到其他标准库也做同样的事情(例如Visual c++)。我看不出这个解决方案为什么会比另一个更快,但我可能错了。

那么,"三个指针"的解决方案比"指针+大小"的解决方案更受欢迎,这有什么特别的原因吗?

这是因为性能应该针对迭代器而不是索引进行优化。
(换句话说,应该针对begin()/end()而不是size()/operator[]进行性能优化。)
为什么?因为迭代器是泛化指针,因此c++鼓励使用它们,并反过来确保它们的性能与原始指针相当时的性能相匹配。

要了解为什么它是一个性能问题,请注意典型的for循环如下:

for (It i = items.begin(); i != items.end(); ++i)
    ...

除了在最简单的情况下,如果我们跟踪大小而不是指针,将会发生的是比较i != items.end()将变成i != items.begin() + items.size(),比您预期的需要更多的指令。(在许多情况下,优化器通常很难分解出代码。)在一个紧密的循环中,这会大大降低速度,因此要避免这种设计。

(当我试图为std::vector编写自己的替代品时,我已经验证了这是一个性能问题。)


Edit:正如Yakk在评论中指出的那样,当元素大小不是2的幂时,使用索引代替指针也会导致生成乘法指令,这在紧密循环中是相当昂贵和明显的。我在写这个答案的时候并没有想到这一点,但这是一个困扰我的现象(例如,见这里)……最重要的是,在一个紧密的循环中,一切都很重要。

对于实现者来说更方便。

存储大小使一个操作更容易实现:size()

size_t size() { return size_; }
另一方面,

使其他代码更难编写,并且使代码更难重用:

iterator end() { return iterator(end_); } // range version
iterator end() { return iterator(begin_ + size_); } // pointer + size version
void push_back(const T& v) // range version
{
    // assume only the case where there is enough capacity
    ::new(static_cast<void*>(end_)) T(v);
    ++end_;
}
void push_back(const T& v) // pointer + size version
{
    // assume only the case where there is enough capacity
    ::new(static_cast<void*>(begin_ + size_)) T(v);
    // it could use some internal `get_end` function, but the point stil stands:
    // we need to get to the end
    ++size_;
}

如果我们必须找到结束,我们可以直接存储它——无论如何,它比大小更有用。

我想这主要是速度的问题。当对集合进行迭代时,生成的边界检查指令将仅仅是一个带有结束指针的比较语句(可能还有一次加载),而不是一次加载、一次添加和一次比较(也可能还有一次加载)。

当生成end()begin()的迭代器时,代码也只是return pointer;,而不是end()return pointer + offset;

这些都是非常小的优化,但是标准模板库的目的是在每个周期都很重要的生产代码中使用。

PS:关于不同的编译器以相同的方式实现它:有一个参考实现,大多数(所有?)编译器供应商基于他们的STL实现。这个特定的设计决策很可能是参考实现的一部分,这就是为什么您看到的所有实现都以这种方式处理向量。

最新更新