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

978-7-115-40253-0
作者: 【美】Herb Sutter(赫布 萨特)
译者: 刘未鹏
编辑: 傅道坤
分类: C++

图书目录:

详情

本书中,C++大师Herb Sutter通过40个编程问题,使读者不仅“知其然”,更要“知其所以然”,帮助程序设计人员在软件中寻找恰到好处的折中,即讨论如何在开销与功能之间、优雅与可维护性之间、灵活性与过分灵活之间寻找完美的平衡点。

图书摘要

PEARSON

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

Exceptional C++ Style:40 New Engineering Puzzles,Programming Problems,and Solutions

[美]Herb Sutter 著

刘未鹏 译

人民邮电出版社

北京

图书在版编目(CIP)数据

C++编程剖析:问题、方案和设计准则/(美)萨特(Sutterly,H.)著;刘未鹏译.--北京:人民邮电出版社,2016.3

ISBN 978-7-115-40253-0

Ⅰ.①C… Ⅱ.①萨…②刘… Ⅲ.①C语言—程序设计 Ⅳ.①TP312

中国版本图书馆CIP数据核字(2016)第039858号

版权声明

Authorized translation from the English language edition,entitled Exceptional C++ Style:40 New Engineering Puzzles,Programming Problems,and Solutions,1st Edition 0201760428 by Herb Sutter,published by Pearson Education,Inc.,publishing as Addison-Wesley Professional,Copyright © 2005 Pearson Education,Inc.

All rights reserved.No part of this book may be reproduced or transmitted in any form or by any means,electronic or mechanical,including photocopying,recording or by any information storage retrieval system,without permission from Pearson Education,Inc.

CHINESE SIMPLIFIED edition published by PEARSON EDUCATION ASIA LTD.and POSTS & TELECOM PRESS Copyright © 2016.

本书中文简体字版由Pearson Education Asia Ltd.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签,无标签者不得销售。

版权所有,侵权必究。

◆著 [美]Herb Sutter

译 刘未鹏

责任编辑 傅道坤

责任印制 张佳莹 焦志炜

◆人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

三河市海波印务有限公司印刷

◆开本:800×1000 1/16

印张:18.25

字数:418千字  2016年3月第1版

印数:1-3000册  2016年3月河北第1次印刷

著作权合同登记号 图字:01-2005-3577号

定价:49.00元

读者服务热线:(010)81055410 印装质量热线:(010)81055316

反盗版热线:(010)81055315

内容提要

本书中,C++大师Herb Sutter通过40个编程问题,使读者不仅“知其然”,更要“知其所以然”,帮助程序设计人员在软件中寻找恰到好处的折中,即讨论如何在开销与功能之间、优雅与可维护性之间、灵活性与过分灵活之间寻找完美的平衡点。本书是围绕实际问题及其解决方案展开论述的,对一些至关重要的C++细节和相互关系提出了新的见解,为当今关键的C++编程技术(如泛型编程、STL、异常安全等)提供了新的策略。本书的目标是让读者在设计、架构和编码过程中保持良好的风格,从而使编写的C++软件更健壮、更高效。本书适合中高级C++程序员阅读。

译者序

逍遥派武功讲究轻灵飘逸,闲雅清隽,丁春秋和虚竹这一交上手,但见一个童颜白发,宛如神仙,一个僧袖飘飘,冷若御风。两人都是一沾即走,当真便似一对花间蝴蝶,蹁跹不定,于这“逍遥”二字发挥到了淋漓尽致。旁观群雄于这逍遥派的武功大都从未见过,一个个看得心旷神怡,均想:“这二人招招凶险,攻向敌人要害,偏生姿势却如此优雅美观,直如舞蹈。这般举重若轻、潇洒如意的掌法,我可从来没见过……”

——金庸《天龙八部》

金庸小说中描绘的逍遥派武功讲究的是飘逸灵动,然则绝非片面追求招式漂亮,招招看起来都优雅美观,但招招都攻往要害。

写代码也应如此。

毫无疑问,代码的目的是实现既定的功能,所以实用论者可能会说代码只需实现既定功能,无需费时费事去搞那么多周折、弄那么多形式。如果代码写完就可以永远不用再管,这番论断倒是言之有理的。然而事实是,代码的维护占用了软件开发中的大部分乃至绝大部分时间和人力。譬如说,做外包业务的公司大部分时间都是在维护代码或者说改代码,而不是原生开发,这便意味着外来的代码形式“漂亮”与否直接关系到一个项目的开销。另外,一般的公司,就算是作坊公司,日积月累,也一定会有自己的代码库、自己的遗留代码,只要他们想节省开销,复用以前写成的代码是必由之路,因此这里代码的可读性、代码结构的可扩展性等就变得异常重要了。有过一些经验的程序员都知道维护别人(甚至自己)的代码是最痛苦的事情。可见代码的“形式”构成了软件质量的一个重要部分。无论如何,就像书上常说的:“内容决定形式,但形式对内容也有反作用。”

然而徒有其表的代码只是个花架子,没有真材实料,再花哨的形式也无济于事。软件归根到底要的还是功能。这就好比练武,光有花拳绣腿而没有扎实的内功是万万成不了气候的。

那么,在软件开发领域,这两者到底矛不矛盾呢?答案是根本不矛盾。我们之所以平时无法鱼和熊掌兼得,一方面固然是由于进度紧的缘故,另一方面也与编码时的方法学有一定关系。

本书讨论的正是后者。

正是由于C++是一门非常自由的语言,因此C++编码的“形式”才变得异常重要,以至于一些大公司都规定了各自的编码标准。本书正是着眼于C++编码风格的一本书,但这里所谓的编码风格并非指命名风格、注释风格之类浅显的东西,而是指在某些特定的问题领域所采取的编码方式(或“形式”)。在C++中往往有若干条道路都能通往同一个终点,问题是选择哪条道路才最具有远见,最能达到形式和内容的统一,这一点很关键。

为此,本书的40个条款围绕C++日常编码中的种种问题展开讨论,详细考察各解决方案之间的优劣,最终给出权衡之下最为妥帖的方案,并将其提炼为一条条的编码准则。

武侠小说的读者大多数都希望看到招式漂亮而又非常厉害的武功,觉着很过瘾。殊不知维护代码的程序员何尝不希望看到写得漂亮而又有实在功能的代码呢?

相信你在Sutter的书中能够找到一些答案。

最后,感谢老朋友谢轩和罗翼,与他们讨论问题是我的乐事。谢轩(《Symbian OS C++高效编程》译者)提供了第34条的译稿,罗翼则无偿帮我初译了第六部分。他们的热情给了我莫大的帮助,他们的技术和文笔也让我获益颇多。

感谢父母一直以来的支持,令我不敢懈怠。

前言

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

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

为什么会有这么大的差异呢?一方面,国会大厦是在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 Sutter

2004年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<int>& v) {

v[0];   //A

v.at(0);  //B

}

专家级问题

2.考虑如下的代码:

vector<int> v;

v.reserve(2);

assert(v.capacity() == 2);

v[0] = 1;

v[1] = 2;

for(vector<int>::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<int>::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<T>::at。该成员函数会进行下标越界检查,确保当前vector中的确包含了需要的元素。试图在一个目前只包含10个元素的vector中访问第100个元素是毫无意义的,这样做会导致抛出一个std::out_of_range异常。

其二,我们也可以使用vector<T>::operator[],C++98 标准说vector<T>::operator可以、但不一定要进行下标越界检查。实际上,标准对operator[]是否需要进行下标越界检查只字未提,不过标准同样也没有说它是否应该带有异常规格声明。因此,标准库实现方可以自由选择是否为operator[]加上下标越界检查功能。如果使用operator[]访问一个不在vector中的元素,你可就得自己承担后果了,标准对这种情况下会发生什么事情没有做任何担保(尽管你使用的标准库实现的文档可能做了某些保证)——你的程序可能会立即崩溃,对operator[]的调用也许会引发一个异常,甚至也可能看似无恙,不过会偶尔或神秘地出问题。

既然下标越界检查帮助我们避免了许多常见问题,那为什么标准不要求operator[]实施下标越界检查呢?简短的答案是效率。总是强制下标越界检查会增加所有程序的性能开销(虽然不大),即使有些程序根本不会越界访问。有一句名言反映了C++的这一精神:一般说来,不应该为不使用的东西付出代价(或开销)。所以,标准并不强制operator[]进行越界检查。况且我们还有另一个理由要求operator[]具有高效性:设计vector是用来替代内置数组的,因此其效率应该与内置数组一样,内置数组在下标索引时是不进行越界检查的。如果你需要下标越界检查,可以使用at。

调整vector的大小

现在看示例1-2,该示例对vector<int>进行了简单操作。

2.考虑如下的代码:

// 示例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;这行语句很可能被认为是正确的,因为如果接下来输出v[0](cout<<v[0])的话,或许会发现结果确实是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<class Container, class OutputIterator>

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[]()。这么做可以帮助我们节省大量的调试时间。

第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<typename T>

void PrettyFormat(T value, char* buf) {

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

}

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

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

//

template<typename T>

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

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

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

}

template<> void PrettyFormat<char>(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所示。

从这些比较当中,我们可以给出如下的建议。

准则 永远不要使用sprintf。

如果你真的决定使用C的stdio设施,一定要记住,使用那些进行长度检查的函数,如snprintf。即便在你的编译器上它们只是作为非标准扩展存在,也得使用它们,因为使用它们没有任何损失,还能够带来实实在在的好处。

我曾在C++大会上将该主题作为演讲稿的材料,一开始我便惊讶地发现,通常在一场大会的所有到场人员当中只有百分之十的人听说过snprintf。然而,几乎每次,当我问到关于sprintf在实际项目当中导致的问题时,总会有人立即举手,描述他们最近在项目当中发现一些缓冲区溢出bug,而当他们在整个项目中将sprintf全部替换为snprintf之后再去进行测试,发现不但这些bug消失了,就连其他一些早就报告了的、已经在bug队列里面呆了很长时间却一直没人能够解决的神秘bug也随之消失了。

结论,正如我一直所说的,永远不要使用sprintf。

替代方案#2:std::stringstream

(b) std::stringstream

C++中用于字符串化的最常见设施就是stringstream这一族的类了。如果用ostringstream来替代sprintf,示例3-1看起来就会像这样:

// 示例3-2:在C++中进行字符串化,使用ostringstream

//

void PrettyFormat(int i, string& s) {

// 不如原先的简洁优雅

ostringstream temp;

temp << setw(4) << i;

s = temp.str();

}

相对于sprintf来说,stringstream具有一些优点,但同时也有缺点。在sprintf光芒四射的那些地方,stringstream显得并不那么出色。

议题#1:易用性和清晰性。使用stringstream不仅让原先的一行代码变成了3行,而且我们还得引入一个临时变量。使用stringstream的做法有几个优势,不过代码的清晰性并非其中之一。这并不是说像setw(4)这样的流操纵子不好学,实际上它们跟sprintf的格式化标志一样易学,只不过前者通常更为笨拙冗长一些。我发现那些到处“点缀”着像<<setprecision(9)和<<setw(14)这样的长名字的代码会难于阅读(我是说,跟%14.9这种格式化字符串相比),即便所有的操纵子都整齐排列也无济于事。

议题#2:效率(能否直接利用现有缓冲区)。stringstream会自己另外分配一份单独的缓冲区来存放结果,另外还需要使用一些辅助性的对象,通常所有这些都意味着需要进行额外的内存分配。我在两个当前流行的编译器上测试了示例3-2的代码,同时让::operator new统计总共的分配次数。结果发现在某个平台上有两次动态内存分配,另一个平台上则是3次。

而在sprintf一筹莫展的那些地方,stringstream则大显身手。

议题#3:长度安全性。stringstream内部的basic_stringbuf缓冲区类会根据需要自动增长,以便容纳需要存放的数据。

议题#4:类型安全性。使用 operator<<和重载决议,即便是对于那些提供了自己的流插入操作符的自定义流类型,也总能够实现类型安全性。不会因为类型不符而导致一些神秘的运行时错误。

议题#5:模板亲和性。既然编译器会自动调用正确的operator<<,那么将PrettyFormat泛化为可接受任何类型的数据应当是举手之劳:

template<typename T>

void PrettyFormat(T value, string& s) {

ostringstream temp;

temp << setw(4) << value;

s = temp.str();

}

综上所述,stringstream跟sprintf的比较如表3-2所示。

替代方案#3:std::strstream

(c) std::strstream

不管这种说法公平与否,strstream都是要被遗弃的。由于[C++03]标准将它标明为deprecated(不赞成的),因而优秀的C++书籍顶多也只是略微提及一下(见[Josuttis99]的第649页),大多数则是根本不提(见[Stroustrup00]),甚至明确地表态说不会讨论这方面的内容,因为strstream是官方规定的“替补”(见[Langer00]的第587页)。标准委员会觉得stringstream可以取代strstream,因为stringstream更好地封装了内存管理,所以他们将strstream标明为deprecated,然而strstream仍然还是标准的法定成员,任何符合C++标准的实现都必须提供[7]

由于strstream仍然是标准的,所以为了完整起见这里还是提一下它。碰巧它也的确具有一些优点。使用strstream的话,示例3-1看起来就会像这样:

// 示例3-3:在C++中使用ostrstream进行字符串化

//

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

// 不算太差,不过别忘了最后还要输出结束符

ostrstream temp(buf, buflen);

temp << setw(4) << i << ends;

}

议题#1:易用性和清晰性。strstream在易用性跟代码的清晰性方面略逊stringstream一筹。两者都要求建立一个临时对象,不过strstream要求你记得手动输出一个结束符来结束字符串,这除了令人感觉有点不愉快之外,还有点危险,因为如果一不小心忘记了,同时在读取结果串的时候又期望该串是以'\0'字符结尾的话,你就面临着读取超过结果串末尾之后的内存数据的危险,而就算sprintf也没这么脆弱,它总是会在结果串的末尾加上结束符。不过,按照示例3-3所展示的方式那样使用strstream至少有一个优点,即无需在最后调用c_str()来获取结果串。(当然,如果让strstream创建自己的缓冲区,其内存只是部分封装的,你除了得在最后调用.str()来将其中的结果串取出来之外,还得加上一次.freeze(false)调用,否则 strstreambuf 在析构的时候是不会释放内存的。)

议题#2:效率(能否直接利用现有缓冲区)。我们只需在创建ostrstream对象的时候传递一个指向现有缓冲区的指针,就可以避免任何额外的内存分配,ostrstream会将它的结果直接输出到该缓冲区当中。这跟stringstream相比是一个非常重要的区别,在能否将结果串直接输出到现有的目标缓冲区(从而避免额外的内存分配)这个问题上,stringstream根本无法与strstream比拟[8]。当然,如果你并没有现成可利用的缓冲区,ostrstream也可以使用自己动态分配的缓冲区,你只需调用它的默认构造函数即可[9]。确实,strstream是我们所讨论的所有可选方案当中唯一能够提供这种选择自由的方案。

议题#3:长度安全性。ostrstream内部的strstreambuf缓冲区会自动检查它的长度以确保不会写超过给定缓冲区之外的内存区域。而如果我们使用的是一个默认构造的ostrstream对象的话,其内部的strstreambuf缓冲区就会根据需要自动增长以容纳有待存储的值。

议题#4:类型安全性。strstream跟stringstream一样,完全是类型安全的。

议题#5:模板亲和性。完全可以!正如stringstream一样。例如:

template<typename T>

void PrettyFormat(T value, char* buf, int buflen) {

ostrstream temp(buf, buflen);

temp << setw(4) << value << ends;

}

总之,strstream与sprintf的比较结果如表3-3所示。

呃……看到一个已被“打入冷宫”的设施在比较中表现如此良好的确让人有几分尴尬,不过有时候现实就是如此。

替代方案#4:boost::lexical_cast

(d) boost::lexical_cast

如果你还没有接触过[Boost]的话,我的建议是,马上去研究它!Boost是C++的一个开源库,主要由C++标准委员会成员编写。其代码经过严格的同行评审,并由专家编写,遵从C++标准库的风格,同样,其中设施的明确意图就是作为下一代C++标准库的潜在候选子库,因此花些时间去了解它们是完全值得的。此外,从今天开始你就可以完全免费地使用它们了。

Boost库中提供的设施之一就是boost::lexical_cast,它是stringstream的一个易用的包装类。此外Boost中还包括一些其他更为华丽和重要的设施,它们同样在内部借助于流来实现,并提供了更为sprintf式的格式化选择,其中尤为突出的要数boost::format。由于Kevlin Henney写的Boost代码实在是太简洁优雅了,因此我可以完完整整地将它们罗列在下面(删掉了一些为旧的编译器所做的workaround),所以虽说它目前还未被标准化,我也乐于将它介绍给读者:

template<typename Target, typename Source>

Target lexical_cast(Source arg) {

std::stringstream interpreter;

Target result;

if(!(interpreter << arg) || !(interpreter >> result) || !(interpreter >> std::ws).eof())

throw bad_lexical_cast();

return result;

}

注意,lexical_cast的意图并非是想要成为sprintf的直接竞争者。实际上,sprintf比lexical_cast更为通用,而lexical_cast的目的只是为了将数据从一个可流化的类型转换为另一个可流化的类型,因而它与C中的atoi等转换函数以及非标准但广泛使用的itoa等函数的竞争更为直接一些,然而,lexical_cast与我们当前讨论的主题又非常接近,不提及它就明显是个疏忽了。

下面就是使用lexical_cast来改造示例3-1后的情形:

// 示例3-4:在C++中使用boost::lexical_cast进行字符串化

//

void PrettyFormat(int i, string& s) {

// 如果这确实恰是你所需要的,那么可以说这是目前最为简洁优雅的做法了

s = lexical_cast<string>(i);

}

议题#1:易用性和清晰性。在所有这些例子当中,使用lexical_cast的代码最为直接地表达了实际意图。

议题#2:效率(能否直接利用现有缓冲区)。由于lexical_cast使用的是stringstream,因此毫不奇怪它需要至少跟stringstream一样多的内存分配次数。在我测试的一个平台上,示例3-4比示例3-2中直接使用stringstream的版本多进行了一次内存分配,而在另一个平台上则没有多。

跟 stringstream 一样,在长度安全性、类型安全性以及模板亲和性这些方面,lexical_cast也有优秀的表现。

总之,lexical_cast跟sprintf的比较如表3-4所示。

小结

到目前为止,还有一些问题是我们未曾详细讨论过的,例如,这里所讨论的所有字符串格式化都是针对基于char类型的窄字符串的,而并没有涉及宽字符串。我们也考察了让sprintf、snprintf、strstream 直接利用现有缓冲区而带来的性能提升,然而“你自己管理内存”的另一面就是“你得自己管理内存”,因而stringstream、strstream还有lexical_cast提供的封装得更好的内存管理可能相当吸引你。(这里并没有打错字,strstream的确是个双面手,具体要看你如何使用它。)

另外,还有一些我们并没有详细讨论的非标准方案。我选择将boost::lexical_cast作为非标准方案的代表是因为它简洁优雅,不过即便是在Boost当中也存在着更为完备更为重量级的方案,特别值得注意的是boost::format,它建立在与这儿提到的stringstream和strstream技术类似的方法基础之上,提供了更自动化的能力来支持sprintf式的格式化。

将这些放到一起,我们可以得到综合比较的表3-5。考虑到我们在判断每种方案的优劣时应考虑的那些方面,很显然这些方案当中没有哪个是在任何情况下都合适的。

表3-5 C/C++字符串格式化方案

① 测试程序将相应的示例代码调用1 000 000次,运行测试程序3次,取所耗时间的平均值。随着编译器版本以及编译选项设置的不同,测试结果可能会有所不同。

在表3-5的基础上,我们可以得出如下准则(表3-6中同样也进行了一番总结)。

如果你所要做的只是将一个值转换为一个字符串(甚至于其他任何类型)的话,尽量默认使用boost::lexical_cast。

如果想进行简单的格式化,或者需要支持宽字符串,或者想要让进行格式化的代码能够用在模板当中,尽量使用stringstream或strstream。跟snprintf相比,使用stringstream/strstream的代码会比较冗长,且难于理解,不过对于简单的格式化任务来说,情况不会太糟。

如果想要进行一些更为复杂的格式化任务,同时并不需要宽字符串支持,也不想让代码用在模板当中的话,尽量使用snprintf。这是 C的做法,但并不意味着C++程序员就不能用它。

仅当实际的性能测试显示那些较好的替代方案在你代码中某个特定的地方确实都会造成瓶颈时,只在这些特定地点改用strstream或snprintf,具体用哪个则要看谁更适合。

永远不要使用sprintf。

最后,我还想针对被“贬”的strstream说一点:strstream提供了一个有意思的特性组合,尤其是它是所有方案当中唯一一个允许你自由选择是否自己进行内存管理的;使用 strstream,如果你不想自己管理内存的话,你大可以把担子交给它,让它来(不完全地)封装内存管理。从技术上来说,它的唯一缺陷在于使用起来过于脆弱,这是因为它需要你手动输出结束符来结束一个字符串,还因为它的内存管理方式。它的另外一个软肋则是它在公众当中的坏名声。由于它曾经被束之高阁,没有人会再去过多使用它,而且你还得留神着标准委员会以及你的编译器/库供应商们,因为他们以后可能真的会将这一设施移除,虽然这种可能性微乎其微,但并不等于零。

看到一个被“贬”的特性如此出风头确实令人有几分尴尬。虽说某种特定的“动物”可能会具有一些独一无二的优势,但是即便是在标准中,有些动物仍然要比其他动物更“平等”。

第4条 标准库成员函数

难度系数:5

复用是好的,但你是否总能够将标准库设施复用于自身呢?下面是一个可能会让你吃惊的例子,你将会看到,标准库中有一个设施可以可移植地复用到你的任何代码中,只要你愿意。然而,它却不能可移植地复用到标准库自身。

初级问题

1.什么是std::mem_fun?你什么时候会用它?给出一个例子。

专家级问题

2.假设在下面代码中的/*...*/处放入恰当的模板参数,这行代码会成为合法且可移植的C++代码吗?请说出理由。

std::mem_fun</*…*/>(&(std::vector<int>::clear))

解决方案

mem_fun

1.什么是std::mem_fun?你什么时候会用它?给出一个例子。

标准库里面的mem_fun 是一个适配器(adapter)类,它能够将成员函数适配为所谓的仿函数(functor),从而可被标准库算法以及其他正常情况下只使用自由函数的代码所使用。

例如,假设有如下的代码:

class Employee {

public:

int DoStandardRaise() { /*…*/ }

//……

};

int GiveStandardRaise(Employee& e) {

return e.DoStandardRaise();

}

std::vector<Employee> emps;

下面这种用法可能是我们司空见惯了的:

std::for_each(emps.begin(), emps.end(), &GiveStandardRaise);

但是,如果GiveStandardRaise()并不存在,或者由于某些原因需要直接去调用Employee中对应的成员函数的话,我们该怎么办呢?答案是我们可以这么写:

std::vector<Employee> emps;

std::for_each(emps.begin(), emps.end(),

std::mem_fun_ref(&Employee::DoStandardRaise));

mem_fun_ref末尾的_ref是由一些历史性的古怪癖好造成的。在写这类代码的时候,如果你的容器是老式的,其中包含的是对象的话,你就应当记住使用mem_fun_ref,因为for_each操纵的将会是容器中对象的引用。而倘若容器中包含的是指向对象的指针,你就应该使用mem_fun:

std::vector<Employee*> emp_ptrs;

std::for_each(emp_ptrs.begin(), emp_ptrs.end(),

std::mem_fun(&Employee::DoStandardRaise));

你可能已经注意到了,我在示例中展示的成员函数皆是无参的。对于那些接受一个参数的成员函数,可以利用std::bind...()辅助函数,使用原则跟mem_fun一样。然而遗憾的是,这种做法不适用于那些接受两个或多个参数的函数。不过话说回来,这并不能抹杀其用处。

这就是mem_fun。mem_fun的这些特质给我们带来了一些尴尬。

使用mem_fun,但不要将它用在标准库自己身上

2.假设在下面代码中的/*...*/处放入恰当的模板参数,这行代码会成为合法且可移植的C++代码吗?请说出理由。

std::mem_fun</*…*/>(&(std::vector<int>::clear))

首先我们注意到,其实/*...*/处根本没必要放置任何模板参数。我故意这样问你是因为在写作本书的时候仍然还有部分流行的编译器不能正确地推导出模板参数。在这些编译器上,你必须手动给出模板参数。取决于你的标准库实现,你的代码看起来可能像这样:

std::mem_fun<void, std::vector<int, std::allocator<int> > >(&(std::vector<int>::clear));

随着时间的推移,这种局限性将会慢慢消失,并且编译器也可以让你放心地省略模板参数。

你可能想知道为什么我刚才要说“取决于你的标准库的实现”。毕竟,std::vector<int>::clear()的函数签名是无参、返回void,不是吗?标准中是这么说的,对吗?

也许你错了!这里我们触摸到了问题的症结所在。

C++标准里面关于标准库的部分在描述某些成员函数的实现时故意留了一些余地,尤其是下面这两句话。

一个具有默认参数的成员函数签名可以被“两个或多个具有等价行为的成员函数签名”所替代。

成员函数签名可以具有额外的默认参数。

是的,上面的第二句话就是问题所在,即那些可有可无、若隐若现的额外参数(让我们简称它们为“皮卡布”[10]参数)就是肇事者。

大多数时候,那些由实现定义的、额外的默认“皮卡布”参数根本不会引起人们的注意。例如,当你调用一个成员函数的时候,那些“皮卡布”参数会绑定到它们的默认值,因而平常你根本不用去关心标准库是否为这些函数添加了一些额外的形参。然而遗憾的是,当你不得不知道某个成员函数的确切签名时,这些可能的额外形参就成了大问题!譬如在你使用mem_fun的时候。注意,即便你的编译器能够正确地推导出模板实参也无济于事,这是因为两个潜在问题。

如果有问题的成员函数实际上具有一个带默认值的形参,而你又不期望出现这个形参的话,你就得使用像std::bind2nd这样的设施来对付它(要想知道更多关于标准约束器(binder)辅助设施的描述,请参考[Josuttis99])。当然,这么做了之后,如果你将代码移植到另一个平台上,而该平台上的标准库中相应的成员函数却具有一个不同类型的额外形参,或者根本没有额外的形参,那么你的代码同样无法工作。不过话说回来,你的代码本来就是不可移植的,不是吗?

如果有问题的成员函数实际上具有两个或多个形参(即便它们都是有默认值的),就根本没法将mem_fun用在自身上。很不愉快,是吗?不过还是那句话,你的代码反正是不可移植的,对不对?

不过实际上问题也许并没有那么糟。我不知道库实现者是否普遍都会利用这种自由度来添加额外的形参,或者是否可能以后这么做。我知道的是,只要他们不这么做,你在实际中就不会遇到这些难题。

然而遗憾的是,这还不是故事的结尾。最后,让我们考虑一个更为一般性的结果。

使用指向成员函数的指针,只不过不是标准库里的成员函数

唉,这里还存在一个更为基本的问题:我们不可能可移植地创建一个指向标准库里的成员函数的指针。

毕竟,如果想要创建一个指向函数的指针,不管该函数是不是成员函数,你都得知道该函数指针的类型,这就意味着你首先必须知道该函数的签名。

由于标准库里面的成员函数的签名无法确切得知,除非你去查看标准库实现的相应头文件,看看所需要的成员函数是否被添加了某些“皮卡布”形参,而且即便这次知道了,库的下一个发布版仍然有可能改动,总之,就是无法既可靠地使用指向标准库中成员函数的指针又得到可移植的代码。

小结

这确实让人有几分意外,我们可以将一个标准库设施(即mem_fun)可移植地运用到几乎任何成员函数身上,可惜就是没法可移植地用到标准库中的成员函数上。同样令人感到意外的是,你可以将一个语言特性(即指向成员函数的指针)可移植地运用到任何地方,然而该语言自身的标准库除外。

通常标准成员函数的实现自由度对用户来说是不可见的,如果所要做的仅仅是调用这些函数的话,你永远也不会注意到有什么差别。然而,如果要使用指向成员函数的指针,或者约束器设施的话,你就得记住,将它们用在标准库本身的成员函数上是不可靠的。即便今天碰巧在你的特定标准库实现中某些特定的成员函数上能够工作了,等到库的版本一更新,说不定就又不能工作了。

第5条 泛型性的风味之一:基础

难度系数:4

在第6条的深入探讨之前,我们先来做一些准备工作,考虑下面这个简单的例子,它展示了C++中灵活的泛型代码。本条以及下一条中的代码示例都是从Exceptional C++[Sutter00,第42页]一书中摘选出来的。

初级问题

1.“C++模板提供了编译期多态的能力”,请解释这句话。

专家级问题

2.下面这个函数的语义是什么?请尽量说全面一些,并务必解释为什么这里有两个模板参数而不是一个。

template <class T1, class T2>

void construct(T1* p, const T2& value) {

new (p) T1(value);

}

解决方案

1.“C++模板提供了编译期多态的能力”,请解释这句话。

当我们在一个面向对象的世界当中考虑多态的时候,所考虑的是运行时多态,这种能力来自虚函数。基类建立起一个接口“合约”,该“合约”由一系列虚函数构成,派生类可以从基类进行派生,并在不违反合约所蕴涵的语义的前提下重写基类的虚函数。这么一来,那些期望使用基类对象(通过指针或引用来持有基类对象)来工作的代码使用派生类同样能够运行良好。

// 示例5-1(a):我们司空见惯的运行时多态

//

class Base {

public:

virtual void f();

virtual void g();

};

class Derived : public Base {

// 如果有必要的话,重写f和/或g

};

void h(Base& b) {

b.f();

b.g();

}

int main() {

Derived d;

h(d);

}

这是极其有用的特性,带来了若干运行时的灵活性。不过,运行时多态有两大主要缺点:首先,这些类型必须位于同一个继承自某个公共基类的类层次结构当中;其次,当虚函数在一个密集的循环当中被调用的时候,你可能会注意到它会带来一些运行时开销,因为通常每次对虚函数的调用都要通过一个额外的间接层——虚函数指针,同时,编译器负责根据你的意思将虚函数调用分发到派生类当中对应的函数。

而如果你在编译期就知道正在使用的是什么类型的话,就可以避免这两个缺陷:你可以使用那些并非具有继承关系的类型,只要它们支持你要的操作即可。

// 示例5-1(b):新型的编译期多态。这是个强大的特性

// 至今我们仍在不断探寻这种能力可能带来哪些令人惊奇的东西

//

class Xyzzy {

public:

void f(bool someParm = true);

void g();

void GoToGazebo();

// ……更多函数……

};

class Murgatroyd {

public:

void f();

void g(double two = 6.28, double e = 2.71828);

int HeavensTo(const Z&) const;

// ……更多函数……

};

template<class T>

void h(T& t) {

t.f();

t.g();

}

int main() {

Xyzzy x;

Murgatroyd m;

h(x);

h(m);

}

只要x跟m这两个对象都提供了名为f和g的无参成员函数,h()就能够工作。在示例5-1(b)中,实际上Xyzzy和Murgatroyd中的成员函数f和g分别具有不同的函数签名,而且它们各自还提供了其他的成员函数,不过h()并不关心这些。只要的确可以无参地调用f跟g,编译器就允许h()进行这种调用。当然,当f和g被h()调用时,它们也应当做一些对h()来说有意义的事情!

也就是说,模板提供了强大的编译期多态能力。虽说在大多数编译器上,模板使用不当可能会导致难以读懂的错误信息,然而不能否认,模板同样也是C++中最强大的特性之一。

2.下面这个函数的语义是什么?请尽量说全面一些,并务必解释一下为什么这里有两个模板参数而不是一个。

// 示例5-2(a): construct

//

template <class T1, class T2>

void construct(T1* p, const T2& value) {

new (p) T1(value);

}

construct()函数模板负责在给定的内存位置构造对象,并同时以某个初始值来初始化它。这里使用new操作符的形式称为定位new(placement new),这种形式的new并不为新对象分配内存,而只不过是将新对象放置到p所指的内存位置。任何以这种方式new出来的对象都应当通过显式调用其析构函数来销毁(正如第6条中的问题1所示),而不是使用delete表达式。

那么,为什么construct()具有两个模板参数呢?难道使用一个模板参数我们就无法复制value对象了吗?如果construct()只有一个模板参数的话,如果要从一个不同类型的对象进行复制,就得明确地给出参数类型:

// 示例5-2(b):一个功能稍差的construct(),以及为什么说它功能稍差

//

template <class T1>

void construct(T1* p, const T1& value) {

new (p) T1(value);

}

// 假设p1和p2皆指向未初始化内存(raw memory)

//

void f(double* p1, Base* p2) {

Base b;

Derived d;

construct(p1,2.718);       //ok

construct(p2,b);         //ok

construct(p1,42);        // 错误:T1究竟是double还是int呢

construct<double>(p1,42);    //ok

construct(p2,d);         // 错误:T1究竟是Base还是Derived呢

construct<Base>(p2,d);      //ok

}

这里有两处地方之所以会发生二义性是因为编译器没有足够的信息推导出模板形参T1的值,因而程序员不得不手动显式地给出一个模板实参。然而我们是否应当允许程序员一声不响地从一个int值构造出一个double值呢?或许吧,可能发生的最糟糕的事情就是损失一些精度。我们是否应该允许程序员一声不响地从Derived对象构造出一个Base对象呢?或许吧,如果Base允许这么做的话,对象切片就有可能发生,不过这也可以是合法的选择。

假如我们想允许程序员在不用显式给出类型名的情况下就能够完成上面这些事情,就需要使用原先给出的具有两个独立的模板参数的construct()了。

第6条 泛型性的风味之二:够“泛”了吗

难度系数:7

评价一个泛型函数到底有多“泛”。答案不仅取决于其自身的实现,同样取决于其使用接口。不过,一个经过完美泛化的接口却有可能在一个简单(而且诊断起来很费事)的编程失误面前栽跟头。

专家级问题

1.在如下的函数中存在着一个不易觉察的陷阱,是关于代码泛型性的。找出它,并给出修正它的最佳办法。

template <class T>

void destroy(T* p) {

p->~T();

}

template <class FwdIter>

void destroy(FwdIter first, FwdIter last) {

while(first != last) {

destroy(first);

++first;

}

}

2.下面这个函数的语义是什么?其中模板形参 T 的要求又是什么?有可能将这些要求当中的任何一条消掉吗?如果可能的话,请说明如何做,并讨论这么做是好还是坏,给出相应的理由。

template <class T>

void swap(T& a, T& b) {

T temp(a); a = b; b = temp;

}

解决方案

1.在如下的函数中存在着一个不易觉察的陷阱,是关于代码泛型性的。找出它,并给出修正它的最佳办法。

// 示例6-1: destroy

//

template <class T>

void destroy(T* p) {

p->~T();

}

template <class FwdIter>

void destroy(FwdIter first, FwdIter last) {

while(first != last) {

destroy(first);

++first;

}

}

destroy()负责销毁一个对象或者一个区间内的所有对象。其第一个重载版本接受单个指针,并调用它所指对象的析构函数。第二个版本则接受一个以一对迭代器来表示的区间,并逐一销毁该区间当中的所有对象。

不过这儿存在着一个不易觉察的陷阱,即两个参数的 destroy(FwdIter,FwdIter)是被模板化了的,因而可以接受任何泛型迭代器,它会依次将区间当中的每个迭代器传递给单参的destroy(T*),而后者要求FwdIter必须为纯粹的指针!这就毫无必要地丧失了一些原本对 FwdIter 进行模板化而带来的泛型性。

准则 记住,(指向数组内元素的)指针永远都是迭代器,而迭代器并非总是指针。

这同样意味着,某些代码如果试图调用destroy(FwdIter,FwdIter),而给出的迭代器又不是指针类型的话,则可能会导致相当晦涩的错误信息,因为至少其中的一处错误是在destroy(FwdIter,FwdIter)中调用destroy(first)之处,这时通常情况下编译器会生成类似下面这样的有用的错误信息(从某个流行编译器给出的错误信息当中摘选出来的):

'void __cdecl destroy(template-parameter-1,template-parameter-1)' : expects 2

arguments - 1 provided

'void __cdecl destroy(template-parameter-1 *)' : could not deduce template

argument for 'template-parameter-1 *' from '[the iterator type I was using]'

就我所见识过的错误信息之中,上面这些错误信息并非最晦涩难懂。只需稍微多花一点工夫去阅读,你就会发现它们其实(差不多)告诉了你问题所在。第一则消息告诉你编译器正试图对destroy(first)进行重载决议,编译器试图将它跟destroy()的第一个版本,即接受两个参数的版本进行匹配,从而导致了这行错误信息。而第二则信息则表示编译器试图将该调用匹配到destroy()的第二个版本,即单参的版本。然而,编译器的两次尝试均告失败,分别是由于不同的原因:双参的destroy()固然可以接受迭代器为参数,但却需要一对迭代器;而单参的destroy()虽然只接受一个参数,却要求该参数的类型为指针。没辙!

话虽如此,在实际中,由于使用该函数的目的,以及考虑它会将对象“打回”未初始化内存这一点,我们几乎从来也不会想要将它用到除了指针之外的其他地方。虽说如此,我们也只不过需要对代码动一点小小的手脚就可以让destroy()能够接受任意类型,而不仅仅是指针类型的迭代器。既然如此,何乐而不为呢?具体做法是在两个参数的destroy()版本中将下面这行代码稍做改动。

destroy(first);

改为:

destroy(&*first);

这种做法几乎总是行之有效的。我们先将迭代器解引用,获得容器内对象的直接引用,然后获取它的地址,这就确保了我们能够得到想要的指针。更详细地说就是,所有符合标准的迭代器都要求提供operator*(),而且该操作符必须返回一个真正的T&。这便是C++标准之所以不支持代理容器的原因。如果你还想知道关于这方面更多的信息以及相关的议题,可参考More Exceptional C++[Sutt02]中的条款39中关于表达式&*t.begin()的讨论。(导致destroy(&*first)不能正常工作的情况还是有的,虽然极其罕见,正如精明的读者Bill Wade所指出的,如果T重载了operator&()并用它来返回其他东西而不是当前对象的地址,那么改进后的形式仍然是无法正常工作的,不过那是病态的做法,我从来也没有见过哪个设计有正当理由这么做。)

那么,这个故事的精神是什么呢?在使用一个泛型函数来实现另一个泛型函数的时候,须留神一些不易觉察的泛型性丧失。本例中就是一个不易觉察但非常严重的泛型性丧失,即两个参数的destroy()在可接受的迭代器类型方面并不像代码作者原先设想的那样宽泛。甚至就连改进后的版本仍旧存在着一个更微妙的次要缺陷,即将destroy(first);改为destroy(&*first);之后,我们实际对T增加了一个额外的要求,即T上的operator&()应当具有常规语义,换句话说,应当返回目标对象的地址。而如果我们不使用一个函数来实现另一函数的话,这两个陷阱则都可以优雅地回避掉。

注意,我并不是叫你不要使用模板来实现模板,我只是建议你在这么做的时候要留意它们之间潜在的相互影响。勿庸置疑,人们常常会使用模板来实现模板,而且常常能妥善地完成任务。例如,当程序员知道他们的某两个类型可以以一种更为高效的方式来互换值,他们通常就会将std::swap()针对这两个类型特化,这么一来,以后如果你要写一个sort()模板的话,sort()就应当使用swap()来进行值交换,否则就无法针对具体的元素类型选出最高效的值交换途径。

2.下面这个函数的语义是什么?其中模板形参 T 的要求又是什么?可能将这些要求当中的任何一条消掉吗?如果可能的话,请说明如何做,并讨论这么做是好还是坏,给出相应的理由。

// 示例6-2(a): swap。

//

template <class T>

void swap(T& a, T& b) {

T temp(a); a = b; b = temp;

}

swap()只不过是通过复制构造函数和复制赋值操作符来将两个值进行交换而已,因而它要求T具有一个复制构造函数和一个复制赋值操作符。

如果你的答案就是这些的话,只能得一半分。在考察任何函数的语义的时候,最值得注意的一个方面就是异常安全性,包括它提供了什么程度的异常安全保证等。本例中,swap()根本就不是异常安全的,例如我们可以假设T的复制赋值操作符会抛出异常,特别是如果T::operator=()会抛出异常但它同时又具有原子语义(要么全部成功要么全部失败),再假设第二次复制赋值失败的话,我们就会因异常的抛出而离开swap()函数,然而届时a的值已经被修改了[11]。更糟的情况是T::operator=()不仅会抛出异常,而且不具有原子语义,那么就可能发生这样的结果,swap()异常退出,然而它的两个参数都被修改了,其中一个被修改后的值可能既不是a也不是b的值。这样的话,swap()的文档就必须像下面这样来描述。

如果T::operator=()不抛出异常,那么除了T操作[12]的副作用之外,swap()就能够给出“要么全部成功,要么全部失败”的原子语义保证(参考[Sutter99])。

否则,如果T::operator=可能抛出异常的话:

如果T::operator=()具有原子语义,而且 swap()是异常退出,那么第一个实参就有可能被也有可能不被修改;

否则,如果T::operator=()不具有原子语义,而且 swap()是异常退出,那么两个实参皆有可能被也有可能不被修改,而且其中一个实参的值有可能既不是实参一的值也不是实参二的值。

有两个办法可以消除“T必须具有一个复制赋值操作符”这一限制,第一个办法还能额外提供更好的异常安全性。

1.第一个办法即特化或重载swap(),假设我们有一个类MyClass,该类遵循了一个常用手法,即提供一个不抛出异常的 Swap()成员函数。这么一来我们就可以针对MyClass来特化标准的swap()函数了,如下:

// 示例6-2(b): 特化swap()。

//

class MyClass {

public:

void Swap(MyClass&) /* throw() */ ;

// ……

};

namespace std {

template<> swap<MyClass>(MyClass& a, MyClass& b) { // throw()

a.Swap(b);

}

}

针对MyClass来重载标准的swap()函数也可以达到同样的效果,如下:

// 示例6-2(c): 重载swap()。

//

class MyClass {

public:

void Swap(MyClass&) /* throw() */ ;

// ……

};

// 注意,这个重载并没有放在std名字空间当中

swap(MyClass& a, MyClass& b) /* throw() */ {

a.Swap(b);

}

即便T确实具有一个赋值操作符(从而原先那个swap()也是可行的),这种做法通常也不失为一个好主意。

例如,标准库自身就针对vector重载[13]了swap(),从而对vector调用swap()实际上调用的是vector::swap()。这一举措意义重大,因为vector::swap()不用对vector内的元素做任何复制就可以实现交换,所以效率高得多。而这件事如果让示例 6-2(a)中的swap()主模板来做,它就会首先创建其中一个vector的一份全新的副本(temp),然后将另一个vector复制到这个,再将temp复制到另一个vector,这会导致一系列的T操作[14],其时间复杂度为O(N),N为互换的两个vector的大小的和。而针对vector特化过的swap()则通常只需简单地对一些指针和整型变量进行赋值即可,耗常量时间(通常可以忽略不计)。一转眼的工夫就完成了。

这就是说,如果创建的新类型提供了类似于swap的操作,那么通常最好也为你的类特化一下std::swap()(或者在其他名字空间中[15]提供自己的swap()重载也行)。你的swap()通常要比一般化的std::swap()更高效,后者使的是蛮力,而你的swap()则可以用巧劲,而且你的swap()往往能够改善swap()本身的异常安全性。

准则 跟只知道蛮干的std::swap()相比,如果你的类型有一个更好的途径可以用于交换其对象的值的话,请考虑为它特化std::swap()。

2.消除“T必须具有一个复制赋值操作符”这一限制的第二条途径是“先析构再重建”。其核心理念是通过T的复制构造函数而非复制赋值操作符来实现交换,当然这种做法的前提是T必须确实具有复制构造函数。

// 示例6-2(d): 不用复制赋值的swap()

//

template <class T>

void swap(T& a, T& b) {

if(&a!=&b){          // 现在这个检查变得有必要了!

T temp(a);

destroy(&a);

construct(&a, b);

destroy(&b);

construct(&b, temp);

}

}

首先,如果T的复制构造函数可能会抛出异常的话,这种做法则肯定是不妥当的,因为这样一来你就会面临跟最初的std::swap()一样的异常安全性问题。甚至只会更严重,你可能会遇到这样的情况:被操作的对象不仅具有不确定的值,甚至根本就不存在!

而如果我们知道T的复制构造函数一定不会抛出异常的话,该版本倒的确能够额外对付那些不能被赋值但可以被复制构造的类型,而且实际中也确实存在着许多这样的类型。但能够交换这些类型的值并不一定是什么好事,例如这种类型可能并不具有值语义,而且它可能具有const或引用成员,因而我们等于是在提供一种可以为类型强加值语义的机制,这可能具有误导效果,并导致意外或不正确的结果。

更糟的是,这种做法是在“玩弄”对象的生命期,而后者总是应该受到质疑的。这里我之所以说“玩弄”是因为它不仅改变了对象的值,而且还从真正意义上对被操作的对象的存在状态进行了改变。使用示例6-2(d)中的swap()的用户如果忘记它具有非常规的析构语义的话,则容易遇到意料之外的结果,

准则 下面内容也可作为一条准则。如果必须操纵对象生命期,而且知道这么做是正确的,而且确信被操作的对象的复制构造函数永远不会抛出异常,而且你非常肯定当你的swap()被运用到特定对象上的时候,这种非常规的“强加的”值语义不会带来什么问题的话,那么(只有在这些前提之下)或许才可以合理地决定针对某些特定情况(对象)使用这种做法。然而即便如此,也要记住不要将这种做法用在一个通用的,可能会意外地由任何类型来实例化的模板当中,而且一定得将它的不同寻常的行为记入文档,这样可怜的毫无戒备的隔壁程序员才能知道会发生什么样的行为,因为该技术已经完全属于C++编码中的“非常规手段”了。

第7条 为什么不特化函数模板

难度系数:8

尽管本条的标题是个问句,但它同样也可以表达成陈述句:本条讲述的是“何时/为何不特化函数模板”这一主题。

初级问题

1.C++中主要有哪两种形式的模板,它们分别如何进行特化?

专家级问题

2.在如下的代码中,最后一行代码调用的是f()的哪个版本?为什么?

template<class T>

void f(T);

template<>

void f<int*>(int*);

template<class T>

void f(T*);

// ……

int *p;

f(p);       // 调用哪个f()?

解决方案

重要的区别:重载与特化

首先确保我们已对这两个概念了然于胸是很重要的,因此下面是一个快速的回顾。

1.C++中主要有哪两种形式的模板,它们分别如何进行特化?

C++中有类模板和函数模板之分。这两种模板的工作方式并不完全一样,最明显的区别在于重载,普通的C++类是不能重载的,因此类模板也不能够重载。另一方面,普通的C++函数如果名字相同(且函数签名不同)就会发生重载,因而函数模板也允许重载。这些规则相当合乎情理。我们目前所拥有的能力如示例7-1所示。

// 示例7-1:类模板与函数模板,以及重载

//

// 一个类模板

template<class T>class X{/*...*/};    //(a)

// 一个具有两个重载版本的函数模板

template<class T>void f(T);        //(b)

template<class T>void f(int,T,double);   //(c)

这些非特化版的模板同样也被称为主模板。

此外,主模板还可以被特化。这一点使得类模板跟函数模板之间的差异性更大了,在本条款的后面部分,我们会看到这种差异性产生了重大的影响。类模板可以被偏特化或者全特化[16]。函数模板则只能够被全特化,不过,由于函数模板可以被重载,所以我们通过重载也能够达到类似偏特化的效果。下面的代码说明了这些差别:

// 示例7-1(续):特化模板

//

// 将上例中的(a)针对指针类型做一个偏特化

template<class T> class X<T*> { /*...*/ };

// 将上例中的(a)针对int做一个偏特化

template<> class X<int> { /*...*/ };

// 一个独立的主模板,重载了(b)和(c),注意,它并非(b)的偏特化,因为

// 函数模板并不允许偏特化

template<class T>void f(T*);       //(d)

// (b)关于int的全特化

template<>void f<int>(int);       //(e)

// 一个普通的函数,恰好重载了(b)、(c)和(d),但并非(e),后面我们将会

// 讨论这一点

void f(double);            //(f)

准则 记住,函数模板不能偏特化,只能重载。写一个看似函数模板偏特化的函数模板实际上是在写一个单独的主函数模板。

最后,让我们将注意力集中到函数模板上,考虑一下它们的重载规则,看看在不同的情况下到底哪个函数会被调用。规则相当简单,至少从总体上来看是这样,我们可以用经典的分类讨论法来描述该规则。

非模板函数是C++中的一等公民。如果一个普通的非模板函数跟一个函数模板在重载解析的参数匹配中表现一样好的话,编译器会选择普通函数。

如果编译器发现没有合适的“一等公民”可选的话,那么主函数模板作为 C++中的二等公民就会被纳入考虑。具体选择哪个主函数模板则取决于哪个的参数类型匹配得最好,如果这样还不能选出唯一的主函数模板的话,编译器则会根据下面一组相当晦涩的规则[17]确定哪个主函数模板是“最特化”的(重要提示:这里虽然称“特化”,然而实际上却跟“模板特化”没有任何关系,只是词汇上的不幸冲突而已)。

如果显而易见存在着一个“最特化”的主函数模板的话,该主函数模板就会被选中,如果这个被选中的主函数模板碰巧又针对所使用的模板实参(列表)做了特化的话,该特化版本就会被编译器选中,否则编译器将使用以正确类型实例化的主模板。

否则,如果有两个或多个“最特化”的主函数模板互相之间不能分出孰优孰劣的话,调用就是二义性的,因为编译器不能确定它们中哪个是更好的匹配。程序员只能以某种方式来对调用进行一些限定,明确指出想要调用的是哪个函数。

否则,如果没有任何主函数模板可以匹配调用的话,调用就是错误的,这时候程序员就有必要去纠正他们的代码。

这些规则加在一起作用的结果可以用下面这个例子来加以说明:

// 示例7-1(续): 重载决议

//

bool  b;

int   i;

double d;

f(b);     // 调用(b),T推导为bool

f(i,42,d);   // 调用(c),T推导为int

f(&i);    // 调用(d),T推导为int

f(i);     // 调用(e)

f(d);     // 调用(f)

到目前为止,我都是在故意选择一些较为简单的例子,而下面将下潜到C++的深层去。

为什么不特化:Dimov/Abrahams的例子

考虑如下的代码:

// 示例7-2(a): 显式特化

//

template<class T>   //(a):一个主模板

void f(T);

template<class T>   //(b):一个主模板,重载了(a):由于函数模板不能

void f(T*);      // 被偏特化,所以只能用重载来代替

template<>     //(c):(b)的一个显式特化

void f<int>(int*);

// ……

int *p;

f(p);       // 调用(c)

示例7-2(a)中最后一行的结果是意料之中的。然而,这里的问题是,为什么你会认为调用的是(c)?如果是歪打正着的话,当你看到下面的例子的时候可能就会大吃一惊了。毕竟,人们可能会说:“那又如何?我针对int写了一个特化,那么显然这个特化就是应当被调用的那个了。”如果你是这么认为,可就大错特错了。

现在考虑问题2的代码,仍然是由Peter Dimov和Dave Abrahams给出的。

2.在如下的代码中,最后一行代码调用的是f()的哪个版本?为什么?

// 示例7-2(b): Dimov/Abrahams的例子

//

template<class T>

void f(T);

template<>

void f<int*>(int*);

template<class T>

void f(T*);

// ……

int *p;

f(p);       // 这里调用的是哪个f()?

答案是……第三个f()!下面重复一遍刚才的代码,不过这次加上了类似示例7-2(a)中的注释,以便比较和对照这两个例子。

template<class T>  //(a):跟前面一样的主模板

void f(T);

template<>     //(c):显式特化,这次是对(a)进行特化

void f<int*>(int*);

template<class T>  //(b):第二个主模板,重载了(a)

void f(T*);

// ……

int *p;

f(p);        // 调用(b)!重载决议无视特化的存在,只在

// 主函数模板之间进行决议

如果这令你感到惊讶的话,你并不是唯一一个,当时它曾让许多专家大吃一惊。理解这个例子的关键其实很简单:模板特化并不参加重载。

只有主模板才会参加重载(当然,还有普通函数)。再次考虑我前面给出的关于重载决议规则的总结中某些比较突出的部分,这次我将一些特定的单词醒目地标了出来,如下所示。

……

如果编译器发现没有合适的一等公民可选的话,那么主函数模板作为 C++中的二等公民就会被纳入考虑。具体选择哪个主函数模板则取决于哪个的参数类型匹配得最好,如果这样还不能选出唯一的主函数模板的话,编译器则会根据下面一组相当晦涩的规则确定出哪个主函数模板是“最特化”的。

如果显而易见存在着一个“最特化”的主函数模板的话,该主函数模板就会被选中,如果该被选中的主函数模板碰巧又针对被使用的模板实参(列表)作了特化的话,该特化版本就会被编译器选中,否则编译器将使用以正确类型实例化的主模板。

等等。

重载决议只会选出主模板(也可能选中普通函数,如果存在这么一个可用的普通函数的话)。只有在确定了到底调用哪个主模板之后,编译器才会着手四处寻找该主模板是否有某个合适的特化版本是可用的,如果找到了,那么就会采用那个特化版本。

准则 记住,函数模板特化并不参与重载决议。只有在某个主模板被重载决议选中的前提下,其特化版本才有可能被使用。而且,编译器在选择主模板的时候并不关心它是否有某个特化版本。

重要教训

如果你跟我一样,那么你第一次看到这些东西的时候或许也会问同样的问题:“呃,不过对我来说似乎是我特地写了一个针对int*的版本,而且实参的类型又的确是int*,这是个完全匹配,那么难道我的特化版本不应该被选用吗?”只可惜这是个错误的想法:如果想确保你的特化版本在类型完全匹配的情况下总是被选用,你就应该将它写成一个普通的函数,而不是模板特化。

至于模板特化为何不参与重载决议,只要稍加解释你就会发现其实理由很简单,因为引起人们惊讶的因素恰恰相反:标准委员会认为,如果仅仅由于你碰巧为某个特定的模板写了一个特化就导致编译器选择了不同的模板,人们就会感到惊讶。基于这个理念,又由于倘若有需要的话我们已经有方法可以确保我们的某个特殊版本得到采用(只需将它写成普通函数,而不是模板特化即可),就不难理解为什么模板特化不会影响重载决议。

准则 教训#1:如果你想要将一个主模板特化,同时又希望该特化版本能够参与重载决议(或者希望确保当它能够完全匹配用户调用的时候能被编译器选用)的话,只需将其写成普通函数即可。

推论:如果你确实提供了某个函数模板的重载,那么你应当避免为它提供特化。

然而,如果你是某个函数模板的作者而不是用户,又该怎么办呢?你是否能够做得更好一些,在第一时间为自己也为你的用户避免这类(以及其他)问题呢?实际上,答案是肯定的。

// 示例7-2(c): 阐释教训#2

//

template<class T>

struct FImpl;

template<class T>

void f(T t){FImpl<T>::f(t);}   // 客户,请不要动这个函数!

template<class T>

struct FImpl {

static void f(T t);      // 客户,你可以对这个函数进行特化

};

准则 教训#2:如果你正在写一个可能需要被特化的主函数模板的话,请尽量将它写成一个孤立的、永远不该被特化或重载的函数模板,并将其具体实现全部放入一个包含了一个静态函数的类模板当中。这么一来任何人都可以对后者进行特化(全特化或偏特化),而同时又不会影响到主函数模板的重载决议。

小结

对函数模板进行重载是允许的。编译器在进行重载决议时会平等对待所有的主模板,因此整个过程和结果跟你在普通的C++函数重载中获得的经验应当完全吻合,即任何可见的模板都会被纳入到重载决议当中,然后编译器负责从中选出最匹配的。

而当你去特化函数模板的时候,事情就不那么符合直觉了。一方面,你不能对它们进行偏特化,而只能重载。另一方面,函数模板的特化并不参与重载决议。这意味着不管你写什么样的特化都不会影响重载决议的结果,这跟大多数人的直觉恰恰相反。不过,如果你写了一个跟模板特化具有同样签名的普通函数的话,编译器就会选择你的普通函数,因为在其他条件都不相上下的情况下,普通函数总是被认为是比模板更好的匹配。

倘若你要写一个函数模板的话,请尽量将它写成一个孤立的、永远不该被特化或重载的函数模板,并将它的具体实现全部放入一个包含了一个静态函数的类模板当中。这种众所周知的增加一个间接层的方法能够帮你避开函数模板的一些限制和黑暗角落。这样,使用你的模板的程序员就能够随心所欲地对包含了具体实现的类模板进行偏特化或显式特化,而再也不用担心会影响到函数模板的重载决议了。这不仅避开了函数模板不能偏特化这一限制,而且还免除了因函数模板特化不会参与重载而带来的一些出人意料的结果。问题解决了!

然而,如果你正在使用的是其他人写的“普通”函数模板(即不像我们刚才所述的使用类模板来实现的函数模板),而且你又想要加入一个自己的针对特殊情况进行处理的“特别版”并想让它参与重载,请千万不要将它写成函数模板特化,只需将它写成一个具有相应签名的普通函数(或主函数模板)即可。

第8条 友元模板

难度系数:4

如果你想要将一个函数模板的特化声明为友元的话,你应当怎么做?根据C++标准,你可以在两种合法的语法当中任选其一。然而,受到现实世界中编译器的约束,其中的一种语法普遍不支持,而另一种语法则在所有流行编译器的当前版本上是可行的……但是一个版本例外!

假设我们有一个函数模板,它会对其所操作的对象做一些“私事”。例如,考虑[Boost]中的boost::checked_delete()函数模板,它负责将接受的对象销毁,在它做的其他事中有一件事就是调用该对象的析构函数。

namespace boost {

template<typename T> void checked_delete(T* x) {

//……其他内容……

delete x;

}

}

现在,假设你想要将这个函数模板用到一个类上,而这个类的析构函数又碰巧是私有的。

class Test {

~Test(){}        // 私有!

};

Test* t = new Test;

boost::checked_delete(t);  // 错误:Test的析构函数是私有的

// 因此checked_delete不能调用它

解决方案很简单,只需将checked_delete声明为Test的友元即可(除此之外唯一的选择就是放弃Test的析构函数的私有性,即将它声明为公用的)。还有比这更容易的吗?

实际上,在标准C++当中有两条合法而简单的途径可以达到这个目的。只不过那也得编译器答应才行……

初级问题

1.有一种显而易见且符合标准的语法可以将boost::checked_delete声明为Test的友元,请说出这种语法。

专家级问题

2.为什么说这种显而易见的语法在实际中是不可靠的?请给出更为可靠的替代方案。

解决方案

本条会让你了解到现实的残酷性。将另一个名字空间中的模板声明为友元其实是说起来容易(在标准中)做起来难(在现实世界中的那些没能很好地“领悟”标准的编译器面前)。

总之,我有一些好消息、一些坏消息,最后再加上一些好消息。

好消息是,有两种完全OK且遵从标准的途径可以用来实现我们的目的,而且其语法也自然而然平淡无奇。

坏消息是,这两种标准语法都不可能在所有当前的编译器上皆有效。即便是一些最强大、最符合标准的编译器也不能完全支持这两种理应可用的、合法的和符合标准的语法。

好消息是,这两种做法中的一个的确在我测试过的所有当前编译器上都是可行的,但GCC除外。

下面就让我们来考察一下。

最初的尝试

1.有一种显而易见且符合标准的语法可以将boost::checked_delete声明为Test的友元,请说出这种语法。

本条是因Stephan Born在新闻组上提出的一个问题而起的,Stephan想让boost::checked_delete()成为类的友元。然而他遇到了问题。当他试图写一个友元声明,将 boost::checked_delete()的一个特化版本声明为Test类的友元时,代码无法通过编译。

这是他当时写的代码。

// 示例8-1: 授予友元关系的一种做法

//

class Test {

~Test() { }

friend void boost::checked_delete(Test* x);

};

可惜,上面的代码不光是在Stephan的编译器上通不过编译,实际上在很大一部分编译器上同样也都通不过。简而言之,示例8-1中的友元声明具有如下的性质。

根据标准,这种做法是合法的,然而它却依赖于语言中的一个冷僻特性。

它在当前的许多编译器,包括一些非常好的编译器上都通不过编译。

只需稍做修改就可以令它不再依赖于语言的冷僻特性,修改后的版本在除了 gcc 以外的所有其他编译器上都能够通过编译。

我打算深入解释C++语言允许我们声明友元的4种方式。这4种方式都很简单。我也乐于展示现实中的编译器是怎么做的,最后还会给出一个准则,说明如何编写可移植性最佳的代码。

为什么说它虽然合法但是冷僻

2.为什么说这种显而易见的语法在实际中是不可靠的?请给出更可靠的替代方案。

在声明友元时,你有4种选择(在[C++03]的14.5.3节有详细的列举)。我将它们归结如下。

当声明一个友元,同时又没有任何地方使用template关键字的时候。

(1) 如果友元的名字看起来像是一个模板特化,即显式给出了模板实参的话(例如Name<SomeType>)

则该友元就是指那个模板的特化体。

(2) 否则如果该友元的名字由一个类或名字空间所限定(例如Some::Name),并且这个类或名字空间当中的确包含一个名字与友元声明中的名字相匹配的非模板函数的话

则这个非模板函数就是所声明的友元。

(3) 否则如果该友元的名字由一个类或名字空间所限定(例如Some::Name),并且该类或名字空间当中的确包含一个名字与友元声明中的名字相匹配的函数模板的话(推导出相应的模板参数)

则该友元就是指那个函数模板的特化体。

(4) 否则友元声明中的名字肯定是非限定名,声明(或重复声明)了一个普通(非模板)函数。

显然,第(2)种跟第(4)种情形只匹配非模板函数,因此要想将模板特化声明为类的友元,我们有两个选择,一是制造情形#1,二是制造情形#3。在我们的例子当中,有如下选择。

// 原始代码,合法,因为它属于情形#3

friend void boost::checked_delete(Test* x);

或者

// 添加“<Test>”,仍然合法,因为它属于情形#1

friend void boost::checked_delete<Test>(Test* x);

第一种做法是第二种做法的简略形式,只不过第一种做法的生效有一个前提条件,即仅当友元名字是限定名(这里是boost::),且在同一个域中找不到匹配的非模板函数的情况下它才表示友元模板。虽说这两种做法都是合法的,但第一种做法利用了友元声明规则的一个冷僻特性,这一特性会令许多人乃至时下的大多数编译器都感到惊讶。我能给出不下3个理由来避免这种做法,虽然它从技术角度来说是合法的。

问题1:它并非总是行之有效的

正如我们已经注意到的,情形#3的语法是显式给出模板实参的做法的一个简略形式,然而它只有在友元名是限定名,且限定的类或名字空间当中不包含匹配的非模板函数的情况下才是行之有效的。

特别地,如果限定的名字空间中具有(或者后来拥有了)一个匹配的非模板函数,那么编译器就会选择那个非模板的函数,这是因为非模板函数的存在意味着我们会先落入情形#2,而并非情形#3。有点微妙和惊讶,是吗?有点容易出错,是吗?让我们避开这种微妙的情况。

问题2:它让程序员感到惊讶

情形#3是脆弱的,当程序员看到这样的代码并试图弄明白它到底做了什么的时候,结果往往令人大吃一惊。例如,考虑下面这个稍做改动后的例子,我所做的仅仅是将boost::限定去掉了而已。

// 稍加改动后的例子,将友元的名字改成非限定名,结果就完全是两码事了

//

class Test {

~Test() { }

friend void checked_delete(Test* x);

};

如果将boost::省略的话(即如果改成非限定名),你就落入了一个完全不同的情形,即情形#4中,而情形#4根本不会匹配出一个函数模板。想想看,我们仅仅省略了一个名字空间前缀就完全改变了友元声明的含义,我打赌绝大多数人都会同意这的确令人很意外。因此最好别去碰这个烫手山芋。

问题3:它令编译器感到“惊讶”

情形#3是脆弱的,而且令一些编译器也感到“惊讶”,因此即便我们不去理会前面的两个缺点,仅此一点就会导致它无法在实践中使用。

现在让我们在一组广泛的当前编译器上测试情形#1跟情形#3,看看编译器们怎么说。编译器能够像我们一样理解标准吗(当然,是指我们在读了本条款之后的理解)?是不是所有强大的编译器给出的答复都像我们所预期的那样呢?两个问题的答案皆是否定的。

首先让我们来测试情形#3。

// 示例8-1,再来一次

//

namespace boost {

template<typename T> void checked_delete(T* x) {

// ……其他内容……

delete x;

}

}

class Test {

~Test() { }

friend void boost::checked_delete(Test* x); // 原始代码

};

int main() {

boost::checked_delete(new Test);

}

在你的编译器上测试这些代码,然后将结果跟我们的比较。如果你曾在电视上看过家庭之争节目的话,现在就可以想象Richard Dawson在说:“调查表……明……”(见表8-1)。

在本例中,调查表明实际上编译器并不能很好地识别这种语法,甚至同一个编译器的各个版本间也存在着分歧。当然,毫不奇怪地,Comeau、EDG以及Intel编译器都接受该语法,因为它们都是基于EDG C++语言实现而开发的。我一共测试了5种不同的C++语言实现,其中就有3种(Digital Mars、gcc和Metrowerks)不接受这种做法,另外两个(Borland和EDG)则接受,而Microsoft的编译器的不同的版本则有不同的表现。

那么让我们再来测试另一个符合标准的语法,即情形#1。

// 示例8-2:声明友元的另一种途径

//

namespace boost {

template<typename T> void checked_delete(T* x) {

// ……其他内容……

delete x;

}

}

class Test {

~Test() { }

friend void boost::checked_delete<>(Test* x); // 替代方案

};

int main() {

boost::checked_delete(new Test);

}

或者等价地,我们也可以这么写:

friend void boost::checked_delete<Test>(Test* x); // 等价的做法

我们的调查表明,编译器对以上两种语法的支持明显要好得多(见表8-2)。

因此情形#1当然让人感觉更为放心——示例8-2除了在gcc上通不过编译之外,在其他所有当前编译器上都畅通无阻,而在旧的编译器中则除了VC6.0之外其他都没问题。

题外话:难道说是名字空间困扰了编译器

注意,如果要声明为友元的函数模板并不位于其他名字空间中,我们就可以在几乎所有的编译器上使用情形#1的做法。

// 示例8-3:假设checked_delete并不位于名字空间中……

//

template<typename T> void checked_delete(T* x) { // 不再位于boost::中

// ……其他内容……

delete x;

}

class Test {

friend void checked_delete<Test>(Test* x); // 不再需要boost::前缀了

};

int main() {

checked_delete(new Test);

}

调查结果见表8-3。

因此,大多数不能正确处理示例8-1的编译器的主要问题,都出在将一个位于另一名字空间中的函数模板的特化体声明为友元的时候。

两个不是办法的办法

当时这个问题在新闻组上贴出来之后,一些人的回复就建议写一个using声明(或者也可以用using指示),并将友元声明改成非限定名。

namespace boost {

template<typename T> void checked_delete(T* x) {

// ……其他内容……

delete x;

}

}

using boost::checked_delete;

// 或者 "using namespace boost;"

class Test {

~Test() { }

friend void checked_delete(Test* x); // 并不是指模板特化

};

上面的友元声明属于情形#4:“4.否则该名字必须为非限定名,并且声明(或重新声明)了一个普通(非模板)函数。”因此这种做法实际上是在外围名字空间作用域中声明了一个全新的普通函数,叫做::checked_delete(Test*)。

如果你试着编译上面的代码,就会发现这些编译器中许多都会报错,说 checked_delete()还没有被定义。然后如果你试图利用这个友元关系,将一个私有成员调用放到boost::checked_delete()模板中的话,会发现所有的编译器都开始“抱怨”了!

终于,一个专家给出了他的建议:只需对上例稍作改动,在使用using语句的同时也使用模板语法<>。

namespace boost {

template<typename T> void checked_delete(T* x) {

// ……其他内容……

delete x;

}

}

using boost::checked_delete;

// 或者"using namespace boost;"

class Test {

~Test() { }

friend void checked_delete<>(Test* x); // 合法吗

};

然而上面的代码也许并非合法的C++代码,标准并没有明确指出这种做法到底合不合法,标准委员会的C++ Standard Core Language Issue List中就有这个问题,即像上面这样的代码究竟是否应该合法的问题,有些人的意见是它不应该是合法的,而且实际上几乎所有我所尝试过的当前编译器都拒绝它。

为什么人们会觉得这种做法是不合法的呢?问题在于一致性,因为using关键字的作用是让程序员更容易地使用名字,例如在调用函数的时候,或者在声明变量或参数时给出类型名的时候。而声明就完全是另外一回事了,因为你必须在主模板所在的名字空间中才能声明它的特化版本(不能通过using从而在另一个名字空间中声明其特化版本),因此在声明友元的时候你也应当恪守一致性,既然想将一个模板的特化体声明为友元,那么就应当指出它的主模板所在的名字空间(而不仅仅是通过一个using)。

小结

要想将一个函数模板特化声明为友元,你可在以下两种方法中任选其一。

// 来自示例8-1

friend void boost::checked_delete (Test* x);

// 来自示例8-2:加上''<>''或''<Test>''

friend void boost::checked_delete<>(Test* x); // 或"<Test>"

本条说明在C++中仅仅忽略小小的<>或<Test>(示例8-2)就有可能损失相当多的可移植性。

准则(在代码中)明确地表达出你的意图。如果你所指的确实是一个模板,然而你的表达方式可能存在歧义的话,可以在模板名字的后面加上模板实参列表(这个列表可能是空的,即<>)。

避开语言的冷僻特性,包括那些虽然可以说是合法但容易使程序员甚至编译器迷惑的地方。

当你想将一个函数模板声明为友元的时候,一定要显式地加上一个模板列表(<...>),哪怕是空的模板列表<>也行,因为它至少能够明确表示你所指的是一个模板。

namespace boost {

template<typename T> void checked_delete(T* x);

}

class Test {

friend void boost::checked_delete(Test*x);   // 糟糕的做法

friend void boost::checked_delete<>(Test*x);  // 好的做法

};

然而,如果你的编译器目前连这两种合法的语法都不允许的话,你就只能将某些函数放到公用区段了[18],不过同时还要记住加上一句注释,说明为什么这么做,并且做个记号,以便在将来升级了编译器之后及时改回去。

第9条 导出限制之一:基础

难度系数:7

人们认为export能做些什么,而实际上它能做什么,为什么说在C++标准中包含的主要语言特性里面,export是最普遍忽略的一个。

初级问题

1.“包含模式”对模板来说意味着什么?

专家级问题

2.“分离模式”对模板来说意味着什么?

3.对于普通函数来说包含模式的主要缺陷有哪些?对于函数模板来说呢?

4.对于普通函数来说,标准 C++分离模式对于问题 3 中的缺陷能有哪些帮助?而对于函数模板来说呢?

解决方案

标准C++模板导出(export)特性是一个被普遍误解的特性,它的能力比大多数人最初认为的更有限,而它造成的影响却比大多数人最初认为的更大。本条以及下一条将对我们迄今为止在模板导出特性方面积累的经验做一个仔细的考察。

在写作本书的时候,有且仅有一个商业C++编译器尚支持export,即Comeau[19]编译器,它是基于Edison Design Group(简称EDG[20])的C++语言实现前端开发的,后者是当时(以及到目前为止)唯一支持export特性的C++实现,发布于2002年。而在现实项目中使用export的经验则仍然少之又少,尽管要是有更多C++实现加入export支持的话情况或许会有所改观。然而话虽如此,仍然有些东西我们是知道的,另外,export特性最初的实现者也获得了不少经验。

下面就是本条以及下一条将要讨论和涉及的内容。

export究竟是什么?其用途是什么?

人们广泛认为export应当解决的问题,以及为什么它实际上却不像大多数人所想的那样解决了那些问题。

export的当前形势,包括到目前为止我们在实现export特性的过程中得到的经验。

export 怎样(通常是以一种不明显的方式)对 C++语言中其他看似与它无关部分的基本意义产生影响。

一些关于如何有效地利用export特性的建议,当然,前提是如果你碰巧有一个支持export特性的编译器。

两种模式

C++标准支持两种不同的模板源代码组织方式,其中之一就是我们年复一年地使用着的包含模式,另一种相对较新鲜的模式是分离模式。

1.“包含模式”对模板来说意味着什么?

使用包含模式时,从源代码角度来看,模板代码和内联代码没什么区别(尽管模板并不一定要声明为内联的):模板的全部代码对于使用它的客户代码来说必须是完全可见的。这被称为包含模式,因为基本上我们必须在模板头文件中包含所有的模板定义代码[21]

如果你了解目前的C++模板技术的话,就应当知道包含模式。包含模式是近十年来唯一得到实际运用的模板代码组织方式,因为到目前为止它仍然是唯一被标准C++编译器(广泛)支持的模板代码组织方式。到目前为止你所看到的所有C++书籍和文章中的模板代码无不是以这种方式来组织的。

2.“分离模式”对模板来说意味着什么?

(模板的)分离模式以允许模板“分离式”编译为目的(这里“分离式”之所以加双引号是有原因的)。在分离模式下,模板定义对调用者不一定得是可见的。我很想在这句话后面加上“就像普通函数那样”,然而那样做是不尊重事实的。在我们的概念中似乎这两者并没有什么区别,然而它们实际表现出来的效果却是截然不同的,后面我们看到一些令人惊讶的部分时就会明白了。(模板的)分离模式是相对较新的模式,它是在20 世纪90 年代中期加入C++标准的,然而其第一个商业实现却直到2002年才由EDG实现[22]

下面我将用一整段的内容深入描述一些有关编译器实现的细节。首先我们得牢牢记住包含模式跟分离模式之间的一个细微但重要的区别,即其实它们的不同之处在于它们是不同的源代码组织方式。也就是说,它们的区别之处在于如何安排和组织源代码。它们并不会影响模板实例化的方式,换句话说,不管你使用包含模式还是分离模式,编译器在实例化模板的时候都是做完全一样的工作。记住这一点很重要,因为它一定程度上解释了为什么export的局限性(待会我们就会接触到)会令许多人惊讶,尤其是它还解释了为什么使用export不大可能将构建时间降低到普通函数的分离式编译通常达到的那种程度。例如,不管你使用哪种模式,编译器都仍然能够进行某些优化,如根据(而不是强制实施)唯一定义规则(ODR)对同样的一组模板实参只进行一次实例化,而不管这个实例在整个工程中有多么常用或者用得多么普遍。这种优化及实例化策略对于编译器作者来说总是可行的,而不管实际上源代码的物理组织方式是包含模式还是分离模式,尽管分离模式允许这种优化,但包含模式同样允许。

阐释问题

3.对于普通函数来说包含模式的主要缺陷是什么?对于模板函数来说呢?

为了阐释这个问题,让我们先来看一些代码。

我们将会分别以包含模式和分离模式来组织一个函数模板的代码,出于比较的目的,我打算同样给出一个普通函数在内联与分离编译模式下的代码组织形式。这样做有助于凸显当前的普通函数分离编译模式与export所支持的模板“分离”编译模式之间的区别。这两者是不同的,虽然用于描述它们的术语是相同的。事实上,正因如此我才在后一个“分离”上加上双引号。

考虑如下的代码,它们是一个普通的内联函数,以及一个以包含模式组织的函数模板:

// 示例9-3(a): 一个我们司空见惯的内联函数

//

// --- file f.h,给用户使用的---

namespace MyLib {

inline void f(int) {

// 优雅而高超的实现,多年来工作的结晶

// 使用了一些其他的辅助类和函数

}

}

下面是一个函数模板在包含模式下的代码组织形式,跟上例恰巧形成对比:

// 示例9-3(b): 一个毫不知情的可怜的模板,使用了包含模式

//

// --- file g.h,给用户使用的 ---

namespace MyLib {

template<typename T>

void g(T&) {

// 同样漂亮的实现,多年来工作的结晶

// 使用了另一些辅助类和函数。模板函数不一定要声明为内联,

// 不过函数体仍然需要全部放在这个头文件当中

}

}

C++程序员应当熟悉这两段示例代码中存在的问题。

实现代码暴露:全世界都可以看到f()和g()的代码,而后者可能是专有代码。当然,这件事情本身或许并没那么糟糕,这个问题我们待会儿再说。

源代码依赖性:f()和g()的所有调用方都依赖于它们所调用函数的函数体内的实现细节,因而每次这些函数的作者改过函数体之后,其所有调用方都必须重新编译。另外,如果f()或g()的定义中使用了其他类型,那么调用它们的客户代码同样也得依赖于这些类型的定义。

export的实际能力

我们有办法解决或者至少缓解这些问题吗?

4.对于普通函数来说,标准C++分离模式对于问题3中的缺陷能有哪些帮助?

对于普通函数来说,答案再简单不过了,我们只需改用分离编译模式即可。

// 示例9-4(a): 再普通不过的分离式编译(普通函数)

//

// --- file f.h,给用户使用的 ---

namespace MyLib {

void f(int);        //MYOB

}

// --- file f.cpp,可以不发布给用户---

namespace MyLib {

void f(int) {

// 优雅而高超的实现,多年来工作的结晶

// 使用了一些其他的辅助类和函数,不同的是现在这个函数体

// 跟函数声明分开了

}

}

毫不奇怪,该方案解决了我们上面所提到的“实现代码暴露”和“源代码依赖性”问题,至少对于f()是这样(同样的手段也可以用在类的“分离式编译”上,即Pimpl惯用法,参见Exceptional C++[Sutter00])。

定义/实现代码不再暴露给外界:现在,如果愿意的话,我们当然仍可以发布实现的源代码,但假若不愿意的话,我们就完全不必发布它们了。注意,许多流行的库,即便是那些对专有权保护得非常严密的,有时也会因用户需要源代码改善程序调试或者其他原因而同时发布实现源代码(有可能需要用户付额外的差价)。

消除了源代码依赖性:调用方不再依赖于 f()的内部实现细节,因而每当其定义体改变,调用它的代码只需重新连接一下即可。这往往能够令构建速度快上一个数量级乃至更多。类似地,f()的调用方不再依赖于那些只是在 f()的定义体中被使用的类型,这也会缩短构建时间,只不过效果较小。

对于普通函数来说,所有这些听起来都挺不错,只不过对这些内容我们早就耳熟能详了。我们从C开始,乃至在C时代之前就开始这么干了(那是很久很久以前的事了)。

真正的问题是:对于模板来说,上面的这些讨论还成立吗?如果不成立,情况又是怎样的呢?

export背后的理念是对模板也能够实现类似于普通函数分离式编译那样的效果。人们可能会天真地认为下面这样的代码能够像示例9-4(a)那样具有类似的优势。然而如果你这么想的话可就错了,不过别气馁,这么想的人并不止你一个,许多人在这个问题上都栽过跟头,包括一些世界级的C++专家。考虑如下的代码:

// 示例9-4(b): 一个依赖性“稍有减弱”的函数模板

//

// --- file g.h,给用户使用的 ---

namespace MyLib {

export template<typename T>

void g(T&);       //MYOB

}

// --- file g.cpp,??也给用户使用?? ---

namespace MyLib {

template<typename T>

void g(T&) {

// 优雅而炫目的实现,多年来努力的成果

// 使用了一些其他类或模板,现在被“分离式”编译了!

}

}

令许多人非常惊讶的是,对于g()来说这种做法并不能像f()那样解决那两个问题。它也许可以缓解其中一个问题。下面将逐一讨论这些问题。

问题一:源代码暴露

模板的分离式编译并不能解决这个问题,源代码暴露问题依然存在。

C++标准当中并没有说或者暗示你可以仅通过一个小小的export关键字就能避免发布g()的完整源代码。实际上,就export现有的唯一实现而言,编译器仍然得提供被export之模板的完整定义,即全部源代码[23]。原因之一是C++编译器在实例化这些模板的时候仍会需要用到它们的完整 6定义。我用一个例子来说明为什么是这样。C++标准中对实例化模板的时候会发生什么情况是这样来描述的:

“[依赖]名字是‘非绑定’的,编译器对它们的名字查找会在模板实例化处,在模板定义的上下文跟实例化的上下文中进行。”——[C++03]的14.6.2小节

依赖名字是指那些依赖于某个模板形参的名字,大多数有用的模板都包含依赖名字。在模板的实例化/使用处,编译器会在两个地方查找依赖名字。一是实例化处的上下文,这很简单,因为编译器正处于这个点上。然而还有另一个上下文,即模板定义的上下文,而这正是问题的关键,因为这便意味着编译器不仅需要知道模板的完整定义,而且还需知道包括该定义在内的其所在文件的上下文,包括哪些相关的函数签名位于作用域内等,这样重载决议以及其他工作才能顺利进行。

从编译器的角度来看示例9-4(b),情况是这样的:你的库里面有一个导出的函数模板g(),其定义体被很好地安置在了其头文件之外。然后你将库发布了。一年之后,一个阳光明媚的下午,某个用户在他的h.cpp编译单元中使用了你的模板,他决定用早晨刚定义出来的新类CustType来实例化你的模板,即g<CustType>……,那么,这种情况下编译器要生成目标代码应该做些什么呢?它应该在实现文件中四处寻找g()的定义。而这意味着什么呢?这意味着export并没有消除这种对于模板定义的依赖性,而只不过是将它隐藏了而已!

导出的模板并非是我们通常(对于普通函数)所说的真正的“分离式编译”。导出模板通常不能在使用之前就分离编译成目标代码。一方面,这是因为编译器直到使用模板的确切地点才能知道用哪些模板实参来实例化它。因此导出模板至多只能算是“分离式部分编译”或者“分离式解析”。而实际上,在每次实例化时模板的定义都要使用对应的一组模板实参编译一次。(这里有一点跟Java和.NET库是类似的,后者的字节码或IL可以被逆向工程,从而得到非常类似于源代码的东西。)

准则 记住,export并不能带来像普通函数那样真正的分离式编译。

问题二:依赖性与构建时间

第二个问题同样也没有得到解决:依赖性被隐藏了起来,但依然存在。

每次模板的定义体改变时,编译器都要将使用它的客户代码统统重新实例化。而在这个过程中,使用了g()的编译单元仍然跟g()的内部实现密不可分,后者包括g()的定义体以及仅在g()的定义体中使用的类型。

而当日后被客户使用,且实例化上下文皆已知的情况下,模板的(实现)代码仍然需要全部经过编译器编译。下面就是我们必须记住的关键概念。

准则 记住,export只能隐藏依赖性,并不能消除依赖性。

的确,使用了export之后,调用方不再显式地依赖于g()的内部实现细节了,但也只不过是g()的定义不再被公然地通过#include带入调用方的编译单元中而已,我们可以说依赖性在“用户阅读源代码”层面上被隐藏起来了。

然而这还不是故事的全部,因为这里我们关心的是“编译器必须进行的编译”依赖性,而不是“程序员一边喝着咖啡一边读代码”时的依赖性,而模板定义的编译依赖性仍然存在。诚然,当模板定义改变时,编译器或许并没有必要将每个使用了该模板的编译单元全部重编译一遍,然而它必须至少重编译足够多的编译单元,以便使得所有实例化并使用过的模板实例都已“刷新”。这种情况下编译器并不能像在真正的分离式编译模式下那样仅仅重新连接目标代码即可。

注意,即便是在包含模式下,例如代码是像示例9-4(b)中那样组织的,只不过将export关键字移除,同时加上一行#include "g.cpp"到g.h中,编译器也可以“聪明”到像上面所描述的那样来处理重编译事宜,即仅仅重编译足够多的编译单元从而令所有实例化并使用过的模板实例都得到“刷新”即可。编译器之所以可以进行这种优化是仰赖于(而不是强制实施)唯一定义规则的,例如,根据唯一定义规则,编译器可以合理假设如果出现两个或两个以上的模板实例对应于同一组模板实参的话,那么这些实例肯定是相同的,这样一来编译器就只需实例化其中的一处,而无需一一实例化并检查它们是否真的一致。

此外,还值得注意的一点是,许多模板都会使用其他模板,因而编译器就会重编译那些已用的模板(以及它们的编译单元),依此类推,后者用的模板也会被重编译,如此递归,直到不能再继续为止。(如果听到这里你挺庆幸自己不用去实现export特性的话,那纯属正常反应:-))

即便有了export特性,也并非就意味着那些使用了export模板的客户代码在前者被改动后“只需要重新连接(relink)即可”。跟普通函数的真正的分离式编译不同,到写作本书为止关于export特性通常会导致构建过程更快、更慢还是无甚改观这个问题仍没有明确的答案。

小结

本章我们考察了export特性背后的动机,以及为什么对于模板来说它并非是像普通函数那样的真正的分离式编译。许多人认为export即意味着可以不用发布模板库的实现源代码,或者构建速度会变得更快。然而实际上export根本没有保证会带来这两个优点。到目前为止 C++社区的经验是:即便你使用了export,模板的源代码或者与它等价的东西仍然必须随库一同提供,同时构建速度可能并无甚改观,甚至更慢,鲜有更快的情况,从原则上来说这是因为依赖性虽然被掩盖起来了但依然不可否认地存在着,而只要存在依赖性,编译器就仍然需要像往常那样做一系列的工作(或许更多)。

下一条将会讨论为什么export致使C++语言变得更复杂,并让它变得更难于使用,我们将会看到,export实际上改变了语言的其他某些部分的基本意义,而且人们当时并没有很清晰地认识到这一点。我们同样还会看到当初的一些关于如何有效利用export的建议,如果你碰巧有一个支持export特性的编译器的话。

第10条 导出限制之二:相互影响,可用性问题以及准则

难度系数:9

export与现有的C++语言特性之间是如何相互影响的?安全使用它的首要准则是什么?

初级问题

1.export的当前形式是从何时起在C++标准中确立的?其首次实现是在何时?

专家级问题

2.export以何种方式改变了C++语言其他特性的基本意义?简要地解释它们之间的相互影响。

3.export对程序员有所影响?

4.export究竟有何实际的潜在优点?

解决方案

这是该主题的第二部分。在上一部分,我们讨论了如下几个方面。

什么是export,该特性的使用意图是什么?我们分析了“包含”和“导出”这两种模板源代码组织模型之间的异同,以及为什么说它们不同于普通函数的内联和分离式编译之间的差别。

人们普遍认为export应当解决的问题,以及为什么实际上它并没有众望所归地解决这些问题。

尽管有着很多的期望,然而export并不能够为模板带来与非模板的分离式编译一样的真正的分离式编译。许多人都期望,export意味着模板库可以不必发布全部源代码定义(或者其等价物),同时构建速度也会有所提高。然而实际上export没有提供这两个保证。

到目前为止,C++界所获得的关于export的经验表明,使用export仍需发布全部源代码(或者其等价物),而且人们尚不知道在实际使用当中export究竟会使构建速度变得更快、更慢,还是相差无几。为什么?主要是因为依赖性仍然存在,只不过被隐藏了起来,而且在一般应用中编译器仍至少需要做等量的工作。简而言之,虽然人们很自然会认为export能够为模板带来真正的分离式编译,赋予开发者只需发布模板头文件与目标代码文件的能力,然而这是个误解。更进一步说,被export的东西在某种意义上类似于Java和.NET的库,Java的字节码与.NET的IL代码都可以通过逆向工程得到非常类似于源文件的内容,所以说被export的东西并非传统意义上的目标(二进制)代码。

本条就以下主题做一些讨论。

export当前的形势,包括目前在实现export的过程当中积累起来的经验。

export 会以某些(通常并不明显的)方式改变 C++语言当中看上去与它无关的某些部分的基本意义。

当碰巧使用的是支持export的编译器时,一些关于有效使用export的建议。

不过首先还是来回顾一些有趣的历史。

历史回顾:1988—1996

1.export的当前形式是从何时起在C++标准中确立的?其首次实现是在何时?

答案分别是1996年和2002年。(你也许觉得某个特性的第一次实现通常应该在它被标准化之前,这情有可原,C++标准在对待export的时候采取了“先标准化再实现”的反常态做法,但这并不是唯一的例外。)

正因如此,再加上对于export存在一些中肯的批评,似乎有理由能对提出这个馊主意的家伙群起而攻之。但这是不公正的,也缺乏君子之风。本条之所以有这些背景介绍就是为了持论公允,因为在export这个问题上,人们很容易走向极端,旗帜鲜明地赞成或反对。

倘若export不能提供众所期望的好处,那为什么它还会存在于语言之中呢?原因非常简单:在20世纪90年代中期,C++标准委员会成员普遍认为颁布一个不具备模板的分离式编译特性(C已经为普通函数做到了这一点)的标准是不完备且令人尴尬的。简单的说,根据一贯的原则,export就被留在了标准草案之中。

原则往往是好事情。原则的力量永远不该被小觑,尤其是对我们这些事后诸葛亮而言,因为我们是在事情发生之后若干年才来看待这件事情,而这时候关于这件事情人们已经有了多年的认识了。(当然,我说的“我们这些事后诸葛亮”,也包括我自己在内。虽说事隔多年以后,我已经担任ISO C++标准委员会的主席,但在这之前我并没有参加委员会的会议。)

1995年和1996年的时候,模板本身还算是相当新颖的技术。

++模板的第一份设计方案是Bjarne Stroustrup在1988年10月提出的[Stroustrup88]。

到了1990年,Margaret Ellis和Bjarne Stroustrup合著了The Annotated C++Reference Manual(也称ARM)[Ellis90]。同年,ISO/ANSI C++标准委员会继续进展,并将ARM选作阶段的“起点”基础文档。ARM是第一份包含模板描述的C++参考,其中描述的模板并非今天所熟知的样子,那时的模板还相当单薄,整个规范说明和描述只占了10页纸那时,人们关注的重心完全落在如何支持参数化类型以及参数化函数,ARM中给出的例子是一个能够存放各种类型对象的List容器,以及一个sort算法,它可以对各种类型的序列进行排序。然而,即便是在那时,人们已经思考是否给模板一个分离式编译能力。Cfront(Stroustrup的C++编译器)就支持当时尚处于初级阶段的模板的“分离式”编译,尽管它当时采用的做法并不具有可伸缩性(参见上一条中的标注)。

从1990年至1996年这6年间,C++编译器开发商纷纷涌现,并各自为战,采用不同的途径实现模板这一语言特性,同时标准委员会也对模板进行了极大的改进和扩充(使其更为复杂化了)。在透彻讲述C++模板的著作C++Templates[Vandevoorde03]中,仅标准C++模板的完整描述就占了 552 页中的大约 133 页篇幅,而整本书则完全用来详细描述模板这一语言特性以及如何有效地使用它。

在20世纪90年代的早期至中期,C++标准委员会将主要的精力放在让模板更健壮和实用,从而对它的基本使用有更好的支持。很少有人会怀疑他们创造出了一个极度灵活而有点古怪的奇迹般的东西,后来人们才知道模板原来本身就是一门图灵完备Turing-complete)的元语言,用它可以写出任意复杂的程序,而这个程序是在编译期执行的。然而,如今在 C++中大行其道的模板元编程以及高级的库设计技术却是当初赋予C++模板生命的人怎么也没有想到的,正是他们当时的决策造就了今天的一切,在 1990年至1996年之间这些模板技术绝大部分还没出现呢!还记得吗,1994年底Stepanov把他的STL库第一次提交给标准委员会,1995 年STL被采纳为标准,当时人们认为它是突破性的成就,然而在今天看来STL“只不过”是一个容器和算法库而已。当然,STL在1995年的背景下的确是个革命性的突破,而且即便是放在今天也仍然是令C++区别于其他语言的强大证据之一,然而从如今的标准来看,STL却只不过是C++模板的牛刀小试而已。

这正是为什么我会说“在1995年至1996年间,模板本身仍然算是相当新颖的事物”的原因。如今C++中的模板几乎可以算是最终形式了,然而即便是它的创造者当初也并没有完全意识到它们所能带来的影响。当时,整个C++界的规模比今天小得多,鲜有编译器具有支持ARM中描述的模板之外的能力,大多数编译器对模板的支持都很单薄,甚至根本没有用。举个例子,那时只有一种商用编译器能够支持最初的STL实现。

因此,C++界尤其是C++标准委员会对出现的简单ARM模板的实际经验仍然是比较缺乏的。1996年整个C++界已不能算处于萌芽期了,但还是比较年轻,处于不断成长和成型之中。

而正是在这种氛围当中,在如此有限的经验条件之下,标准委员会不得不决定是否将export特性留在标准草案当中。

1996

到了1996年,虽然可据参照的信息仍然少得可怜,然而大家都已经知道,export使许多专家感到紧张。尤其是它令所有的编译器开发商都感到了紧张。即便是export的支持者也只不过把它当成一个不得已的折中,并同时为它所带来的复杂性深感不安。他们中有些人更喜欢无需任何特定关键字支持的一般性分离式编译。

在1996年,C++标准委员会曾一度出现大伙齐心协力想要将“分离式”模板编译的概念抛弃的状况(最终事情以妥协收场,export关键字正是因此从那时起被加入语言的,export至少告诉了编译器哪些模板是应当分离式编译的)。特别地,曾有一种论调认为,分离式模板编译模式从来也没有真正实现过,标准委员会对于这个语言特性能否像当初预期的那样工作也毫无概念。几个C++开发商都曾实现过各种形式的模板源代码组织模式,然而export与它们一概无关,export是一个全新的、实验性质的特性,其背后没有任何的实现经验积累。事实上,当时曾有人就export发表了一些文章,详细讨论了标准草案中描述的export模式的主要潜在缺点,这些文章在现在看来可以说是具有洞察力和远见的。

特别地,当时所有的编译器实现方一致反对将分离式模板编译加入标准,他们认为这么做还过早,以至于没法判断这样做到底对不对。他们还对该特性当时的形式有着很深切但没有答案的关注和担忧(到底要不要export关键字),同时也觉得并没有足够的经验实现出一个成熟的替代方案(而且当时时间仓促——C++标准已经处于稳定状态,次年即1997年就将定案)。正是基于这些原因,所有的编译器开发商都一直不愿意将模板的分离式编译匆匆纳入第一代 C++标准[C++98]中。更确切地说,他们是想花费一些时间来尝试正确地设计它,等到下一代 C++标准再决定是否加入该特性。虽然他们从原则上也喜欢分离式模板编译这个想法,但他们更感觉到export尚未经过锤炼,况且仍然不知道如何正确地实现它。

然而可惜的是他们都未能成功,export 最终还是留在了标准当中[24]。不过对于这个结果我们是无法过分指责什么的。正如我前面所总结的,标准委员会的大部分成员(虽然人数并不多)相信,颁布一个标准,却缺乏像C对普通函数的分离式编译那样的模板“分离式”编译,是不完备且尴尬的。几个编译器都已经试验了“分离式”模板编译的一些不同形式,而且似乎从原则上来说这是个好主意。因而根据一贯原则(而且这是个良好的原则,不容轻视),export 就被留在了标准草案中。

为了强调,请注意世界范围内的编译器开发商只是反对export,而并不反对模板的分离式编译原则。他们只是觉得需要更多的时间来确信标准这么做是正确的。尽管当年(1996 年)在会议上投票赞成保留export的一些世界级专家现在也认为当初的做法是个错误,但当时人们的动机和意图是良好的。而且就算是现在,由于人们在首次实现 export 的编译器(Comeau 4.3.01,发布于2002年)处得到一些经验,因而也仍然希望export能够带来一些好处(就算不能带来当初预期的那些大优点)。

目前我们获得的经验

全世界唯一实现了export特性的是EDG(Edison Design Group)。他们反映说,就他们的经验来看,export是至今最难实现的C++特性,与他们实现过的其他3个主要特性(例如名字空间或成员模板)耗费的工作量不相上下。仅是export本身就耗费了不止3个人年来编码和测试,而且这还没算上设计工作的开销,而同样这3个人实现整个Java语言也只不过耗费了两个人年。

那么,究竟为什么export这么难于实现,这么复杂呢?下面是两个主要原因。

(1) export依赖于Koenig查找。大多数编译器在单个编译单元(其实就意味着一个源文件)中尚不能正确实现 Koenig 查找。export 要求跨编译单元实施 Koenig 查找。(参见[Sutter00,第27条]更多关于Koenig查找的讨论)

(2) 从概念上来说,export要求编译器同时处理多个符号表。实例化一个export的模板可能会触发其他编译单元中一系列的实例化,而当这个export模板的定义被解析的时候,这一系列的实例化必须都能够代表真实存在(或“某种意义上存在”)着的实体。在C++中,处理单个符号表已经是够复杂的了,再加上export,那么至少从概念上来说需要同时处理任意数目的符号表。

改……改……改……改变:export的阴影落在现有的语言特性之上

2.export以何种方式改变了C++语言其他特性的基本意义?简要地解释它们之间的相互影响。

export对某些现有的语言特性具有一些惊人的影响。标准中对export的这些实际影响并没有提及或解决。尤其是,export其实不止“导出”了它所标记的模板。

一旦有了export,那么export模板所使用到的那些位于匿名名字空间中的函数和对象,现在就必须能够被跨编译单元访问和调用到了。类似地,文件作用域级的 static函数和对象如果被export模板使用了的话,也就必须具有外部连接,或者至少得看上去具有外部连接。这跟匿名名字空间和名字空间范围内的静态对象的意图是背道而驰的,因为它们原本的意图是将这些名字严格限制在它们所处的编译单元中。(文件作用域级的static函数和对象是不推荐使用的,你应当使用匿名名字空间,然而它们仍然还是标准 C++的一部分。)

此外,有了 export,重载决议必须能够对来自任意多不同编译单元中的名字进行决议,有意思的是,这包括来自任意多个匿名名字空间中的重载名字。将“内部使用”的函数放入匿名名字空间中或使用不被推荐的 static,其用意本就是要将这些函数“私有化”,从而你可以赋予它们简单的名字,而不用担心名字冲突以及“跨源文件重载”。然而现在,由于这堵围墙已经为export开了一个口子,所以它们互相之间变得可见了,都参与了重载决议,这都得“归功”于export。所以,如果某个内部名字被export模板使用了的话,你最好重新将它“修饰”一番[25],即便这些名字是在匿名名字空间中或者是static也一样,只有这样做才能确保它们的意义不会被悄无声息地抹杀掉。

有了export还会带来新的二义性以及可能违反唯一定义规则(ODR)。例如,一个类可能在不同的编译单元中有多个友元实体,而位于这些单元中的该类的声明可能会同时参加一次实例化。如果出现这种情况的话,哪一组访问规则才是应当被采用的呢?这些问题可能看起来不太起眼,似乎有些错误也不会带来多大的危害,然而在某些常见的平台之上,ODR的违反变得越来越值得重视(见[Sutter02c]中的一个例子)。

export难于正确使用

3.export对程序员有何影响?

跟普通模板相比,export或许更难于正确使用。这里有三个例子可以说明这一点。

例1:有了export的存在,程序员更是一不小心就会写出含义难以预测的程序了。被导出的模板跟包含模式的模板一样,也可以通过不同的路径被实例化,每条路径通常都意味着一个不同的上下文。有人可能会说:“但是我们已经碰到过这种问题了呀,定义在头文件当中的函数(例如内联函数)不就是这样的吗?”但是请注意,这里我们面对的是模板,而当涉及到模板的时候这个问题就严重得多了,因为模板可能改变更多名字的意义,尤其是考虑到模板所使用到的名字比普通函数所用到的要广泛得多。模板使用了依赖名字,即那些依赖于模板实参(因而随之改变)的名字,因此对于同一组模板参数多次实例化某个模板的情况而言,模板的使用者必须小心确保其每次实例化点都具有同样的上下文(例如,能够接受某个模板实参类型的对象的重载函数集),以防止一不小心令模板的同一实例在不同文件中具有了不同的意义,造成典型的违反ODR的情况。那么,为什么我们会认为这个问题在export模式下比在包含模式下更恶劣呢?要知道,关于export的一个非常重要的地方就是除了模板通常具有的复杂性之外,export使得来自多个编译单元中的名字都位于名字查找的“势力范围”之内了,而在C++标准中的其他任何地方都未曾出现过这种情况。

例2:export使得编译器难以生成高质量的诊断信息来帮助程序员纠错。模板错误信息本身就因含有难以理解的冗长名字而声名狼藉,此外,程序员不太注意的是,对于编译器厂商来说,为模板提供良好的错误信息本就已经够困难了,因为某个模板的实例化可能会引发一系列瀑布式的实例化。而现在又有了export,于是实例化的扩张链上又增加了一维——来自多个编译单元的实例化。结果我们所司空见惯的下述错误信息:“第 X 行错误,由该函数的实例化导致,由该函数的实例化导致,由该函数的实例化导致……”现在就必须得在每级实例化前面加上它所属的编译单元的名字,而且这个回溯实例化过程的每一层实例化都可能是来自不同的编译单元。诚然,对于编译器来说,如何检测导出的模板是否违反了 ODR,本身也是一个富有挑战性的问题,然而要想“嗅”出程序代码的意图,以便能在提供错误信息的时候给出一点提示,则更是难上加难。许多人只盼望编译器能够为一般的模板提供可读一点的错误信息就心满意足了。

例3:export给构建环境也带来了新的约束。有了export的话,构建环境便不再单纯由.cpp和.h文件组成,今天的许多工具根本就不知道,在连接器(linker)可以回过头去改变.obj(或.o)文件的情况下[26],如何处理哪怕是明显的循环依赖问题。正如前一条所指出的,如果你改变了一个被导出的模板的实现文件,就需要将它重新编译一遍,而且不仅如此,你还要将对它进行实例化的客户代码也重编译一遍。也就是说,export实际上并没有隔离依赖性,而只是将其隐藏了起来而已。

EDG的John Spicer是模板技术的世界级专家,他曾经说过:“export从本质上来说是复杂且难以理解的,而且要想搞清它所带来的后果,需要相当大量的工作。对于export来说,我们很难给出浅显易懂的使用原则使用户免遇麻烦。”

export的潜在优点

4.export究竟有何实际的潜在优点?

既然我们现在总算第一次拥有了可用的export实现,那么C++界中当初支持该特性的家伙该可以捋起袖子去看看这玩意到底好不好用了。下面就是当初它的支持者期望它具有的两个实际和潜在的价值。

(1) 构建速度。在现实编码当中,export 会对使用了模板的代码的构建速度产生怎样的影响(如果有影响的话)仍然是个未知数。如果该特性得到了广泛采用的话,在这个领域做些探索,我们会发现它所带来的优点出现得有多普遍,以及要想利用起这些优点我们还要做多少努力。特别地,人们期望那些使用了被导出模板的编译单元(跟使用一般包含式模板的编译单元相比)对被使用模板的定义改变较不敏感(即当被使用的导出模板的定义改变时,使用它们的编译单元重编译的代价较小)。

告诫 如果你想知道为什么export不能隔离依赖性,或为什么依赖性依然存在的话,请参考上一条。同样要注意,在EDG的模板实现当中,该潜在优势(即构建速度)不仅存在于export源代码组织方式上,而且还存在于一般的包含模式模板上,这意味着至少对于该实现(目前唯一的export实现)来说,export跟包含模式相比并没有更多的优势。

(2) 宏渗漏问题。这倒是export的一个实际优势。在传统的包含模式下,某些宏可能会渗漏进模板头文件中。由于包含模式模板的源代码对于每个使用它的编译单元来说都是完全可见的,所以其头文件外部的宏如果位于头文件#include指令之前的话,就可能会对头文件中的模板定义产生影响。而有了export,再加上宏并不能渗漏过编译单元的壁垒,这样模板代码的作者就能够对他们的模板定义(位于单独的源文件(编译单元)之中)有更好的掌控,并防止外部的宏对模板内部定义产生干扰。

防止宏渗漏是export的一个实际好处,然而并不是只有export才能提供这个好处。在C++标准委员会早期在C++0x方面的工作中,EWG(Evolution Working Group,专门负责语言核心进化方面的工作)已经对宏的这个问题制定了更好、更通用的解决方案,适用于任何情况,例如Stroustrup的建议是加入可能是全新的#scope和#endscope预处理扩展指令。这类方案如果得到采纳的话,将会使export的这个优势完全消失,因为预处理域控制方案所带来的宏保护方面的能力将完全超过export的,而且更为妥善,更为通用。

总之,我们仍需要在将来的几年里继续观察export跟通常的包含模式相比能够带来哪些额外的好处,不过我还是强烈建议那些对此进行测试的人,同时也去完整利用一下EDG实现的非导出模板优化能力,以作为对照,看看export究竟还剩多少额外的优点。

教训

那么,你究竟是否应当使用export呢?如果使用的话,又当如何安全地使用它呢?呃……就目前的情况来看,只有小部分C++程序员会使用支持export的编译器来进行试验。对于绝大多数 C++程序员来说,关心是否使用export的问题是毫无意义的:他们手头没有相应的编译器,至少不会立即就有,因此他们无法使用它。

那如果你正在使用的恰恰是很有前途的、新式的、支持export的编译器,那又如何呢?啊哈,现在我们终于可以给出一个准则了。

准则 如果你想编写可移植的代码,那就别用export。

这似乎是不言而喻的,export当然不能用在可移植的代码当中,因为如今的编译器对它的支持太贫乏了,因此,目前任何使用了export的代码从实际而言都是不可移植的,这种状况得持续一段时间。

然而,如果你并不需要可移植的代码,再加上你手头恰好有一个支持export的编译器,而且你很想去使用它的话,那该怎么办呢?如果真是那样的话,就得注意以下几个方面:记住export仍然是处于试验阶段的特性,而且它并没有带来人们所预期的好处,反而给C++语言已有的其他特性带来了一些小问题。注意,编写导出模板的时候可能也会遇到一些棘手的问题,原因在这两条中和刚才的小结里面已经讲得很清楚了。

就目前而言,我能给出的最好的建议是:即便你现在只使用唯一一种编译器,而且它支持export,通常你也应当尽量避免在产品代码中使用export,因为这还是一个实验性的特性,让其他人冲锋陷阵去吧,因为我们将会在接下来的一两年中试验该特性,并弄清export真正能给我们带来什么。

准则(就目前而言)避免使用export。

然而,如果你下定决心要身先士卒的话,下面是一些你可以做的事情,能让你的试验更安全,也更轻松。

准则 如果你打算有选择性地针对某些模板使用export的话,确保做到以下几点。

●不要以为export可以让你不用发布源代码(或其等价形式)。那是个误会,而且这一点永远也不会改变。

●不要期望export会给代码的构建过程带来令人惊讶的速度提升。虽说最初的经验并不能说明什么,但你的构建时间反而可能会变得更长。

●确信你手头的工具和环境可以应付新的构建要求和依赖性(例如,如果你的export实现是让连接器去改变它所接收到的.obj/.o文件的话,那么得确保你所有的工具都能够理解这一点)。

如果你的导出模板使用了任何来自匿名名字空间中的函数或对象,或者使用了任何文件范围内的static函数或对象的话,确保做到以下几点。

●必须认识到,这些函数/对象的行为就好像它们是被声明为extern似的,如果是个函数的话,它就有可能参与到来自任意数目的源文件中的其他匿名名字空间中的任意数目的函数重载决议当中去。

●因此你应当总是将这些函数的名字加上额外的修饰(丑化,例如加上类似“xxx_”这样的前缀)以防它们无意中遭受语义改变。(这个结果令人遗憾,因为匿名名字空间和文件范围的static修饰的目的正是为了防止这种情况的发生,然而只要你使用了export,就很容易失去这种保护,因而你不得不重新将它们的名字修饰一番)。

认识到上面这些并非全部,你或许还会遇到一些其他的问题,而这些问题很可能超出了我们在通常的模板使用中积累的经验和认识。正如Spicer指出的:“很难单凭一些简单的准则就能够使我们免于麻烦。”要深刻认识到export某种程度上仍然还是一个试验性的特性,而且C++界目前尚没有机会来学习如何去使用export,因此目前我们尚未能够列出一些让你可安全使用export的良好原则。在不久的将来这种状况或许会有所改观。

现在断言说“避免export”准则会永远正确还为时过早。时间和试验会说明一切。在接下来的几年中,随着编译器厂商逐渐(缓慢地)开始接受export并支持这一特性,我们将会逐渐了解到何时以及如何使用它,或者干脆不使用它。

[1].乔治·奥威尔(1903—1950),英国著名作家,作品有《动物庄园》和《一九八四》。他的作品中创造的“冷战”、“老大哥”等词和许多名言,已成为英语中的日常语汇。——编者注

[2].一个初学者易犯的错误就是依赖于宽度指示,这里是4,这种做法是不行的,因为宽度指示只表示最小宽度,不表示最大宽度。

[3].注意,在某些情况下,至少理论上你可以缓解缓冲区长度问题,做法是在运行时创建自己的格式。之所以说“理论上”,是因为这种做法通常是不现实的,代码总是显得晦涩而脆弱。正如Bjarne Stroustrup在[Stroustrup99]中所提到的一个类似的情况:我并不认为将下面这个“专家级”的替代方案介绍给新手是个好主意:char fmt[10];// 创建一个格式字符串:简单地使用%s可能会导致缓冲区溢出sprintf(fmt,"%%%ds",max-1);// 至多将max-1个字符读入name中scanf(fmt,name);

[4].使用类似于lint的工具有助于捕获这类错误。

[5].这是一个实实在在的问题,而且不仅是 sprintf(),C 标准库当中的其他任何不进行(缓冲区)长度检查的调用都存在这个问题。试着用Google搜索strcpy和buffer overflow,你就会发现这一周来strcpy又闯了哪些祸。

[6].例如,以前有很长一段时间,许多恶意网站都流行一种做法,就是向访问者的浏览器发送非常长的URL字符串,长到大于浏览器的内部URL缓冲区,从而造成浏览器的缓冲区溢出,进而造成浏览器程序崩溃。那些不对URL串长度进行检查而是直接将它们复制到内部定长缓冲区中的浏览器,遇到这种恶意攻击的结果,就是改写超过缓冲区尾部之后的内存,通常这也就是覆盖原本的数据而已,不过在黑客精心安排下也可以达到改写代码段的目的,借机写入一些会执行的恶意代码。我们简直没法想象“古往今来”有多少软件在使用着不加检查的调用。

[7].从理论以及从实际上说,deprecated 究竟意味着什么呢?当用在标准当中时,带有 deprecated 标记的特性表示,标准委员会警告你该特性可能会在将来的任何时候消失,很可能就是在下一代标准颁布的时候。deprecate某个特性就意味着以一种标准化的方式来给予用户善意的劝阻,在不将某个特性立即移除的前提下,这种做法是标准委员会所能做出的最为有力的劝阻。而实际上,即便想要将那些最不被赞成使用的特性从标准中移除也是相当难的,因为一旦某个特性出现在了标准当中,人们就会编写出依赖于该特性的代码,而标准委员会里谁都不愿意破坏向后兼容性。因而即便某个特性被移除了,标准的实现商们通常仍然会继续提供它们,因为他们也不想破坏向后兼容性。因而通常来说标明为deprecated的特性从来也没有真正地从标准中除掉过。例如,Fortran标准中仍然残留着十余年前就标明为deprecated的特性。

[8].stringstream确实也提供了一个能够接受一个string&参数的构造函数,只不过它只是简单地对该string参数的内容进行复制,而不是直接将它当作工作区。

[9].在表3-5的性能测试中,strstream在两个平台(Borland C++ 5.5.1和Visual C++ 7)上皆出人意料地表现出了较差的性能。原因似乎是在这些实现上每次调用示例3-3的PrettyFormat()时不知何故总会进行一些内存分配(尽管就这两个平台而言,利用现有缓冲区(示例3-3)跟任由 strstream自己分配缓冲区相比仍然是前者进行的分配次数较少)。而在其他环境中,正如我们所预期的,这种做法没有任何内存分配。

[10].peekaboo的音译,peekaboo是一种类似于捉迷藏的游戏,这里被作者用来戏称那些可有可无的默认形参。——译者注

[11].就好像剃头只剃了一半一样,换句话说,swap()整体上却并不具有原子语义。——译者注

[12].即T的复制构造函数。——译者注

[13].并非“特化”,因为无法偏特化函数模板。见第7条更多关于函数模板和特化的讨论。

[14].即复制构造。——译者注

[15].通常就是你的类定义所位于的名字空间,这样Koenig查找(类型依赖的名字查找)才会查找到它。

[16].在标准的术语当中,全特化被称为“显式特化”。——译者注

[17].即模板偏序规则[C++03:14.5.5.2]。——译者注

[18].当然,并非只有这一种弥补手段,只不过其他手段都显得太笨拙了。例如,可以在boost名字空间中为想声明为友元的函数模板创建一个代理类,然后将这个代理类声明为类的友元。

[19].见www.comeaucomputing.com。——译者注

[20].见www.edg.com。——译者注

[21].另外还有一些等价的做法,例如将模板定义分离到一个单独的.cpp文件中,然后让该模板的.h头文件#include这个.cpp文件,这样做的效果也一样。

[22].注意,Cfront在大约十年之前就有了一些类似的功能。然而Cfront的实现太慢了,况且它还是基于“大部分时候能工作”的启发式方法来工作的:当Cfront用户在构建过程中碰到一些与模板有关的问题时,Cfront就会不管三七二十一先将模板实例化缓冲区全部清空,然后再重头开始实例化所有的东西。

[23].通常人们会问:“但难道我们不能发布加密后的源代码吗?”答案是任何仅由程序解密而无需用户介入(例如每次键入密码)的加密手段都是不牢靠的。同样,以前也有过一些公司出于各种各样的目的,如为了保护包含模式的C++模板代码,而尝试过“加密”或者混淆源代码这些手段,但最终这些努力基本都放弃了,因为这些做法在实际当中会给用户带来麻烦,而且也并不真的能很好地保护源代码,更何况源代码原本就几乎不需要这种保护,因为还有其他更好的方法来保护知识产权。

[24].当时争论相当激烈,双方持续拉锯似的难分高下。在1996年3月的会议上,投票结果是2比1偏向于反对方。而在同年7月的会议上,export关键字被引入了,结果投票最终又以2比1眷顾了export。

[25].例如,像C里面那样,加上类似“xxx_”的前缀。——译者注

[26].export的实现一般要求连接器(linker)能够回调编译器来进行实例化。这是因为在分离式编译的前提下,传统编译器是看不到其他编译单元的,而被导出的模板的定义体却位于其他编译单元之中,因此,编译器在编译那些使用了被导出模板的客户代码时,并不即刻进行实例化(因为编译器的视野并不能“穿透”编译单元,即编译器这时并不能看到被导出模板的定义,只能看到其头文件中的声明,因而无法进行实例化),而只是创建一个符号提示(hint),让连接器在连接期能够知道哪些符号是待决议的,从而回调编译器去完成相应的实例化。——译者注

相关图书

代码审计——C/C++实践
代码审计——C/C++实践
CMake构建实战:项目开发卷
CMake构建实战:项目开发卷
C++ Templates(第2版)中文版
C++ Templates(第2版)中文版
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
Qt 6 C++开发指南
Qt 6 C++开发指南

相关文章

相关课程