注意我们必须小心保存insert返回值以用于下次循环迭代。如果我们在每次插入后没有更新insertLoc,我们就会有两个问题。首先,所有第一次以后的循环迭代会导致未定义行为,因为每次调用insert会使insertLoc无效。第二,即使insertLoc保持有效,我们总是在vector的前部插入(也就是,在v.begin()),这样的结果就是整数以反序拷贝到v中。
如果我们按照条款43的指引,用调用copy来代替循环,我们会得出像这样的东西:
copy(data, data + numValues, inserter(v, v.begin()));
这次演示了copy模板,这段代码基于copy,这和使用显式循环的代码几乎一样,所以处于效率分析的目的,我们会关注于显示循环,要牢记分析也是一样有效于使用copy的代码。着眼于显式循环可以更容易地了解效率冲击(s)在哪里。是的,那是“冲击(s)”复数,因为使用insert单元素版本的代码对你征收了三种不同的性能税,而如果你用区间版本的insert,则一种都没有。
第一种税在于没有必要的函数调用。把numValues个元素插入v,每次一个,自然会花费你numValues次调用insert。使用insert的区间形式,你只要花费一次调用,节省了numValues-1次调用。当然,可能的内联会使你节省这种税,但再次说明,它也可能不会。只有一件事情是确定的,使用insert的区间形式,你明确地不必为此花费。
内联也节省不了你的第二种税——无效率地把v中的现有元素移动到它们最终插入后的位置的开销。每次调用insert来增加一个新元素到v,插入点以上的每个元素都必须向上移动一次来为新元素腾出空间。所以在位置p的元素必须向上移动到位置p+1等。在我们的例子中,我们在v的前部插入了numValues个元素。那意味着在v中每个插入之前的元素都得向上移动一共numValues个位置。但每次insert调用时每个只能向上移动一个位置,所以每个元素一共会被移动numValues次。如果v在插入前有n个元素,则一共会发生n*numValues次移动。在这个例子里,v容纳int,所以每次移动可能会归结为一次memmove调用,但如果v容纳了用户自定义类型比如Widget,每次移动会导致调用那个类型的赋值操作符或者拷贝构造函数。(大部分是调用赋值操作符,但每次vector的最后一个元素被移动,那个移动会通过调用元素的拷贝构造函数来完成。)于是在一般情况下,把numValues个新对象每次一个地插入容纳了n个元素的vector的前部需要花费n*numValues次函数调用:(n-1)*numValues调用Widget赋值操作符和numValues调用Widget拷贝构造函数。即使这些调用内联了,你仍然做了移动numValues次v中的元素的工作。
相反的是,标准要求区间insert函数直接把现有元素移动到它们最后的位置,也就是,开销是每个元素一次移动。总共开销是n次移动,numValues次容器中的对象类型的拷贝构造函数,剩下的是类型的赋值操作符。相比单元素插入策略,区间insert少执行了n*(numValues-1)次移动。花一分钟想想。这意味着如果numValues是100,insert的区间形式会比重复调用insert的单元素形式的代码少花费99%的移动!
在我转向单元素成员函数和它们的区间兄弟的第三个效率开销前,我有一个小修正。我在前面写的段落都是真理,而且除了真理没别的了,但并不是真理的全部。仅当可以不用失去两个迭代器的位置就能决定它们之间的距离时,一个区间insert函数才能在一次移动中把一个元素移动到它的最终位置。这几乎总是可能的,因为所有前向迭代器提供了这个功能,而且前向迭代器几乎到处都是。所有用于标准容器的迭代器都提供了前向迭代器的功能。非标准的hash容器(参见条款25)的迭代器也是。在数组中表现为迭代器的指针也提供了这样的功能。事实上,唯一不提供前向迭代器能力的标准迭代器是输入和输出迭代器。因此,除了当传给insert区间形式的迭代器是输入迭代器(比如istream_iterator——参见条款6)外,我在上面写的东西都是真的。在那个唯一的情况下,区间插入必须每次一位地把元素移动到它们的最终位置,期望中的优点就消失了。(对于输出迭代器,这个问题不会发生,因为输出迭代器不能用于为insert指定一个区间。)
留下的最后一种性能税很愚蠢,重复使用单元素插入而不是一个区间插入就必须处理内存分配,虽然在它里面也有一个令人讨厌的拷贝。就像条款14解释的,当你试图去把一个元素插入内存已经满了的vector时,这个vector会分配具有更多容量的新内存,从旧内存把它的元素拷贝到新内存,销毁旧内存里的元素,回收旧内存。然后它添加插入的元素。条款14也解释了每当用完内存时,大部分vector实现都使它们的容量翻倍,所以插入numValues个新元素会导致最多log2numValues次新内存的分配。条款14也关注了展示该行为的现有实现,所以每次一个地插入1000个元素会导致10次新的分配(包括它们负责的元素拷贝)。与之对比的是(而且,就目前来看,是可预测的),一个区间插入可以在开始插入东西前计算出需要多少新内存(假设给的是前向迭代器),所以它不用多于一次地重新分配vector的内在内存。就像你可以想象到的,这个节省相当可观。
我刚才进行分析是用于vector的,但同样的理由也作用于string。对于deque,理由也很相似,但deque管理它们内存的方式和vector和string不同,所以重复内存分配的论点不能应用。但是,关于很多次不必要的元素移动的论点通常通过对函数调用次数的观察也应用到了(虽然细节不同)。
在标准序列容器中,就剩下list了,在这里使用insert区间形式代替单元素形式也有一个性能优势。关于重复函数调用的论点当然继续有效,但因为链表的工作方式,拷贝和内存分配问题没有发生。取而代之的是,这里有一个新问题:过多重复地对list中的一些节点的next和prev指针赋值。
每当一个元素添加到一个链表时,持有元素的链表节点必须有它的next和prev指针集,而且当然新节点前面的节点(我们叫它B,就是“before”)必须设置它的next指针,新节点后面的节点(我们叫它A,就是“after”)必须设置它的prev指针:
当一系列新节点通过调用list的单元素insert一个接一个添加时,除了最后一个以外的其他新节点都会设置它的next指针两次,第一次指向A,第二次指向在它后面插入的元素。每次在A前面插入时,它都会设置它的prev指针指向一个新节点。如果numValues个节点插入A前面,插入节点的next指针会发生numValues-1次多余的赋值,而且A的prev指针会发生numValues-1次多余的赋值。合计2*(numValues-1)次没有必要的指针赋值。当然,指针赋值很轻量,但如果不是必须,为什么要为它们花费呢?
现在已经很清楚你可以不必,避免开销的关键是使用list的insert区间形式。因为那个函数知道最后有多少节点会被插入,它可以避免多余的指针赋值,对每个指针只使用一次赋值就能设置它正确的插入后的值。
对于标准序列容器,当在单元素插入和区间插入之间选择时,除编程风格之外还有很多东西都浮出水面。对于关联容器,效率问题几乎没有,但是附加的重复调用单元素insert函数的开销问题仍然存在。此外,区间插入的特别类型在关联容器中也可能导致优化,但据我所知,这样的优化目前只存在于理论中。当然,在你看到这点的时候,理论可能已经变成实践了,所以关联容器的区间插入可能变得比单元素插入更有效。毫无疑问它们不会降低效率,所以你选择它们没有任何损失。
即使没有效率的论点,当你写代码时使用区间成员函数需要更少的输入这个事实仍然存在,它产生的代码也更容易懂,从而增强你软件的长期维护。只要两个特性就足以使你尽量选择区间成员函数。效率优势真的只是一个红利。
经历了关于区间成员函数的奇迹的长篇大论后,只需要我为你总结一下就可以了。知道那个成员函数支持区间可以使你更容易去发现使用它们的时机。在下面的,参数类型iterator意思是容器的迭代器类型,也就是container::iterator。另一方面,参数类型InputIterator意思是可以接受任何输入迭代器。
container::container(InputIterator begin, // 区间的起点
InputIterator end); // 区间的终点
如果传给这个构造函数的迭代器是istream_iterators或istreambuf_iterators(参见条款29),你可能会遇到C++的最惊异的解析,原因之一是你的编译器可能会因为把这个构造看作一个函数声明而不是一个新容器对象的定义而中断。条款6告诉你需要知道所有关于解析的东西,包括怎么对付它。
- 区间插入。所有标准序列容器都提供这种形式的insert:
void container::insert(iterator position, // 区间插入的位置
InputIterator begin, // 插入区间的起点
InputIterator end); // 插入区间的终点
关联容器使用它们的比较函数来决定元素要放在哪里,所以它们了省略position参数。
void container::insert(lnputIterator begin, InputIterator end);
当寻找用区间版本代替单元素插入的方法时,不要忘记有些单元素变量用采用不同的函数名伪装它们自己。比如,push_front和push_back都把单元素插入容器,即使它们不叫insert。如果你看见一个循环调用push_front或push_back,或如果你看见一个算法——比如copy——的参数是front_inserter或者back_inserter,你就发现了一个insert的区间形式应该作为优先策略的地方。
- 区间删除。每个标准容器都提供了一个区间形式的erase,但是序列和关联容器的返回类型不同。序列容器提供了这个:
iterator container::erase(iterator begin, iterator end);
而关联容器提供这个:
void container::erase(iterator begin, iterator end);
为什么不同?解释是如果erase的关联容器版本返回一个迭代器(被删除的那个元素的下一个)会招致一个无法接受的性能下降。我是众多发现这个徒有其表的解释的人之一,但标准说的就是标准说的,标准说erase的序列和关联容器版本有不同的返回类型。
这个条款的对insert的性能分析大部分也同样可以用于erase。单元素删除的函数调用次数仍然大于一次调用区间删除。当使用单元素删除时,每一次元素值仍然必须向它们的目的地移动一位,而区间删除可以在一个单独的移动中把它们移动到目标位置。
关于vector和string的插入和删除的一个论点是必须做很多重复的分配。(当然对于删除,会发生重复的回收。)那是因为用于vector和string的内存自动增长来适应于新元素,但当元素的数目减少时它不自动收缩。(条款17描述了你怎么减少被vector或string持有的不必要的内存。)
一个非常重要的区间erase的表现是erase-remove惯用法。你可以在条款32了解到所有关于它的信息。
- 区间赋值。就像我在这个条款的一开始提到的,所有标准列容器都提供了区间形式的assign:
void container::assign(InputIterator begin, InputIterator end);
所以现在我们明白了,尽量使用区间成员函数来代替单元素兄弟的三个可靠的论点。区间成员函数更容易写,它们更清楚地表达你的意图,而且它们提供了更高的性能。那是很难打败的三驾马车。