9 风格
第二层次:认知
欧亚松鸦可能独居,也可能成对出现。它以模仿其他鸟类的鸣叫、机警敏锐,以及传播种子并由此促进森林扩展而闻名。
现在,我们已经有足够基础,可以进入 C 的核心。完成这一层次后,你应当能够专业地编写 C 代码。因此,本层次先对 C 程序的编写与组织作一番不可或缺的讨论(第 9、10 章),随后补全此前跳过的主要 C 构造:全面解释指针(第 11 章),带你熟悉 C 的内存模型(第 12 章)和动态存储分配(第 13 章),并使你能够理解 C 库的大部分接口(第 14 章)。最后,我们将更系统地讨论 C 程序可能出现的所有失败(第 15 章)。
本章内容
- 编写可读代码
- 格式化代码
- 命名标识符
程序具有双重用途。首先,正如我们所见,程序用于向编译器和最终的可执行文件发出指令。同样重要的是,它要为必须与系统打交道的人(用户、客户、维护者、律师等)记录系统的预期行为。因此有一条首要指令。
要点 9 #1
所有 C 代码都必须可读。
这条指令的难点,在于怎样才算“可读”。即使经验丰富的 C 程序员也未必意见一致,所以先尝试确立一份最低限度的必要条件清单。讨论人的状况时,首先必须牢记,它受到两大因素制约:生理能力和文化积淀。
要点 9 #2
短期记忆容量很小,视野范围也很小。
Torvalds 等人(1996)制定的 Linux 内核编码风格就是一个强调这一点的好例子;如果你还没有读过,很值得花点时间看一看。它的核心假设至今依然成立:程序代码必须能在相对较小的“窗口”中显示(无论是控制台还是图形编辑器),这个窗口大约只有 30 行高、每行 80 列宽,即仅有 2400 个字符的“表面积”。任何超出这个窗口范围的内容,程序员都必须靠大脑去记忆。例如,清单 1.1 中展示的第一个程序就很好地符合了这些限制。
Linux 编码风格幽默地提及 Kernighan 和 Ritchie(1978),同时也点出了另一项基本事实。
要点 9 #3
编码风格不是品味问题,而是文化问题。
忽视这一点,很容易导致没完没了、无谓、又几乎无关紧要的争论。
要点 9 #4
每个已经建立的项目都构成自己的文化空间。
请努力适应其中居民的习惯。创建自己的项目时,你有一定自由来确立自己的规则;但如果希望别人也遵守,就要谨慎:不能偏离相应社群盛行的常识太远。
9.1 格式化
C 语言本身对格式问题相当宽容。通常情况下,即使整个程序只写在一行上,只保留最少的空白,而且所有标识符都只由字母 l 和数字 1 组成,C 编译器也会不假思索地解析它。代码格式化的需求,源自人的能力局限。
要点 9.1 #1
为空白及其他文本格式选择一套一致的策略。
格式化涉及缩进、圆括号与各种括号({}、[] 和 ())的位置、运算符前后的空格、行尾空格,以及多个换行。人眼和大脑的习惯颇为挑剔;要让它们正确而高效地工作,一切都必须协调一致。
在第一层次的引言中,你已经看到本书代码应用了许多编码风格规则。可以把它们当作一种风格的示例;继续前进时,你很可能会遇到其他风格。下面回顾一些规则,并介绍此前尚未给出的规则:
- 代码块采用前缀记法,即左花括号
{位于一行末尾。 - 类型修饰符和限定符向左结合。
- 函数名与
()向左结合;条件的()则与if、for等关键字之间留一个空格。 - 条件表达式的
?和:两侧都留空格。 - 标点符号(
:、;和,)前不留空格,后面留一个空格或换行。
可以看到,一旦写成文字,这些规则就显得相当繁琐而武断。它们本身并无价值,只是视觉辅助工具,帮助你和协作者转瞬之间理解新代码。它们不是要让你一丝不苟地手工敲出;你应当取得并掌握能够代劳的工具。
要点 9.1 #2
让文本编辑器自动把代码格式化正确。
我个人用 Emacs 完成这项工作(没错,我就是这么老)。对我来说,它很理想,因为它自己就能理解 C 程序的大量结构。你的体验大概会不同,但日常生活中不要使用能力更差的工具。文本编辑器、集成开发环境(IDE)和代码生成器是为我们服务的,绝非反过来。
在较大的项目中,应对所有流转并供他人阅读的代码强制施行统一格式策略。否则,追踪程序文本不同版本之间的差异会变得困难。这项工作可以由命令行格式化工具自动完成。我长期偏爱 Artistic Style(astyle)。还是那句话,你的体验可能不同;请选择任何能够替你确保完成任务的工具。
9.2 命名
面对命名时,这类自动格式化工具就到达了能力边界。
要点 9.2 #1
为所有标识符选择一致的命名策略。
命名有两个不同方面:一方面是技术限制,另一方面是语义约定。遗憾的是,二者常常混为一谈,成为无休止的意识形态争论主题。
对 C 而言,有多种技术限制需要遵守;它们意在帮助你,所以请认真对待。首先,我们讨论的是所有标识符:类型(无论是否为 struct)、struct 和 union 成员、对象、枚举、宏、函数、类函数宏。相互纠缠的命名空间如此之多,必须十分小心。
头文件与宏定义之间的相互作用尤其可能产生意外效果。下面是一个看似无害的示例:
double memory_sum(size_t N, size_t I, double strip[N][I]);N是大写标识符,你的协作者可能忍不住把宏N定义为某个大数。- 只要有人包含
<complex.h>,I就用于表示 的平方根。 - C 实现可能把标识符
strip用作库函数名或宏名。 - 将来的 C 标准可能把标识符
memory_sum用作类型名。
要点 9.2 #2
头文件中可见的任何标识符都必须符合标准。
这里“符合标准”的范围很广。在 C 术语中,如果标识符的含义由 C 标准固定,它就是保留标识符,你不得重新定义为其他含义:
- 以下划线开头,后接另一个下划线或大写字母的名称,为语言扩展和其他内部用途而保留。
- 以下划线开头的名称,为文件作用域标识符以及
enum、struct和union标签而保留。 - 宏使用全大写名称。
- 所有具有预定义含义的标识符都予以保留,不得在文件作用域使用。其中包括大量标识符,例如 C 库中的所有函数、所有以
str开头的标识符(如前面的strip)、所有以E开头的标识符、所有以_t结尾的标识符,等等。
这些规则之所以相当棘手,是因为你可能多年都察觉不到任何违反;随后某一天,换到一台新的客户机器,或者引入了下一版 C 标准和编译器,甚至只是简单升级系统之后,代码突然爆炸。
降低命名冲突概率的一项简单策略,是尽量少暴露名称。
要点 9.2 #3
不要污染全局标识符空间。
只把应用程序编程接口(application programming interface,API)组成部分的类型和函数作为接口暴露,也就是只暴露供代码用户使用的那些内容。
如果一个库会被他人使用,或用于其他项目,一项良好策略是采用不太可能产生冲突的命名前缀。例如,POSIX 线程 API 中的许多函数和类型都带有前缀 pthread_。在我的工具箱 P99 中,API 接口使用前缀 p99_ 和 P99_,内部内容使用 p00_ 和 P00_。
还有两类名称可能与你未曾想到的其他程序员所写的宏产生不良相互作用:
struct和union的成员名;- 函数接口中的形参名。
第一点说明了标准结构体中的成员名为何通常带有前缀:struct timespec 的成员叫 tv_sec,是因为不熟悉规则的用户可能声明一个宏 sec;包含 <time.h> 时,它会以不可预测的方式造成干扰。第二点的例子此前已经见过。在 P99 中,我会把这样的函数规定成类似下面的形式:
double p99_memory_sum(size_t p00_n, size_t p00_i,
double p00_strip[p00_n][p00_i]);2
如果还把程序内部内容暴露给外部查看,问题会变得更严重。这会发生在以下两种情形:
- 所谓的内联函数,即定义(而不只是声明)在头文件中可见的函数;
- 类函数宏。
这些特性要到后面的第 16、17 章才会讨论。
明确命名的技术要点后,我们来看语义方面。
要点 9.2 #4
名称必须容易辨认,而且能够迅速区分。
这句话包含两个方面:能够区分,而且要迅速。请比较表 9.1 中的标识符。
表 9.1 一些容易与不容易区分的标识符示例
| 标识符对 | 可辨认 | 可区分 | 可迅速区分 |
|---|---|---|---|
lllll1llOll、llllll1l0ll | 否 | 否 | 否 |
myLineNumber、myLimeNumber | 是 | 是 | 否 |
n、m | 是 | 是 | 是 |
ffs、clz | 否 | 是 | 是 |
lowBit、highBit | 是 | 是 | 是 |
p00Orb、p00Urb | 否 | 是 | 否 |
p00_orb、p00_urb | 是 | 是 | 是 |
依你的个人品味,表格右侧的答案可能不同。这里反映的是我的品味:这类名称的隐含上下文,是我个人预期的一部分。一边是 n 和 m,另一边是 ffs 和 clz;它们之间的差别就在隐含语义。
我深受数学背景影响,所以对我来说,i 到 n 之间的单字母对象名(如 n 和 m)表示整数对象。它们通常只出现在相当有限的作用域中,用作循环计数等。使用单字母标识符并无不妥(声明始终在视野内),而且很容易迅速区分。
函数名 ffs 和 clz 则不同,因为它们要与所有其他可能用作函数名的三字母缩写竞争。碰巧的是,这里的 ffs 是“寻找第一个置位比特”(find first bit set)的缩写,但我无法一眼看出。它的含义更不清楚:“第一个”比特究竟是最高有效位还是最低有效位?C23 已通过头文件 <stdbit.h> 纳入这些功能,并选用了意义更明确的名称 stdc_bit_width 和 stdc_trailing_zeros。
把多个单词组合成一个标识符有若干约定,最常用的包括:
- 驼峰式命名(camel case),用内部大写字母分隔单词,如
internalCapitalsToBreakWords; - 蛇形式命名(snake case),用内部下划线分隔单词,如
internal_underscores_to_break_words; - 匈牙利命名法(Hungarian notation),[1] 在标识符前缀中编码类型信息,例如
szName,其中sz表示以零终止的字符串(string, zero terminated)。
你大概已经想到,这些方法没有一种尽善尽美。前两种容易遮蔽视野:一条不可读的表达式很容易塞满宝贵的一整行程序文本:
return theVerySeldomlyUsedConstant * theVerySeldomlyUsedConstant /
number_of_elements;2
匈牙利命名法则倾向于为类型或概念采用晦涩缩写,产生无法念出的标识符,而且一旦 API 发生变化就会彻底失灵。
因此在我看来,这些规则或策略都没有绝对价值。我鼓励你务实地处理这个问题。
要点 9.2 #5
命名是一种创造活动。
它无法轻易归入简单的技术规则。显然,一个标识符使用得越广泛,良好命名就越重要。因此,对于那些声明通常不在程序员视野中的标识符,也就是构成 API 的全局名称,命名尤其重要。
要点 9.2 #6
文件作用域标识符必须表意完整。
这里怎样才算表意完整,应根据标识符的类型来判断。类型名、常量、对象和函数通常服务于不同目的,所以应采用不同策略。
要点 9.2 #7
类型名标识一个概念。
这样的概念例如:struct timespec 所表示的时间,size_t 所表示的大小,enum corvid 所表示的鸦科鸟类集合,汇集人员数据的数据结构所表示的人员,一条项目链所表示的列表,用于查询的数据结构所表示的字典,等等。如果很难为某个数据结构、枚举或算术类型找到概念,大概应该重新审视设计。
要点 9.2 #8
全局常量标识一个事物。
也就是说,常量因为某种原因从同类型的其他可能常量中脱颖而出,具有特殊含义。这个含义可能来自我们无法控制的外部原因(表示 M_PI),可能因为 C 标准如此规定(false、true),可能来自执行平台的限制(SIZE_MAX),可能只是事实如此(corvid_num),可能出于文化动机(fortytwo),也可能源自设计决定。
很快就会看到,通常并不赞成使用文件作用域对象(全局对象)。不过,有时无法避免,所以必须知道该怎样命名它们。
要点 9.2 #9
全局对象标识状态。
这类对象的典型名称包括:toto_initialized,表示库 toto 已经初始化;onError,一个具有文件作用域但只供内部使用的对象,在必须拆除的库中设置;visited_entries,用于收集共享数据的散列表。
要点 9.2 #A
函数或类函数宏标识一个动作。
C 标准库中的函数并非全部、但有许多遵守这条规则,用动词作为名称组成部分。例如:
- 比较两个字符串的标准函数是
strcmp; - 查询某项性质的标准宏是
isless; - 访问数据成员的函数可以叫
toto_getFlag; - 设置相应成员的函数可以叫
toto_setFlag; - 两个矩阵相乘的函数可以叫
matrixMult。
9.3 所谓“国际化”
一般来说,在英语世界,“国际化”一词指平台或特性适应英语之外其他语言约定的能力。我给这个词加上引号,是因为在我看来,它本身已经包含相当程度的傲慢:仿佛英语位于世界文化的中心,其余一切都只是不甚重要地绕着它旋转。
此外,这个词的范围又太窄,因为它通常所指的特性不只涉及不同民族文化或语言,也涉及亚文化(例如人造克林贡语的爱好者),以及专业环境中的特殊技术要求,如数学记法、国际音标和图形字符。
所以,我们先回到编码时使用其他语言和文字的问题。请注意,这个问题不同于程序是否面向用户支持一种或多种语言,二者应当分开。8.7 节已经介绍过一些特性,可以帮助程序适应其执行环境。
毫无疑问,英语在计算机科学产业中极为重要,而且常常作为通用语。作为一名生活在法国、用英语撰写 C 语言图书的德国人,我每天都在面对这一点。[2]
但在我的小圈子之外,为对象名、函数名和代码注释使用不同语言的约定,完全可能合情合理。尤其是在文化中使用非拉丁文字的编程社群里,人们应当(而且确实)大量使用其他文字和语言。C 是用英语发展起来的,因而背负着一种历史包袱:它在编码中隐式强迫人们使用英语。每当有人公开讨论这些问题(例如在社交网络上),往往就会听到十分鲜明(而且明显傲慢)的意见:为什么居然会有人想到使用另一种语言;甚至还会有人一本正经地说,自己不知道怎样在键盘上输入非英文字符。
幸运的是,事情缓慢演变到了今天,各个社群如今可以真正选择想使用哪些语言特性。Unicode 支持(见第 14 章)通常已经足够好;哪怕对编码本身,C 也至少接纳了一定程度的语言支持。下面这样的代码理应轻松得到接受和维护,不会造成困难:
long année = 1990L; // Année de l'écriture de l'œuvre如果做不到,错不在写下代码的人,而在你的环境、实现、系统或机构。如果这样的代码让你惊讶,在建议修改之前,应当先质疑自己的判断准绳与偏见。
要点 9.3 #1
项目的自然语言应当以适应大多数参与者为准来选择。
换句话说,选择哪种语言仍然取决于许多因素。最重要的是,项目中的每个人大体都感到自在。在西方社会,这常常意味着项目使用英语;但不要仅仅因为从小接受这种假设,就把它视为天经地义。
另一个更应重视的方面,是针对特定领域问题采用恰当的技术语言。以数学为例。查看通常的数学文本,你会发现其中混用许多不同文字,并且对专名(例如
说到在语言核心中——也就是作为标识符——使用其他文字和数学符号,争议就大得多,过去的技术困难也大得多。原则上,先前版本的 C 标准已经允许加入大量字符,但允许用作标识符的集合规定不易理解,平台也并没有真正帮助你把它们融入代码。唯一可移植的方法是使用如下粗陋语法:
long ann\u00E9e = 1990L; // Ann\u00E9e de l'\u00E9criture de l'\u0153uvre这里,怪异的 \u00E9 和 \u0153 分别表示字符“é”和“œ”。不得不用这种方式编码多少违背了初衷,唯一明确的用途只是作为中间格式。必须借助工具把本机编码转换后再送给编译器;毫不奇怪,根本没有人使用这种特性。
C23 也许已经扭转局面。语言支持如今更直接地引用 Unicode;允许使用的标识符规则也变得清晰,并引用 Unicode 的一项子标准 UAX #31(“Unicode Identifier and Pattern Syntax”,Unicode 标识符与模式语法)。Unicode 在这里解决的问题是,非英语语言中的许多字符由不同部分组成。例如,此前使用的“é”,可以由编码为 \u0065 的普通拉丁字母“e”和编码为 \u0301 的尖音符“´”组成。因而一般来说,会有多种输入(这里是 \u00E9 和 \u0065\u0301),它们依上下文被视为表示同一个字符。
C 语言选择 UAX #31 中的 C 规范化形式(Normalization Form C)来处理这类歧义。[3] 要得知某个字符串(这里是标识符)映射到哪个唯一字符序列,首先把它分解为所有基础字符、附加符号等,再重新组合。对“é”或韩文音节 Gag“각”(\uAC01)而言,这并不十分有趣;过程得到的字符正是起始字符:
\u00E9 -> \u0065\u0301 -> \u00E9
\uAC01 -> \u1100\u1161\u11A8 -> \uAC012
当若干字符具有相同分解时,情况就更有意思。例如“Å”(带上圆圈的拉丁大写字母 A)与“Å”(埃格斯特朗符号):[4]
\u00C5 -> \u0041\u030A -> \u00C5
\u212B -> \u0041\u030A -> \u00C52
这里,码位 \u212B 被视为具有与直接字母组合相同的分解,C 形式将其投射到码位 \u00C5。
另一种歧义出现在本身被视为字母、但源自希腊字母的符号上,例如欧姆符号[5]“Ω”(\u2126),它源自希腊大写字母 Omega“Ω”(\u03A9):
\u03A9 -> \u03A9 -> \u03A9
\u2126 -> \u03A9 -> \u03A92
另一方面,被认定为字母、但字形显然不同于其他所有字母的技术符号,例如“ℜ”和“ℑ”(分别表示复数的实部和虚部),会映射到自身。
要点 9.3 #2
只有在 C 规范化形式下映射到自身的字母,才允许用于标识符。
这条规则也许还不够明确,让我们换一种说法。
要点 9.3 #3
只有直接源于自然语言,或者与所有自然语言字符都明显不同的字母,才可用于标识符。
不过要注意,C 规范化形式并不能解决所有可能问题。尤其是,不同语言的字形可能无法区分,例如希腊大写字母 Alpha 与拉丁大写字母 A,它们具有相同字形“A”。
在首字符以外的位置,标识符还可以包含数字。你可以使用 Unicode“十进制数字”类别中的广泛字符,但要小心,因为它们的字形也可能难以区分。例如,名称 a𝟎(使用数学粗体数字零“𝟎”)可能与 a0(使用普通数字零字符“0”)看起来非常相似。
要点 9.3 #4
只有当不同文字的字母或十进制数字的不同形式彼此明显可辨时,才在标识符中使用它们。
C23 引入的规则仍未涵盖下标和上标数字。依我看,如果能够区分 a₁ 和 a₂ 这样的对象名会很不错。我所用平台的编译器已经把这当作扩展来支持,但它可能还没有完全可移植。
要点 9.3 #5
在标识符中使用下标或上标字母不可移植。
小结
- 编码风格属于文化问题。请保持宽容与耐心。
- 项目自然语言的选择十分重要,应由参与者达成共识。
- 代码格式化关乎视觉习惯。环境应当自动提供格式化,让你和同事可以毫不费力地读写代码。
- 为对象、函数和类型命名是一门艺术,对代码是否表意完整起着核心作用。
- 用作名称的标识符可以使用非拉丁字符,以表达项目自然语言中的思想,或特定领域公认术语中的概念。