跳转到内容

编写有风格的代码

如果你每天都要花几个小时坐在键盘前写代码,那你就该对自己的作品有点自豪感。写出“能跑、能完成任务”的代码,只是程序员工作的一部分。毕竟,谁都能学会编程基础。真正的大师,会写出有风格的代码。

本章将围绕一个问题展开:到底什么样的代码才算“风格上优秀”? 在这个过程中,你会看到多种不同的 C++ 风格做法。正如你将会发现的,仅仅改变代码风格,就足以让同一份代码看起来像是完全不同的东西。例如,Windows 程序员写出来的 C++ 代码常常带有一套自己的风格,沿用 Windows 体系下的习惯。它看上去几乎像是另一门语言,和 macOS 程序员写出的 C++ 代码差异极大。接触多种不同风格,能帮助你避免那种“打开一个 C++ 源文件却几乎认不出这是你所理解的 C++”的沮丧感。

写出“风格上优秀”的代码需要时间。你大概不需要花太多时间,就能拼出一个粗糙但能用的小程序,拿来解析 XML 文件。但如果要把同样的程序写得具备良好的功能分解、适当的注释以及清晰的结构,那就会花更多时间。这真的值得吗?

如果一年后有一个新程序员需要接手你的代码,你会对自己的代码有多大信心? 我的一个朋友曾面对着日益失控的 web 应用代码,于是鼓励团队去想象一个假想中的实习生,他会在一年后加入团队。如果没有文档,函数又长得吓人、动辄跨越好几页,这个可怜的实习生到底要怎样才能理解整个代码库? 当你在写代码时,请想象一下未来会有某个新人,甚至是未来的你自己,不得不来维护这份代码。到那时,你还记得它是怎么工作的吗? 如果你本人又不在场,没人能帮忙呢? 写得好的代码之所以能避免这些问题,就在于它易于阅读,也易于理解。

要枚举出“什么特征会让一段代码在风格上显得优秀”并不容易。随着经验积累,你会逐渐发现自己喜欢哪些风格,也会在别人写的代码里注意到哪些技巧特别有用。或许更重要的是,你也会见识到极其糟糕的代码,从而知道哪些事情必须避免。不过,好的代码通常都遵循几个具有普遍性的原则,而本章会围绕这些原则展开:

  • 文档化
  • 分解
  • 命名
  • 语言特性的使用方式
  • 格式化

在编程语境中,文档通常指源文件中的注释。注释是你向世界解释“当初写这段代码时脑子里在想什么”的机会。它也是你表达那些“单看代码本身并不明显的信息”的地方。

“写注释是好事”这件事看上去似乎理所当然,但你是否真的停下来想过,代码为什么需要注释? 有时程序员会口头上承认注释的重要性,却并没有真正理解注释为什么重要。原因其实有很多,本章会逐一展开。

使用注释的一个原因,是解释客户端应当如何与代码交互。通常来说,开发者应该仅凭函数名、返回值类型以及参数的名称和类型,就能理解一个函数的大致用途。但并不是所有信息都能仅靠代码本身表达出来。函数的前置条件(preconditions)、后置条件(postconditions)1,以及函数可能抛出的异常,都属于只能在注释中说明的内容。在我看来,只有在注释确实能补充有用信息时(例如前/后置条件和异常说明),才有必要添加注释;否则,不写注释也是可以接受的。不过,实际上“完全没有前置或后置条件”的函数非常少见。归根结底,某个函数是否需要注释,要由开发者自己判断。经验丰富的程序员通常不会在这件事上犯难,但经验不足的开发者就未必总能做出正确判断。因此,有些公司会制定规则,要求至少模块或头文件中每个公开可访问的函数/成员函数,都应有注释解释它做什么、它的参数是什么、它返回什么值、需要满足哪些前后置条件,以及它可能抛出哪些异常。

注释给了你一个机会,用自然语言把那些无法在代码中直接表达的东西写出来。例如,在 C++ 代码里,你很难显式表达:如果还没调用 openDatabase(),那么数据库对象的 saveRecord() 成员函数就会抛出异常(exception)。但注释却是记录这种限制的绝佳位置,例如:

// 抛出异常:
// 如果尚未调用 openDatabase(), 则抛出 DatabaseNotOpenedException。
int saveRecord(Record& record);

saveRecord() 成员函数接收的是一个“引用到非 const Record 对象”。使用者也许会好奇:为什么它不是“引用到 const”? 这就属于必须通过注释来解释的内容:

// 参数:
// record: 如果给定的记录还没有数据库 ID, 那么 saveRecord()
// 会修改该记录对象, 以存储数据库分配的 ID。
// 抛出异常:
// 如果尚未调用 openDatabase(), 则抛出 DatabaseNotOpenedException。
int saveRecord(Record& record);

C++ 语言强制你声明函数的返回类型,但它并不提供一种机制,让你明确说明这个返回值究竟“代表什么”。例如,saveRecord() 的声明表明它返回一个 int(这其实是一个糟糕的设计决定,稍后本节会继续讨论),但一个客户端仅读到这个声明时,并不会知道这个 int 到底意味着什么。注释就能把它说明清楚:

// 将给定记录保存到数据库。
//
// 参数:
// record: 如果给定的记录还没有数据库 ID, 那么 saveRecord()
// 会修改该记录对象, 以存储数据库分配的 ID。
// 返回值: int
// 代表已保存记录 ID 的整数。
// 抛出异常:
// 如果尚未调用 openDatabase(), 则抛出 DatabaseNotOpenedException。
int saveRecord(Record& record);

前面这段注释以一种正式方式完整记录了 saveRecord() 的全部信息,包括一句描述它到底做什么的话。不过,我并不推荐你总是采用这种风格。比如第一行那句其实就相当无用,因为函数名本身已经足够自解释。参数说明很重要,异常说明也同样重要,因此它们当然应该保留。至于这个版本的 saveRecord() 返回的到底是什么,由于它返回的是一个通用的 int,这就必须写清楚。不过,更好的设计是让它返回 RecordID,而不是裸 int,这样返回类型本身就具备含义,也就不再需要专门为返回值写注释了。RecordID 可以只是一个仅包含单个 int 数据成员的小类,但它本身就能承载更多语义,而且将来如果需要,你还可以往其中加入更多数据成员。因此,下面这样的 saveRecord() 明显更好:

// 参数:
// record: 如果给定的记录还没有数据库 ID, 那么 saveRecord()
// 会修改该记录对象, 以存储数据库分配的 ID。
// 抛出异常:
// 如果尚未调用 openDatabase(), 则抛出 DatabaseNotOpenedException。
RecordID saveRecord(Record& record);

有时,函数的参数和返回类型非常通用,足以承载各种不同信息。这种情况下,你就必须清晰地记录“到底传递的是什么”。例如,Windows 中的消息处理函数会接收两个参数 LPARAMWPARAM,并返回一个 LRESULT。这三者都可以用来传递几乎任意内容,但你又不能修改它们的类型。通过类型转换,它们可以被用来传递简单整数,也可以传递对象指针。此时,你的文档就可以写成这样:

// 参数:
// WPARAM wParam: (WPARAM)(int): 代表……的整数
// LPARAM lParam: (LPARAM)(string*): 代表……的字符串指针
// 返回值: (LRESULT)(Record*)
// 出错时返回 nullptr, 否则返回指向代表……的 Record 对象的指针
LRESULT handleMessage(WPARAM wParam, LPARAM lParam);

你的公开文档应描述代码的行为,而不是实现。所谓行为,包括输入、输出,错误条件及其处理方式、预期使用方式以及性能保证。例如,一段“生成单个随机数”的公开文档,应说明它不接收任何参数、返回一个落在某个既定区间内的整数,并列出当出错时可能抛出的所有异常。这样的公开文档不应去解释“实际如何通过线性同余算法生成随机数”这种实现细节。给代码使用者看的注释里塞太多实现细节,大概是编写公开注释时最常见的错误。

真正的源代码内部同样也需要好的注释。在一个简单程序里,若只是接收用户输入并向控制台输出结果,通常整段代码都很容易读懂。但在真实专业环境中,你往往需要编写一些算法上很复杂、或过于晦涩、仅靠肉眼很难看懂的代码。

看看下面这段代码。它写得并不差,但并不容易一眼看出它到底在做什么。如果你以前见过这个算法,也许能认出来;但一个新人很可能根本看不懂这段代码是怎么运作的。

void sort(int data[], std::size_t size)
{
for (int i { 1 }; i < size; ++i) {
int element { data[i] };
int j { i };
while (j> 0 && data[j - 1]> element) {
data[j] = data[j - 1];
j--;
}
data[j] = element;
}
}

更好的做法是加入注释,说明函数参数的含义、它使用的算法,以及任何(循环)不变式(invariants)。所谓不变式,是指某段代码(例如某次循环迭代) 执行期间必须始终为真的条件。下面这个改进后的函数中,顶部的注释解释了两个参数的含义,函数开头有一段较完整的注释从高层说明所使用的算法,而嵌入式注释则解释了某些可能令人困惑的具体语句:

// 实现“插入排序”算法。
// data 是包含待排序元素的数组。
// size 包含 data 数组中的元素数量。
void sort(int data[], std::size_t size)
{
// 插入排序算法将数组分为两部分——已排序部分和未排序部分。
// 从位置 1 开始检查每个元素。数组中较早的所有内容都在已排序部分,
// 因此算法会移动每个元素, 直到找到插入当前元素的正确位置。
// 当算法完成最后一个元素时, 整个数组就排序好了。
// 从位置 1 开始并检查每个元素。
for (int i { 1 }; i < size; ++i) {
// 循环不变式:
// 0 到 i-1 范围内的所有元素(含)均已排序。
int element { data[i] };
// j 标记已排序部分中将插入元素的位置。
int j { i };
// 只要已排序数组中当前槽位之前的值高于 element,
// 就向右移动数值, 为在正确位置插入 element 腾出空间
// (因此得名“插入排序”)。
while (j> 0 && data[j - 1]> element) {
// 不变式: j+1 到 i 范围内的元素 > element。
data[j] = data[j - 1];
// 不变式: j 到 i 范围内的元素 > element。
j--;
}
// 此时, 已排序数组中的当前位置不大于 element,
// 因此这就是它的新位置。
data[j] = element;
}
}

新的版本显然更啰唆了,但对于一个并不熟悉排序算法的读者来说,加上这些注释之后,理解起来会容易得多。

警告:本节提到的所有元信息(meta-information),都是过去的一些做法。如今,这类元信息已经非常不推荐写进代码中,因为使用版本控制系统(正如 第 28 章“最大化软件工程方法的价值”中会讨论的那样) 已经是强制要求。版本控制系统本身就会提供带注释的变更历史,其中包括修订日期、作者名,以及如果用得规范,还会记录每次修改的注释,其中往往还附带变更请求和缺陷报告的引用。你应该把每个变更请求或 bug 修复分别独立提交(commit),并附上描述清晰的注释。既然如此,你根本没必要再手工把这些元信息直接维护在源代码文件里。

不过在一些旧的遗留代码库中,你仍然可能会遇到这类注释。它们提供的是比具体代码本身更高一层的信息,这样的元信息描述的是代码的产生背景,而不是代码行为的细节。例如,它们可能记录每个函数最初的作者、某段代码写于何日、某个函数是为了解决哪个具体功能、某行代码对应哪个 bug 编号、未来需要回头检查的问题、变更日志等等。下面就是一个例子:

// 日期 | 变更
//------------+--------------------------------------------------
// 2001-04-13 | REQ #005: <marcg> 不要对值进行归一化。
// 2001-04-17 | REQ #006: <marcg> 使用 nullptr 而不是 NULL。
// 作者: marcg
// 日期: 110412
// 特性: PRD 版本 3, 特性 5.10
RecordID saveRecord(Record& record)
{
if (!m_databaseOpen) { throw DatabaseNotOpenedException { }; }
RecordID id { getDB()->saveRecord(record) };
if (id != -1) { // 添加以解决 bug #142 – jsmith 110428
record.setId(id);
}
// TODO: 如果 setId() 抛出异常怎么办? – akshayr 110501
return id;
}

不过值得再次强调的是,这种遗留式元信息在新代码中已经没有立足之地。

另一类元信息是版权声明。有些公司要求每个源文件开头都加上一段版权声明,尽管自 1886 年伯尔尼公约(Berne Convention) 以来,你其实不需要显式写出版权声明,也照样对自己的作品自动拥有版权。

每个组织在代码注释上的做法都不一样。有些环境会强制规定特定风格,以便整个代码库在文档形式上保持统一。另一些环境则完全把“注释写多少、怎么写”交给程序员自己决定。下面这些例子展示了几种不同的代码注释方式。

避免文档不足的一种极端方式,是强迫自己“过度文档化”:给每一行代码都配上一条注释。看上去,为每一行都写注释,似乎就能保证你写下的每一行都有明确理由。现实中,这种大规模的重度注释既笨重、又凌乱、还非常烦人! 例如,看看下面这些没用的注释:

int result; // 声明一个整数来保存结果。
result = doodad.getResult(); // 获取 doodad 的结果。
if (result % 2 == 0) { // 如果结果模 2 为 0 ……
logError(); // 那么记录一个错误,
} else { // 否则 ……
logSuccess(); // 记录成功。
} // if/else 结束。
return result; // 返回结果。

这段代码中的注释,只不过是把每一行代码翻译成一个读起来顺溜的英文故事而已。如果你默认读者至少具备基本 C++ 能力,这类注释就是彻底无用的。它们并没有为代码增加任何额外信息。尤其看看这一行:

if (result % 2 == 0) { // 如果结果模 2 为 0 ……

这条注释不过是在把代码逐字翻译成英语。它根本没有解释为什么程序员要用结果值对 2 取模。下面这种注释就会好得多:

if (result % 2 == 0) { // 如果结果是偶数 ……

修改后的注释虽然对多数程序员而言依旧挺明显,但它至少补充了额外信息:这里之所以对 2 取模,是因为代码想判断结果是否为偶数。

更进一步地说,如果某个表达式的作用对很多人来说都未必能立刻看懂,我会建议你直接把它提取成一个命名恰当的函数。这样代码本身就能自解释,也就不必在调用处写注释了,同时还产出了一段可复用代码。比如,你可以像下面这样定义一个 isEven() 函数:

bool isEven(int value) { return value % 2 == 0; }

然后就可以这样使用它,而不必写任何注释:

if (isEven(result)) {

尽管“给每一行都写注释”很容易变得冗长且多余,但在某些代码若没有注释就会很难理解的场景中,这种重注释方式仍然会很有帮助。下面这段代码同样给每一行都写了注释,但这里这些注释就是有价值的:

// 计算 doodad。起始、结束和偏移值来自
// “Doodad API v1.6”第 96 页的表格。
result = doodad.calculate(Start, End, Offset);
// 为了确定成功或失败, 我们需要将结果与
// 处理器特定的掩码进行按位与操作(参见“Doodad API v1.6”第 201 页)。
result &= getProcessorMask();
// 根据“Marigold Formula”设置用户字段值。
// (参见“Doodad API v1.6”, 第 136 页)
setUserField((result + MarigoldOffset) / MarigoldConstant + MarigoldConstant);

这段代码是脱离上下文展示的,但这些注释足以让你大致理解每一行在做什么。如果没有这些说明,其中涉及 & 的计算以及神秘的 “Marigold Formula” 就会很难看懂。

你的团队也许会决定在每个源文件开头都加一段标准注释。这是一个记录“程序级别和文件级别关键信息”的机会。通常可以写在每个文件顶部的内容包括:

  • 版权信息
  • 文件/类的简要说明
  • 尚未完成的功能*
  • 已知 bug*

上面带星号的这两项,通常更适合交给 bug / feature 跟踪系统处理(见 第 30 章“成为测试高手”)。

而下面这些信息则通常不应出现在这种注释中,因为它们本来就由版本控制系统自动替你管理(见 第 28 章):

  • 最后修改日期
  • 原始作者
  • 变更日志(前面已提到)
  • 该文件对应的功能 ID

下面是一个文件头注释的例子:

// 实现西瓜的基本功能。所有单位都以
// 每立方厘米的种子数表示。西瓜理论基于
// 白皮书《西瓜处理算法》。
//
// 以下代码版权所有 (c) 2023, FruitSoft, Inc. 保留所有权利

使用一种标准格式来书写注释,并让外部文档工具去解析这些注释,是一种非常流行的编程实践。在 Java 语言中,程序员可以使用一种标准格式书写注释,从而让 JavaDoc 之类的工具自动为项目生成带超链接的文档。对于 C++,一个名为 Doxygen(地址为 doxygen.org) 的免费工具可以解析注释,自动生成 HTML 文档、类图、UNIX man pages 以及其他实用文档。Doxygen 甚至还能识别并解析 C++ 程序中的 JavaDoc 风格注释。下面这段代码展示了 Doxygen 可识别的 JavaDoc 风格注释:

/**
* 实现西瓜的基本功能
* TODO: 实现更新后的算法!
*/
export class Watermelon
{
public:
/**
* @param initialSeeds 种子的初始数量,必须 > 5。
* @throws invalid_argument 如果 initialSeeds <= 5。
*/
Watermelon(std::size_t initialSeeds);
/**
* 使用 Marigold 算法计算种子比例。
* @param slow 是否使用较长(较慢)的计算。
* @return Marigold 比例。
*/
double calculateSeedRatio(bool slow);
};

Doxygen 能识别 C++ 语法以及诸如 @param@return@throws 这样的特殊注释指令,从而生成可定制的输出。图 3.1 展示了一份由 Doxygen 生成的 HTML 类参考文档示例。

需要注意的是,即便你使用了自动生成文档的工具,仍然应该避免编写无用注释。看看前面那段代码中的 Watermelon 构造函数。它的注释并没有添加一段函数描述,而只是说明了参数和可能抛出的异常。若再加上下面这种描述,反而就显得多余了:

/**
* Watermelon 构造函数。
* @param initialSeeds 种子的初始数量,必须 > 5。
* @throws invalid_argument 如果 initialSeeds <= 5。
*/
Watermelon(std::size_t initialSeeds);

Watermelon 类参考文档截图,其中包括公开成员函数、详细描述、构造与析构文档以及 calculateSeedRatio。

[^图 3.1]

图 3.1 那样自动生成的文档,在开发期间会非常有帮助,因为它能让开发者从高层视角浏览类及其关系。你的团队可以轻松定制 Doxygen 这类工具,让它适配你们已经采用的注释风格。理想情况下,团队甚至应搭建一台机器,每日自动生成文档。

大多数时候,你会按需写注释。对于出现在代码主体中的注释,以下这些原则值得牢记:

  • 在添加注释之前,先想想能否通过重写代码来让注释变得多余——例如通过重命名变量、函数和类;调整代码步骤顺序;引入命名良好的中间变量;等等。
  • 想象一下是别人来读你的代码。如果其中存在一些不那么显而易见的微妙之处,你就应当把它们记录下来。
  • 不要在代码里写自己的姓名缩写。版本控制系统会自动替你记录这类信息。
  • 如果你在使用某个 API 的方式上有一些不那么直观的地方,那就附上该 API 文档中解释这些细节的参考位置。
  • 修改代码时别忘了同步更新注释。没有什么比“注释写得满满当当,但全是错的”更让人困惑。
  • 如果你正在用注释把一个函数人为划分成若干小节,那就考虑一下:这个函数是不是其实应该拆成多个更小的函数?
  • 避免使用冒犯性或贬低性语言。你永远不知道将来会是谁来读你的代码。
  • 大量使用内部玩笑通常被认为是可以接受的。请先和你的经理确认。

写得好的代码,往往并不需要大量注释。最好的代码本身就是可读的。如果你发现自己在给每一行都写注释,不妨反过来思考:这段代码能不能重写得更贴近你在注释里想表达的意思? 例如,给函数、参数、变量、类等取更具描述性的名字。正确使用 const;也就是说,如果某个变量本不应该被修改,那就把它标记为 const。重新安排函数中的执行步骤,让读者更容易看出它在做什么。引入命名清晰的中间变量,让某个算法更容易理解。要记住,C++ 是一门语言。它的首要目标当然是告诉计算机该做什么,但语言本身的语义也可以用来向读者解释“这段代码意味着什么”。

编写自文档化代码的另一种方式,就是把代码拆分成更小的部分,也就是对代码进行分解(decompose)。这正是下一节的主题。

分解,就是把代码拆成更小的部分。对程序员来说,没有什么比打开一个源文件,看到里面塞满了 300 行长函数和层层嵌套的大块代码更令人绝望的了。理想情况下,每个函数都应只完成一个任务。任何相对复杂的子任务,都应拆成独立函数。例如,如果有人问你某个函数是做什么的,而你的回答是“它先做 A,然后做 B;接着如果满足 C,就做 D,否则就做 E”,那你多半就应该为 A、B、C、D、E 分别写出独立的辅助函数。

分解并不是一门精确科学。有些程序员会说:任何函数都不应超过一页纸的打印长度。这也许是个不错的经验法则,但你当然也能找到只有四分之一页长、却极其迫切需要进一步分解的代码。另一个经验法则是:如果你眯起眼睛,只看代码的版式而不看实际内容,任何局部都不应该显得过于密集。例如,图 3.2图 3.3 就展示了两段故意模糊处理后的代码,这样你不会被内容本身吸引注意力。很显然,图 3.3 中的代码比 图 3.2 中的代码分解得更好。

一张经过刻意模糊处理的代码截图,使你无法专注于具体内容。

[^图 3.2]

另一张经过刻意模糊处理的代码截图,其分解程度更好、更清晰。

[^图 3.3]

有时,当你灌下几杯咖啡,进入编程状态之后,你会写得飞快,结果最后得到的代码虽然功能完全正确,但离“漂亮”还差得很远。所有程序员都会时不时这样。短时间内高强度编码,有时恰恰是一个项目里效率最高的时刻。随着代码不断演化,致密混乱的代码也会自然出现。随着新需求和 bug 修复不断冒出来,旧代码会被一点点打补丁式改动。计算机领域里有个词叫 cruft,指的就是这种“少量又少量的代码碎片不断累积”,最终把一段原本优雅的代码变成乱七八糟、充满补丁和特例的怪物。

重构(refactoring) 指的是重新组织你的代码。Martin Fowler 的著作 Refactoring: Improving the Design of Existing Code, 2nd edition, 是关于重构最有影响力的书之一(见 附录 B“带注释的参考书目”)。下面列出了一些典型重构技术:

  • 能带来更强抽象的技术:
    • 封装数据成员(Encapsulate data member): 把数据成员设为 private,并通过 getter 和 setter 来访问。
    • 泛化类型(Generalize type): 创建更一般化的类型,以便共享更多代码。
  • 把代码拆成更合理片段的技术:
    • 提取成员函数(Extract member function): 把一个较大成员函数中的一部分提取成新成员函数,使其更容易理解。
    • 提取类(Extract class): 把现有类中的一部分代码挪到一个新类中。
  • 用于改善命名和代码位置的技术:
    • 移动成员函数 / 数据成员(Move member function / move data member): 把它移到更合适的类或源文件中。
    • 重命名成员函数 / 数据成员(Rename member function / rename data member): 通过改名更准确表达其用途。
    • 上移(Pull up): 在面向对象编程中,移到基类中。
    • 下移(Push down): 在面向对象编程中,移到派生类中。

无论你的代码一开始就是一大坨难以阅读的 cruft,还是随着时间演变成那样,你都需要周期性地通过重构把这些累积下来的 hack 清理掉。重构的过程,就是回过头去审视现有代码,并重新改写它,让它更易读、更易维护。重构也是重新思考“代码如何分解”的好机会。如果代码的职责已经变化,或者一开始根本没有做好分解,那么在重构时,你就应该像前面那样“眯起眼睛看看”,判断它是否需要被继续拆成更小的部分。

在重构代码时,拥有一套可靠的测试框架来捕获你可能引入的缺陷非常重要。尤其是单元测试(unit tests),会在 第 30 章 中讨论,它们特别适合帮助你在重构过程中及时发现错误。

如果你采用模块化分解(modular decomposition),并且在面对每个模块和每个函数时,都认真思考“其中有哪些部分可以暂时留待后面实现”,那么你的程序通常就会比那种“边写边把所有功能一股脑全塞进去”的方式更不拥挤、更有条理。

当然,你仍然应该在真正跳进代码之前先把程序设计出来。

你会在本书的很多例子中看到分解。在许多场景下,例子会提到某些函数,但并不展示它们的实现,因为这些实现与当前示例无关,而且会占用太多篇幅。

C++ 编译器对名称有几条基本规则:

  • 名称可以包含大小写字母、数字以及下划线。
  • 字母并不限于英文字母,也可以来自任何语言,例如日语、阿拉伯语等等。
  • 名称不能以数字开头(例如 9to5)。
  • 含有双下划线的名字(例如 my__name) 由标准库保留,不得使用。
  • 以下划线开头并紧跟一个大写字母的名字(例如 _Name) 永远由标准库保留,不得使用。
  • 在全局命名空间中,以下划线开头的名字(例如 _name) 也是保留的,不得使用。

除这些规则之外,名称存在的唯一目的,就是帮助你和你的同事更顺畅地与程序中的各个元素打交道。既然如此,程序员却如此频繁地使用模糊甚至不恰当的名字,就显得很不可思议了。

无论是变量、成员函数、普通函数、参数、类、命名空间还是别的什么,最好的名字都应准确描述该项事物的用途。名称有时还可以隐含一些额外信息,例如类型或特定用法。当然,真正的检验标准在于:别的程序员是否能看懂你想通过这个名字传达什么。

除了你所在组织自身的规则外,命名并没有放之四海皆准的硬性铁律。不过,有些名字几乎总是不合适。下表展示了命名光谱两端的一些例子:

好名字坏名字
sourceName, destinationName 能区分两个对象thing1, thing2 过于笼统
m_nameCounter 传达出“它是数据成员”m_NC 过于晦涩,过于简短
calculateMarigoldOffset() 简洁且准确doAction() 过于宽泛,不精确
m_typeString 看起来舒服typeSTR256 只有机器才会喜欢的名字
g_settings 传达出“它是全局量”m_IHateLarry 无法接受的内部玩笑
errorMessage 描述清晰string 完全不能说明用途
sourceFile, destinationFile 不使用缩写srcFile, dstFile 缩写

选名字并不总是需要很多创造力和深思熟虑。在很多情况下,你会希望采用一些标准化的命名技巧。下面这些类别的数据,就尤其适合使用规范化命名方式。

在编程生涯初期,你大概见过用变量 i 作为计数器的代码。在惯例中,i 常被用作计数器,j 则常被用作内层循环计数器。不过在嵌套循环中要格外小心。一个常见错误就是:你本来想访问第 “j 个” 元素,结果却误写成第 “i 个”。如果你处理的是二维矩阵,用 rowcolumn 作为索引,往往比 ij 更容易读懂。有些程序员更喜欢用 outerLoopIndexinnerLoopIndex 这样的名字,甚至还有人完全不赞成在循环里使用 ij

很多程序员会在变量名前加一个字母,用来传达这个变量的类型或用途信息。另一方面,也有同样多甚至更多的程序员反对任何前缀,因为这会让“代码在未来演进时”变得更难维护。例如,如果某个成员变量从 static 改成了非 static,那你就必须把所有引用它的名称都一起改掉。要是你没改,名字继续传达着某种语义,但那已经是错误的语义了。

不过现实中你往往没有选择,必须遵守公司规范。下表展示了一些常见前缀示例:

前缀示例名称字面含义用途
m m_mData m_data“member”类中的数据成员
s ms ms_sLookupTable msLookupTable ms_lookupTable“static”静态变量或静态数据成员
kkMaximumLength“konstant”(德语中的“常量”)常量值。有些程序员不会为常量加任何前缀。
b isbCompleted isCompleted“Boolean”表示布尔值

匈牙利命名法(Hungarian notation) 是一种在 Microsoft Windows 程序员中很流行的变量和数据成员命名约定。其核心思想是:不要只用像 m 这样单个字母的前缀,而是使用更冗长的前缀来传递更多信息。下面这行代码就使用了匈牙利命名法:

char* pszName; // psz 表示“指向以零结尾的字符串的指针”

之所以叫 Hungarian notation,是因为它的发明者 Charles Simonyi 是匈牙利人。也有人开玩笑说,这个名字还恰好反映了另一件事:用了匈牙利命名法的程序看上去就像是用一种陌生语言写出来的。也正因如此,很多程序员都不喜欢匈牙利命名法。在本书中,我们会使用前缀,但不会采用匈牙利命名法。一个足够命名良好的变量,除了少量前缀信息之外,通常并不需要额外上下文。比如,名为 m_name 的数据成员已经把信息表达得足够清楚了。

如果你的类中包含一个数据成员,例如 m_status,那么惯例上会通过一个名为 getStatus() 的 getter 来访问它,并且可选地提供一个名为 setStatus() 的 setter。若要访问某个布尔型数据成员,则通常会使用 is 作为前缀,而不是 get,例如 isRunning()。C++ 语言本身并没有强制这类函数必须如何命名,但你的组织很可能会希望采纳类似这样的命名方案。

代码中的名字可以采用很多不同的大小写风格。和编码风格中的大多数要素一样,最重要的是你的团队应采纳一种统一做法,并确保所有成员都遵守它。导致代码看起来凌乱的一种典型方式,就是有些程序员把类名写成全小写并以下划线分词(例如 priority_queue),而另一些程序员则使用驼峰式大小写(例如 PriorityQueue)。变量和数据成员通常都以小写字母开头,并用下划线(my_queue)或大写字母(myQueue) 来表示单词边界。传统上,C++ 中的函数名常以大写字母开头,但正如你已经看到的,本书采用的是“函数名首字母小写”的风格,以便把函数名和类名区分开来。

想象一下,你正在写一个图形用户界面程序。这个程序有多个菜单,例如 File、Edit 和 Help。为了表示每个菜单的 ID,你可能会决定使用常量。对 Help 菜单而言,一个看起来再自然不过的常量名就是 Help

这个名字一开始当然没问题——直到你给主窗口又加了一个 Help 按钮。此时你还需要另一个常量来表示这个按钮的 ID,可名字 Help 已经被占用了。

一种解决方案是把这些常量放到不同命名空间中。命名空间会在 第 1 章“C++ 与标准库速成”中讨论。你可以创建两个命名空间:MenuButton。它们各自都有一个名为 Help 的常量,你使用时就写成 Menu::HelpButton::Help。不过在这种例子里,更推荐的做法其实是使用枚举(enumerations),它在 第 1 章 中也已经介绍过。

C++ 语言允许你写出各种极其难读的东西。看看下面这段离谱代码:

i++ + ++i;

这段代码不仅难读,更重要的是,它的行为按照 C++ 标准来说是未定义的。问题在于,i++ 会使用 i 的值,但同时还带有“把它递增”的副作用。标准并没有规定这个递增必须在何时完成,只规定“这个副作用(递增) 在序列点 ; 之后必须可见”。也就是说,编译器可以在执行该语句期间的任意时刻做这件事。你根本无法知道 ++i 那部分会使用 i 的哪个值。在不同编译器和平台上运行这段代码,完全可能得到不同结果。

像下面这样的表达式:

a[i] = ++i;

从 C++17 开始就已经是定义良好的,因为 C++17 保证在对赋值语句左侧求值之前,会先完成右侧所有操作的求值。所以在这个例子中,会先对 i 递增,然后再把它拿来作为 a[i] 的索引。即便如此,出于清晰性考虑,依然建议避免写出这种表达式。

在 C++ 提供了如此强大能力的前提下,非常重要的一点是:要去思考如何让这些语言特性服务于“风格上的善”,而不是“风格上的恶”。

糟糕代码里常常充斥着所谓的“魔法数字”(magic numbers)。在某个函数中,代码可能突然出现 2.71828243600 等等。为什么? 这些值到底是什么意思? 对于有数学背景的人来说,也许一眼就知道 2.71828 是超越数 e 的近似值,但大多数人并不知道。C++ 语言提供了常量,让你可以给那些不会变化的值(例如 2.71828243600) 起一个符号化的名字。如下所示:

const double ApproximationForE { 2.71828182845904523536 };
const int HoursPerDay { 24 };
const int SecondsPerHour { 3'600 };

过去很多 C++ 程序员都是先学 C,再学 C++。而在 C 中,指针是唯一的“按引用传递”机制,并且它在很多年里都确实工作得很好。如今在某些场景下,指针仍然是必须的,但在大量场景中,你都可以把它换成引用。如果你是先学 C 的,你大概会觉得引用并没有给语言增加什么新能力。你也许会认为,它不过是为“本来用指针就能实现的能力”额外引入了一套新语法而已。

相较于指针,使用引用有几个好处。首先,引用比指针更安全,因为它们不直接处理内存地址,也不可能是 nullptr。其次,引用在风格上也通常比指针更讨喜,因为它们的使用语法和栈变量一样;也就是说,你不需要显式通过 & 去取地址,也不需要通过 * 去解引用。引用本身也很好用,因此你完全可以把它纳入自己的风格工具箱。不幸的是,有些程序员会说,只要在函数调用里看见了 &,他们就知道被调用函数会修改对象;如果没看到 &,那就一定是按值传递。对于引用,他们则抱怨说,除非去看函数原型,否则根本不知道函数会不会改对象。这种思维方式本身就是错的。传入一个指针并不自动意味着对象一定会被修改,因为参数类型也可能是 const T*。无论是传指针还是传引用,函数都可能会改对象,也可能不会,这取决于参数到底是 const T*T*const T& 还是 T&。所以,无论如何,你都必须看函数原型,才能知道函数是否有可能修改对象。

引用的另一项好处,在于它能更清晰地表达内存所有权。如果你正在编写一个函数,而另一个程序员向你传入了某个对象的引用,那么很明显:你可以读取并且可能修改这个对象,但你几乎不可能去释放它的内存。如果传给你的是一个指针,这一点就未必那么清楚了。你是否需要 delete 这个对象来清理内存? 还是调用方会负责? 当然,在现代 C++ 中,含义已经很清晰了:任何原始指针都表示“非拥有关系(non-owning)”,而所有权及其转移则应由智能指针来表达,这一点会在 第 7 章“内存管理”中讨论。

C++ 让“忽略异常”这件事变得太容易了。语言语法本身没有任何地方会强迫你去处理异常;理论上,你完全可以继续沿用传统机制去写所谓“容错程序”,例如返回某些特殊值(比如 -1nullptr 等) 或设置错误标志位。当你用“返回特殊值”来表示错误时,还可以像 第 1 章 介绍过的那样,借助 [[nodiscard]] 属性强制函数调用者必须对返回值做点什么。

不过,异常机制显然能提供更丰富的错误处理能力,而自定义异常则允许你把这一机制裁剪得更适合自身需求。例如,某个 web 浏览器的自定义异常类型,就可以附带字段来记录“发生错误时对应的网页”“当时的网络状态”以及其他上下文信息。

第 14 章“错误处理”中包含大量关于 C++ 异常的信息。

很多编程团队都曾因为代码格式之争而分崩离析,甚至友谊决裂。大学时,我有个朋友就曾和同学围绕 if 语句里到底该不该加空格展开一场极其激烈的争论,以至于路过的人都会停下来确认他们是不是快打起来了。

如果你的组织已经制定了代码格式标准,那你算是幸运的。你也许并不喜欢这些标准,但至少你不必再就它们进行无休止争论了。

如果组织里还没有格式规范,我建议你推动建立它们。统一的编码规范可以确保团队中所有程序员都遵守同一套命名约定、格式规则等,从而让代码更加统一,也更容易理解。

如今还有很多自动化工具,能在你把代码提交到版本控制系统之前,按照指定规则自动格式化代码。有些 IDE 本身就内建了类似能力,例如在保存文件时自动格式化代码。

如果团队中的每个人都坚持按自己的方式写代码,那就尽量保持宽容吧。你会看到,有些做法真的只是口味差异,而另一些做法则会实实在在让团队协作变得困难。

也许最常被争论的一个点,就是“表示代码块的花括号到底该放在哪里”。花括号用法存在多种不同风格。本书中的风格是:除了类、函数或成员函数的情况之外,左花括号都放在引导语句的同一行。下面这段代码(也是本书全书采用的风格) 就是这种写法:

void someFunction()
{
if (condition()) {
println("condition was true");
} else {
println("condition was false");
}
}

这种风格节省了垂直空间,同时依靠缩进来清晰表现代码块。有些程序员会反驳说,在真实开发中,“节省垂直空间”根本不重要。下面则展示了一种更冗长的风格:

void someFunction()
{
if (condition())
{
println("condition was true");
}
else
{
println("condition was false");
}
}

还有一些程序员在水平方向上也非常慷慨,于是代码会长成这样:

void someFunction()
{
if (condition())
{
println("condition was true");
}
else
{
println("condition was false");
}
}

另一个争论点是:对于只有一条语句的代码块,到底要不要保留花括号。例如:

void someFunction()
{
if (condition())
println("condition was true");
else
println("condition was false");
}

显然,我不会在这里推荐某一种特定风格,因为我不想收到仇恨邮件。就我个人而言,我总是会加花括号,即便只有单条语句也一样,因为这样既能防御某些写得不太好的 C 风格宏(见 第 11 章“模块、头文件与杂项主题”),也能防止将来往代码块里新增语句时出错。

单行代码内部的格式,同样也可能成为争论来源。还是一样,我不会强推某种做法,但你很可能会在现实中碰到下面这些风格中的若干种。

本书中,我会在任何关键字后面加一个空格,在任何运算符前后加一个空格,在参数列表或调用中的每个逗号后加一个空格,并且使用括号来明确运算顺序,例如:

if (i == 2) {
j = i + (k / m);
}

另一种风格如下所示,它在风格上把 if 看得更像函数,因此关键字和左括号之间不留空格。此外,在 if 语句内部,为了表达运算顺序而额外添加的括号也被省略掉了,因为它们在语义上并不必要。

if( i == 2 ) {
j = i + k / m;
}

这两种差异非常细微,到底哪种更好,就留给读者自己判断吧。不过在这个问题上,我还是忍不住想指出一句:if 终究不是函数。

空格和 Tab 的使用不只是纯粹的审美偏好。如果你的团队在空格和 Tab 的约定上无法达成一致,那当程序员共同协作时,就一定会出现大麻烦。最显而易见的问题是:如果 Alice 用四空格表示一个 Tab 缩进,而 Bob 用五空格表示一个 Tab,那么两个人在同一个文件上工作时,谁也没法正确显示代码。更糟的问题还会出现在这种情况下:Bob 正在把代码统一格式化成 Tab,与此同时 Alice 又对同一段代码做了修改——很多版本控制系统根本没法把 Alice 的修改自动合并进去。

绝大多数(虽然不是全部) 编辑器,都提供了空格与 Tab 的可配置选项。有些环境甚至会自动适应读入代码本身的格式,或者即便你按的是 Tab 键,保存时也统一写成空格。如果你的开发环境足够灵活,那你与他人的代码协作就会轻松很多。只要记住:Tab 和空格不是一回事,因为 Tab 的显示宽度可以变化,而空格始终就是一个空格。

最后,不同平台并不总是以同样方式表示换行。例如,Windows 会用 \r\n 表示换行,而 Linux 平台通常使用 \n。如果你的公司里同时使用多个平台,那就必须明确约定到底采用哪种换行风格。这里同样地,你的 IDE 大概率也能被配置成使用你所需要的换行风格;或者,也可以用自动化工具在你提交代码到版本控制系统时自动修正换行风格。

很多程序员在开始一个新项目时,都会暗暗发誓“这一次我要把一切都做好”。凡是本不该修改的变量和参数,统统加上 const。所有变量都用清晰、简洁、可读的名字。每个开发者都把左花括号放到下一行,并统一采用标准的文本编辑器及其关于 Tab 和空格的约定。

然而,要一直保持这样高水平的风格一致性并不容易。就拿 const 来说,有时程序员只是单纯没有被正确训练过,不知道该如何用它。你迟早会碰到一些旧代码或某个库函数,它根本没有 const 意识。比如,假设你正在编写一个接收 const 参数的函数,但你必须调用一个只接受非 const 参数的遗留函数。如果你无法修改遗留代码让它理解 const——例如它是第三方库——而你又百分之百确定这个遗留函数根本不会修改那个非 const 参数,那么一个优秀程序员会使用 const_cast()(见 第 1 章) 临时去掉参数的 const 属性;而一个缺乏经验的程序员则很可能会从调用点一路往外层不断撤销 const,最终又得到一个处处都不用 const 的程序。

另一些时候,风格标准化会直接撞上程序员个人的偏好和成见。也许你所在团队的文化根本不适合强推一套严格风格规范。在这种情况下,你也许就必须判断:哪些要素是真正必须统一的(例如变量命名和 Tab 规则),而哪些要素可以安全地留给个人裁量(例如空格风格、注释风格)。你甚至可以引入或编写脚本,自动修正风格上的“bug”,或者把风格问题和代码错误一起标记出来。有些开发环境,例如 Microsoft Visual C++,就支持按照你指定的规则自动格式化代码。这样一来,始终写出符合团队既定规范的代码就变得轻而易举。

C++ 语言为代码风格提供了不少工具,但并没有对“应当怎样使用这些工具”给出正式规定。归根结底,任何一种风格约定的价值,都体现在两个维度上:它被多广泛地采纳,以及它对代码可读性到底带来了多大帮助。当你在团队中协作编码时,你应尽早把风格问题摆上台面,作为“选择何种语言与工具”讨论的一部分。

关于风格,最重要的一点是:真正意识到它是编程中一个很重要的方面。养成在代码交给别人之前,主动检查其风格的习惯。学会识别你所接触代码中的好风格,并采纳那些对你和你所在组织确实有帮助的约定。

为了结束本章,请牢牢记住下面这句话:

永远把代码写得像是那个将来要维护你代码的人,是个知道你住哪儿的暴力疯子。代码应为可读性而写。

JOHN F. WOODS, SEP 24, 1991, COMP.LANG.c++

本章也至此结束了本书的第一部分。接下来的部分将从更高层角度讨论软件设计。

通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 的代码下载包中。不过,如果你在某道题上卡住了,请先回头重读本章相关部分,尝试自己找出答案,再去查看网站上的解答。

代码注释和编码风格本身都带有主观性。下面这些练习并没有唯一完美答案。网站给出的参考解答,只是众多可能正确答案中的一种。

  1. 练习 3-1: 第 1 章 讨论了一个员工记录系统示例。那个系统里有一个数据库类,其中一个成员函数叫 displayCurrent()。下面是这个成员函数的一种实现,还配上了一些注释:

    void Database::displayCurrent() const // The displayCurrent() member function
    {
    for (const auto& employee : m_employees) { // For each employee…
    if (employee.isHired()) { // If the employee is hired
    employee.display(); // Then display that employee
    }
    }
    }

    你能看出这些注释有什么问题吗? 为什么? 你能提出更好的注释方式吗?

  2. 练习 3-2: 第 1 章 中的员工记录系统包含一个 Database 类。下面是该类的一段节选,其中只保留了三个成员函数。请为这段代码添加合适的 JavaDoc 风格注释。你可以回看 第 1 章,回忆这些成员函数具体都做了什么。

    class Database
    {
    public:
    Employee& addEmployee(const std::string& firstName,
    const std::string& lastName);
    Employee& getEmployee(int employeeNumber);
    Employee& getEmployee(const std::string& firstName,
    const std::string& lastName);
    // Remainder omitted…
    };
  3. 练习 3-3: 下列类中存在多个命名问题。你能全部指出来,并提出更好的名字吗?

    class xrayController
    {
    public:
    // Gets the active X-ray current in μA.
    double getCurrent() const;
    // Sets the current of the X-rays to the given current in μA.
    void setIt(double Val);
    // Sets the current to 0 μA.
    void 0Current();
    // Gets the X-ray source type.
    const std::string& getSourceType() const;
    // Sets the X-ray source type.
    void setSourceType(std::string_view _Type);
    private:
    double d; // The X-ray current in μA.
    std::string m_src__type; // The type of the X-ray source.
    };
  4. 练习 3-4: 给定下面这段代码,请分别用三种方式重新格式化它:首先把花括号都放到单独一行;然后连花括号本身也进行缩进;最后再把那些只有单条语句的代码块中的花括号去掉。这个练习的目的是让你直观感受不同格式风格对代码可读性的影响。

    Employee& Database::getEmployee(int employeeNumber)
    {
    for (auto& employee : m_employees) {
    if (employee.getEmployeeNumber() == employeeNumber) {
    return employee;
    }
    }
    throw logic_error { "No employee found." };
    }
  1. Preconditions 指的是:调用方在调用函数前必须满足的条件。Postconditions 指的是:函数执行完成后必须成立的条件。