QT备忘录之八:QList到底是个什么玩意儿?
- 再创世纪·代码厨房
- 2024-07-28
- 160热度
- 0评论
使用Qt这么多年,一直有一个疑问:为什么需要“一批”输入/输出参数时,大量的Qt接口以QList作为参数形式,而不是QVector?
0. 缘起
vector(向量)和list(链表)作为常见的容器,使用方式非常类似,但是针对不同场景存在一些性能上的区别:
vector | list | |
---|---|---|
数据结构 | 动态数组 | 链表 |
随机访问 | 支持 | 不支持 |
访问的时间复杂度 | O(1) | O(n) |
插入/移除的时间复杂度 | 尾部为O(1),其他位置为O(n) | 任何位置皆为O(1) |
迭代器失效 | 插入/移除操作可能失效 | 插入/移除/移动操作不会失效,删除会失效 |
额外性能开销 | 预分配的内存耗尽时,需要重新分配内存; 重新分配内存时,有可能需要拷贝部分或者所有元素 |
无 |
(参考资料:https://en.cppreference.com/w/cpp/container/vector、https://en.cppreference.com/w/cpp/container/list)
由此可以得出以下结论:
- 在更关心访问效率,而不关心插入/删除效率时,使用vector;在更关心插入/删除效率,而不关心访问效率时,使用list。
- 对于主要在尾部执行插入/移除操作的情况,如果总体数据量不大(不太需要重新分配内存),vector总体性能上也不会比list差太多;即使是大数据量,如果总体数据量可以估计,那么完全可以预先分配足够的内存,这样就避免了大多数需要重新分配内存的情况,从而节省性能开销。
这么看来,除非存在大量非尾的插入/删除操作,vector都应该是首选。
1. Qt的首选不是vector?
在Qt5中,也有两个容器类QVector和QList,很容易联想到这两个容器类与STL中两个相近命名的容器类的对应关系。既是如此,按照上面的推演分析,应该有大量的Qt接口使用QVector作为参数和返回的形式。然而在Qt的各种API中,使用得更多的反而是QList,甚至在很多显然更应该倾向查询性能的接口中也是如此,却是为何?
几乎随处可见。那岂不是完蛋了,这样的性能谁敢用啊?
2. 解析
重新审视前面的整个推理链条,发现了一个漏洞:单纯因为QList与list命名相似,就认为QList应当具备list所有的优势与劣势。
Qt官方文档关于QList的介绍开篇名义:
QList<T> is one of Qt's generic container classes. It stores items in a list that provides fast index-based access and index-based insertions and removals.
这里说QList将对象存储在一个list中,并且提供了基于索引的访问、插入和删除。既然基于索引了,那它就不是一个简单的链表!
Internally, QList<T> is represented as an array of T if
sizeof(T) <= sizeof(void*)
and T has been declared to be either aQ_MOVABLE_TYPE
or aQ_PRIMITIVE_TYPE
using Q_DECLARE_TYPEINFO. Otherwise, QList<T> is represented as an array of T* and the items are allocated on the heap.The array representation allows very fast insertions and index-based access. The prepend() and append() operations are also very fast because QList preallocates memory at both ends of its internal array. (See Algorithmic Complexity for details.
QList的内部实现,不管sizeof(T) <= sizeof(void*)是否成立,都是一个array(数组)。如果size小于等于指针,那么它是一个T的数组,否则是一个指针数组。这也就解释了为什么它可以提供基于索引的操作,它内部可是个数组啊。这里还有个高级操作,QList在其内部数组的两端都做了预分配内存,所以插头插尾都能非常快。多说无益,来看源码。(Qt版本5.5.0)
2.1 访问操作的解析
// qlist.h
template <typename T>
inline const T &QList::at(int i) const
{ Q_ASSERT_X(i >= 0 && i < p.size(), "QList::at", "index out of range");
return reinterpret_cast(p.at(i))->t(); }
inline const T &QList::operator[](int i) const
{ Q_ASSERT_X(i >= 0 && i < p.size(), "QList::operator[]", "index out of range");
return reinterpret_cast(p.at(i))->t(); }
template
inline T &QList::operator[](int i)
{ Q_ASSERT_X(i >= 0 && i < p.size(), "QList::operator[]", "index out of range");
detach(); return reinterpret_cast(p.at(i))->t(); }
可以看到,不管是[]运算符还是at函数,都是通过p.at()实现的。
// qlist.h
class QList : public QListSpecialMethods<T>
{
public:
// ...
private:
// ...
union { QListData p; QListData::Data *d; };
// ...
}
这是p的定义,p是QListData的一个实例。
// qlist.h
struct Q_CORE_EXPORT QListData {
// ...
struct Data {
QtPrivate::RefCount ref;
int alloc, begin, end;
void *array[1];
};
// ...
Data *d;
// ...
inline void **at(int i) const { return d->array + d->begin + i; }
// ...
};
这是QListData::at的定义,以及它需要用到的成员变量d的定义。从这里可以看出,数据在内存中的实际位置,是计算出来的。
2.2 尾追加(append)操作的解析
// qlist.h
template <typename T>
class QList : public QListSpecialMethods
{
// ...
// stl compatibility
inline void push_back(const T &t) { append(t); }
// ...
}
这是QList::push_back的实现,显然它相当于append
的别名函数。
// qlist.h
template <typename T>
Q_OUTOFLINE_TEMPLATE void QList<T>::append(const T &t)
{
if (d->ref.isShared()) {
Node *n = detach_helper_grow(INT_MAX, 1);
QT_TRY {
node_construct(n, t);
} QT_CATCH(...) {
--d->end;
QT_RETHROW;
}
} else {
if (QTypeInfo<T>::isLarge || QTypeInfo<T>::isStatic) {
Node *n = reinterpret_cast<Node *>(p.append());
QT_TRY {
node_construct(n, t);
} QT_CATCH(...) {
--d->end;
QT_RETHROW;
}
} else {
Node *n, copy;
node_construct(©, t); // t might be a reference to an object in the array
QT_TRY {
n = reinterpret_cast<Node *>(p.append());;
} QT_CATCH(...) {
node_destruct(©);
QT_RETHROW;
}
*n = copy;
}
}
}
这是QList::append的实现。不管走到哪个分支,都走了先分配地址(Node *n = reinterpret_cast<Node *>(p.append())
),再创建node(node_construct(node*, t)
)的流程。其中if (QTypeInfo<T>::isLarge || QTypeInfo<T>::isStatic)
这个分支通过判断sizeof(T)与size(void*)的大小,来决定实际数据格式(T或者T*)。
// qlist.cpp
// ensures that enough space is available to append n elements
void **QListData::append(int n)
{
Q_ASSERT(!d->ref.isShared());
int e = d->end;
if (e + n > d->alloc) {
int b = d->begin;
if (b - n >= 2 * d->alloc / 3) {
// we have enough space. Just not at the end -> move it.
e -= b;
::memcpy(d->array, d->array + b, e * sizeof(void *));
d->begin = 0;
} else {
realloc(grow(d->alloc + n));
}
}
d->end = e + n;
return d->array + e;
}
// ensures that enough space is available to append one element
void **QListData::append()
{
return append(1);
}
这是QListData::append的实现。该函数确保实际分配的内存空间足够(可能需要调整内存分布或者重新分配内存),并返回一个用于构造node的地址。可能出现以下几种情况:
- 当append将写入的地址不超过当前的尾部,直接计算出一个node地址;
- 当append将写入的地址,超过了当前的尾部(
e + n > d->alloc
),但是已分配的总内存空间足够(b - n >= 2 * d->alloc / 3
),则先做内部调整(::memcpy(d->array, d->array + b, e * sizeof(void *))
),再计算node地址; - 当append将写入的地址,且需要使用的总内存也超过了当前已分配的内存,则需要重新分配内存(
grow(d->alloc + n)
),再计算node地址。
2.3 头追加(prepend)操作的解析
// qlist.h
template <typename T>
class QList : public QListSpecialMethods<T>
{
// ...
// stl compatibility
inline void push_front(const T &t) { prepend(t); }
// ...
};
这是QList::push_front的实现,它是prepend
的别名函数。
// qlist.h
template <typename T>
inline void QList::prepend(const T &t)
{
if (d->ref.isShared()) {
Node *n = detach_helper_grow(0, 1);
QT_TRY {
node_construct(n, t);
} QT_CATCH(...) {
++d->begin;
QT_RETHROW;
}
} else {
if (QTypeInfo::isLarge || QTypeInfo::isStatic) {
Node *n = reinterpret_cast(p.prepend());
QT_TRY {
node_construct(n, t);
} QT_CATCH(...) {
++d->begin;
QT_RETHROW;
}
} else {
Node *n, copy;
node_construct(©, t); // t might be a reference to an object in the array
QT_TRY {
n = reinterpret_cast(p.prepend());;
} QT_CATCH(...) {
node_destruct(©);
QT_RETHROW;
}
*n = copy;
}
}
}
这是QList::prepend的实现,最后几个分支都走到同一个函数p.prepend()
。
// qlist.cpp
void **QListData::prepend()
{
Q_ASSERT(!d->ref.isShared());
if (d->begin == 0) {
if (d->end >= d->alloc / 3)
realloc(grow(d->alloc + 1));
if (d->end < d->alloc / 3)
d->begin = d->alloc - 2 * d->end;
else
d->begin = d->alloc - d->end;
::memmove(d->array + d->begin, d->array, d->end * sizeof(void *));
d->end += d->begin;
}
return d->array + --d->begin;
}
这是QLiatData::prepend的实现。该函数确保实际内存大小足够(可能需要调整内存分布或者重新分配内存),并返回一个用于构造node的地址。可能出现以下几种情况:
- 当prepend将写入的地址不超过当前的头部,直接计算出一个node地址;
- 当prepend将写入的地址,超过了当前的头部(
d->begin == 0
),且需要使用的总内存也超过了当前已分配的内存(d->end >= d->alloc / 3
),则需要重新分配内存(grow(d->alloc + 1)
),再计算node地址。 - 当prepend将写入的地址,超过了当前的头部(
d->begin == 0
),但是已分配的总内存空间足够,则先做内部调整(::memmove(d->array + d->begin, d->array, d->end * sizeof(void *))
),再计算node地址。
2.4 随机插入(insert)操作的解析
// qlist.h
template <typename T>
inline void QList<T>::insert(int i, const T &t)
{
if (d->ref.isShared()) {
Node *n = detach_helper_grow(i, 1);
QT_TRY {
node_construct(n, t);
} QT_CATCH(...) {
p.remove(i);
QT_RETHROW;
}
} else {
if (QTypeInfo<T>::isLarge || QTypeInfo<T>::isStatic) {
Node *n = reinterpret_cast<Node *>(p.insert(i));
QT_TRY {
node_construct(n, t);
} QT_CATCH(...) {
p.remove(i);
QT_RETHROW;
}
} else {
Node *n, copy;
node_construct(©, t); // t might be a reference to an object in the array
QT_TRY {
n = reinterpret_cast<Node *>(p.insert(i));;
} QT_CATCH(...) {
node_destruct(©);
QT_RETHROW;
}
*n = copy;
}
}
}
这是QList::insert的实现,最后几个分支都走到同一个函数p.insert(int)
。
// qlist.cpp
void **QListData::insert(int i)
{
Q_ASSERT(!d->ref.isShared());
if (i <= 0)
return prepend();
int size = d->end - d->begin;
if (i >= size)
return append();
bool leftward = false;
if (d->begin == 0) {
if (d->end == d->alloc) {
// If the array is full, we expand it and move some items rightward
realloc(grow(d->alloc + 1));
} else {
// If there is free space at the end of the array, we move some items rightward
}
} else {
if (d->end == d->alloc) {
// If there is free space at the beginning of the array, we move some items leftward
leftward = true;
} else {
// If there is free space at both ends, we move as few items as possible
leftward = (i < size - i);
}
}
if (leftward) {
--d->begin;
::memmove(d->array + d->begin, d->array + d->begin + 1, i * sizeof(void *));
} else {
::memmove(d->array + d->begin + i + 1, d->array + d->begin + i,
(size - i) * sizeof(void *));
++d->end;
}
return d->array + d->begin + i;
}
这是QLiatData::insert的实现。该函数确保实际内存大小足够(可能需要调整内存分布或者重新分配内存),并返回一个用于构造node的地址。可能出现以下几种情况:
- 当insert将写入的位置超过当前有效尾部时,直接调用append();
- 当insert将写入的位置超过当前有效头部时,直接调用prepend();
- 当需要使用的总内存超过了当前已分配的内存(
d->begin == 0
&&d->end == d->alloc
),则需要重新分配内存(grow(d->alloc + 1)
),再计算node地址。 - 调用
::memmove
实现内存移动,优先选择向尾部方向移动;如果已到达缓存末端,则向前移动。
2.5 删除(erase)操作的解析
// qlist.h template <typename T> inline typename QList<T>::iterator QList<T>::erase(iterator it) { Q_ASSERT_X(isValidIterator(it), "QList::erase", "The specified iterator argument 'it' is invalid"); if (d->ref.isShared()) { int offset = int(it.i - reinterpret_cast<Node *>(p.begin())); it = begin(); // implies detach() it += offset; } node_destruct(it.i); return reinterpret_cast<Node *>(p.erase(reinterpret_cast<void**>(it.i))); }
这是QList::erase的实现,最后走到函数p.erase(int)。
// qlist.cpp
void **QListData::erase(void **xi)
{
Q_ASSERT(!d->ref.isShared());
int i = xi - (d->array + d->begin);
remove(i);
return d->array + d->begin + i;
}
这是QLiatData::erase的实现,最后走到了remove(int)。
// qlist.h
template <typename T>
Q_OUTOFLINE_TEMPLATE typename QList<T>::iterator QList<T>::erase(typename QList<T>::iterator afirst,
typename QList<T>::iterator alast)
{
Q_ASSERT_X(isValidIterator(afirst), "QList::erase", "The specified iterator argument 'afirst' is invalid");
Q_ASSERT_X(isValidIterator(alast), "QList::erase", "The specified iterator argument 'alast' is invalid");
if (d->ref.isShared()) {
// ### A block is erased and a detach is needed. We should shrink and only copy relevant items.
int offsetfirst = int(afirst.i - reinterpret_cast<Node *>(p.begin()));
int offsetlast = int(alast.i - reinterpret_cast<Node *>(p.begin()));
afirst = begin(); // implies detach()
alast = afirst;
afirst += offsetfirst;
alast += offsetlast;
}
for (Node *n = afirst.i; n < alast.i; ++n)
node_destruct(n);
int idx = afirst - begin();
p.remove(idx, alast - afirst);
return begin() + idx;
}
这是QList::erase的另一种实现,最后走到函数p.remove(int, int)。
// qlist.cpp
void QListData::remove(int i)
{
Q_ASSERT(!d->ref.isShared());
i += d->begin;
if (i - d->begin < d->end - i) {
if (int offset = i - d->begin)
::memmove(d->array + d->begin + 1, d->array + d->begin, offset * sizeof(void *));
d->begin++;
} else {
if (int offset = d->end - i - 1)
::memmove(d->array + i, d->array + i + 1, offset * sizeof(void *));
d->end--;
}
}
void QListData::remove(int i, int n)
{
Q_ASSERT(!d->ref.isShared());
i += d->begin;
int middle = i + n/2;
if (middle - d->begin < d->end - middle) {
::memmove(d->array + d->begin + n, d->array + d->begin,
(i - d->begin) * sizeof(void*));
d->begin += n;
} else {
::memmove(d->array + i, d->array + i + n,
(d->end - i - n) * sizeof(void*));
d->end -= n;
}
}
这是QLiatData::remove的实现,向前执行memmove安全地覆盖不需要的数据,并重新标记头部或尾部。
// qlist.h
template <typename T>
class QList : public QListSpecialMethods<T>
{
// ...
inline void removeFirst() { Q_ASSERT(!isEmpty()); erase(begin()); }
inline void removeLast() { Q_ASSERT(!isEmpty()); erase(--end()); }
// ...
};
此外,还有几个方便接口,也是调用erase实现的。
3. 结论
- QList是一个使用动态数组实现的类似list的容器类,但是它不是list的Qt实现。
- QList没有实现list(链表)的next和last功能。
- QList的随机访问很快,可以认为与数组的随机访问相当(只多了计算数据真实内存地址的开销)。
- 在大多数情况下,QList的头尾位置的追加删除都非常快,只有在预分配内存不足的情况下,需要重新分配内存和拷贝数据。
- 在非头尾位置的插入删除会发生内存拷贝,但是对于大数据结构(sizeof(T) > sizeof(int)),在数组内保存的是一个void*,不需要对数据结构本身做析构和构造,内存拷贝量不超过4 * 整体长度,总体上也比简单数组要快很多。
可以认为QList是一个升级版的QVector。在大多数情况下,性能并不比QVector差,放心大胆地用吧!
4. 不用纠结了
后来我在QList官方介绍页面发现了一个更详细的链接( Pros and Cons of Using QList ),摘录其中一个表说明所有问题:
Qt | STL |
---|---|
Sequential Containers | |
QVector | std::vector |
— | std::deque |
QList | — |
QLinkedList | std::list |
— | std::forward_list |
Associative Containers | |
QMap | std::map |
QMultiMap | std::multimap |
— | std::set |
— | std::multiset |
QHash | std::unordered_map |
QMultiHash | std::unordered_multimap |
QSet | std::unordered_set |
— | std::unordered_multiset |
这个表里把Qt和STL的容器类做了个类比,存在对应关系的列在同一行。QList不跟STL任何一个对应,与list对应的是QLinkedList。
二零二四年七月二十八日 顾毅写于厦门