本节书摘来自异步社区出版社《C++编程剖析:问题、方案和设计准则》一书中的第1章,第1.1节,作者:【美】Herb Sutter(赫布 萨特),更多章节内容可以访问云栖社区“异步社区”公众号查看。
第一章泛型编程与C++标准库
C++编程剖析:问题、方案和设计准则
C++最强大的特性之一就是对泛型编程的支持。C++标准库的高度灵活性就是明证,尤其是标准库中的容器、迭代器以及算法部分(最初也称为STL)。
与我的另一本书More Exceptional C++ [Sutter02]一样,本书的开头几条也是介绍STL中一些我们平常熟悉的部件,如vector和string,另外也介绍了一些不那么常见的设施。例如,在使用最基本的容器vector时如何避免常见的陷阱?如何在C++中进行常见的C风格字符串操纵?我们能够从STL中学到哪些库设计经验(不管是好的、坏的,还是极其糟糕的)?
在考察了STL中的模板设施之后,接着讨论关于C++中的模板和泛型编程的一些更一般性的问题。例如,如何让我们的模板代码避免不必要地(且相当不经意地)损失泛型性。为什么说特化函数模板实际上是个糟糕的主意,而我们又应当怎么替换它?在模板的世界中,我们如何才能正确且可移植地完成像授予友元关系这样看似简单的操作?此外还有围绕着export这个有趣的关键字发生的种种故事。
随着我们逐步深入与C++标准库及泛型编程相关的主题,就会看到关于上述(以及其他)问题的讨论。
1.1:vector的使用
难度系数:4
几乎每个人都会使用std::vector,这是个好现象。不过遗憾的是,许多人都误解了它的语义,结果无意间以奇怪和危险的方式使用它。本条款中阐述的哪些问题会出现在你目前的程序中呢?
初级问题
- 下面的代码中,注释A跟注释B所示的两行代码有何区别?
void f(vector<int>& v) {
v[0]; // A
v.at(0); // B
}```
专家级问题
2. 考虑如下的代码:
vector v;
v.reserve(2);
assert(v.capacity() == 2);
v[0] = 1;
v[1] = 2;
for(vector::iterator i = v.begin(); i < v.end(); i++) {
cout << *i << endl;
}
cout << v[0];
v.reserve(100);
assert(v.capacity() == 100);
cout << v[0];
v[2] = 3;
v[3] = 4;
// ……
v[99] = 100;
for(vector::iterator i = v.begin(); i < v.end(); i++) {
cout << *i << endl;
}
请从代码的风格和正确性方面对这段代码做出评价。
解决方案
访问vector的元素
1. 下面的代码中,注释A跟注释B所示的两行代码有何区别?
// 示例1-1: [] vs. at
//
void f(vector< int >& v) {
v[0]; // A
v.at(0); // B
}`
在示例1-1中,如果v非空,A行跟B行就没有任何区别;如果v为空,B行一定会抛出一个std::out_of_range异常,至于A行的行为,标准未加任何说明。
有两种途径可以访问vector内的元素。其一,使用vector::at。该成员函数会进行下标越界检查,确保当前vector中的确包含了需要的元素。试图在一个目前只包含10个元素的vector中访问第100个元素是毫无意义的,这样做会导致抛出一个std::out_of_range异常。
其二,我们也可以使用vector::operator[],C++98标准说vector::operator可以、但不一定要进行下标越界检查。实际上,标准对operator[]是否需要进行下标越界检查只字未提,不过标准同样也没有说它是否应该带有异常规格声明。因此,标准库实现方可以自由选择是否为operator[]加上下标越界检查功能。如果使用operator[]访问一个不在vector中的元素,你可就得自己承担后果了,标准对这种情况下会发生什么事情没有做任何担保(尽管你使用的标准库实现的文档可能做了某些保证)——你的程序可能会立即崩溃,对operator[]的调用也许会引发一个异常,甚至也可能看似无恙,不过会偶尔或神秘地出问题。
既然下标越界检查帮助我们避免了许多常见问题,那为什么标准不要求operator[]实施下标越界检查呢?简短的答案是效率。总是强制下标越界检查会增加所有程序的性能开销(虽然不大),即使有些程序根本不会越界访问。有一句名言反映了C++的这一精神:一般说来,不应该为不使用的东西付出代价(或开销)。所以,标准并不强制operator[]进行越界检查。况且我们还有另一个理由要求operator[]具有高效性:设计vector是用来替代内置数组的,因此其效率应该与内置数组一样,内置数组在下标索引时是不进行越界检查的。如果你需要下标越界检查,可以使用at。
调整vector的大小
现在看示例1-2,该示例对vector进行了简单操作。
- 考虑如下的代码:
// 示例1-2: vector的一些函数
//
vector< int > v;
v.reserve(2);
assert(v.capacity() == 2);```
这里的断言存在两个问题,一个是实质性的,另一个则是风格上的。
首先,实质性问题是,这里的断言可能会失败。为什么?因为上一行代码中对reserve的调用将保证vector的容量至少为2,然而它也可能大于2。事实上这种可能性是很大的,因为vector的大小必须呈指数速度上升,因而vector的典型实现可能会选择总是按指数边界来增大其内部缓冲区,即使是通过reserve来申请特定大小的时候。因此,上面代码中的断言条件表达式应该使用>=,而不是==,如下所示:
assert(v.capacity() >= 2);
其次,风格上的问题是,该断言(即使是改正后的版本)是多余的。为什么?因为标准已经保证了这里所断言的内容,所以再将它明确地写出来只会带来不必要的混乱。这样做毫无意义,除非你怀疑正在使用的标准库实现有问题,如果真有问题,你可就遇到大麻烦了。
v[0] = 1;
v[1] = 2;`
上面这些代码中的问题都是比较明显的,但可能是比较难于发现的明显错误,因为它们很可能会在你所使用的标准库实现上“勉强”能够“正常运行”。
大小(size,跟resize相对应)跟容量(capacity,与reserve相对应)之间有着很大的区别。
size告诉你容器中目前实际有多少个元素,而对应地,resize则会在容器的尾部添加或删除一些元素,来调整容器当中实际的内容,使容器达到指定大小。这两个函数对list、vector和deque都适用,但对其他容器并不适用。
capacity则告诉你最少添加多少个元素才会导致容器重分配内存,而reserve在必要的时候总是会使容器的内部缓冲区扩充至一个更大的容量,以确保至少能满足你所指出的空间大小。这两个函数仅对vector适用。
本例中我们使用的是v.reserve(2),因此我们知道v.capacity()>=2,这没有问题,但值得注意的是,我们实际上并没有向v当中添加任何元素,因而v仍然是空的!v.reserve(2)只是确保v当中有空间能够放得下两个或更多的元素而已。
准则
记住size/resize以及capacity/reserve之间的区别。
我们只可以使用operator(或at())去改动那些确实存在于容器中的元素,这就意味着它们是跟容器的大小息息相关的。首先你可能想知道为什么operator[]不能更智能一点,比如当指定地点的元素不存在的时候“聪明地”往那里塞一个元素,但问题是假设我们允许operator以这种方式工作,就可以创建一个有“漏洞”的vector了!例如,考虑如下的代码:
vector<int> v;
v.reserve(100);
v[99] = 42; // 错误!但出于讨论的目的,让我们假设这是允许的……
//……这里v[0]至v[98]的值是什么呢
正是因为标准并不强制要求operator进行区间检查,所以在大多数实现上,v[0]都会简单地返回内部缓冲区中用于存放但尚未存放第一个元素的那块空间的引用。因此v[0]=1;这行语句很可能被认为是正确的,因为如果接下来输出v0的话,或许会发现结果确实是1,跟(错误的)预期相符合。
再一次提醒,标准并无任何保证说在你使用的标准库实现上一定会出现上述情形,本例只是展示了一种典型的可能情况。标准并没有要求特定的实现在这类情况下(诸如对一个空的vector v写v[0])该采取什么措施,因为它假定程序员对这类情况有足够的认识。毕竟,如果程序员想要库来帮助进行下标越界检查的话,他们可以使用v.at(0),不是吗?
当然,如果将v.reserve(2)改成v.resize(2)的话,v[0]=1;v[1]=2;这两行赋值语句就能够顺利工作了。只不过上文中的代码并没有使用resize(),因此代码并不能保证正常工作。作为一个替代方案,我们可以将这两行语句替换成v.push_back(1)和v.push_back(2),它们的作用是向容器的尾部追加元素,而使用它们总是安全的。
for(vector<int>::iterator i = v.begin(); i < v.end(); i++) {
cout << *i << endl;
}```
首先,上面这段代码什么都不会打印,因为vector现在根本就是空的!这可能会让代码的作者感到意外,因为他们还没意识到其实前面的代码根本就没有往vector中添加任何东西。实际上,跟vector中的那些已经预留但尚未正式使用的空间“玩游戏”是很危险的。
话虽如此,这个循环本身并没有任何明显的问题,只不过如果在代码审查阶段看到这段代码的话,我会指出其中存在的一些风格上的问题。大多数意见都是基础性的,如下所示。
(1) 尽量做到const正确性。以上的循环当中,迭代器并没有用来修改vector中的元素,因此应当改用const_iterator。
(2) 尽量使用!=而不是<来比较两个迭代器。确实,由于vector<int>::iterator恰巧是一个随机访问迭代器(当然,并不一定是int*),因此在这种特定情况下将它跟v.end()比较是没有任何问题的。但问题是<只对随机访问迭代器有效,而!=对于任何迭代器都是有效的,因此我们应该将使用!=比较迭代器作为日常惯例,除非某些情况下确实需要<(注意,使用!=还有一个好处,就是便于将来需要时更改容器类型)。例如,std::list的迭代器并不支持<,因为它们只不过是双向迭代器。
(3) 尽量使用前缀形式的- -和++。让自己习惯于写++i而不是i++,除非真的需要用到i原来的值。例如,如果既要访问i所指的元素,又要将i向后递增一位,后缀形式v[i++]就比较适用了。
(4) 避免无谓的重复求值。本例中v.end()所返回的值在整个循环的过程中是不会改变的,因此应当避免在每次判断循环条件时都调用一次v.end(),或许我们应当在循环之前预先将v.end()求出来。
注意,如果你的标准库实现中的vector<int>::iterator就是int*,而且能够将end()进行内联及合理优化的话,原先的代码也许并无任何额外开销,因为编译器或许能够看出end()返回的值一直是不变的,从而安全地将求值提到循环外部。这是一种相当常见的情况。然而,如果你的标准库实现的vector<int>::iterator并非int*(例如,在大多数调试版实现当中,其类型都是类类型的),或者end()之类的函数并没有内联,或者编译器并不能进行相应的优化,那么只有手动将这部分代码提出才能获得一定程度的性能提升。
(5) 尽量使用\n而不是endl。使用endl会迫使输出流刷新其内部缓冲区。如果该流的确有内部缓冲区,而且又确实不需要每次都刷新它的话,可以在整个循环结束之后写一行刷新语句,这样程序会执行得快很多。
最后一个意见稍微高级一些。
(6) 尽量使用标准库中的copy()和for_each(),而不是自己手写循环,因为利用标准库的设施,你的代码可以变得更为干净简洁。这里,风格跟美学判断起作用了。在简单的情况下,copy()和for_each()可以而且确实比手写循环的可读性要强。不过,也只有像本例这样的简单情形才会如此,如果情况稍微复杂一些的话,除非你有一个很好的表达式模板库,否则使用for_each()来写循环反而会降低代码的可读性,因为原先位于循环体中的代码必须被提到一个仿函数当中才能使用for_each()。有时候这种提取是件好事,但有时它只会导致混淆晦涩。
之所以说大家的口味可能各不相同,就是这个原因。另外,在本例中我倾向于将原先的手写循环替换成如下的形式:
`
copy(v.begin(), v.end(), ostream_iterator<int>(cout, "\n"));`
此外,如果你如此使用copy(),那么原先关于!=、++、end()以及endl的问题就不用操心了,因为copy()已经帮你做了这些事情。(当然,我还是假定你并不希望在每输出一个int的时候都去刷新输出流,否则你只有手写循环了。)复用如果运用得当的话不但能够改善代码的可读性,而且还可以避开一些陷阱,从而让代码更佳。
你可以更进一步,编写一个基于容器的复制算法,也就是说,施加在整个容器(而不仅仅是迭代器区间)之上的算法。这种做法同样也可以自动纠正const_iterator问题。例如:
template
OutputIterator copy(const Container& c, OutputIterator result) {
return std::copy(c.begin(), c.end(), result);
}`
`
这里,我们只需简单地包装std::copy(),让它对整个容器进行操作,此外由于我们是以const&来接受容器参数的,因而迭代器自然就是const_iterator了。
准则
确保const正确性。特别是不对容器内的元素做任何改动的时候,记得使用const_iterator。
尽量使用!=而不是<来比较两个迭代器。
养成默认情况下使用前缀形式的--和++的习惯,除非你的确需要用到原来的值。
实施复用:尽量复用已有的算法,特别是标准库算法(例如for_each()),而不是手写循环。
接下来我们遇到下面这行代码:
cout << v[0];
当程序执行这一行的时候,可能会打印出1。这是因为前面的程序以错误的方式改写了v[0]所引用的那块内存,只不过,这行代码也许并不会导致程序立即崩溃,真遗憾!
`
v.reserve(100);
assert(v.capacity() == 100);`
同样,这里的断言表达式当中应该使用>=,而且和前面一样,这也是多余的。
cout << v[0];
很奇怪!这次的输出结果可能为0,我们刚刚赋值的1神秘失踪了!
为什么?我们假设reserve(100)确实引发了一次内部缓冲区的重分配(即如果第一次reserve(2)并没有使内部缓冲区扩大到100或更多的话),这时v就只会将它确实拥有的那些元素复制到“新家”当中,而问题是实际上v认为它内部空空如也(因此不复制任何元素)!另一方面,新分配的内部缓冲区最初值可能为0(严格讲不确定),因此就出现了上述情况。
v[2] = 3;
v[3] = 4;
// ……
v[99] = 100;```
毫无疑问,看到如上的代码你可能已经叹着气摇头了。这真是糟糕、糟糕、太糟糕了!但由于标准并不强制operator进行越界检查,所以在大多数实现上这种代码或许会静悄悄地“正确”运行着,而不会立即导致异常或内存陷阱。
如果这样改写:
v.at(2) = 3;
v.at(3) = 4;
// ……
v.at(99) = 100;`
那么问题就会变得明朗了,因为第一个调用语句就会抛出一个out_of_range异常。
for(vector<int>::iterator i = v.begin(); i < v.end(); i++) {
cout << *i << endl;
}```
再一次提醒,以上代码什么也不会打印出来,应当考虑将它改写成:
`
copy(v.begin(), v.end(), ostream_iterator<int>(cout, "\n"));`
再次注意,这种复用自动地解决了!=、前缀++、end()以及endl问题,因此程序永远不会在这些方面犯错误。良好的复用通常也会让代码自动变得更快和更安全。
小结
了解size()和capacity()之间的区别,了解operator跟at()之间的区别。如果需要越界检查,请使用at()而不是operator。这么做可以帮助我们节省大量的调试时间。