C++编程规范:101条规则、准则与最佳实践

978-7-115-35135-7
作者: 【加】Herb Sutter 【罗】Andrei
译者: 刘基诚
编辑: 傅道坤
分类: C++

图书目录:

详情

在本书中,两位知名的C++专家将全球C++界20年的集体智慧和经验凝结成一套编程规范。这些规范可以作为每一个开发团队制定实际开发规范的基础,更是每一位C++程序员应该遵循的行事准则。书中对每一条规范都给出了精确的描述,并辅以实例说明;从类型定义到错误处理,都给出了最佳的C++实践,即使使用C++多年的程序员也会从本书中受益匪浅。

图书摘要

PEARSON

C++编程规范101条规则、准则与最佳实践

C++Coding Standards:101 Rules,Guidelines,and Best Practices

[加]Herb Sutter [罗]Andrei Alexandrescu 著

刘基诚 译

人民邮电出版社

北京

图书在版编目(CIP)数据

C++编程规范:101条规则、准则与最佳实践/(加)萨特(Sutter,H.),(罗)安德烈亚历克斯安德莱斯库(Alexandrescu,A.)著;刘基诚译.--北京:人民邮电出版社,2016.3

ISBN 978-7-115-35135-7

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

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

版权声明

Authorized translation from the English language edition,entitled C++Coding Standards:101 Rules,Guidelines,and Best Practices,1st Edition 0321113586 by Herb Sutter and Andrei Alexandrescu,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 [罗]Andrei Alexandrescu

译 刘基诚

责任编辑 傅道坤

责任印制 张佳莹 焦志炜

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

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

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

北京艺辉印刷有限公司印刷

◆开本:800×1000 1/16

印张:14.25

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

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

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

定价:39.00元

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

反盗版热线:(010)81055315

内容提要

在本书中,两位知名的C++专家将全球C++界20年的集体智慧和经验凝结成一套编程规范。这些规范可以作为每一个开发团队制定实际开发规范的基础,更是每一位C++程序员应该遵循的行事准则。书中对每一条规范都给出了精确的描述,并辅以实例说明;从类型定义到错误处理,都给出了最佳的C++实践,即使使用C++多年的程序员也会从本书中受益匪浅。

本书适合于各层次C++程序员使用,也可作为高等院校C++课程的教学参考书。

前言

尽早进入正轨:以同样的方式实施同样的过程。不断积累惯用法。

将其标准化。如此,你与莎士比亚之间的唯一区别将只是掌握

惯用法的多少,而非词汇的多少。

——Alan Perlis[1]

标准最大的优点在于,它提供了如此多样的选择。

——出处尚无定论

我们之所以编写本书,作为各开发团队编程规范的基础,有下面两个主要原因。

编程规范应该反映业界最久经考验的经验。它应该包含凝聚了经验和对语言的深刻理解的公认的惯用法。具体而言,编程规范应该牢固地建立在大量丰富的软件开发文献的基础之上,把散布在各种来源的规则、准则和最佳实践汇集在一起。

不可能存在真空状态。通常,如果你不能有意识地制定合理的规则,那么就会有其他人推行他们自己喜欢的规则集。这样产生的编程规范往往具有各种最不应该出现的属性。

例如,许多这样的编程规范都试图强制尽量少地按C语言的方式使用C++。

许多糟糕的编程规范都是由一些没有很好地理解语言、没有很好地理解软件开发或者试图标准化过多东西的人制定的。糟糕的编程规范会很快丧失可信度,如果程序员不喜欢或者不同意其中一些糟糕的准则,那么即使规范中有一些合理的准则,也可能被不抱幻想的程序员所忽略,这还是最好的情况,最坏的情况下,糟糕的标准可能真会被强制执行。

如何使用本书

三思而行。应该遵循好的准则,但是不要盲从。在本书的各准则中,请注意“例外情况”部分阐明了该准则可能不适用的不太常见的情况。任何准则,无论如何正确(当然,我们自认为本书中的准则是正确的),都不能代替自己的思考。

每个开发团队都应该制定自己的标准,制定标准的时候都应该尽职尽责。这项工作是整个团队的事情。如果你是团队负责人,应该让团队成员都参与制定标准。人们当然更愿意遵守“自己的”标准,而非别人强加的一堆规矩。

编写本书的目的是为各开发团队提供编程规范的基础和参考。它并不是要成为终极编程规范,因为不同的团队会有适合特定群体或者特定任务的更多准则,应该大胆地将这些准则加入本书的条款中。但是我们希望本书能够通过记载和引用广泛接受的、权威的、几乎可以通用的(“例外情况”指出的除外)实践经验,减少读者制定或重新制定自己的编程规范的工作量,从而帮助提高读者所用编程规范的质量和一致性。

让团队人员阅读这些准则及其原理阐释(也就是本书全文,根据需要还包括所选条款引用的其他书籍和论文),共同决定是否有团队根本无法接受的内容(比如,由于某些项目特殊的情况),然后实践其余规范。一旦采纳,如果未与整个团队协商,任何人不得违反团队编程规范。

最后,团队还需定期复查这些准则,加入实际应用中得出的经验和反馈。

编程规范与人的关系

好的编程规范能够带来下列许多相互关联的优点。

改善代码质量。鼓励开发人员一贯地正确行事,从而能够直接提高软件的质量和可维护性。

提高开发速度。开发人员不需要总是从一些基本原则出发进行决策。

增进团队精神。有助于减少在一些小事上不必要的争论,使团队成员更容易阅读和维护其他成员的代码。

在正确的方向上取得一致。使开发人员放开手脚,在有意义的方向上发挥创造性。

在压力和时间的要求下,人们将按所受到的训练行事。他们会求助于习惯。这正是医院的急诊室之所以要雇佣有经验的、训练有素的人员的原因所在,知识再渊博的新手到时候也会手足无措。

作为软件开发人员,我们总是面临着工期压缩的巨大的压力。在进度压力下,我们按所受过的训练和习惯工作。平时不知道软件工程良好实践的(或者不习惯应用这些实践的)马虎程序员,在压力下将编写出更加马虎、错误更多的代码。相反,养成良好习惯并经常按此工作的程序员将保持自己的组织性,快速提交高质量的代码。

本书所介绍的编程规范是集编写高质量C++代码准则之大成。它们是C++社区丰富集体经验的精华总结。这一知识体系中的大量内容,此前要么只能零零碎碎地从不同的书中找到,要么需要依靠口口相传。编写本书的目的就是要将这些知识收集起来,汇成一组简练合理、易于理解、容易实施的规则。

当然,即使有最佳编程规范,还是有人会编写出糟糕的代码。任何语言、过程或者方法皆然。但是,好的编程规范集能够培养超越规则本身的良好习惯和纪律。这种基础一旦打好,就将打开通往更高层次的大门。这里没有捷径可走:在学会写诗之前必须首先扩大词汇量,熟悉语法。我们只希望本书能够对读者所经历的这一过程有所裨益。

我们希望本书适用于各种层次的C++程序员。

如果你是初级程序员,我们希望你能够发现,这些规则及其原理阐释有助于理解C++语言对哪些风格和惯用法的支持最为自然。我们为每一规则和准则都提供了简洁的原理阐释和讨论,这是为了鼓励你重在理解,而不是死记硬背。

对于中级或者高级程序员,我们下了很大功夫为每一规则都提供了详细的准确引用列表。这样,你就能够对规则在C++类型系统、语法和对象模型中的来历作进一步的研究。

总而言之,你很可能工作在一个复杂项目的团队中。这正是编程规范的用武之地——你可以使用这些规范将团队统一提高到同一层次,并为代码审查打下基础。

关于本书

我们为本书制定了以下设计目标。

一寸短,一寸强。篇幅极长的编程规范很难被人接受,而短小精悍的才会有人阅读和使用。同样,长的条款容易被人忽视,而短的条款才会被阅读和使用。

每个条款都必须是无可争议的。本书的目的是为了记载已达成广泛共识的标准,而不是凭空发明一些规范。如果有什么准则并非在所有情况下都适用,我们会明确指出(比如用“考虑……”而不是“应该……”来陈述),并提供普遍接受的例外情况。

每个条款都必须具有权威性。本书中的准则均引用已出版著作作为支持。编写本书的目的也包括提供C++文献的索引。

每个条款都必须有阐述的价值。我们不为肯定会做的事情(比如编译器已经强制要求或者检查的事情)或者其他条款已经涵盖的事情定义准则。

例如,“不要返回自动变量的指针/引用”是一个不错的准则,但是我们没有将它放在本书中,因为我们测试过的所有编译器都会对此发出警告,所以这一问题已经涵盖在更广的第1条“在高警告级别干净利落地进行编译”中了。

例如,“使用编辑器(或者编译器,或者调试器)”是一个不错的准则,但是你当然会使用这些工具,这是不言自明的。但前四个条款中有两个是关于其他工具的:“使用自动构建系统”和“使用版本控制系统”。

例如,“不要滥用 goto 语句”是一个很好的条款,但是根据我们的经验,程序员普遍都知道这一点,对此毋庸多言。

每个条款都遵照下面的格式。

条款标题:最简单而又意味深长的“原音重现”,有助于更好地记忆规则。

摘要:最核心的要点,简要陈述。

讨论:对准则的展开说明。通常包括对原理的简要阐释,但是请记住,原理阐释的主要部分都有意识地留在参考文献中了。

示例(可选):说明规则或者有助于记忆规则的实例。

例外情况(可选):规则不适用的任何(通常是比较罕见的)情况。但是要小心,不要掉入过于匆忙而不加思考的陷阱:“噢,我很特殊,所以这条规则对我的情况并不适用。”这种推理很常见,但是通常都是错的。

参考文献:可以参考其中提到的C++文献的章节,进而获得完整的细节和分析。

每一部分中,我们都会选择推荐一个“最有价值条款”。通常,它是该部分中的第一个条款,因为我们尽量将重要的条款放在每一部分的前面。但是有时候,出于连贯和可读性的考虑,我们不能将重要的条款前置,因此需要采取这样的办法突出它们,以引起特别注意。

致谢

非常感谢丛书 [2]主编Bjarne Stroustrup、本书的编辑Peter Gordon 和 Debbie Lafferty,还有Tyrrell Albaugh、Kim Boedigheimer、John Fuller、Bernard Gaffney、Curt Johnson、Chanda Leary-Coutu、Charles Leddy、Heather Mullane、Chuti Prasertsith、Lara Wysong,以及Addison-Wesley团队的其他成员,感谢他们在本书写作过程中给予的协助和坚持。能和他们共事很荣幸。

各条款标题中“原音重现”的灵感有许多来源,包括[Cline99]的幽默风格和[Peters99]中经典的“import this”,还有具传奇色彩的Alan Perlis和他被广为引用的格言。

我们特别想感谢在本书技术审阅方面做出贡献的人,他们的工作使本书许多部分增色不少。从选题开始直到最终成稿,丛书主编Bjarne Stroustrup所提出的尖锐而又透彻的意见对本书影响至深。我们要特别感谢Dave Abrahams、Marshall Cline、Kevlin Henney、Howard Hinnant、Jim Hyslop、Nicolai Josuttis、Jon Kalb、Max Khesin、Stan Lippman、Scott Meyers和Daveed Vandevoorde积极参与审阅,并对原稿多个版本的草稿都提出了详细的意见。其他有价值的意见和反馈要归功于Chuck Allison、Samir Bajaj、Marc Barbour、Damian Dechev、Steve Dewhurst、Peter Dimov、Alan Griffiths、Michi Henning、James Kanze、Matt Marcus、Petru Marginean、Robert C.“Uncle Bob”Martin、Jeff Peil、Peter Pirkelbauer、Vladimir Prus、Dan Saks、Luke Wagner、Matthew Wilson和Leor Zolman。

与往常一样,书中仍然会存在错误、疏漏和含混之处,对此作者负全责。

Herb Sutter

Andrei Alexandrescu

2004年9月

于美国华盛顿州西雅图市

[1.Alan Perlis(1922—1990)“因为在高级程序设计技术和编译器构造领域的影响”而获得1966年首届图灵奖。他是影响深远的计算机科学先驱之一,曾担任 ALGOL 语言设计委员会主席,倡议创办了Communications of ACM杂志并担任首任主编,对计算机成为独立学科起到了关键性的作用。1982年他在SIGPLAN杂志上发表的“Epigrams in Programming”(编程格言)一文,由130条格言组成,凝练隽永,多年来一直被广泛引用,本书亦然。——译者注

[2].指Addison-Wesley公司出版的著名的C++In-Depth丛书,包括Essential C++、Accelerated C++、Exceptional C++、Modern C++ Design等,这些书多已在国内引进出版。——译者注

设计风格

复杂性啊,愚人对你视而不见,实干家受你所累。有些人避而远之。惟智者能够善加消除。

——Alan Perlis

我知道,但是却又忘记了Hoare的至理名言:不成熟的优化是程序设计中的万恶之源。

——Donald Knuth[1]

The Errors of TeX[Knuth89]

完全区分设计风格与编码风格是非常困难的。我们将一般在实际编写代码时才用得到的条款留到下一部分介绍。

本部分集中讨论适用面比一个特定的类或者函数更广的原则和实践。比较典型的包括:简单和清晰之间的平衡(第6条),避免不成熟的优化(第8条),避免不成熟的劣化(第9条)。这三个条款不仅适用于函数编写的层次,而且适用于类和模块设计权衡的更大范围,适用于更深的应用程序架构决策。(它们也适用于所有程序员。如果你不以为然,请重读上面Knuth的话,注意其中的引用部分。)

紧接其后,本部分和下一部分的其他条款讨论的都是依赖性管理的各个方面。依赖性管理是软件工程的一个基础,也是贯穿本书不断出现的主题。停下来,任意选择一个优秀的软件工程技术(任何好的技术都行),思考一下。无论选择哪一个,都将发现,它都是在想尽办法减少依赖性。继承?是为了使所编写的代码使用不依赖于实际派生类的基类。尽量减少全局变量?是为了减少因可见范围太大的数据所产生的远距离依赖。抽象?是为了消除处理概念的代码和实现它们的代码之间的依赖。信息隐藏?是为了使客户代码不依赖实体的实现细节。依赖性管理的一个相关问题还反映在避免使用共享状态(第10条)中,反映在应用信息隐藏(第11条),以及更多的其他条款中。

本部分中我们选出的最有价值条款是第6条:正确、简单和清晰第一。因为这些要求真地太必需了。

第5条 一个实体应该只有一个紧凑的职责

摘要

一次只解决一个问题:只给一个实体(变量、类、函数、名字空间、模块和库)赋予一个定义良好的职责。随着实体变大,其职责范围自然也会扩大,但是职责不应该发散。

讨论

人们常说,好的商业理念能够一言以敝之。同样,每个程序实体也应该只有一个明确的目的。

如果一个实体有几个不同的目的,那么其使用难度往往会激增,因为这种实体除了会增加理解难度、复杂性和各部分中的错误外,还会导致其他问题。这种实体不仅更大(常常毫无合理理由),而且更难以使用和维护。此外,这种实体经常会为自身的一些特定用途提供有问题的接口,因为各个功能领域之间的部分重叠,会影响干净利落地实现每个功能所需的洞察力。

具有多个不同职责的实体通常都是难于设计和实现的。“多个职责”经常意味着“多重性格”——可能的行为和状态的各种组合方式。应该选择目的单一的函数(见第39条),小而且目的单一的类,以及边界清晰的紧凑模块。

应该用较小的低层抽象构建更高层次的抽象。要避免将几个低层抽象集合成一个较大的低层次抽象聚合体。用几个简单的行为来实现一个复杂的行为,比反其道而行之更加容易。

示例

例1 realloc。在标准C语言中,realloc是一个臭名昭著的不良设计。这个函数承担了太多的任务:如果传入的指针参数为NULL就分配内存空间,如果传入的大小参数为0就释放内存空间,如果可行则就地重新分配,如果不行则移到其他地方分配。这个函数不易于扩展,普遍认为它是一个目光短浅的失败设计。

例2 basic_string。在标准C++语言中,std:: basic_string是另一个臭名昭著的不良设计——巨大的类设计。在一个臃肿的类中添加了太多“多多益善”的功能,而这只是为了试图成为容器但却没有做到,在用迭代还是索引上犹豫不决,还毫无道理地重复了许多标准算法,而为扩展所留的裕度又很小(见第44条的示例)。

参考文献

[Henney02a]·[Henney02b]·[McConnell93]§10.5·[Stroustrup00]§3.8, §4.9.4, §23.4.3.1·[Sutter00]§10, §12, §19, §23·[Sutter02]§1·[Sutter04]§37-40

第6条 正确、简单和清晰第一

摘要

软件简单为美(Keep It Simple Software,KISS):质量优于速度,简单优于复杂,清晰优于机巧,安全优于不安全(见第83条和第99条)。

讨论

简单设计和清晰代码的价值怎么强调都不过分。代码的维护者将因为你编写的代码容易理解而感谢你——而且这个维护者往往就是未来的你,要努力回忆起6个月前的所思所想。于是有了下面这些经典的格言警句。

程序必须为阅读它的人而编写,只是顺便用于机器执行。——Harold Abelson 和 Gerald Jay Sussman

编写程序应该以人为本,计算机第二。——Steve McConnell

计算机系统中最便宜、最快速、最可靠的组件还不曾出现过。——Gordon Bell[2]

所缺乏的恰恰是最精确(永不出错),最安全(坚不可摧),以及设计、文档编写、测试和维护起来最容易的部分。简单设计的重要性怎么强调也不过分。——Jon Bentley

本书中的许多条款都能够自然地产生易于修改的设计和代码,而清晰性是易于维护、易于重构的程序最必需的特征。自己不能充分理解的设计和代码,就更无法充满自信地进行修改了。

这里最常见的紧张关系恐怕就在代码清晰和代码优化(见第7条、第8条和第9条)之间。当(不是假如)你想为了性能而进行不成熟的优化因而影响了清晰性时,请回想一下第8条的要点:使一个正确的程序变快,比使一个快速的程序正确要容易得多。

要避免使用程序设计语言中的冷僻特性。应该使用最简单的有效技术。

示例

例 1 不要使用不必要的或者小聪明式的操作符重载。有一个毫无必要古怪的图形用户界面库,竟然允许用户编写w+c;这样的语句表示在图形组件w上添加子控件c。(见第 26条。)

例2 应该使用命名变量,而不要使用临时变量,作为构造函数的参数。这能够避免可能的声明二义性。这还经常能使代码的意图更加清晰,从而更容易维护,而且通常也更安全(见第13条和第31条)。

参考文献

[Abelson96]·[Bentley00]§4·[Cargill92]pp.91-93·[Cline99]§3.05-06·[Constantine95]§29·[Keffer95]p.17·[Lakos96]§9.1, §10.2.4·[McConnell93]·[Meyers01]§47·[Stroustrup00]§1.7, §2.1, §6.2.3, §23.4.2, §23.4.3.2·[Sutter00]§40-41, §46·[Sutter04]§29

第7条 编程中应知道何时和如何考虑可伸缩性

摘要

小心数据的爆炸性增长:不要进行不成熟的优化,但是要密切关注渐近复杂性。处理用户数据的算法应该能够预测所处理的数据量耗费的时间,最好不差于线性关系。如果能够证明优化必要而且非常重要,尤其在数据量逐渐增长的情况下,那么应该集中精力改善算法的O(N)复杂性,而不是进行小型的优化,比如节省一个多余的加法运算。

讨论

本条款阐述了第8条“不要进行不成熟的优化”和第9条“不要进行不成熟的劣化”之间的一个重要的平衡点。所以,这个条款非常难写,不小心就可能将其错误地解释成“不成熟的优化”了。请注意,我们绝不是这个意思。

这一问题的背景和缘起是这样的:内存和硬盘空间一直在以指数速度增长。例如,从1988年到2004年,硬盘空间每年增长112%(差不多每10年增长1900倍),然而即使是摩尔定律也不过是每年增长59%(每10年100倍)。这种现象所导致的一个显然的结果就是,无论今天你的代码如何,明天它都会被要求处理更多的数据——多得多的数据。一个算法如果具有恶性(差于线性)的渐近行为,那么再强大的系统也迟早会在其面前臣服:只需扔给它足够的数据就行了。

防范可能的未来,也就是说我们要避免设计中含有面对更大的文件、更大的数据库、更多像素、更多窗口、更多进程和更多线路上传输的数据时会出现的性能陷阱的现象。C++标准库能够成功防范未来的重大因素之一,就是它已经保证了STL容器操作和算法的性能复杂性。

如何取得平衡呢?使用不够清晰的算法,为永远都不会成为现实的大数据量做好准备,这样的不成熟的优化显然是错误的。但是,对算法复杂性——O(N)复杂性,即计算的代价是所处理数据的元素量的函数故意视而不见,这样的不成熟劣化显然也同样是错误的。

这一问题的建议可以分为两部分。首先,即使不知道数据量是否会大到成为某个特定计算的问题,默认情况下也应该避免使用不能很好地应付用户数据量(可能增加)的算法,除非这种伸缩性不好的算法有明显的清晰性和可读性方面的好处(见第6条)。在这方面,我们遇到的意外情况简直是太多了:编写10段代码,满以为它们永远不会处理巨量的数据集合,而且对于其中的9段代码而言,情况也确实如此,但是第10段代码就让我们遇到了性能陷阱——我们都碰到过这种情况,而且我们知道你们也都会碰到,也许已经碰到了。当然,我们可以进行修补,然后给客户发布补丁,但最好还是能避免这样的尴尬和返工。既然所有事物都是平等的(包括清晰性与可读性),那么应该预先做这些事情。

使用灵活的、动态分配的数据,不要使用固定大小的数组。那种“比我所需要的最大数组还要大”的数组,在正确性和安全性方面都存在严重问题(见第 77条)。只有在编译时大小固定不变的数组才是可接受的。

了解算法的实际复杂性。要留心那些不易发觉的陷阱,比如看似线性的算法实际上要调用其他线性操作,结果算法实际上是二次的。(见第81条中的例子。)

优先使用线性算法或者尽可能快的算法。常数时间复杂性的算法,比如push_back和散列表查询,是最完美的(见第76条和第80条)。O(logN)对数复杂性的算法,比如set/map操作和带有随机迭代器的lower_bound和upper_bound,也不错(见第76条、第85条和第86条)。O(N)线性复杂性的算法,比如vector::insert和for_each,也可以接受(见第76条、第81条和第84条)。

尽可能避免劣于线性复杂性的算法。例如,如果面对的是一个O(NlogN)或者O(N2)算法,就必须花费精力寻找替代方案,这样代码才不至于在数据量显著增长的情况下陷入深度激增的性能深潭。例如,这是在第81条中建议使用范围成员函数(通常是线性的)而不是反复调用单元素替代函数的主要原因(后者会很容易在一个线性操作要调用另一个线性操作时变成二次复杂性的,见第81条中的例1)。

永远不要使用指数复杂性的算法,除非你已经山穷水尽,确实别无选择。在决定接受指数算法之前,必须尽力寻找替代方案,因为对于指数算法来说,即使是数据量的有限增加,也会使算法的性能急剧下降。

其次,如果有测试数据表明优化非常必要而且重要,尤其是在数据量不断增加的情况下,那么应该集中精力改善O(N)复杂性,而不是把精力花在节省一个多余加法这样的微观优化上。

总而言之,要尽可能优先使用线性(或者更好的)算法。尽可能合理地避免使用比线性算法差的多项式算法。竭尽全力避免使用指数算法。

参考文献

[Bentley00]§6,§8, Appendix 4·[Cormen01]·[Kernighan99]§7·[Knuth97a]·[Knuth97b]·[Knuth98]·[McConnell93]§5.1-4, §10.6·[Murray93]§9.11·[Sedgewick98]·[Stroustrup00]§17.1.2

第8条 不要进行不成熟的优化

摘要

拉丁谚语云,快马无需鞭策:不成熟优化的诱惑非常大,而它的无效性也同样严重。优化的第一原则就是:不要优化。优化的第二原则(仅适用于专家)是:还是不要优化。再三测试,而后优化。

讨论

正如[Stroustrup00]§6开始所引用的优美名言说的那样:

不成熟的优化是万恶之源。——Donald Knuth (引用Hoare的话)

另一方面,我们不能忽视效率。——Jon Bentley

Hoare和Knuth当然而且永远是完全正确的(见第6条和本条)。Bentley亦然(见第9条)。

我们将不成熟的优化定义为这样的行为:以性能为名,使设计或代码更加复杂,从而导致可读性更差,但是并没有经过验证的性能需求(比如实际的度量数据和与目标的比较结果)作为正当理由,因此本质上对程序没有真正的好处。毫无必要而且无法度量的优化行为其实根本不能使程序运行得更快,这种情况简直是太常见了。

请永远记住:

让一个正确的程序更快速,比让一个快速的程序正确,要容易得太多、太多。

因此,默认时,不要把注意力集中在如何使代码更快上;首先关注的应该是使代码尽可能地清晰和易读(见第6条)。清晰的代码更容易正确编写,更容易理解,更容易重构——当然也更容易优化。使事情复杂的行为,包括优化,总是以后再进行的——而且只在必要的时候进行。

不成熟的优化经常并不能使程序更快,这主要有两方面原因。一方面,我们程序员在估计哪些代码应该更快或者更小,以及代码中哪里会成为瓶颈上名声很臭。包括本书的作者,也包括读者你。考虑一下这些事实吧:现代计算机都具有极为复杂的计算模型,经常是几个流水线处理单元并行工作,深高速缓存层次结构,猜测执行(speculative execution)[3],分支预测……这还只是CPU芯片。在硬件之上,编译器也在尽其所能地猜测,将源代码转换为最能发掘硬件潜力的机器码。而在这些复杂的架构之上,还有……还有你——程序员的猜测。所以,如果只是猜测的话,你的那些目标不明确的微观优化就很难有机会显著地改善代码。因此,优化之前必须进行度量;而度量之前必须确定优化的目标。在需求得到验证之前,注意力应该放在头号优先的事情上——为人编写代码。(当有什么人要求你进行优化的时候,请进行需求验证。)

另一方面,在现代程序中,许多操作越来越不受 CPU 的限制。它们可能更受内存的限制、网络的限制、硬盘的限制,需要等待 Web Service,或等待数据库。即使在最好的情况下,优化这些操作的应用程序代码,也只不过能使等待操作更快。这也意味着程序员浪费了宝贵的时间去改善没有必要改善的地方,却没有进行需要的有价值的改善。

当然,迟早有一天需要优化某些代码。到那时,首先要考虑算法优化(见第7条),并尝试将优化封装和模块化(比如,用一个函数或者类,见第5条和第11条),然后在注释中清楚地说明优化的原因并列出所用算法作为参考。

初学者常犯的一个错误,就是编写新代码时着迷于进行过度优化(而且充满自信),却牺牲了代码的可理解性。这常常会产生大杂烩代码,这种代码即使开始时是正确的,也非常难以阅读和修改。(见第6条。)

通过引用传递(见第25条),优先调用前缀形式的++和--(见第28条),和使用很自然地从指尖流出的惯用法,都不属于不成熟的优化。这些都不是不成熟的优化,而是在避免不成熟的劣化(见第9条)。

示例

例 inline悖论。这个例子简单阐述了不成熟的微观优化所带来的隐性代价。分析器(profiler)能够通过函数的命中计数出色地告诉我们哪些函数应该但是没有标记为 inline;然而,分析器在寻找哪些函数已经标记为inline但是不应该标记方面,却极不擅长。太多的程序员习惯以优化的名义“将 inline 作为默认选择”,这几乎总是以更高的耦合性为代价,而换来的好处到底如何却很可疑。(这里有一个前提,编写inline在所用的编译器上确实起作用。参阅[Sutter00]、[Sutter02]和[Sutter04]。)

例外情况

在编写程序库的时候,预测哪些操作最后会用于性能敏感的代码中更加困难。但即使是程序库的编写者,在实施容易令人糊涂的优化之前,也会对很大范围内的客户代码进行性能测试。

参考文献

[Bentley00]§6·[Cline99]§13.01-09·[Kernighan99]§7·[Lakos96]§9.1.14·[Meyers97]§33·[Murray93]§9.9-10, §9.13·[Stroustrup00]§6 introduction·[Sutter00]§30, §46·[Sutter02]§12·[Sutter04]§25

第9条 不要进行不成熟的劣化

摘要

放松自己,轻松编程:在所有其他事情特别是代码复杂性和可读性都相同的情况下,一些高效的设计模式和编程惯用法会从你的指尖自然流出,而且不会比悲观的替代方案更难写。这并不是不成熟的优化,而是避免不必要的劣化(pessimization)。

讨论

避免不成熟的优化并不意味着必然损害性能。所谓不成熟的劣化,指的就是编写如下这些没有必要的、可能比较低效的程序。

在可以通过引用传递的时候,却定义了通过值传递的参数(见第25条)。

在使用前缀 ++ 操作符很合适的场合,却使用后缀版本(见第28条)。

在构造函数中使用赋值操作而不是初始化列表(见第48条)。

如果减少对象的伪临时副本(尤其是在内循环中)并不影响代码的复杂性,那么这个优化就算不上是不成熟的优化。在第18条中,我们提倡尽可能将变量声明为局部的,但是又提到了一个例外情况,即有时候将变量从循环中提出来是有好处的。大多数时候,这一点也不会混淆代码的意图,相反,实际上这有助于澄清循环内部执行了哪些功能,哪些计算是不随循环变化的。当然,应该优先使用算法,而不是显式的循环(见第84条)。

构造既清晰又有效的程序有两种重要的方式:使用抽象(见第11条和第36条)和库(见第84条)。例如,使用标准库的 vector、list、map、find、sort 和其他设施,这些都是由世界级的专家标准化并实现的,不仅能使你的代码更加清晰,更容易理解,而且启动也经常更快。

避免不成熟的劣化在编写库的时候尤其重要。要了解库所使用的所有上下文,通常是不可能的,因此可能需要达到一种平衡,在更加倾向效率和可复用性的同时,又不能因为一小部分潜在的调用者的利益过分提高效率。其中的界限需要你来划定,但是正如第7条所说明的,更需要关注的是可伸缩性,而不是挤掉一个小小的循环。

参考文献

[Keffer95]pp.12-13·[Stroustrup00]§6 introduction·[Sutter00]§6

第10条 尽量减少全局和共享数据

摘要

共享会导致冲突:避免共享数据,尤其是全局数据。共享数据会增加耦合度,从而降低可维护性,通常还会降低性能。

讨论

这里的论述比第18条的具体讨论更加通用。

避免使用名字空间作用域中具有外部连接的数据或者作为静态类成员的数据。这些数据会使程序逻辑变得更加复杂,使程序不同的(而且可能更糟,距离较远的)部分耦合得更加紧密。共享数据对单元测试会产生不良影响,因为使用共享数据的代码片断的正确性不仅取决于数据变化的过程,更取决于以后会使用该数据的未知代码区域的机能。

全局名字空间中的对象名称还会污染全局名字空间。

如果必须使用全局的、名字空间作用域的或者静态的类对象,一定要仔细地对其进行初始化。在不同编译单位中这种对象的初始化顺序是未定义的,正确处理它们需要特殊的技术(参阅本条的参考文献)。初始化顺序规则是非常难于掌握的,应该尽量避免使用;如果不得不用,应该充分了解,谨慎使用。

名字空间作用域中的对象、静态成员对象或者跨线程或跨进程共享的对象会减少多线程和多处理器环境中的并行性,往往是产生性能和可伸缩性瓶颈的原因(见第7条)。为“无共享”而奋斗吧,用通信方式(比如消息队列)代替数据共享。

应该尽量降低类之间的耦合,尽量减少交互(参阅[Cargill92])。

例外情况

程序范围的设施cin、cout和cerr比较特殊,其实现方式很特别。工厂类必须维护一个注册表,记录创建给定类型时要调用哪个函数,而且通常应该有一个用于整个程序的注册表(但最好是属于工厂类,而不是属于共享全局对象,见第11条)。

跨线程共享对象的代码应该总是将对这些共享对象的所有访问序列化(见第 12条并参阅[Sutter04c])。

参考文献

[Cargill92]pp.126.136,169-173·[Dewhurst03]§3·[Lakos96]§2.3.1·[McConnell93]§5.1-4·[Stroustrup00]§C.10.1·[Sutter00]§47·[Sutter02]§16, Appendix A·[Sutter04c]·[SuttHysl03]

第11条 隐藏信息

摘要

不要泄密:不要公开提供抽象的实体的内部信息。

讨论

为了尽量减少操作抽象的调用代码和抽象的实现之间的依赖性,必须隐藏实现内部的数据。否则,调用代码就能够访问该信息,或者更糟,操作该信息,而原本应属于内部的信息就泄漏给了调用代码所依赖的抽象。应该公开抽象(如果有的话,还是公开领域抽象更好,但至少应该是get/set 抽象),而不是数据。

信息隐藏主要从下列两个方面降低了项目的成本,加快了项目的进度,减少了项目的风险。

它限制了变化的影响范围。信息隐藏缩小了变化所引起的“连锁反应”的范围,也降低了由此带来的成本。

它强化了不变式。它限制了负责维护(如果有错误的话,也可能是破坏)程序不变式的代码(见第41条)。

不要从任何提供抽象的实体中公开数据(另见第10条)。数据只是抽象、概念性状态的一种可能的具体化而已。如果将注意力集中在概念而不是其表示形式上,就能够提供富于提示性的接口,并按需要对实现进行调整——比如缓存还是实时地计算,又比如使用不同的表示方式,针对某种使用模式(如极坐标与笛卡儿坐标)进行优化。

绝对不要将类的数据成员设为public(见第41条),或者公开指向它们的指针或句柄(见第42条)而使其公开,这是一个很常见的信息隐藏的例子,但是它同样适用于更大的实体比如程序库——程序库同样不能暴露内部信息。模块和程序库同样应该提供定义抽象和其中信息流的接口,从而使与调用代码的通信比采用数据共享方式更安全,耦合度更低。

例外情况

测试代码经常需要对被测试类或者模块进行白箱访问。

值的聚合(“C语言式的struct”)只是简单地将数据绑在了一起,并没有提供任何抽象,所以它不需要隐藏数据,数据本身就是接口(见第41条)。

参考文献

[Brooks95]§19·[McConnell93]§6.2·[Parnas02]·[Stroustrup00]§24.4·[SuttHysl04a]

第12条 懂得何时和如何进行并发性编程

摘要

安线全程地[4]:如果应用程序使用了多个线程或者进程,应该知道如何尽量减少共享对象(见第10条),以及如何安全地共享必须共享的对象。

讨论

线程处理是一个大课题。之所以撰写本条,是因为这个课题很重要,需要明确地予以阐述,但是单凭一个条款显然无法做出公允的评价,所以我们只简单地概述几个要点。更多的细节和具体技术,参阅本条的参考文献。其中最重要的问题是避免死锁、活锁(livelock)[5]和恶性的竞争条件(包括加锁不足导致的崩溃)。

C++标准关于线程未置一词。然而,C++经常而且广泛地用于编写可靠的多线程代码。如果应用程序需要跨线程共享数据,请如下安全行事。

参考目标平台的文档,了解该平台的同步化原语。典型的原语包括从轻量级的原子整数操作到内存障栅(memory barrier)[6] 再到进程内和跨进程的互斥体。

最好将平台的原语用自己设计的抽象包装起来。在需要跨平台移植性的时候,这样做尤其有益。或者,也可以使用程序库(比如pthreads [Butenhof 97])为我们代劳。

确保正在使用的类型在多线程程序中使用是安全的。说得具体一些,就是类型必须至少做到以下两个方面。

保证非共享的对象独立。两个线程能够自由地使用不同的对象,无需调用者的任何特殊操作。

记载调用者在不同线程中使用该类型的同一个对象需要做什么。许多类型要求对这种共享对象进行串行访问,但是有些类型却不要求这样。后者通常要么从设计中去掉加锁需求,要么自己进行内部加锁,无论哪种情况,仍然需要留意内部加锁粒度的局限。

请注意,无论类型是字符串类型,还是STL 容器比如vector,或者任何其他类型,上面的原则都适用。(我们留意到有些书的作者曾经给出建议,暗示标准容器有特殊性。其实并非如此,容器也只不过是一种对象而已。)说得具体一些,如果要在多线程程序中使用标准库组件(例如 string,容器),如前所述,应该参考标准库实现的文档,了解是否支持多线程。

在自己编写可用于多线程程序的类型时,也必须完成两项任务。首先,必须保证不同线程能够不加锁地使用该类型的不同对象(注意:具有可修改的静态数据的类型通常不能保证这一点)。其次,必须在文档中说明使用者在不同线程中使用该类型的同一个对象需要做什么,基本的设计问题是如何在类及其客户之间分配正确执行(即无竞争和无死锁地执行)的职责。主要的选择有下列几个方面。

外部加锁:调用者负责加锁。在这种选择下,由使用对象的代码负责了解是否跨线程共享了对象,如果是,还要负责串行化所有对该对象的使用。例如,字符串类型通常使用外部加锁(或者不变性,见第三种选择)。

内部加锁:每个对象将所有对自己的访问串行化,通常采用为每个公用成员函数加锁的方法来实现,这样调用者就可以不用串行化对象的使用了。例如,生产者/消费者队列通常使用内部加锁,因为它们存在的目的就是被跨线程共享,而且它们的接口就是为了在单独的成员函数调用(Push, Pop)期间能够进行适当的层次加锁而设计的。更一般的情况下,需要注意,只有在知道了以下两件事情之后这个选项才适用。

第一,必须事先知道该类型的对象几乎总是要被跨线程共享的,否则到头来只不过进行了无效加锁。请注意大多数类型都不会遇到这种情况,即使是在多线程处理分量很重的程序中,大多数对象也不会被跨线程共享(这是好现象,见第10条)。

第二,必须事先知道成员函数级加锁的粒度是合适的,而且能满足大多数调用者的需要。具体而言,类型接口的设计应该有利于粗粒度的、自给自足的操作。如果调用者总是需要对多个而不是一个操作加锁,那么就不能满足需要了,只能通过增加更多的(外部)锁,将单独加锁的函数组装成一个更大规模的已加锁工作单位。例如一个容器类型,如果它返回一个迭代器,则迭代器可能在用到之前就失效了;如果它提供 find 之类的能返回正确答案的成员算法,那么答案可能在用到之前就出错了;如果它的用户想要编写这样的代码:if( c.empty() ) c.push_back(x);,同样会出现问题。(更多的例子,参阅[Sutter02]。)在这些情况下,调用者需要进行外部加锁,以获得生存期能够跨越多个单独成员函数调用的锁,这样一来每个成员函数的内部加锁就毫无用武之地了。

因此,内部加锁是绑定于类型的公用接口的:在类型的各个单独操作本身都完整时,内部加锁才适用;换句话说,类型的抽象级别不仅提升了,而且表达和封装得更加精确了(比如,以生产者-消费者队列的形式,而不是普通的vector)。将多个原语操作结合起来,形成粒度更粗的公开操作,不仅可以确保函数调用有意义,而且可以确保调用简单。如果原语的结合是不能确定的,而且也无法将合理的使用场景集合集中到一个命名操作中,那么有两种选择:一是使用基于回调的模型(即让调用者调用一个单独的成员函数,但是以一个命令或者函数对象的形式传入它们想要执行的任务,见第87条到第89条);二是在接口中以某种方式暴露加锁。

不加锁的设计,包括不变性(只读对象):无需加锁。将类型设计得根本无需加锁是可能的(参阅本条的参考文献)。常见的例子是不变对象,它无需加锁,因为它从不发生变化。

例如,对于一个不变的字符串类型而言,字符串对象一旦创建就不会改变,每个字符串操作都会创建新的字符串。

请注意,调用代码应该不需要知道你的类型的实现细节(见第11条)。如果类型使用了底层数据共享技术[如写时复制(copy-on-write)],那么你就不需要为所有可能的线程安全性问题负责了,但是必须负责恢复“恰到好处的”线程安全,以确保调用代码在履行其通常职责时仍是正确的:类型必须能够尽可能地安全使用,如果它没有使用隐蔽的实现共享(见[Sutter04c])。前面已经提到,所有正确编写的类型都必须允许在不同线程中无需同步便可操作不同的可见对象。

如果编写的是一个将要广泛使用的程序库,那么尤其要考虑保证对象能够在前面叙述的多线程程序中安全使用,而且又不会增加单线程程序的开销。例如,如果你正在编写的程序库包含一个使用了写时复制的类型,并且因而必须至少进行某种内部加锁,那么最好安排加锁在程序库的单线程编译版本中消失[#ifdef和空操作(no-op)实现是常见的策略]。

在获取多个锁时,通过安排所有获取同样的锁的代码以相同的顺序获取锁,可以避免死锁情况的发生。(释放锁则可以按照任意顺序进行。)解决方案之一,是按内存地址的升序获取锁,地址恰好提供了一个方便、唯一而且是应用程序范围的排序。

参考文献

[Alexandrescu02a]·[Alexandrescu04]·[Butenhof97]·[Henney00]·[Henney01]·[Meyers04]·[Schmidt01]·[Stroustrup00]§14.9·[Sutter02]§16·[Sutter04c]

第13条 确保资源为对象所拥有。使用显式的RAII和智能指针

摘要

利器在手,不要再徒手为之:C++的“资源获取即初始化”(Resource Acquisition Is Initialization,RAII)惯用法是正确处理资源的利器。RAII 使编译器能够提供强大且自动的保证,这在其他语言中可是需要脆弱的手工编写的惯用法才能实现的。分配原始资源的时候,应该立即将其传递给属主对象。永远不要在一条语句中分配一个以上的资源。

讨论

C++语言所强制施行的构造函数/析构函数对称反映了资源获取/释放函数对比如fopen/fclose、lock/unlock和new/delete的本质的对称性。这使具有资源获取的构造函数和具有资源释放的析构函数的基于栈(或引用计数)的对象成为了自动化资源管理和清除的极佳工具。

这种自动化很容易实现、简洁、低成本而且天生防错。如果不予采用,就需要手工将调用正确配对,包括存在分支控制流和异常的情形,这可是很不容易而且需要注意力高度集中的任务。既然C++已经通过易用的RAII提供了如此直接的自动化,这种C语言式的仍然依赖于对资源解除分配的微观管理方式就是不可接受的了。

每当处理需要配对的获取/释放函数调用的资源时,都应该将资源封装在一个对象中,让对象为我们强制配对,并在其析构函数中执行资源释放。例如,我们无需直接调用一对非成员函数OpenPort/ClosePort,而是可以考虑如下方法:

class Port {

public:

Port(const string&destination);   // 调用OpenPort

~Port();             // 调用ClosePort

// ……通常无法复制端口,因此需要禁用复制和赋值……

};

void DoSomething() {

Port port1( "server1:80" );

// ……

}// 不会忘记关闭port1;它会在作用域结束时自动关闭

shared_ptr<Port>port2=/*…*/;   //port2在最后一个引用它的

// shared_ptr离开作用域后关闭

还可以使用实现了这种模式的软件库(参阅[Alexandrescu00c])。

在实现RAII时,要小心复制构造和赋值(见第49条),编译器生成的版本可能并不正确。如果复制没有意义,请通过将复制构造和赋值设为私有并且不做定义来明确禁用二者(见第53条)。否则,让复制构造函数复制资源或者引用计数所使用的次数,并让赋值操作符如法炮制,如果必要,同时还要确保它释放了最开始持有的资源。一个经典的疏漏是在新资源成功复制之前释放了老资源(见第71条)。

确保所有资源都为对象所有。最好用智能指针而不是原始指针来保存动态分配的资源。同样,应该在自己的语句中执行显式的资源分配(比如 new),而且每次都应该马上将分配的资源赋予管理对象(比如shared_ptr),否则,就可能泄漏资源,因为函数参数的计算顺序是未定义的(见第31条)。例如:

void Fun( shared_ptr<Widget> sp1, shared_ptr<Widget> sp2 );

// ……

Fun( shared_ptr<Widget>(new Widget), shared_ptr<Widget>(new Widget) );

这种代码是不安全的。C++标准给了编译器巨大的回旋余地,可以将构成函数两个参数的两个表达式重新排序。说得更具体一些,就是编译器可以交叉执行两个表达式:可能先执行两个对象的内存分配(通过调用operator new),然后再试图调用两个Widget构造函数。这恰恰为资源泄漏准备了温床,因为如果其中一个构造函数调用抛出异常的话,另一个对象的内存就永远也没有机会释放了!(详细情况请参阅 [Sutter02]。)

这种微妙的问题有一个简单的解决办法:遵循建议,绝对不要在一条语句中分配一个以上的资源,应该在自己的代码语句中执行显式的资源分配(比如 new),而且每次都应该马上将分配的资源赋予管理对象(比如shared_ptr)。例如:

shared_ptr<Widget> sp1(new Widget), sp2(new Widget);

Fun( sp1, sp2 );

另见第31条,了解使用这种风格的其他优点。

例外情况

智能指针有可能会被过度使用。如果被指向的对象只对有限的代码(比如纯粹在类的内部,诸如一个Tree类的内部节点导航指针)可见,那么原始指针就够用了。

参考文献

[Alexandrescu00c]·[Cline99]§31.03-05·[Dewhurst03]§24, §67·[Meyers96]§9-10·[Milewski01]·[Stroustrup00]§14.3-4, §25.7, §E.3, §E.6·[Sutter00]§16·[Sutter02]§20-21·[Vandevoorde03]§20.1.4

[1].Donald Knuth(中文名高德纳),斯坦福大学荣誉退休教授,计算机科学大师,曾获得1974年图灵奖。撰有名著《计算机程序设计艺术》。——译者注

[2].Gordon Bell是微软研究院的研究人员,因在DEC公司任职期间设计了PDP系列计算机而声名远播。——译者注

[3].猜测执行是程序设计和计算机系统体系结构中的一种优化措施。在现代流水线微处理器中,使用猜测执行降低条件分支指令的代价。遇到条件分支指令时,处理器猜测最有可能转向的分支,并立即从此点开始执行。如果猜测不正确,此点之后的计算全部放弃。由于在下一指令知道之前,所涉及的流水线级是休眠的,所以这种计算的代价很低。——译者注

[4].此处原文为th sa rea fed ly,作者将线程与安全拼接,是为了造成一种令人难忘的效果。——译者注

[5].活锁与死锁类似,区别在于活锁情况中,两个进程的状态不断地根据另一个进程而改变。——译者注

[6].内存障栅指的是计算机的一组特殊指令,它们能够使CPU对位于自己前后发出的内存操作施加顺序约束,以解决并发操作中的乱序问题。——译者注

相关图书

代码审计——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++开发指南

相关文章

相关课程