包含容器的对象的C++布局

C++ layout of an object containing containers

本文关键字:布局 C++ 对象 包含容      更新时间:2023-10-16

作为一个有着丰富汇编语言经验和旧习惯的人,我最近用C++做了一个项目,使用了C++03和C++11必须提供的许多功能(主要是容器类,包括Boost中的一些)。它出奇地简单——我尽可能地支持简单性,而不是过早的优化。当我们进入代码审查和性能测试时,我相信有些老手会因为看不到每个字节是如何被操作的而感到不安,所以我想拥有一些先进的弹药。

我定义了一个类,它的实例成员包含几个向量和映射。而不是"指向"矢量和地图的指针。我意识到,我根本不知道我的对象占用了多少连续空间,也不知道频繁清理和重新填充这些容器可能会对性能产生什么影响。

一旦实例化,这样的对象会是什么样子?

形式上,对实现没有任何约束除标准中规定的以外界面和复杂性。实际上,即使不是全部,也是大多数实现源于相同的代码库,并且相像的

矢量的基本实现是三个指针。这个向量中对象的实际内存是动态的已分配。根据矢量的"生长"方式区域可能包含额外的内存;三个指针指向内存的开始,当前最后一个字节之后的字节已使用,以及分配的最后一个字节之后的字节。也许实现的最重要的方面是分离分配和初始化:矢量将在在许多情况下,分配的内存超出了需要,而没有在其中构造对象,并且只构造对象当需要时。此外,当删除对象或清除矢量,它不会释放内存;它只会破坏对象,并将指针更改为已使用的记忆来反映这一点。稍后,当您插入对象时将需要分配。

当添加的对象超出了分配的空间量时,vector将分配一个新的、更大的区域;将对象复制到它,然后销毁旧空间中的对象,并将其删除。由于复杂性的限制,矢量必须扩大面积指数,通过将大小乘以某个固定常数(1.5和2是最常见的因素),而不是将其递增一定的固定量。结果是,如果使用push_back从空增长矢量,将不会过多的重新分配和拷贝;另一个结果是,如果从空开始增长向量,它最终可能使用几乎两倍的尽可能多的内存。如果您使用CCD_ 2进行预分配。

至于地图,复杂性的限制以及它必须被排序意味着必须使用某种平衡树。在我所知道的所有实现中,这是一个经典的红黑树:每个条目在一个节点中单独分配它包含两个或三个指针,加上一个布尔值,在除了数据之外。

我可以补充一下,以上内容适用于集装箱。当未优化时,将添加额外的指针,将所有迭代器链接到容器,以便在容器一些会使他们无效的事情,这样他们就可以做边界检查。

最后:这些类是模板,所以在实践中访问源,并可以查看它们。(诸如异常安全有时会减少实现直截了当,但实现使用g++或VC++并不是很难理解。)

map是一个二叉树(有一些种类,我相信它通常是一个红黑树),所以map本身可能只包含一个指针和一些内务数据(例如元素数量)。

与任何其他二叉树一样,每个节点都将包含两个或三个指针(两个用于"左&右"节点,也许一个指向上面的前一个节点,以避免必须遍历整个树才能找到前一个或多个节点所在的位置)。

一般来说,vector应该不会明显比常规数组慢,当然也不会比您自己使用指针实现的可变大小数组差。

向量是数组的包装器。向量类包含一个指向连续内存块的指针,并且不知何故知道它的大小。当您清除一个向量时,它通常会保留其旧的缓冲区(取决于实现),以便下次重用它时,分配更少。如果将向量的大小调整为当前缓冲区大小以上,它将不得不分配一个新的缓冲区。重用和清除相同的向量来存储对象是有效的。(std::string类似)。如果您想知道一个向量在其缓冲区中到底分配了多少,请调用capacity函数并将其乘以元素类型的大小。您可以调用reserve函数来手动增加缓冲区大小,以期望向量很快会占用更多元素。

地图比较复杂,所以我不知道。但如果你需要一个关联容器,你也必须在C中使用一些复杂的东西,对吧?

我只是想在其他人的答案中添加一些我认为重要的东西。

首先,默认的(在我看到的实现中)sizeof(std::vector<T>)是常量,由三个指针组成。以下是GCC 4.7.2 STL标题的摘录,相关部分:

template<typename _Tp, typename _Alloc>
struct _Vector_base
{
 ...
 struct _Vector_impl : public _Tp_alloc_type
 {
  pointer _M_start;
  pointer _M_finish;
  pointer _M_end_of_storage;
  ...
 };
 ...
 _Vector_impl _M_impl;
 ...
};
template<typename _Tp, typename _Alloc = std::allocator<_Tp> >
class vector : protected _Vector_base<_Tp, _Alloc>
{
 ...
};

这就是三分球的来源。我认为他们的名字不言自明。但是还有一个基类——分配器。这就引出了我的第二点。

其次,std::vector< T, Allocator = std::allocator<T>>采用第二个模板参数,它是一个处理内存操作的类。它是通过这个类的函数向量来做内存管理的。有一个默认的STL分配器std::allocator<T>>。它没有数据成员,只有allocatedestroy等函数。它的内存处理基于new/delete。但您可以编写自己的分配器,并将其作为第二个模板参数提供给std::vector。它必须遵守某些规则,比如它提供的功能等,但内存管理是如何在内部完成的——这取决于你,只要它不违反std::vector所依赖的逻辑。它可能会引入一些数据成员,这些成员将通过上面的继承添加到sizeof(std::vector)中。它还为您提供了"对每一位的控制"。

基本上,vector只是一个指向数组的指针,以及它的容量(分配的总内存)和大小(实际使用的元素):

struct vector {
    Item* elements;
    size_t capacity;
    size_t size;
};

当然,由于封装,所有这些都隐藏得很好,用户永远无法直接处理血腥的细节(重新分配、在需要时调用构造函数/析构函数等)。

至于你关于清除的性能问题,这取决于你如何清除矢量:

  • 用临时空向量(通常的习惯用法)交换它将删除旧数组:std::vector<int>().swap(myVector);
  • 使用std::vector<>::reserve()0或resize(0)将擦除所有项目,并保持分配的内存和容量不变

如果您关心效率,IMHO需要考虑的要点是提前调用reserve()(如果可以的话),以便预分配阵列,避免无用的重新分配和复制(或使用C++11移动)。当向向量中添加大量项目时,这会产生很大的差异(众所周知,动态分配的成本非常高,因此减少它可以大大提高性能)。

关于这件事还有很多要说的,但我相信我已经涵盖了基本的细节。如果你需要更多关于某一点的信息,请毫不犹豫地询问。


关于地图,它们通常是使用红黑树实现的。但该标准并没有强制要求这样做,它只给出了功能和复杂性要求,因此任何其他符合要求的数据结构都是可以使用的。我不得不承认,我不知道RB树是如何实现的,但我想,再一次,映射至少包含一个指针和一个大小。

当然,每种容器类型都是不同的(例如,无序映射通常是哈希表)。