注:本文是我对Qt官方文档的翻译,错误之处还请指正。
原文链接:Container Classes
介绍
Qt库提供了一套通用的基于模板的容器类,可以用这些类存储指定类型的项。比如,你需要一个大小可变的QString的数组,则使用QVector<QString>。
这些容器类比STL(C++标准模板库)容器设计得更轻量、更安全并且更易于使用。如果对STL不熟悉,或者倾向于用“Qt的方式”,那么你可以使用这些类,而不去用STL的类。
这些容器类是隐式共享的(可参考我的一篇博文)、可重入的,并且对速度、内存消耗等进行了优化。除此之外,当它们作为只读的容器时是线程安全的,所有线程都可以使用它们。
你可以用两种方式遍历容器内存储的项:Java风格的迭代器和STL风格的迭代器。Java风格的迭代器更易于使用,并且提供了更高级的功能;STL风格的迭代器更高效,并且可以和Qt与STL的泛型算法一起使用。
Qt还提供了foreach关键字使我们方便地遍历容器中的项。
容器类
Qt提供了几个有序容器:QList、QLinkedList、QVector、QStack和QQueue。大多数时候,QList是最好的选择,虽然是用数组实现的,但在它的首尾添加元素都非常快。如果你需要一个链表(linked-list)就用QLinkedList;想要你的项在内存中连续存储,就使用QVector。QStack和QQueue(栈和队列)分别提供了后进先出(LIFO)和先进先出(FIFO)的机制。
Qt还有一些关联容器:QMap、QMultiMap、QHash、QMultiHash、QSet。“Multi”容器支持一个键对应多个值。“Hash”容器在有序集合上使用hash函数进行快速的查找,而没有用二叉搜索。
作为特殊的情况,QCache和QContiguousCache类在有限的缓存中提供对对象高效的哈希查找。
类 | 概述 |
QList<T> | 这是目前使用最频繁的容器类,它存储了指定类型(T)的一串值,可以通过索引来获得。本质上QList是用数组实现的,从而保证基于索引的访问非常快。可以通过QList::append()和QList::prepend在两端添加项,或者通过QList::insert()在中间插入项。QStringList是从QList<QString>得到的。 |
QLinkedList<T> | 类似于QList,但它使用迭代器而不是整数索引来获得项。当在一个很大的list中间插入项时,它提供了更好的性能,并且它有更好的迭代器机制。(只要那一项存在,指向那一项的迭代器依然保持有效。但插入或移除之后,QList中的迭代器可能会失效) |
QVector<T> | 在内存中相邻的位置存储一组值,在开头或中间插入会非常慢,因为它会导致内存中很多项移动一个位置。 |
QStack<T> | QVector的一个子类,提供后进先出的机制。在当前的QVector中增加了几个方法:push()、pos()、top()。 |
QQueue<T> | QList的一个子类,提供了先进先出的机制,在当前的QList中增加了几个方法:enqueue()、dequeue()、head()。 |
QSet<T> | 单值的数学集合,能够快速查找。 |
QMap<Key, T> | 提供了字典(关联数组)将类型Key的键对应类型T的值。通常一个键对应一个值,QMap以Key的顺序存储数据,如果顺序不重要,QHash是一个更快的选择。 |
QMultiMap<Key, T> | QMap的子类,提供了多值的接口,一个键对应多个值。 |
QHash<Key, T> | 和QMap几乎有着相同的接口,但查找起来更快。QHash存储数据没有什么顺序。 |
QMultiHash<Key, T> | QHash的子类,提供了多值的接口。 |
容器是可嵌套的。比如当键是QString类型、值是QList<int>类型时,用QMap<QString, QList<int> >是最好的选择,唯一的缺点是你必须在结尾的两个尖括号(>)之间插入一个空格,否则C++编译器可能会误将两个>当作右移运算符来解释,出现语法错误。
存储在容器中的值可以是任何可赋值的数据类型,为了达到这一点,一个类型必须有默认构造函数、拷贝构造函数还有一个赋值运算符。这个已经涵盖了大多数你可能想要存在容器中的类型,包括基本类型,比如int和double、指针类型,还有Qt中的类型,比如QString、QDate和QTime,但是它不包括QObject或者QObject的子类(QWidget, QDialog, QTimer等等)。如果你尝试使用QList<QWidget>,编译器可能会提示QWidget的拷贝构造函数和赋值操作符不可用。所以如果你想在容器中存储这些类型,把它们当做指针就行了,比如QList<QWidget *>。
这里有一个例子,达到可赋值的数据类型条件的一个普通数据类型:
class Employee { public: Employee() {} Employee(const Employee &other); Employee &operator=(const Employee &other); private: QString myName; QDate myDateOfBirth; };
如果我们没有提供拷贝构函数或赋值运算符,C++会提供“一个值一个值地赋值”的默认实现。而且,如果你没有提供任何构造函数,C++将提供一个默认的构造函数,使用默认构造函数初始化它的成员。虽然没有任何显式的构造函数或赋值运算符,下面的数据类型可以被存在容器中:
struct Movie { int id; QString title; QDate releaseDate; };
有些容器对它们能够存储的数据类型有特殊的要求,例如QMap<Key, T>键Key的必须提供<()运算符。在一些情况中,特定的函数有特殊的要求,达不到要求的话编译器将会报错。
Qt的容器提供运算符<<()和运算符>>(),这样一来它们很容易使用QDataStream来读写,这意味着容器中的数据类型也必须支持运算符<<()和>>()。我们可以对上面的Movie类做一些事:
QDataStream &operator<<(QDataStream &out, const Movie &movie) { out << (quint32)movie.id << movie.title << movie.releaseDate; return out; } QDataStream &operator>>(QDataStream &in, Movie &movie) { quint32 id; QDate date; in >> id >> movie.title >> date; movie.id = (int)id; movie.releaseDate = date; return in; }
迭代器类
迭代器提供了获得容器中项的一套方法,Qt容器类有两种类型的迭代器:Java风格的以及STL风格的。当调用非const的成员函数将容器中的数据从隐式共享的拷贝中修改或分离时,两种迭代器都会失效。
Java风格的迭代器
Java风格的迭代器在Qt4中加入,比STL风格的迭代器更易于使用,但是以轻微的效率作为代价,它们的API以Java的迭代器类为模型。
对于每个容器类,都有两种Java风格的迭代器类型:一种是只读,另一种是可读写。
容器 | 只读迭代器 | 可读写迭代器 |
---|---|---|
QList<T>, QQueue<T> | QListIterator<T> | QMutableListIterator<T> |
QLinkedList<T> | QLinkedListIterator<T> | QMutableLinkedListIterator<T> |
QVector<T>, QStack<T> | QVectorIterator<T> | QMutableVectorIterator<T> |
QSet<T> | QSetIterator<T> | QMutableSetIterator<T> |
QMap<Key, T>, QMultiMap<Key, T> | QMapIterator<Key, T> | QMutableMapIterator<Key, T> |
QHash<Key, T>, QMultiHash<Key, T> | QHashIterator<Key, T> | QMutableHashIterator<Key, T> |
在这里,我们只关注QList和QMap。QLinkedList、QVector和QSet与QList的迭代器有同样的接口;QHash与QMap迭代器也有同样的接口。
与STL风格的迭代器不同,Java风格的迭代器指向项之间的位置,而不是直接指向项。由于这个原因,它们指向第一项之前,或者最后一项之后,或者两项之间。下面的图展示了包含4项的list的有效的迭代器位置,用红色箭头表示:
下面是一个典型的例子,迭代器按顺序循环遍历QList<QString>的所有元素,并把它们打印到控制台上:
QList<QString> list; list << "A" << "B" << "C" << "D"; QListIterator<QString> i(list); while (i.hasNext()) qDebug() << i.next();
流程是这样的:将要遍历的Qlist被传到QListIterator的构造函数,这时迭代器定位在list的第一项之前("A"之前),接下来我们调用hasNext()来检测迭代器后面是否有一项,如果有,我们调用next()来跳过那一项,next()函数返回它跳过的那一项。对一个QList<QString>来说,那一项的类型是QString。
下面是如何在QList中倒序遍历:
QListIterator<QString> i(list); i.toBack(); while (i.hasPrevious()) qDebug() << i.previous();
代码和正序遍历是对称的,我们调用toBack()将迭代器移到最后一项后面的位置。
下图描述了在一个迭代器上调用next()和previous()函数的效果:
下面的表概括了QListIterator的API:
函数 | 用途 |
---|---|
toFront() | 将迭代器移到list的最前面(在第一个项之前) |
toBack() | 将迭代器移到list的最后面 (最后一项之后) |
hasNext() | 如果迭代器没有到list的最后则返回true |
next() | 返回下一项,并将迭代器向前移动一个位置 |
peekNext() | 返回下一项,不会移动迭代器 |
hasPrevious() | 如果迭代器没有到list的最前面则返回true |
previous() | 返回上一项,并将迭代器移到上一个位置 |
peekPrevious() | 返回上一项,不会移动迭代器 |
QListIterator没有提供从list中插入或移除项的函数,想要实现插入和移除,你必须使用QMutableListIterator。下面举例说明使用QMutableListIterator从QList<int>中移除所有奇数。
QMutableListIterator<int> i(list); while (i.hasNext()) { if (i.next() % 2 != 0) i.remove(); }
每次循环都会调用next(),它跳过list中的下一项,然后remove()函数移除我们刚刚从list中跳过的那一项,调用remove()不会使迭代器失效,所以它是安全的,我们可以继续使用它。在倒序遍历中同样有效:
QMutableListIterator<int> i(list); i.toBack(); while (i.hasPrevious()) { if (i.previous() % 2 != 0) i.remove(); }
如果想修改某项的值,我们可以使用setValue(),下面的代码中,我们用128来替换所以大于128的值:
QMutableListIterator<int> i(list); while (i.hasNext()) { if (i.next() > 128) i.setValue(128); }
和remove()一样,setValue()操作我们刚刚跳过的那一项。如果是正序遍历,这一项在当前迭代器之前;如果是倒序遍历,这一项在当前迭代器之后。
next()函数返回list中这一项的非const引用,简单点,我们甚至连setValue()都不需要:
QMutableListIterator<int> i(list); while (i.hasNext()) i.next() *= 2;
正如上面所说,QLinkedList、QVector还有QSet的迭代器类和QList的迭代器有着相同的API。现在,我们来看看QMapIterator,有点不同,因为他在键值对上遍历。
类似于QListIterator,QMapIterator提供了toFront()、toBack()、hasNext()、next()、peekNext()、hasPrevious()、previous()以及peekPrevious()。键和值的部分通过调用next()、peekNext()、previous()或peekPrevious()返回的对象的key()和value()来获得。
下面的例子中,移除所有首都名字以“City”结尾的一对(capital, country):
QMap<QString, QString> map; map.insert("Paris", "France"); map.insert("Guatemala City", "Guatemala"); map.insert("Mexico City", "Mexico"); map.insert("Moscow", "Russia"); ... QMutableMapIterator<QString, QString> i(map); while (i.hasNext()) { if (i.next().key().endsWith("City")) i.remove(); }
QMapIterator还提供了直接在迭代器上操作的key()和value()函数,返回迭代器跳过的上一项的键和值。比如,下面的代码把QMap中的内容复制到QHash中:
QMap<int, QWidget *> map; QHash<int, QWidget *> hash; QMapIterator<int, QWidget *> i(map); while (i.hasNext()) { i.next(); hash.insert(i.key(), i.value()); }
如果想要使用同一个值遍历所有项,我们使用findNext()或findPrevious()。下面例子中,我们移除所有带有某个特定值的项:
QMutableMapIterator<int, QWidget *> i(map); while (i.findNext(widget)) i.remove();
STL风格的迭代器
自从Qt2.0发布就可以使用STL风格的迭代器了,它们适用于Qt和STL的泛型算法,并且对速度作了优化。
对于每个容器类,有两种STL风格的迭代器类型:只读的和可读写的。尽可能使用只读的迭代器,因为它们比可读写的迭代器要快。
容器 | 只读迭代器 | 可读写的迭代器 |
---|---|---|
QList<T>, QQueue<T> | QList<T>::const_iterator | QList<T>::iterator |
QLinkedList<T> | QLinkedList<T>::const_iterator | QLinkedList<T>::iterator |
QVector<T>, QStack<T> | QVector<T>::const_iterator | QVector<T>::iterator |
QSet<T> | QSet<T>::const_iterator | QSet<T>::iterator |
QMap<Key, T>, QMultiMap<Key, T> | QMap<Key, T>::const_iterator | QMap<Key, T>::iterator |
QHash<Key, T>, QMultiHash<Key, T> | QHash<Key, T>::const_iterator | QHash<Key, T>::iterator |
STL迭代器的API是以数组中的指针为模型的,比如++运算符将迭代器前移到下一项,*运算符返回迭代器所指的那一项。事实上,对于QVector和QStack,它们的项在内存中存储在相邻的位置,迭代器类型正是T *,const迭代器类型正是const T *。
在讨论中,我们重点放在QList和QMap,QLinkedList、QVector和QSet的迭代器类型与QList的迭代器有相同的接口;同样地,QHash的迭代器类型与QMap的迭代器有相同的接口。
下面是一个典型例子,按顺序循环遍历QList<QString>中的所有元素,并将它们转为小写:
QList<QString> list; list << "A" << "B" << "C" << "D"; QList<QString>::iterator i; for (i = list.begin(); i != list.end(); ++i) *i = (*i).toLower();
不同于Java风格的迭代器,STL风格的迭代器直接指向每一项。begin()函数返回指向容器中第一项的迭代器。end()函数返回指向容器中最后一项后面一个位置的迭代器,end()标记着一个无效的位置,不可以被解引用,主要用在循环的break条件。如果list是空的,begin()等于end(),所以我们永远不会执行循环。
下图展示了一个包含4个元素的vector的所有有效迭代器位置,用红色箭头标出:
倒序遍历需要我们在获得项之前减少迭代器,这需要一个while循环:
QList<QString> list; list << "A" << "B" << "C" << "D"; QList<QString>::iterator i = list.end(); while (i != list.begin()) { --i; *i = (*i).toLower(); }
在上面的代码中,我们使用一元运算符*来获得存储在某个迭代器位置的项,然后我们调用QString::toLower(),大多数C++编译器还允许我们使用i->toLower(),但有些不允许。
如果是只读的,你可以使用const_iterator、constBegin()和constEnd(),比如:
QList<QString>::const_iterator i; for (i = list.constBegin(); i != list.constEnd(); ++i) qDebug() << *i;
下面的表概括了STL风格迭代器的API:
表达式 | 用途 |
---|---|
*i |
返回当前项 |
++i |
将迭代器指向下一项 |
i += n |
迭代器向前移动n项 |
--i |
将迭代器指向上一项 |
i -= n |
将迭代器你向后移动n项 |
i - j |
返回迭代器i和j之间项的数目 |
++和--运算符可以使用前缀(++i, --i)和后缀(i++, i--)的形式,前缀的版本修改迭代器并返回修改后迭代器的引用,后缀版本在修改之前先复制迭代器,然后返回它的拷贝。在不需要考虑返回值的情况下,我们推荐使用前缀运算符(++i, --i),因为它们稍微快一点。
对于非const的迭代器类型,一元运算符*可以被用在赋值运算符的左边。
对于QMap和QHash,*运算符返回项的值,如果你想要获得键,只需在迭代器上调用key()。为了对称,迭代器类型还提供了value()函数来获得值。举个例子,下面是如何将QMap中的所有项打印到控制台上:
QMap<int, int> map; ... QMap<int, int>::const_iterator i; for (i = map.constBegin(); i != map.constEnd(); ++i) qDebug() << i.key() << ":" << i.value();
幸好有隐式共享,函数返回容器中的每个值效率很高。Qt的API包含很多返回QList或QStringList值的函数(比如QSplitter::sizes())。如果想要使用STL迭代器遍历它们,你应该存储一个拷贝,并在拷贝上进行遍历。比如:
// RIGHT const QList<int> sizes = splitter->sizes(); QList<int>::const_iterator i; for (i = sizes.begin(); i != sizes.end(); ++i) ... // WRONG QList<int>::const_iterator i; for (i = splitter->sizes().begin(); i != splitter->sizes().end(); ++i) ...
当函数返回容器的const或非const的引用,这个问题将不会发生。
foreach关键字
如果你想要按顺序遍历容器中的所有项,你可以使用Qt的foreach关键字。这个关键字是Qt特有的,与C++语言无关,并且使用了预处理器实现。
它的语法是:foreach (variable, container) statement。比如,下面是如何使用foreach遍历QLinkedList<QString>:
QLinkedList<QString> list; ... QString str; foreach (str, list) qDebug() << str;
foreach代码明显比使用迭代器的代码少:
QLinkedList<QString> list; ... QLinkedListIterator<QString> i(list); while (i.hasNext()) qDebug() << i.next();
除非数据类型包含一个逗号(比如QPair<int, int>),用于遍历的变量可以在foreach语句中定义:
QLinkedList<QString> list; ... foreach (const QString &str, list) qDebug() << str;
和其它任何C++循环一样,你可以在foreach循环中把主体放在括号里,而且你可以使用break来结束循环:
QLinkedList<QString> list; ... foreach (const QString &str, list) { if (str.isEmpty()) break; qDebug() << str; }
在QMap和QHash中,foreach可以获得键值对中值的部分。如果你遍历既想获得键又想获得值,则可以使用迭代器(这样是最快的),或者你可以这样写:
QMap<QString, int> map; ... foreach (const QString &str, map.keys()) qDebug() << str << ":" << map.value(str);
对于一个多值的(multi-valued)map:
QMultiMap<QString, int> map; ... foreach (const QString &str, map.uniqueKeys()) { foreach (int i, map.values(str)) qDebug() << str << ":" << i; }
当进入foreach循环时Qt自动获得容器的一份拷贝,如果想修改你所遍历的容器,并不会影响循环。
foreach创建了容器的一份拷贝,使用变量的非const引用可以禁止你修改最初的容器,但它会影响拷贝,这也许是你不愿看到的。
其它类似容器的类
Qt提供了三个模板类,在一些方面与容器有点像。这些类不提供迭代器,而且不能使用foreach关键字。
- QVarLengthArray<T, Prealloc>提供一个低级的可变长度的数组,当速度特别重要的时候,它可以被用来替换QVector。
- QCache<Key, T>提供缓存,用来存储和Key类型键相关联的T类型的对象。
- QContiguousCache<T>提供了一种缓存可连续获得的数据的有效方式。
- QPair<T1, T2>存储一对元素。
其它类似于模板容器的非模板类型有QBitArray、QByteArray、QString和QStringList。
算法复杂度
算法复杂度关注当容器中项的数目增长时,函数有多快。例如,在QLinkedList中间插入一项是非常快的,无论其中存了多少项。另一方面,在QVector中项很多时,在中间插入一项是非常低效的,因为一半的项必须在内存中移动位置。
为了描述算法复杂度,我们使用下面的术语,基于“大O”标记法:
常量时间:O(1)。
指数时间:O(log n)。
线性时间:O(n)。
线性指数时间:O(nlog n)。
平方时间:O(n2)。
下面的表概括了Qt顺序容器的算法复杂度:
按索引查找 | 插入 | 在前面增加 | 在后面增加 | |
---|---|---|---|---|
QLinkedList<T> | O(n) | O(1) | O(1) | O(1) |
QList<T> | O(1) | O(n) | Amort. O(1) | Amort. O(1) |
QVector<T> | O(1) | O(n) | O(n) | Amort. O(1) |
在表中,“Amort”指的是“平摊行为”。比如,“Amort.O(1)”指的是如果你只调用函数1次,你可能得到O(n),但如果你多次调用,平均下来将是O(1)。
下面的表概括了Qt关联容器的算法复杂度:
关键字查找 | 插入 | |||
平均 | 最坏情况 | 平均 | 最坏情况 | |
QMap<Key, T> | O(log n) | O(log n) | O(log n) | O(log n) |
QMultiMap<Key, T> | O(log n) | O(log n) | O(log n) | O(log n) |
QHash<Key, T> | Amort. O(1) | O(n) | Amort. O(1) | O(n) |
QSet<Key> | Amort. O(1) | O(n) | Amort. O(1) | O(n) |
增长策略
QVector<T>、QString和 QByteArray在内存中连续存储它们的项;QList<T>维护一个指向每一项指针的数组,从而提供快速的基于索引的获得方法;QHash<Key, T>维护一个哈希表,它的大小与其中项的个数成比例。为了避免每次在容器末尾增加一项就分配一次内存,这些容器比实际需要的分配了更多的内存。
我们考虑下面的程序,根据一个QString来建立另一个QString:
QString onlyLetters(const QString &in) { QString out; for (int j = 0; j < in.size(); ++j) { if (in[j].isLetter()) out += in[j]; } return out; }
我们通过一次增加一个字符来动态地建立字符串。假设需要增加15000个字符,当字符串空间不够时,将会发生18次重新分配内存:4, 8, 12, 16, 20, 52, 116, 244, 500, 1012, 2036, 4084, 6132, 8180, 10228, 12276, 14324, 16372。最后,QString有16372个Unicode字符被分配,15000个被占用。
这些值可能看起来有点奇怪,下面是增长的规则:
- 1.QString一次分配4个字符,直到它增长到20。
- 2.从20到4084,每次增长一倍,更准确地说,增长到下一个2的次方,减去12。
- 3.从4084往后,每次增长2048个字符(4096字节)。这是因为当重新分配时,现代操作系统不会复制所有数据;物理内存被简单地重新排序,只有第一页和最后一页的数据需要被拷贝。
QByteArray和QList<T>使用了与QString差不多的算法。
QVector<T>对一些数据类型也使用同样的算法,这些数据类型可以使用memcpy()在内存中移动(包括基本的C++类型,指针类型以及Qt的共享类)。但是QVector<T>对只能调用构造和析构函数来移动的数据类型使用了不同的算法,这些情况下重新分配内存的代价更高,当空间不够时,QVector<T>通过内存加一倍来减少再分配的次数。
QHash<Key, T>是一个完全不同的情况。QHash的内部哈希表以2的次方增长,每次增长时,项被定为到新的存储块中,通过qHash(key) % QHash::capacity()(存储快的数目)计算。这个机制同样适用于QSet<T>和QCache<Key, T>。
QVector<T>、QHash<Key, T>、QSet<T>、QString和QByteArray提供了一些函数,让你能够检测和确定存储这些项用了多少内存:
- capacity()返回内存分配的项的数目(对QHash和QSet来说,是hash表中存储块的数目)。
- reserve(size)显式地为size个项预分配内存。
- squeeze()释放不需要用来存储项的内存。
如果你知道在容器中大约要存储多少项,可以调用reserve()开始,当你在容器中存储结束,可以调用squeeze()来释放额外的预分配的内存。