重新审视 QList 与 QVector

QList vs QVector revisited

我的问题基本上是何时选择 QVector 以及何时选择 QList 作为您的 Qt 容器。我已经知道的:

  1. Qt 文档:QList class

For most purposes, QList is the right class to use. Its index-based API is more convenient than QLinkedList's iterator-based API, and it is usually faster than QVector because of the way it stores its items in memory. It also expands to less code in your executable.

  1. 同样写的是这个很火的问答:QVector vs QList。它也有利于 QList。

  2. 但是:在最近的 Qt World Summit 2015 KDAB 上提出了"Why QList is harmful",这基本上是在这里:

QList considered harmful

Don't use QList, use Q_DECLARE_TYPEINFO

据我了解,在堆中分配新元素时,几乎所有类型的 QList 都是低效的。每次添加新元素时,它都会调用 new(每个元素一次),与 QVector 相比,这是低效的。

这就是为什么我现在想了解:我们应该选择 QVector 作为默认容器吗?

QList 是文档中最常用的容器。如果元素类型的大小 <= 指针的大小 = machine & OS 位数 = 4 或 8 字节,则对象的存储方式与 QVector 相同——顺序存储在内存中。如果 QList 的元素类型的大小大于指针的大小,则 QList 的性能优于 QVector,因为它不按顺序存储对象,而是按顺序存储指向堆副本的指针。 32位情况下图片如下:

sizeof( T ) <= sizeof( void* )
=====
QList< T > = [1][1][1][1][1]
                   or
             [2][2][2][2][2]
                   or
             [3][3][3][3][3]
                   or
             [4][4][4][4][4] = new T[];

sizeof( T ) > sizeof( void* )
=====
QList< T > = [4][4][4][4][4] = new T*[]; // 4 = pointer's size
              |   |  ...  |
           new T new T   new T

如果您希望对象在内存中按顺序排列,而不管它们的元素大小如何,就像 OpenGL 编程通常的情况一样,那么您应该使用 QVector。

这是 QList 内部的 detailed description

If the size of the QList's element type is greater than the pointer's size QList performs better than QVector because it doesn't store the objects sequentially but stores sequentially pointers to heap copies.

我倾向于说相反的话。在检查项目时,情况会更糟。 如果它把它作为指针存储在堆上,QList 不会比 QVector 差很多吗?顺序存储(一直是 QVector)如此优秀的原因在于,它对缓存友好,一旦你存储指针,你就会失去数据局部性,开始出现缓存未命中,这对性能来说很糟糕。

恕我直言,"default" 容器应该是 QVector(或 std::vector),如果您担心大量重新分配,那么预分配一个合理的数量,支付一次性成本,然后您'将受益于长期 运行。

默认情况下使用 *Vector,如果遇到性能问题,请根据需要进行配置和更改。

Qt 将 QList 宣传为 "jack of all trades",但该说法的另一半是 "master of none"。我会说 QList 是一个很好的选择,如果你计划追加到列表的两端,并且它们不大于指针,因为 QList 保留 space 前后.就是这样,我的意思是就使用 QList 的充分理由而言。

QList会自动存储"large"object作为指针,并将object分配到堆上,如果你是宝贝,它不知道如何声明 QVector<T*> 和使用动态分配。这不一定是好事,在某些情况下,它只会增加内存使用量并增加额外的间接访问。 IMO 明确说明你想要什么总是一个好主意,无论是指针还是实例。即使你确实想要堆分配,最好自己分配它并简单地将指针添加到列表而不是构造一次 object,然后在堆上复制构造它。

Qt 会在很多地方 return 给你一个 QList 开销,例如当你得到一个 QObject 的 children 或者你搜索对于 children。在这种情况下,使用在第一个元素之前分配 space 的容器没有意义,因为它是一个已经存在的 object 的列表,而不是您可能要添加的内容.我也不太喜欢缺少 resize() 方法。

想象这样一种情况,在 64 位系统上,您有一个大小为 9 字节且字节对齐的 object。它是 QList 的 "far too much",因此它将使用 8 字节指针 + CPU 用于慢速堆分配的开销 + 用于堆分配的内存开销。它将使用两倍的内存和额外的间接访问,它几乎不会像宣传的那样提供性能优势。

至于为什么 QVector 不能突然变成 "default" 容器 - 你不换马 mid-race - 这是一个遗留的东西,Qt 是一个如此古老的框架,而且即使很多东西已被弃用,但对广泛使用的默认值进行更改并不总是可能的,而不是在不破坏大量代码或产生不良行为的情况下进行。好或坏,QList 可能会在整个 Qt 5 中一直是默认值,并且可能在下一个主要版本中也是如此。同样的原因 Qt 将继续使用 "dumb" 指针,在智能指针成为必须的多年之后,每个人都在抱怨普通指针有多糟糕以及如何永远不应该使用它们。

也就是说,没有人强迫在你的设计中使用QList。没有理由 QVector 不应该是 您的 默认容器。我自己不在任何地方使用 QList,在 return 和 QList 的 Qt 函数中,我只是临时使用 QVector.

此外,这只是我的个人意见,但我确实发现 Qt 中的很多设计决策没有必要有意义,无论是性能还是内存使用效率还是易用性,总的来说有很多框架和语言喜欢推广他们做事的方式,不是因为这是最好的做事方式,而是因为这是他们做事的方式。

最后但同样重要的是:

For most purposes, QList is the right class to use.

这真的归结为你如何理解这一点。 IMO 在这种情况下,"the right" 不代表 "the best" 或 "the optimal",而是代表 "good enough",如 "it will do, even if not the best"。特别是如果您对不同的容器 类 及其工作原理一无所知。

For most purposes, QList will do.


总结一下:

QList 优点

  • 你打算在前面添加 objects 不大于指针的大小,因为它在前面保留 一些 space
  • 你打算在列表的中间插入 objects(基本上)比指针大(我在这里很慷慨,因为你可以很容易地使用 QVector 显式指针实现相同且更便宜 - 无需额外副本),因为在调整列表大小时,不会移动 objects,只会移动指针

QList 缺点

  • 没有 resize() 方法,reserve() 是一个微妙的陷阱,因为它不会增加有效列表大小,即使索引访问有效它也属于 UB 类别,也您将无法迭代该列表
  • 在 object 大于指针时进行额外的复制和堆分配,如果 object 身份很重要,这也可能是一个问题
  • 使用额外的间接寻址来访问比指针
  • 大的objects
  • 由于最后两个,有 CPU 时间和内存使用开销,缓存友好性也较低
  • 用作 "search" return 值时会带来额外的开销,因为您不太可能在
  • 之前添加甚至追加
  • 只有在必须进行索引访问时才有意义,为了获得最佳的前置和插入性能,链表可能是更好的选择。

CON 略微超过 PRO,这意味着在 "casual" 中使用QList 可能是可以接受的,您绝对不想在 CPU 时间 and/or 内存使用是关键因素的情况下使用它。总而言之,当您不想为用例考虑最佳存储容器时,QList 最适合懒惰和粗心的使用,通常是 QVector<T>QVector<T*>QLinkedList(并且我排除了 "STL" 容器,因为我们在这里谈论 Qt,Qt 容器同样可移植,有时更快,而且肯定更容易和更清洁地使用,而 std 容器不必要地冗长。

想象一下,我们有 DataType class。

QVector - 对象数组,例如:

// QVector<DataType> internal structure
DataType* pArray = new DataType[100];

QList - 指向对象的指针数组,例如:

// QList<DataType> internal structure
DataType** pPointersArray = new DataType*[100];

因此,对于 QVector,通过索引直接访问会更快:

{
// ...
cout << pArray[index]; //fast
cout << *pPointersArray[index]; //slow, need additional operation for dereferencing
// ...
}

但是如果 sizeof(DataType) > sizeof(DataType*):

,QList 的交换速度会更快
{
// QVector swaping
DataType copy = pArray[index];
pArray[index] = pArray[index + 1];
pArray[index + 1] = copy; // copy object

// QList swaping
DataType* pCopy = pPointersArray [index];
pPointersArray[index] = pPointersArray [index + 1];
pPointersArray[index + 1] = pCopy; // copy pointer
// ...
}

因此,如果您需要直接访问而不需要在元素之间交换操作(例如排序)或 sizeof(DataType) <= sizeof(DataType*),则更好的方法是使用 QVector。在其他情况下使用 QList.

QList 的行为因内部内容而异(参见 source code struct MemoryLayout):

  • 如果sizeof T == sizeof void*T定义为Q_MOVABLE_TYPE,则QList<T>的行为与QVector完全相同,即数据连续存储在内存中。

  • 如果 sizeof T < sizeof void*T 定义为 Q_MOVABLE_TYPE,则 QList<T> 将每个条目填充到 sizeof void*,并丢失布局-与 QVector 的兼容性。

  • 在所有其他情况下,QList<T> 是一个链表,因此在一定程度上较慢。

这种行为使得 QList<T> 几乎总是一个糟糕的选择,因为根据漂亮的细节,QList<T> 要么是一个列表,要么是一个向量。那是糟糕的 API 设计并且容易出错。 (例如,如果你有一个带有 public 接口的库,它在内部和它的 public 接口中使用了 QList<MyType>,你将 运行 陷入错误。 sizeof MyType is < sizeof void* ,但是说你忘记声明 MyType 为 Q_MOVABLE_TYPE。稍后,你想添加 Q_MOVABLE_TYPE。这是二进制不兼容的,意味着你现在必须重新编译所有使用你的库的代码,作为内存布局QList<MyType> 在 public API 中更改。如果你不小心,你会错过这个并引入错误。这很好地说明了为什么 QList 在这里是一个糟糕的选择。)

也就是说,QList 仍然不完全糟糕:它可能会在大多数情况下完成您想要的,但它在幕后的工作可能与您预期的不同。

经验法则是:

  • 而不是 QList,使用 QVector<T>QVector<T*>,因为它明确说明了您想要什么。您可以将其与 std::unique_ptr.

  • 结合使用
  • 在 C++11 及以后的版本中,甚至认为最好只使用 std::vector,因为它在 range-based for loop 中的行为是正确的。 (QVector 和 QList 可能会分离并因此执行深层复制)。

您可以在 presentation from Marc Mutz and in the video by Olivier Goffart 中找到所有这些详细信息以及更多信息。

QListvoid*.

的数组

在其正常操作中,它new存储堆上的元素并将指向它们的指针存储在void* 数组中。与链表一样,这意味着对列表中包含的元素的引用(但与链表不同,不是迭代器!)在所有容器修改下仍然有效,直到元素再次从容器中删除。因此名称 "list"。这种数据结构称为数组列表,用于许多编程语言,其中每个对象都是引用类型(例如,Java)。它是一种对缓存非常不友好的数据结构,就像所有基于节点的容器一样。

但是数组列表的大小调整可以分解为类型无关的帮助程序 class (QListData),这应该可以节省一些可执行代码的大小。在我的实验中,几乎不可能预测 QListQVectorstd::vector 中哪一个生成的可执行代码最少。

对于许多类似 Qt 引用的类型,例如 QStringQByteArray 等,这本来是一个很好的数据类型,它们仅由一个 pimpl 指针组成。对于这些类型,QList 获得了重要的优化:当类型不大于指针时(请注意,此定义取决于平台的指针大小——32 或 64 位),而不是堆分配对象,对象直接存储在 void* 个槽中。

不过,这只有在类型可简单重定位 时才有可能。这意味着它可以使用 memcpy 在内存中重新定位。这里的重定位意味着我拿一个对象,memcpy 它到另一个地址 - 关键 - 不是 运行 旧对象的析构函数。

这就是事情开始出错的地方。因为与 Java 不同,在 C++ 中对对象的引用是它的 地址 。虽然在原始 QList 中,引用是稳定的,直到对象再次从集合中删除,通过将它们放入 void* 数组中,这个 属性 不再成立。这不再是所有意图和目的的 "list"。

不过,事情继续出错,因为它们也允许将严格小于 void* 的类型放置在 QList 中。但是内存管理代码需要指针大小的元素,所以 QList 添加填充(!)。这意味着 64 位平台上的 QList<bool> 看起来像这样:

[ | | | | | | | [ | | | | | | | [ ...
[b|   padding   [b|   padding   [b...

不像 QVector 那样将 64 个布尔值放入缓存行,QList 只管理 8.

当文档开始调用 QList 一个好的默认容器时,事情出了任何问题。不是。 original STL states:

Vector is the simplest of the STL container classes, and in many cases the most efficient.

Scott Meyer 的 Effective STL 有几项以 "Prefer std::vector over...".

开头

一般 C++ 中正确的东西不会因为你使用 Qt 而突然出错。

Qt 6 将修复那个特定的设计错误。同时,使用 QVectorstd::vector.

在 Qt 5.7 中,有关此处讨论的主题的文档已更改。在 QVector 中现在声明:

QVector should be your default first choice. QVector<T> will usually give better performance than QList<T>, because QVector<T> always stores its items sequentially in memory, where QList<T> will allocate its items on the heap unless sizeof(T) <= sizeof(void*) and T has been declared to be either a Q_MOVABLE_TYPE or a Q_PRIMITIVE_TYPE using Q_DECLARE_TYPEINFO.

他们指的是这个article by Marc Mutz

所以官方的观点变了。

请注意,这在 Qt6 中已完全改变: https://www.qt.io/blog/qlist-changes-in-qt-6

统一QVector和QList,使用QVector的模型作为底层实现。这意味着 Qt 5 QList 对泛型类型的额外间接级别现在消失了,元素总是直接存储在分配的内存中。 QList是真正的class,有实现,而QVector只是QList的一个别名。 Qt 6 中的 QList 支持优化的前缀。它现在可以在不使用保留的情况下缩小元素删除。并且删除了 2GB 的大小限制。