序列式容器
QVector<T>是一个类数组(array-like)的数据结构,它将它的条目存储在内存中相邻的位置上。 向量区别于普通的C++数组的是向量知道自己的大小并能够调整自己的大小。 在向量的尾部追加数据是非常高效的,而从向量的头部或中间插入数据的代价是很昂贵的。
图 11.1. 一个double类型向量
如果我们事先知道要用到的条目数,我们可以在定义向量的时候给它一个初始大小并使用[]操作符为条目赋值;否则,我们重新调整大小或追加数据。 这里有一个例子,在这个例子中我们指定初始大小:
QVector<double> vect(3); vect[0] = 1.0; vect[1] = 0.540302; vect[2] = -0.416147;
这是同样的例子,这次使用的是空向量并用append()函数在其尾部追加条目。
QVector<double> vect; vect.append(1.0); vect.append(0.540302); vect.append(-0.416147);
我们还可以用<<操作符来代替append():
vect << 1.0 << 0.540302 << -0.416147;
遍历向量的一个方法是使用[]和count():
double sum = 0.0; for (int i = 0; i < vect.count(); ++i) sum += vect[i];
向量条目在创建时如果没有被显示赋值,那么它用它的缺省构造函数进行初始化。 基本类型和指针类型被初始化为0.
对于一个大型向量来说,从QVector<T>的头部或中间进行插入或删除操作可能是低效的。 因为这个原因,Qt还提供了QLinkedList<T>,它是一种将条目存储在内存中非相邻位置上的数据结构。 不像向量,链表不支持随机存取,但是它们提供“常数时间”的插入和删除操作。
图 11.2. 一个double类型链表
链表不提供[]操作符,所以遍历链表要用迭代器。 迭代器还被用来确定条目的位置。 例如,下面的代码将字符串 "Tote Hosen" 插入"Clash" 和"Ramones"之间:
QLinkedList<QString> list; list.append("Clash"); list.append("Ramones"); QLinkedList<QString>::iterator i = list.find("Ramones"); list.insert(i, "Tote Hosen");
在本节的后面,我们将会看到迭代器的更多细节。
QList<T>序列式容器是一个 "array-list" ,它融合了QVector<T>和QLinkedList<T>中最重要的优势与一个单独的类中。 它支持随机存取,并且它的接口是基于索引的像QVector那样。在QList<T>两端插入或删除条目非常快,并且对于多达一千个条目的列表来说从中间插入也很快。 除非我们希望在一个庞大的列表的中间进行插入或者希望列表中的条目充满内存中连续的空间,否则QList<T>通常是最适合于一般用途的容器类。
QStringList类是QList<QString>的子类,它被Qt的API所广泛使用。 除了从基类继承来的函数,它还提供一些扩展函数来使字符串操作更加多样化。 QStringList在本章的最后一节进行讨论。
QStack<T>和QQueue<T>又是两个便捷子类的例子。 QStack<T>是一个向量,它提供push(),pop(),和top()。 QQueue<T>是一个向量,它提供enqueue(), dequeue(), 和head()。
对于目前所看到的所有容器类,值类型T可能是一个基本类型如int 或 double,一个指针类型,或者是一个拥有缺省构造函数(不带参数的构造函数),一个拷贝构造函数,和一个赋值操作符函数。 合格的类包括QByteArray,QDateTime,QRegExp, QString, 和QVariant。 从QObject派生的类不合格,因为它们缺少一个拷贝构造函数和一个赋值操作符。 这在实践中没有问题,因为我们可以简单地存储指向QObject类型的指针而不是对象本身。
值类型T还可以是一个容器,在这种情况下我们必须记得将连续的尖括号用空格分开;否则,编译器将卡在这,因为它认为这是一个>>操作符。 例如:
QList<QVector<double> > list;
除了刚才提到的类型,一个容器的值类型可以是任何的自定义类型,但它要符合先前描述的标准。 这里有这样的一个类:
class Movie { public: Movie(const QString &title = "", int duration = 0); void setTitle(const QString &title) { myTitle = title; } QString title() const { return myTitle; } void setDuration(int duration) { myDuration = duration; } QString duration() const { return myDuration; } private: QString myTitle; int myDuration; };
这个类的构造函数不需要任何参数(尽管它占用了两个)。 它还有一个拷贝构造函数和一个赋值操作符,这两个由C++隐式实现。 对于这个函数,member-by-member拷贝已经足够,所以没有必要去实现我们自己的拷贝构造函数和赋值操作符。
Qt提供两类迭代器来遍历容器。 Java风格的迭代器和STL风格的迭代器。 Java风格的迭代器更易用,而STL风格的迭代器能与Qt和STL的泛型算法相结合并且更强大。
对于每个容器类,Java风格的迭代器有两种类型。 只读迭代器和读写迭代器。 只读迭代器类是QVectorIterator<T>, QLinkedListIterator<T>, 和 QListIterator<T>。 相应的读写迭代器的名字中有Mutable(如,QMutableVectorIterator<T>)。 这次讨论,我们将集中在QList的迭代器;链表和向量的迭代器有着相同的API。
图 11.3. Java风格迭代器有效的位置
使用Java风格的迭代器首先要记住它们不直接指向条目。 相反,它们可以定位到第一个条目前,最后一个条目后,或者两个条目之间。 一个典型的迭代器循环看起来像这样子:
QList<double> list; ... QListIterator<double> i(list); while (i.hasNext()) { do_something(i.next()); }
用要遍历的容器初始化迭代器。 此时,迭代器被定位到第一个条目之前。 hasNext()返回true如果迭代器的右侧有一个条目。 next()返回迭代器右侧的条目并使迭代器移动到下一个有效位置。
反向迭代也是类似的,除了必须首先调用toBack()使迭代器定位到最后一个条目之后。
QListIterator<double> i(list); i.toBack(); while (i.hasPrevious()) { do_something(i.previous()); }
hasPrevious()函数返回true如果迭代器的左边有一个条目;previous()返回迭代器左边的条目并向左移动一个位置。 另一种思考next()和previous()迭代器的方式是它们返回的是迭代器刚刚越过的条目。
图 11.4. Java风格迭代器上previous()和next()的执行效果
可变迭代器(Mutable iterators)在迭代的时候提供插入,修改,和删除条目的操作 。 下面的循环从列表中删除所有的负数:
QMutableListIterator<double> i(list); while (i.hasNext()) { if (i.next() < 0.0) i.remove(); }
remove()总是操作在那个最后被越过的条目上。 它还可以反向迭代:
QMutableListIterator<double> i(list); i.toBack(); while (i.hasPrevious()) { if (i.previous() < 0.0) i.remove(); }
同样,Java风格可变迭代器提供一个setValue()函数来修改最后被越过的条目。 我们将负数用它们的绝对值替换掉:
QMutableListIterator<double> i(list); while (i.hasNext()) { int val = i.next(); if (val < 0.0) i.setValue(-val); }
还可以通过调用insert()在当前迭代器的位置插入一个条目。 迭代器然后被移动到新的条目和下一个条目之间。
除了Java风格迭代器,每个序列式容器类C<T>有两种STL风格迭代器类型。 C<T>::iterator 和 C<T>::const_iterator。 两者的不同之处是const_iterator不允许我们修改数据。
一个容器的begin()函数返回一个STL风格的迭代器,它引用容器中的第一个条目(如,list[0]),而end()函数返回的迭代器指向位于最后条目之后的条目(如,大小为5的列表中的list[5])。 如果容器为空,那么begin()等于end()。 这可以被用来查看容器是否有条目,尽管为此调用isEmpty()更方便。
图 11.5. STL风格迭代器有效的位置
STL风格迭代器语法仿效C++数组指针。 我们可以使用++和--操作符来移动到下一个或前一个条目,和一元*操作符来获取当前条目。 对于QVector<T>,iterator 和const_iterator类型仅仅是对T * 和const T *的类型定义。 (可能是因为QVector<T>中条目在内存中是连续的)
下面的例子将QList<double>中的每个值替换为相应的绝对值:
QList<double>::iterator i = list.begin(); while (i != list.end()) { *i = qAbs(*i); ++i; }
少数Qt函数返回一个容器。 如果我们希望用STL风格的迭代器遍历返回的值,我们必须取得这个容器的一份拷贝然后在这份拷贝上遍历。 例如,下面的代码是用一种正确的方式来遍历QSplitter::sizes()返回的QList<int>。
QList<int> list = splitter->sizes(); QList<int>::const_iterator i = list.begin(); while (i != list.end()) { do_something(*i); ++i; }
// WRONG QList<int>::const_iterator i = splitter->sizes().begin(); while (i != splitter->sizes().end()) { do_something(*i); ++i; }
这是因为每调用QSplitter::sizes()一次,它就返回一个新的QList<int>变量。 如果我们不保存返回值,C++会自动销毁它在我们开始迭代之前,留给我们的是一个悬空的迭代器(dangling iterator)。 更糟糕的是,每次循环,因为splitter->sizes().end()的调用,QSplitter::sizes()必须生成列表的一份新的拷贝。 总之: 当使用STL风格的迭代器时,总是在容器的拷贝(以值的形式被返回)上进行迭代。
使用只读的Java风格迭代器,我们不需要拷贝。 迭代器在后台为我们取得一份拷贝,确保我们总是在函数第一次返回的数据上进行迭代。 例如:
QListIterator<int> i(splitter->sizes()); while (i.hasNext()) { do_something(i.next()); }
像这样拷贝一个容器似乎代价昂贵,但事实不是这样的,这要多亏一种叫做implicit sharing(隐式共享)的优化手段。 这意味着拷贝一个Qt容器大约和拷贝一个单一的指针一样快。 仅当其中的一个拷贝被修改,才开始实际执行数据拷贝,这些操作会自动地在后台执行。 因为这个原因,隐式共享有时被称作临写拷贝(copy on write)。
隐式共享的好处是它是一种我们不需要考虑的优化;它简单地工作,不需要程序员的干预。 同时,在那些以值的形式返回对象的地方,隐式共享促成一种干净的编程风格。 考虑下面的函数:
QVector<double> sineTable() { QVector<double> vect(360); for (int i = 0; i < 360; ++i) vect[i] = sin(i / (2 * M_PI)); return vect; }
像下面那样调用这个函数:
QVector<double> table = sineTable();
相比之下,当将函数的返回值存储在一个变量中的时候,STL鼓励我们使用non-const引用而非使用拷贝。
using namespace std; void sineTable(vector<double> &vect) { vect.resize(360); for (int i = 0; i < 360; ++i) vect[i] = sin(i / (2 * M_PI)); }
这个调用变得冗长而且不清晰。
vector<double> table; sineTable(table);
Qt为其所有的容器和许多其他的容器使用隐式共享,包括QByteArray, QBrush, QFont, QImage, QPixmap, 和QString。 作为函数参数或返回值,这些类以值的形式进行传递时会非常高效。
Qt的隐式共享保证只有当我们修改了数据的时候才进行数据拷贝。 为了有效利用隐式共享,我们可以采用两个新的编程习惯。 一个习惯是对于只读(non-const)向量或列表使用at()函数,而非[]操作符。 因为Qt的容器不知道[]出现在赋值号的左边还是右边,它假设了最坏的情况并强制进行深拷贝,而at()不允许出现在赋值号的左边。
一个类似的问题出现在我们用STL风格的迭代器遍历一个容器的时候。 每当我们在non-const容器上调用begin()或end()时,如果数据是被共享的,Qt强制进行一个深拷贝。 为了避免这个低效的操作,解决的方法就是使用const_iterator, constBegin(), 和 constEnd(),如果可以的话。
Qt提供的遍历序列式容器的最后一个方法: foreach循环。 它看起来像这样:
QLinkedList<Movie> list; ... foreach (Movie movie, list) { if (movie.title() == "Citizen Kane") { cout << "Found Citizen Kane" << endl; break; } }
foreach伪关键字(pseudo-keyword)的实现目标是标准for循环。 每次循环,迭代变量(movie)被设置为新的条目,从容器的头部开始向前推进。 当进入循环时foreach自动取得源容器的拷贝,因为这个原因,如果源容器在迭代过程中被修改将不会影响循环。
---------------------------------------------------------------------------
隐式共享如何工作
隐式共享在后台自动工作,所以我们没有必要在代码中做任何事来启用这个优化。 既然知道事物如何工作也没什么不好,我们将学习一个例子并看看帽子下的戏法。 这个例子使用QString,Qt众多隐式共享类中的一个。
QString str1 = "Humpty"; QString str2 = str1;
我们设置str1为"Humpty"并让str2等于str1。 此时,两个QString对象指向了内存中同一个内部数据结构。 不仅有字符数据,这个数据结构还持有一个引用计数来指出有多少个QString对象指向这个数据结构。 既然str1 和str2都指向了同一个数据,那么引用计数就是2.
str2[0] = 'D';
当我们修改了str2,它先对数据进行深拷贝,来确保str1和str2指向不同的数据结构,然后它在自己的那份拷贝上进行修改。 str1数据的引用计数变为1,然后str2数据的引用计数设置为1.引用计数为1说明数据没有被共享。
str2.truncate(4);
如果我们再次修改str2,没有拷贝发生,因为str2数据的引用计数为1.TRuncate()直接操作在str2的数据上,生成字符串"Dump"。 引用计数仍为1。
str1 = str2;
当我们将str1赋值为str2时,str1数据的引用计数变为0,这意味着再也没有QString对象使用"Humpty"数据。 "Humpty"数据被释放掉。 两个QString对象指向"Dump",现在的引用计数为2。
在多线程程序中数据共享经常被disregarded,因为引用计数的竞争态。 在Qt,这不是个问题。 在内部,容器类使用汇编指令来执行引用计数的原子操作(perform atomic reference counting)。 Qt用户可以通过QSharedData和QSharedDataPointer类来使用这项技术。
---------------------------------------------------------------------------
break和continue循环语句被支持。 如果循环体是单个的语句,那么大括号可以省略。 像for语句,可以在循环外定义迭代变量,像这样:
QLinkedList<Movie> list; Movie movie; ... foreach (movie, list) { if (movie.title() == "Citizen Kane") { cout << "Found Citizen Kane" << endl; break; } }