C++编程剖析 问题 方案和设计准则(txt+pdf+epub+mobi电子书下载)


发布时间:2021-02-19 16:16:18

点击下载

作者:(美)赫布 萨特(Herb Sutter)

出版社:人民邮电出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

C++编程剖析 问题 方案和设计准则

C++编程剖析 问题 方案和设计准则试读:

前言

布达佩斯,匈牙利的首都。一个炎热的夏日傍晚。穿过美丽的多瑙河望去,余晖中的东岸景色优美恬静。

在本书封面上色彩柔和的欧洲风光中,哪栋建筑首先映入你的眼帘?几乎可以肯定,是照片左边的国会大厦。这栋巨大的新哥特式建筑以它优美的圆穹、直插天际的尖塔、不计其数的外墙雕塑以及其他华丽装饰一下攫住了你的目光,而更引人注目之处在于,它与四周建筑在多瑙河畔那些刻板的实用建筑形成了极其鲜明的对照。

为什么会有这么大的差异呢?一方面,国会大厦是在1902年竣工的,而其他味同嚼蜡的建筑则大部分都是在第二次世界大战以后建成的。“啊哈,”你可能会想,“这的确解释了为什么差异如此之大。然而这与本书到底有什么关系呢?”

毫无疑问,风格的表达与你在表达风格时灌注的哲学和思维方式是有很大关系的,这一点不管对于建筑学还是对于软件架构来说都同样适用。我相信你们都见过像封面上国会大厦那样宏伟而华丽的软件,我同样相信你们也见过仅能工作而且一团乱麻似的软件。极端一点说,我相信你也见过许多过分追求风格反而弄巧成拙的华而不实之作,以及许多只顾尽快完成任务而毫无风格的“丑小鸭”(而且永远也不会变成天鹅)。风格还是实用

哪个更好?

不要太相信自己知道答案。一方面,除非你给出一个明确的标准,否则“更好”只是一个无意义的评价。对什么更好呢?在哪些方面更好呢?另一方面,答案几乎总是这两者的平衡,最开始总是“这取决于……”。

本书讨论的是如何在使用C++进行软件设计和实现的诸多细节方面找到最佳平衡点,如何更好地理解所拥有的工具和设施,弄清它们应该在什么时候应用。

快速回答:与四周索然无味的建筑相比,封面上的国会大厦是更好的建筑吗?其建筑风格更好吗?如果不加思索就给出答案,很可能你会说“当然”,但是别忘了,你还没有考虑其建造和修缮的代价呢。

建造。在1902年竣工之时,它是当时世界上最大的国会大厦。人们花费了难以想象的时间、不计其数的人力物力来兴建它,以至于许多人称它为“白象”(white elephant),意思是耗资过大的美丽事物。考虑这样一个问题:比较起来,花费同样的投资能够建造多少幢周围那种不美观、单调或许干脆是令人厌烦的实用建筑?如果你是在一个工程进度压力远比这座国会大厦建造时代要大得多的行业工作,你又会怎么做?

修缮。你们中那些熟悉这座建筑的人会注意到照片中的建筑正在进行修缮翻新,而且这个工作已经持续了好多年,其间又极有争议地花费了巨额的资金。然而除了最近的这轮昂贵的修缮之外,之前还有多次修缮,因为这座建筑外墙上的精美雕刻所用的材料并不合适,太过柔软了。在大厦建成后不久,这些雕刻就必须不断修缮,它们逐渐被替换为更为坚固而耐久的材料。这些华丽之物的大规模修缮自从20世纪初开始就一直没停过,持续了近一个世纪。

软件开发中的情形也与此类似,重要的是在建造的代价和获得的功能之间、在优雅与可维护性之间、在发展的潜在可能与过分追求华丽之间寻求合理的平衡。

使用C++来进行软件设计和架构时,我们每天都得面对这些类似的权衡。在本书讨论的问题当中有这样几个问题:使代码成为异常安全的就意味着将它变得更好了吗?如果是这样的,那么这里所谓的“更好”是指什么意义上的呢?什么时候它可能不是“更好”的呢?在本书中你会得到明确的答案。封装呢?封装是否令软件变得更好?为什么?什么时候封装反倒不能令软件变得更好?如果你想知道答案,继续往下读。内联是一项有益的优化吗?内联是什么时候进行的呢?(你在回答这个问题的时候可得十分小心了。)C++中的模板导出(export)特性与封面上的国会大厦有什么相通之处呢?std::string与多瑙河畔的巨型建筑又有何相通之处呢?

最后,在考虑了许多C++技术和特性之后,我们会用最后一部分来考察摘自公开发布的著名代码中的几个实际例子,看看代码的作者在哪些方面做得好,在哪些方面做得不好,以及什么样的替代方案可能在实用性与良好的C++风格之间取得更好的平衡。

我希望本书以及Exceptional C++系列的其他图书能够开阔你的视野,增加你有关许多细节及其相互关系的知识,让你进一步了解到如何在编写自己的软件时找到合理的平衡点。

请再看一眼封面上的照片,在照片的右上方,你会看到一个热气球。如果我们乘坐那样的热气球飞越城市的上空,整个城市的景色将尽收眼底。我们会看到风格跟实用是如何相互影响、相互依存的,我们也会知道如何去进行权衡并找到合理的平衡点,所有的决策将各得其所,构成一个富于生机的整体。

是的,我想布达佩斯是一个伟大的城市——充满着丰富的历史底蕴,充满着不尽的神秘喻义。伟大的苏格拉底

古希腊哲学家苏格拉底通过提问来达到教学目的。他精心准备的问题是为了引导并帮助学生从已知的知识引出结论,并说明他们所学的东西是如何彼此相关、如何与他们现有的知识有着千丝万缕的联系的。这种教学方式后来广为人知,我们称它为“苏格拉底方法”。苏格拉底的这种著名的教学方法能够吸引学生,让学生思考,并帮助学生从已知出发去引出新的东西。

本书与它的前面几本书(Exceptional C++[Sutter00]和More Exceptional C++[Sutter02])一样,正是借鉴了苏格拉底的做法。本书假定你在编写C++产品代码方面已有一些经验,书中使用了一种问答的形式来告诉你如何有效地利用标准C++及其标准库,特别地,我们将关注的中心放在如何用现代C++开发可靠的软件上。书中的许多问题都是从我以及其他人在编写C++产品代码时遇到的问题当中提炼出来的。问题的目标是帮助你从已知的以及刚学到的东西出发得出结论,并展示它们之间如何关联。书中给出的问题会展示如何对C++设计和编程问题做出理性的分析和判断,其中有些只是常见问题,有些不是那么常见;有些是非常单纯的问题,而有些则深奥一些;另外还有几个问题之所以放在书中只是因为……因为它们比较有趣。

本书涉及了C++的方方面面。我的意思并不是说它触及了C++的每个细枝末节(那可需要多得多的篇幅了),我只不过是说它是从 C++语言和库特性这块大调色板上取色,并描绘出一幅图景,展示那些看似毫无瓜葛的特性如何编织到一起,从而构成常见问题的一个个漂亮解决方案。另外,本书还展示了那些看似无关的部分是如何互相之间盘根错节、存在着千丝万缕的联系的(即便有时你也许并不希望它们之间有什么联系),以及如何处理这些复杂关系。你会看到一些关于模板和名字空间的讨论,也会看到一些关于异常与继承的讨论,同样,另外还有关于坚实的类设计和设计模式的讨论,关于泛型编程与宏技巧的讨论,等等。此外,还有一些实实在在的(而不是一些花边新闻式的边栏小字)条款,展示现代C++中所有这些部分之间的相互关系。

本书遵循了Exceptional C++和More Exceptional C++两本书的传统:它通过短小精悍的条款的组织形式,并将这些条款再进一步分组为一个个的主题来介绍新知识。读过我的第一本书的读者会发现一些熟悉的主题,不过现在包含了新的东西,诸如异常安全、泛型编程以及优化和内存管理技术。我的几本书在主题上有部分重叠,但内容上并没有重复。本书沿袭了对泛型编程和高效使用标准库的一贯强调态度,包括一些重要的模板和泛型编程技术的讨论。

书中的大多数条款最初出现在杂志专栏上以及网上,尤其是我为C/C++ Users Journal和Dr.Dobb’s Journal、已停刊的C++ Report以及其他期刊所写的专栏文章,另外还有我的Guru of the We e k[GotW]问题63到86。不同的是本书中的材料与最初的版本相比经过了重大的修订、扩展、校正和更新,因此这本书(以及www.gotw.ca网站上的勘误表)应该被当成原先那些文章的最新而且权威的版本。预备知识

我假定读者已经知道一些 C++的基础知识。如果不是这样,那就先去阅读一些好的关于 C++介绍和概述的文章或书籍。像Bjarne Stroustrup的The C++Programming Language[Stroustrup00]或者Stan Lippman和Josée Lajoie合著的C++Primer(第3版)[Lippman98][1],这样的经典是非常不错的选择。接下来,一定要选择一本 Scott Meyers 的经典书籍(More)Effective C++[Meyers96, Meyers97]这样的风格指南,我发现这两本书基于 Web 浏览方式的 CD 版本[Meyers99]比较方便实用。如何阅读本书

本书中的每一条都是以一个谜题或问题来展开的,都有一个介绍性的标题,如下所示。

第##条

难度系数:#

一段简短的介绍性文字,说明该条将要讨论的内容。

标题大致告诉你本条讨论的是什么,通常后面会跟有介绍性的或回顾性的问题(初级问题,原文JG是指新来的、级别较低的少尉军官),然后就是主要问题(即专家级问题)。注意,难度系数只是我针对特定主题对大多数读者而言的难度所做的一个大致推测,这就是说你可能会发现一个难度系数为7的问题对你来说却比一个难度系数为5的问题要来得简单。实际上我的前两本书:Exceptional C++[Sutter00]和More Exceptional C++[Sutter02]就曾不断地收到一些读者来信说:“嗨!第N条比它实际上要更难(简单)!”不同的人对于“简单”的评价标准各有不同。所谓难度系数只是因人而异的,任何条款的难度实际上都取决于你所掌握的知识和经验,而其他人则可能觉得它更容易或更难。不过大多数情况下应当将我给出的难度系数作为一个合理的指示,让你能够知道下面会出现什么问题。

你可能会选择从头至尾按顺序阅读本书,这很好,但是不是非要这么做。你可能决定读某个特定部分的所有条款,因为对该部分的主题特别感兴趣,这也没关系。一般来说书中的所有条款都是基本独立的,除非标注有“之一”、“之二”等的条款之间才会有紧密联系。因此在阅读本书的时候完全可以以跳跃式的方式,顺着条款中的交叉引用(包括对我前两本书的引用)来阅读。唯一需要注意的地方就是,标注了“之几”的连续几个章节之间互有关联,构成了一个主题,除此之外其他条款你完全可以自由选择阅读顺序。

除非我注明某段代码是一个完整的程序,否则它就不是。记住,代码示例通常只是从一个完整程序中摘取出来的一小段代码,不要期望它们都能够独立编译。一般来说你得为其添上一副骨架才能够使其成为一个完整的可编译的程序。

最后,关于书中的URL。网上的东西总是在变,尤其是那些我无权干涉的东西。因此随意将某些网站地址放在一本刊印的书籍中可不大妥当,因为恐怕在书付印之前有些地址就已经作废了,更不用说这本书在你书架上躺了几年之后了。所以说,当我在书中引用其他人的文章或网址的时候,我给出的地址都链接到我自己的网站(即www.gotw.ca)上的相关内容,这是我可以控制的,因此我可以在我网站上的相关网页上随时做相应更新,让其中的相关地址指向实际存在的网页。几乎所有在书中引用到的其他人的作品我都放在参考书目里了,而且在我的网站上也放置了一份副本,其中的链接都是有效的。如果你发现本书中的链接无效了,请发电子邮件向我告知,我会在网站上更新相关的链接(如果我可以找到新地址的话),或者注明链接已经失效(如果我无法找到新地址的话)。无论如何,虽说书一旦印刷便白纸黑字,不可再改,但我网站上的相关内容会保持更新。致谢

首先我最要感谢的是我的妻子Tina,感谢她对我一直以来的爱和支持,另外还有我的家庭一直都与我同行,无论我做什么事情。即便是在我熬夜写另一些文章或修改另一些条款的时候,他们也从来都是全力支持的。如果没有他们的耐心和关心,这本书就绝不可能达到现在这个样子。

当然我们的小狗Frankie也有一份功劳,她总是在我工作的时候时不时打断我,让我得到宝贵的休息时间。Frankie对软件架构或语言设计乃至代码微观优化一无所知,但她仍然生活得无比快乐。

我还要感谢丛书编辑Bjarne Stroustrup,编辑Peter Gordon、Debbie Lafferty、Tyrrell Albaugh、Bernard Gaffney、Curt Johnson、Chanda Leary-Coutu、Charles Leddy、Malinda McCain、Chuti Prasertsith以及其他的Addison-Wesley团队的编辑们,感谢他们在我写作本书的过程中提供的帮助和坚持。他们是我见过的最好的团队,他们的热情和合作精神使我对这本书的所有设想都成为了现实,我很开心能和他们合作。

另外我还要感谢专家评审小组,他们总是一针见血毫不留情地指出书中的纰漏并给出洞见。他们的努力让你手中的这本书更完整、更可读,也更有用,而光靠我一个人的能力是远远做不到这一点的。尤其要感谢他们向丛书编辑Bjarne Stroustrup提供的技术反馈。此外还要感谢Dave Abrahams、Steve Adamczyk、Andrei Alexandrescu、Chuck Allison、Matt Austern、Joerg Barfurth、Pete Becker、Brandon Bray、Steve Dewhurst、Jonathan Caves、Peter Dimov、Javier Estrada、Attila Fehér、Marco Dalla Gasperina、Doug Gregor、Mark Hall、Kevlin Henney、Howard Hinnant、Cay Horstmann、Jim Hyslop、Mark E.Kaminsky、Dennis Mancl、Brian McNamara、Scott Meyers、Jeff Peil、John Potter、P.J.Plauger、Martin Sebor、James Slaughter、Nikolai Smirnov、John Spicer、Jan Christiaan van Winkel、Daveed Vandevoorde和Bill Wade,他们为本书提出了贡献性的见解和建议。当然,书中的错误、遗漏问题和自作聪明的双关语都是我的问题,我负全责。Herb Sutter2004年5月于西雅图

[1].此书第4版中文版已由人民邮电出版社出版。——编者注泛型编程与C++标准库

C++最强大的特性之一就是对泛型编程的支持。C++标准库的高度灵活性就是明证,尤其是标准库中的容器、迭代器以及算法部分(最初也称为STL)。

与我的另一本书More Exceptional C++[Sutter02]一样,本书的开头几条也是介绍STL中一些我们平常熟悉的部件,如vector和string,另外也介绍了一些不那么常见的设施。例如,在使用最基本的容器vector时如何避免常见的陷阱?如何在C++中进行常见的C风格字符串操纵?我们能够从STL中学到哪些库设计经验(不管是好的、坏的,还是极其糟糕的)?

在考察了STL中的模板设施之后,接着讨论关于C++中的模板和泛型编程的一些更一般性的问题。例如,如何让我们的模板代码避免不必要地(且相当不经意地)损失泛型性。为什么说特化函数模板实际上是个糟糕的主意,而我们又应当怎么替换它?在模板的世界中,我们如何才能正确且可移植地完成像授予友元关系这样看似简单的操作?此外还有围绕着export这个有趣的关键字发生的种种故事。

随着我们逐步深入与C++标准库及泛型编程相关的主题,就会看到关于上述(以及其他)问题的讨论。第1条 vector的使用

难度系数:4

几乎每个人都会使用std::vector,这是个好现象。不过遗憾的是,许多人都误解了它的语义,结果无意间以奇怪和危险的方式使用它。本条款中阐述的哪些问题会出现在你目前的程序中呢?初级问题

1.下面的代码中,注释A跟注释B所示的两行代码有何区别?

void f(vector& 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& 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进行了简单操作。

2.考虑如下的代码:

// 示例1-2: vector的一些函数

//

vector 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 v;

v.reserve(100);

v[99] = 42; // 错误!但出于讨论的目的,让我们假设这是允许的……

//……这里v[0]至v[98]的值是什么呢

正是因为标准并不强制要求operator[]()进行区间检查,所以在大多数实现上,v[0]都会简单地返回内部缓冲区中用于存放但尚未存放第一个元素的那块空间的引用。因此v[0]=1;这行语句很可能被认为是正确的,因为如果接下来输出v[0](cout<

再一次提醒,标准并无任何保证说在你使用的标准库实现上一定会出现上述情形,本例只是展示了一种典型的可能情况。标准并没有要求特定的实现在这类情况下(诸如对一个空的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::iterator i = v.begin(); i < v.end(); i++) {

cout << *i << endl;

}

首先,上面这段代码什么都不会打印,因为vector现在根本就是空的!这可能会让代码的作者感到意外,因为他们还没意识到其实前面的代码根本就没有往vector中添加任何东西。实际上,跟vector中的那些已经预留但尚未正式使用的空间“玩游戏”是很危险的。

话虽如此,这个循环本身并没有任何明显的问题,只不过如果在代码审查阶段看到这段代码的话,我会指出其中存在的一些风格上的问题。大多数意见都是基础性的,如下所示。

(1) 尽量做到const正确性。以上的循环当中,迭代器并没有用来修改vector中的元素,因此应当改用const_iterator。

(2) 尽量使用!=而不是<来比较两个迭代器。确实,由于vector::iterator恰巧是一个随机访问迭代器(当然,并不一定是int*),因此在这种特定情况下将它跟v.end()比较是没有任何问题的。但问题是<只对随机访问迭代器有效,而!=对于任何迭代器都是有效的,因此我们应该将使用!=比较迭代器作为日常惯例,除非某些情况下确实需要<(注意,使用!=还有一个好处,就是便于将来需要时更改容器类型)。例如,std::list的迭代器并不支持<,因为它们只不过是双向迭代器。

(3) 尽量使用前缀形式的--和++。让自己习惯于写++i而不是i++,除非真的需要用到i原来的值。例如,如果既要访问i所指的元素,又要将i向后递增一位,后缀形式v[i++]就比较适用了。

(4) 避免无谓的重复求值。本例中v.end()所返回的值在整个循环的过程中是不会改变的,因此应当避免在每次判断循环条件时都调用一次 v.end(),或许我们应当在循环之前预先将 v.end()求出来。

注意,如果你的标准库实现中的vector::iterator就是 int*,而且能够将end()进行内联及合理优化的话,原先的代码也许并无任何额外开销,因为编译器或许能够看出end()返回的值一直是不变的,从而安全地将求值提到循环外部。这是一种相当常见的情况。然而,如果你的标准库实现的vector::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(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::iterator i = v.begin(); i < v.end(); i++) {

cout << *i << endl;

}

再一次提醒,以上代码什么也不会打印出来,应当考虑将它改写成:

copy(v.begin(), v.end(), ostream_iterator(cout, "\n"));

再次注意,这种复用自动地解决了!=、前缀++、end()以及endl问题,因此程序永远不会在这些方面犯错误。良好的复用通常也会让代码自动变得更快和更安全。小结

了解size()和capacity()之间的区别,了解operator[]()跟at()之间的区别。如果需要越界检查,请使用at()而不是operator[]()。这么做可以帮助我们节省大量的调试时间。第2条 字符串格式化的“动物庄园”之一:sprintf

难度系数:3

在本条及下一条中,我们将对sprintf的是是非非进行一次奥威尔[1]式的严格考察,并指出为什么说其他替代方案总是(对,总是)比sprintf好。初级问题

1.什么是sprintf?尽可能多地列举出sprintf的替代方案。专家级问题

2.sprintf的主要优势跟弱点分别是什么?请明确加以说明。解决方案“所有动物都是平等的,但其中有些动物比其他动物更‘平等’。”——乔治·奥威尔,《动物庄园》

1.什么是sprintf?尽可能多地列举出sprintf的替代方案。

考虑如下的C代码,它使用sprintf将一个整型值转化为可读的字符串形式,之所以要这样做可能是为了将一个整型输出到报表或者打印到GUI窗口上:

// 示例2-1:在C里面使用sprintf来字符串化某些数据

// PrettyFormat()接受一个整型为参数,将它格式化并

// 放入给定的输出缓冲区当中

// 出于格式化的考虑,格式化的结果必须至少为4个字符宽

//

void PrettyFormat(int i, char* buf) {

// 代码就这些,优雅、简洁

sprintf(buf, "%4d", i);

}

大奖问题是,在C++中应该如何完成这件事情呢?

呃,好吧,其实问题不该这样问,毕竟示例2-1也是合法的C++代码。真正的大奖问题是:抛开C++标准[C++03]从C标准[C99]那儿承袭来的桎梏和局限性(如果它们的确是桎梏的话),是不是有办法借助于C++中的类和模板等特性来将这件事做得更好呢?

问题在这里开始变得有趣起来,因为实现这一目的,至少有不下4种截然不同的、直截了当的标准做法,示例2-1是其中的第一种。其中任一种都提供了在清晰性、类型安全性、运行时安全性以及效率之间的权衡。此外,套用乔治·奥威尔小说中的那只修正主义的猪的名言:“所有这4种选择都是标准的,但其中有些比其他选择要‘更标准’一些。”而且,说得更严重一些,它们并非全都基于同一个标准。它们分别是(后面将按照此顺序讨论):

sprintf [C99, C++03]

snprintf [C99]

std::stringstream [C++03]

std::strstream [C++03]

除此之外,还有另一个“目前虽不合标准但很有希望成为标准”的替代方案,好像嫌手头的方案还不够多似的,它就是:

boost::lexical_cast [Boost]

boost::lexical_cast主要用在不需要任何特殊格式化的简单转换当中。

好了,闲话少说,言归正传。sprintf()的悲与乐

2.sprintf的主要优势跟弱点分别是什么?请明确加以说明。

示例2-1中的代码只是使用sprintf的众多可能的方式中的一种。我们用示例2-1来引发下文的讨论,不过不要过分依赖于这个简单得只有一行代码的PrettyFormat函数。要记住我们的大方向:我们的兴趣在于通常情况下如何将非字符串的值格式化为字符串形式,或许在实际编码当中,我们的做法是在不断变化和改进着的,而不像示例2-1当中的一行简单代码那样。

下面我们将更详细地分析sprintf(),并列出其中存在的主要问题。sprintf()有两个主要的优势,还有3个明显的缺陷。其中两个优势如下。

议题#1:易用性与清晰性。一旦你学会了sprintf的常用格式化标志以及它们的各种组合,其使用就会变得简洁明了,没有任何拐弯抹角之处。使用sprintf的代码明白无误地说明了它正在做的事情。因此 printf 家族在大多数文本格式化场合下是很难有功能能够与之匹敌的。(确实,我们中的大部分人有时仍然免不了需要去查寻一些不常用的标志,不过它们毕竟用得很少。)

议题#2:效率最佳(能够直接利用现有的缓冲区)。通过使用sprintf将结果直接放入一个已有的缓冲区中,PrettyFormat()将不用牵涉任何动态内存分配或者其他额外的幕后操作就能完成任务。将一块已分配好用于存放输出结果的缓冲区传递给 PrettyFormat(),后者负责将格式化的结果直接写入这块缓冲区。

告诫 当然,现在也不必过分在乎效率,因为你的应用程序或许根本就不会在意这一点效率差别。永远不要过早进行优化,只有当时间测试显示确实有必要时才去进行优化。而且,遇到这种情况的时候,永远不要忘记,效率是以牺牲内存管理封装性而换取的。议题#2等于是在说:“你自己去管理内存。”不过别忘了,这句话换一种说法就是“你得自己管理内存”!

只可惜,正如大多数使用sprintf的程序员所知道的,情况还远远不止这些。sprintf同样存在一些显著的缺陷。

议题#3:长度安全性。sprintf是引起缓冲区溢出错误的原因之一,如果目标缓冲区碰巧不够大,装不下整个输出结果,就会发生缓冲区溢出[2]。例如,考虑如下的代码:

char smallBuf[5];

int value = 42;

PrettyFormat(value,buf);       // 呃……隐患

assert(value == 42);

本例中,42恰好足够小,以至于5B大小的结果“ 42\0”恰巧能够放在smallBuf中。然而,设想某一天代码改成了这样:

char smallBuf[5];

int value = 12108642;

PrettyFormat(value,buf);      // 哦!

assert(value==12108642);    // 这个断言很可能会失败!

这会导致 smallBuf 尾部之后的区域也被改写,而倘若编译器恰巧让 value(在内存中)紧跟在smallBuf之后的话,被改写的区域就是value值本身占用的空间了!

我们无法轻易地改善示例2-1的安全性。的确,我们可以让PrettyFormat()接受缓冲区的长度并对sprintf()的返回值进行检查,但这等于是事后诸葛亮。具体做法如下:

// 糟糕的主意:丝毫没有改观

//

void PrettyFormat(int i, char* buf, int buflen) {

if(buflen <= sprintf(buf, "%4d", i)) { // 并不比以前好

// ……现在情况如何呢?既然在这里问题被侦测出来了,那么这就

// 意味着问题已经发生了,换句话说该被破坏的内存已经被破坏了

}

}

对于这个问题,根本没有解决方案。当错误被侦测出来时,内存已然被破坏,我们已经在不该写的地方写下了一些字节,如果情况糟糕的话,程序甚至根本没机会运行到报错代码处[3]。

议题#4:类型安全性。对于sprintf来说,类型错误就意味着运行时错误,而非编译期错误,更可怕的是这些类型错误甚至根本就不会表现出来。printf家族使用C的可变参数列表,C编译器通常并不检查这类实参列表的类型[4]。几乎每个C程序员都曾在一些微妙的或者不那么微妙的情况下发现他们搞错了格式字符串,这类错误总是再频繁不过地发生着,譬如在熬夜调试之后,试图重现某个关键客户遇到的神秘崩溃问题时。

诚然,示例2-1中的代码非常简单,只要清楚地知道我们只是将一个int传给sprintf,就可能足够简单地维护它。不过,即便如此,事情仍然可能出现纰漏,设想你的手指一不小心按错了键,这类情况并不罕见。例如,在大多数键盘上,c键跟d键是相邻的,所以我们可能一不小心把d错打成了c,结果就成了这样:

sprintf(buf,"%4c",i);    // 哦!

这会导致输出结果为字符而不是数字,这种情况下我们或许很快就能意识到错误所在,因为sprintf会一声不吭地将i的第一个字节解释为一个char值。此外,s键也跟d键相邻,因此如果我们错误地写成了:

sprintf(buf,"%4s",i);    // 糟糕!

如果情况是这样的话,或许我们同样能够很快反应过来,因为这么做很可能会令程序立即崩溃或至少偶发性地崩溃。因为这时sprintf会不加提示地将i解释为指向字符串的指针,并欣然地顺着这个指针所指的方向去寻找一个实际上并不存在的字符串,实际上,这个指针可能指向内存中的任何位置。

不过,下面这种情况可就微妙了,假设我们将d错打成了ld,会出现什么情况呢?

sprintf(buf,"%4ld",i);    // 一个微妙的错误

若是这种情况的话,给出的格式字符串就等于是在告诉sprintf,给它的是long int,而实际上给的却是int。这同样也是糟糕的C代码,不过,问题是它不仅不会以编译期错误的形式表现出来,甚至不会导致运行时错误。在许多流行的平台上,程序的运行结果仍然会跟以前一样,因为int在许多流行平台上碰巧跟long int具有相同的大小和内存布局。因而你也许一直都不会注意到这个潜在的问题,直到某一天将代码移植到某个平台上,该平台上的int跟long int具有不同的大小,这时才发现这个问题,甚至就连这个时候,程序可能也并不总是产生错误的结果或立即崩溃。

最后,考虑一个与此有关的问题。

议题#5:模板亲和性。很难将sprintf放在一个模板当中。考虑如下的代码:

template

void PrettyFormat(T value, char* buf) {

sprintf(buf, "%/* 这里应该写些什么呢?*/", value);

}

你所能做到的最好的(最糟的?)就是声明一个主模板,并为所有那些与sprintf兼容的类型分别提供对应的特化版本:

// 不算好点子: 一个东拼西凑出来的PrettyFormat

//

template

void PrettyFormat(T value,char*buf);  // 注意:主模板只有声明,没有定义

template<> void PrettyFormat(int value, char* buf) {

sprintf(buf, "%d", value);

}

template<> void PrettyFormat(char value, char* buf) {

sprintf(buf, "%c", value);

}

//……还有其他特化版本,呃……

总的来说,sprintf是这样的:

下一条我们将会考虑其他的解决方案,它们是在以上这些考虑因素之中进行取舍的结果。第3条 字符串格式化的“动物庄园”之二:标准的(或极度优雅的)替代方案

难度系数:6

对sprintf问题的奥威尔式的严格考察,最终 以我们对snprintf、std::stringstream、std::strstream以及非标准但极度优雅的boost::lexical_cast的一番对比分析结束。专家级问题

1.比较下面这些替代方案的优点和弱点,使用第2条中的分析和示例代码。

(a) snprintf

(b) std::stringstream

(c) std::strstream

(d) boost::lexical_cast解决方案替代方案#1:snprintf

1.比较下面这些替代方案的优点和弱点,使用第2条中的分析和示例代码。

(a) snprintf

在所有的选择当中,与sprintf最相近的选择当然是snprintf了。snprintf只是在sprintf上增加了一项功能,不过是一项重要功能,即用户可以给出输出缓冲区的最大长度,从而避免缓冲区溢出。当然,如果缓冲区太小的话,输出结果就会被截断。

长久以来,在大多数C实现上,snprintf都是作为一个非标准的扩展存在的。随着C99标准的颁布[C99],snprintf终于浮上台面而成为合法功能,目前snprintf已经是C99标准中的正式一员。不过,除非你的编译器是符合C99标准的,否则可能仍然必须使用供应商提供的非标准扩展,如_snprintf。

坦白地说,早该使用snprintf来取代sprintf了,即使在snprintf标准化之前。大多数良好的编码标准都不推荐你使用像sprintf这样的不检查长度的函数,而且该原则是很有道理的。使用不做检查的sprintf长久以来常引起一些声名狼藉的常见问题,它通常会导致程序崩溃[5],尤其会导致安全脆弱问题[6]。

借助于snprintf,我们就可以正确编写刚才一直试图实现的带长度检查的PrettyFormat()版本。

// 示例3-1:在C中使用snprintf来字符串化某些数据

//

void PrettyFormat(int i, char* buf, int buflen) {

// 这就是代码,简洁优雅,关键是比以前要安全得多:

snprintf(buf, buflen, "%4d", i);

}

注意,即便这样做了,仍然还存在另一种出错的可能,即调用者将缓冲区长度搞错了。这意味着跟那些具有资源管理功能的替代方案相比,snprintf 还算不上百分之百地杜绝缓冲区溢出可能性,不过跟sprintf相比它显然要安全多了,在“长度是否安全”这个问题上应该算是合格的。使用sprintf没有合适的途径来绝对避免缓冲区溢出,而通过snprintf,我们则可以(很大程度上)杜绝缓冲区溢出。

注意,snprintf的一些标准化之前版本的行为稍有不同。尤其是在一个主要实现中,如果输出结果填满或者大于缓冲区容量,缓冲区里的串就不会以'\0'结尾。这种情况下,我们的PrettyFormat()函数就得稍做调整,以应付这种非标准的行为:

// 在C中使用一个并不完全遵从C99标准的_snprintf来将数据字符串化

//

void PrettyFormat(int i, char* buf, int buflen) {

// 这里是代码,简洁优雅,而且安全得多

if(buflen > 0) {

_snprintf(buf, buflen-1, "%4d", i);

buf[buflen-1] = '\0';

}

}

在其他任何方面,sprintf和snprintf都是一样的。综上所述,snprintf跟sprintf的比较如表3-1所示。

表3-1 snprintf与sprintf的比较

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载