简介:《中兴通讯软件编程规范——C/C++》是一份面向C/C++开发工程师、技术负责人与代码评审人员的企业级编码标准文档,针对团队协作中代码风格不统一、可读性与可维护性不足等问题,给出可落地的约束条款。正文按范围与术语、基本原则、布局、注释、命名规则、变量常量与类型、表达式与语句、函数与过程、可靠性、可测试性、断言与错误处理等章节展开;条款区分为须严格执行的规则性和可灵活取舍的建议性两类,并配以正例、反例与说明,附录另附编程模板和规范检查表,便于逐项对照自查。资源包共1个doc文件,约486KB,为完整标准正文。已有301人学习下载。读者可据此统一项目命名与注释习惯、梳理函数职责与错误处理策略,并借助检查表在代码评审中核查,降低维护成本、提升代码可重用性。
1. C/C++ 编码规范:一份二十年前的企业标准,为什么今天读起来还扎手
多数人第一次翻开 C/C++ 编码规范,预期看到的是缩进几格、花括号放哪、变量怎么起名这类风格偏好,翻两页就合上。真正读进去会发现,它解决的是另一个问题:当二十个人的代码要合进同一个编译单元,怎么让「这段代码有问题」从评审会上的各执一词,变成提交前就能判定的结论。这份标准最关键的机制,是把条款拆成「规则」和「建议」两级,规则要求全员执行,建议按项目取舍,再配上成对的正例反例和一张检查表。风格是主观的,判据是客观的,这套结构把前者转成了后者。搞懂它为什么这么设计,比背下八十列、四空格这些数字有用得多。
2. 规则与建议:两级约束怎么把「风格」变成可判定的判据
规范这类东西,最容易死在「一刀切」上。全部强制,存量代码立刻大面积违规,团队要么停工大改,要么集体阳奉阴违;全部建议,等于没有规范,评审时还是各说各话,谁也说服不了谁。这份标准的解法是在术语定义阶段就把约束分成三层:原则是指导思想,规则是必须遵守的约定,建议是必须加以考虑的约定。三个词的分量完全不同,落到执行上就是三种处理方式。
2.1 为什么必须拆成「规则」和「建议」两级
从工程角度看,这是在做约束强度分级,和编译器把诊断分成 error 与 warning 是同一个思路。规则对应 error:违反即缺陷,必须改,没有讨论空间。建议对应 warning:需要结合上下文判断,可以带着理由放行,但放行的理由要能被别人看见。
这么分能 work 的根本原因,是规范的执行成本分布极不均匀。有些条款可以机械判定——一行超过 80 列、用了 TAB 缩进、if 后面没跟花括号,grep 一遍就能列出来,这类天然适合做成硬规则。另一些条款依赖语义判断,比如「关系较为紧密的代码应尽可能相邻」「避免过分的编程技巧」,只有写代码的人知道两行是不是同一个逻辑单元,工具判不了,只能做成建议。
如果把后者也硬做成规则,会出现两种后果:要么评审者为了「合规」把不相干的代码硬凑到一起,制造出更差的代码;要么条款被普遍违反后失去威信,连带那些真正该强制的规则也一起失效。规范的公信力是消耗品,用一次少一次,把不可判定的条款塞进规则里,就是在提前透支它。
# 机械可判定的规则,适合放进提交钩子 grep -nP '\t' src/*.c # 命中即违反「禁止 TAB」 awk 'length($0) > 80 {print FILENAME":"FNR}' src/*.c # 超 80 列的行 grep -nE 'if *\(.*\) *[^{ ]' src/*.c # if 后未紧跟花括号(近似)上面三条命令对应的都是字符级检查,不需要理解代码语义。-n输出行号便于定位,-P启用 PCRE 才能用\t匹配制表符,awk的length($0)统计整行字符数含空格。这类检查的正确率接近 100%,误报也容易人工确认,所以它们适合做成强制规则。而「保持代码简明清晰」这一条,任何静态工具都只能给启发式提示,最终仍要人来判断,这正是它只能待在原则层的原因。
2.2 正例与反例成对出现:把条款编译成可比对样本
规范里几乎每条规则都配了正例和反例,这不是排版习惯,而是把抽象条款转成可判定的样本。类比一下:规范条款是规格说明,正反例就是这份规格的单元测试——读到「指针的 * 应放在变量名前」,光看文字仍有歧义,是float *pfBuffer还是float* pfBuffer?正例反例并排摆着,一秒钟就能对齐理解。这种「用最小差异对展示边界」的手法,和测试里的边界值分析同源。
更值得说的是反例的信息量。反例标出的是「曾经真实发生过的错误写法」,不是编造出来的假想错误。一份规范里反例的质量,往往比正例更能反映编写者的经验密度——写得越具体,说明踩过的坑越真。
/* 正例:* 紧贴变量名,一行只声明一个指针 */ float *pfBuffer; char *pcName; /* 反例:* 紧贴类型名,视觉上诱导人把 float* 当成一个整体类型 */ float* pfBuffer, fValue; /* fValue 其实是 float,不是指针 */这个反例的杀伤力在于float* a, b;里只有 a 是指针。C 的声明语法把*绑定到声明符而不是类型名,视觉上却诱导人把float*读成一个类型。规范选择float *p的写法,本质是让代码的视觉结构与语法结构一致,避免读者在类型系统上产生错觉。
2.3 附录 B 检查表:把评审从「记忆」转移到「清单」
检查表的存在说明编写者想清楚了一件事:规范能不能落地,不取决于写得多全,取决于评审时能不能被想起来。人的工作记忆容量有限,评审几百行代码时注意力很容易被逻辑问题吸走,格式类问题就漏了。检查表的机制是把「该查什么」外化成一张纸,评审者按项打勾,漏检率会明显下降。这套方法在别的工程领域早被验证过,核心不是检查表本身多聪明,而是它把「依赖记忆」换成了「依赖流程」。
从目录结构能看出检查表的覆盖面:布局、注释、命名、变量与类型、表达式与语句、函数与过程、可靠性、可测试性、断言与错误处理,与正文一一对应。也就是说正文负责解释「为什么」,检查表负责执行「查什么」,两者是同一套规则的两种视图,缺一个都不完整。
提示:检查表长期不更新会退化成形式主义。比较务实的做法是每个季度回看一次缺陷库,把反复出现的同类问题补成新检查项,让表保持「活着」。
3. 布局的物理账:80 列、TAB 禁令与花括号到底在防什么
布局这一章看着最像美术指导,其实每条规则背后都有一笔物理账。80 列、4 空格、禁 TAB、花括号独占一行,这些数字不是拍脑袋定的,是当年那套开发环境逼出来的局部最优,而且每一条都对应一类具体的失败。
3.1 80 列不是美学,是并排 diff 和打印的带宽
80 列的历史源头是穿孔卡片和 80×24 的字符终端。到了 2003 年,显示器早就宽了,为什么还留着这条限制?因为代码真正被读的场景不是全屏浏览,而是并排比对。代码评审、版本 diff、两个人对着屏幕讲逻辑,都需要左右两栏同时显示,两栏各 80 列,加上行号和分隔符,正好塞进当时主流的屏幕宽度。超过 80 列的行在并排视图里要么被截断,要么触发横向滚动,评审效率直接掉一个档次。
打印同理。A4 纸横向、等宽字体小五号,一行大约就是 80 到 90 个字符,超过这个宽度打印出来末尾折行,折行的代码在纸上极难读。所以 80 列的本质是「最窄公共显示单元」的宽度上限,它约束的对象不是编辑器,而是评审和打印这两个下游环节。理解这一点,就知道这条规则什么时候可以放宽——如果某个项目完全不做并排评审也不打印,理论上可以改,但要明确这个决定是团队做的,不是偷偷摸摸绕过去的。
配套的断行规则是长表达式要在低优先级操作符处拆分,操作符放在新行之首。
/* 正例:一元操作符紧贴,多元操作符两侧留一个空格 */ fValue = fOldValue; /* 赋值符两侧留白 */ fTotal = fTotalBill + fValue; /* 二元加号两侧留白 */ !bValue; /* 一元 ! 不加空格 */ iCount++; /* 一元 ++ 不加空格 */ *strSource; /* 解引用 * 不加空格 */ tBox.dWidth; /* 成员访问符不加空格 */ tBox->dWidth; /* 正例:长表达式拆行,操作符放行首 */ fTotalBill = fTotalBill + faCustomerPurchases[iID] + fSalesTax(faCustomerPurchases[iID]);操作符放行首的好处很直接:扫一眼行首就知道这一行是独立语句还是续行。放行尾的话,读者要一路看到行末才发现「哦,这句还没完」,视线得来回折返。这个细节在长表达式密集的数值计算代码里,收益非常明显。
3.2 禁 TAB 与 4 空格:消除编辑器差异带来的 diff 噪音
TAB 的问题不在缩进本身,在于它的宽度没有标准。有人设 4,有人设 8,同一份文件在不同人屏幕上长得完全不一样。更麻烦的是版本控制:如果一个人用 TAB 缩进、另一个人用空格,哪怕逻辑一个字没改,diff 也会整片标红,真正的改动淹没在噪音里,评审者看一眼几百行的 diff 就直接放弃了。
禁 TAB 的本质是把「缩进的语义」从编辑器配置里拿出来,固化进文件字节。4 空格是个折中值:2 格太浅,嵌套三层就看不清层次;8 格太深,稍微嵌两层就顶到 80 列边界。4 格在 80 列宽度下能容纳五层嵌套还有余量,覆盖了绝大多数函数的实际深度,所以这两个数字是配套的,不能单独改一个。
/* 正例:花括号独占一行,与引用它的语句左对齐,块内缩进 4 空格 */ void Function(int iVar) { while (condition) { DoSomething(); /* 与 { 缩进 4 空格 */ } } /* 反例:花括号不独占一行,判断层次时视线要在行尾来回找 */ void Function(int iVar) { while (condition) { DoSomething(); } }3.3 花括号独占一行:悬挂 else 与宏展开的防御
{}独占一行、与引用它的语句左对齐,这条规则常被当成风格偏好,其实它防的是两类真实事故。第一类是悬挂 else。
/* 反例:省略花括号,else 归属成谜 */ if (a > 0) if (b > 0) DoSomething(); else DoOtherThing();C 的语法规定 else 与最近的未配对 if 结合,所以这个 else 属于内层 if,不是外层。缩进骗过了眼睛,编译器不会被骗。这种 bug 在评审时极难发现,因为代码「看起来」完全正确,只有构造出 a > 0 且 b <= 0 的输入才会暴露。强制加花括号后,结构被显式表达,歧义消失,这类错误从「靠人细心」变成「语法上不可能」。
第二类是宏。多语句宏如果用裸花括号包裹,在 if 后面直接展开会把语法结构切断。
/* 正例:多语句宏用 do-while(0) 包裹,语法上等价于一条语句 */ #define SWAP(a, b) \ do { \ int t = (a); \ (a) = (b); \ (b) = t; \ } while (0) /* 反例:裸花括号宏 */ #define SWAP_BAD(a, b) { int t = (a); (a) = (b); (b) = t; }if (cond) SWAP_BAD(x, y); else ...展开后变成if (cond) { ... }; else ...,中间那个分号直接切断了 if-else 结构,编译报错。do-while(0)让宏在语法上等价于一条语句,末尾的分号正好收在while(0)后面,无论出现在 if 分支、for 循环体还是独立语句位置,展开后语法都完整。
3.4 空格分区:一元操作符紧贴、多元操作符留白
规范把空格用法拆得很细:一元操作符!~++--*(解引用)&(取地址)前后不加空格,多元操作符两侧各留一个空格,关键字之后留空格,函数名之后不留空格。这套规则的底层逻辑是用空白编码「结合紧密度」——一元操作符与操作数是一个整体,贴紧表示「它们是一体的」;多元操作符连接两个独立表达式,留白表示「这是两个东西在运算」。读者不需要理解语法,靠视觉密度就能判断运算结构。
关键字后留空格(if (、for ()与函数名后不留空格(DoSomething(),是为了区分控制结构和函数调用。这个区分在宏面前尤其重要:if(和foo(长得一样,控制流的视觉锚点就没了,扫代码时容易漏看分支。还有一个隐性收益是=和==的区分——多元操作符留白让if (a == b)里两个等号周围都有空格,而if (a = b)只有赋值一处留白。差异很小,但在等宽字体下扫视时,多一个空格的「鼓包」确实更容易被注意到,这是用排版冗余换错误发现率。
注意:不少编辑器带「保存时自动格式化」,如果它的配置和团队规范冲突,会在提交时把整份文件的格式全改掉,反而制造大量 diff。统一格式化配置和统一缩进风格同等重要,两者要一起管。
4. 文件布局与命名:把依赖顺序和类型信息写在明面上
布局管的是行内,文件布局管的是整个编译单元。这一层规则的价值不在「整齐」,而在于让依赖关系、可见性、类型信息都能被快速定位,不用翻遍整个文件才能确认一件事。
4.1 头文件布局的固定次序与重复包含保护
规范给了头文件的固定次序:文件头注释、include 守卫、其它条件编译、include(标准库在前、非标准库在后)、常量、宏、全局类型、类定义、模板、全局函数原型。这个次序不是随意的。守卫必须在最外层,否则条件编译选项一嵌套,守卫就可能没生效;标准库 include 排前面,是为了让「依赖哪些系统能力」和「依赖哪些项目内模块」在视觉上分层;常量和类型定义放在函数原型之前,因为原型里要用到这些类型,顺序反了编译不过。
/* 正例:头文件的固定骨架 */ #ifndef MODULE_H #define MODULE_H #include <stdio.h> /* 标准库在前,用尖括号 */ #include "project_cfg.h" /* 项目内头文件在后,用双引号 */ #define MAX_BUF_LEN 256 typedef unsigned char BOOLEAN; int DoSomething(BOOLEAN bFlag); #endif /* MODULE_H */守卫宏用文件名全大写加下划线,保证在全局命名空间里唯一。#endif后面带注释,是为了在长头文件里一眼找到配对位置——当头文件几百行、嵌套多层条件编译时,这个注释能省掉大量上下翻找。函数原型区域前面用注释块分隔,也是同样的目的:让文件的「节」在视觉上可跳转。
4.2 include 的尖括号与双引号:搜索路径的语义差别
#include <stdio.h>与#include "heads.h"的区别不只是书写习惯,它告诉编译器走哪条搜索路径。尖括号走编译器指定的标准路径,双引号先查当前文件所在目录,再回落到标准路径。规范的写法是标准库一律尖括号、项目内头文件一律双引号,这个约定让「这个依赖是外部的还是内部的」在 include 行就可见。
这个约定在维护老代码时特别有用。一眼扫过去看到一堆双引号包着系统头文件,基本能判断这个模块是从别的平台移植过来的,或者作者没搞清楚搜索路径,随手写的。这类线索比读注释可靠得多,因为注释会过期,include 写法不会。
4.3 类的定义顺序:public 在前意味着什么
类定义里 public 在最前、protected 居中、private 在最后。这个顺序跟「先讲结论后讲细节」的阅读逻辑一致:使用者打开头文件,最关心这个类能干什么,也就是公有接口;实现细节应该往后放。配套规则是 public/protected/private 关键字与花括号对齐、内容缩进,这样访问控制段落的分界和class关键字平齐,扫一眼就能数出这个类暴露了多少接口。
实现文件里的顺序是构造、析构、公有、保护、私有,与声明顺序一一对应。这样头文件和实现文件可以并排看,左边第几个函数右边第几个函数,位置对得上,检查某个接口有没有实现时不用来回搜索。
/* 正例:public 在前,访问控制符与 { 对齐,成员缩进 */ class CCount { public: /* 与 { 对齐 */ CCount(void); /* 内容缩进 4 空格 */ ~CCount(void); int GetCount(void); void SetCount(int iCount); private: int m_iCount; /* m_ 前缀标明成员变量 */ };4.4 类型前缀命名法:没有 IDE 的年代里的类型编码
规范里的命名大量使用类型前缀:iLength、strCaption、pfBuffer、m_iCount、tPDBRes。i 是 int,str 是字符串,pf 是指向 float 的指针,m_ 是成员变量,t 是结构体。这套匈牙利命名法今天常被批评为反模式,理由是类型一变名字就得改、现代编辑器悬停就能显示类型。但放回 2003 年的语境,它解决的是真实问题。
那个年代的 C 代码没有实时类型提示,一个函数几百行,中间隔着几十个局部变量,读代码时想确认wSize到底是什么,要么往上翻到声明处,要么靠记忆。前缀把类型信息嵌进标识符,等于给每个变量挂了一个随身标签,读到哪里都知道它是什么。m_前缀则区分成员变量和局部变量,在成员函数里尤其重要——不用看this就知道这个变量属于对象还是栈上,作用域一目了然。
代价也很清楚:类型重构时前缀要跟着改,改漏了名字就成了错误信息。C++ 里用类而不是基本类型表达语义之后,类型前缀的收益迅速下降。更合理的理解是:这是特定工具条件下的权衡,不是普适教条。今天写 C++ 不必照搬,但「变量名要携带足够信息」这个目标本身没过时,只是实现手段从类型编码换成了有意义的业务词。
5. 避坑:规范落地时最容易翻车的六类现场
纸面规范和真实代码库之间隔着一段距离。下面这几类问题,几乎是把规范往存量项目上推时必然遇到的,每一条背后都有一个具体的机制没被理解,光记住条文没用。
5.1 头文件漏守卫,或者守卫名字撞车
现象:编译报重复定义,或者改了一个头文件,另一个不相干的模块跟着编不过。
原因:要么漏了#ifndef/#define,要么不同目录下两个同名头文件用了同一个守卫宏名,比如都叫UTIL_H。守卫宏是全局命名空间里的标识符,两个不同的头文件用同一个名字,第二个就被第一个「挡」掉了,内容根本没进来,编译错误报在莫名其妙的地方。
解决:守卫名带上模块或路径前缀,比如NET_UTIL_H、DB_UTIL_H;或者用#pragma once(非标准但主流编译器都支持)。关键是建立命名约束并写进检查表,而不是靠每个人自觉。
5.2 省略花括号,被宏或者后续修改反咬
现象:代码读起来没问题,跑起来 else 分支进不去;或者后来有人在 if 下面加了一行语句,逻辑悄悄变了没人发现。
原因:悬挂 else 的语法规则,加上省略花括号后新增语句只属于 if 的第一条。这两种情况的共同点是「缩进表达的意图」和「编译器理解的意图」不一致。更隐蔽的是后续维护,加一行代码的人看着缩进以为自己加进了分支,实际加在分支外面。
解决:无条件加花括号,包括只有一条语句的情况。这条规则的成本是每次多敲两个字符,收益是消除一整类不可见的逻辑错误,性价比在所有条款里排前列。
5.3 TAB 混进代码库,diff 整片标红
现象:某人提交了一个号称「只改了一行」的 commit,diff 显示几百行变更,评审直接放弃。
原因:编辑器配置不一致。有人 TAB 宽度设 8,有人设 4,有人开了自动转空格有人没开。一次格式化或者一次跨文件复制粘贴,就把整份文件的缩进字符换了一遍,逻辑没变但字节全变了。
解决:在仓库层加.editorconfig或者提交钩子,禁止 TAB 进入版本库;存量文件做一次性的全量转换,单独提交,不要和新功能混在同一个 commit 里,这样历史 blame 还能追溯。转换和重构分开提交,是保住代码历史可读性的关键。
5.4 注释写成语义复读机
现象:i++; // i 自增这类注释遍地都是,真正需要解释的魔数、边界条件、业务原因一个没有。代码改了注释没改,注释变成谎言。
原因:规范的注释章节强调「在功能、意图层次上注释,而不是重复代码表面意义」,但落到执行层,写「i++ 表示自增」比想清楚「这里为什么是 3 不是 4」容易得多,人自然选省力的路径。
解决:把注释要求从「每行都要有」改成「必须解释为什么」——魔数的来源、边界条件的业务依据、看起来奇怪的写法背后的约束。函数头注释写清前置条件、后置条件、失败返回什么。这类注释不会因为重构而失效,因为它们描述的是意图和不变量,不是语句本身。
5.5 用断言代替错误处理
现象:开发环境跑得好好的,发布版一上现场就崩;或者更糟——不崩,但数据错了。
原因:把 assert 当成参数校验用。assert 的语义是「这里不可能发生,发生了就是程序有 bug」,它在发布版里通常被NDEBUG宏关掉,编译后一行不剩。用它检查外部输入,等于在发布版里把检查删干净了。
解决:分清两条线。断言守的是内部不变量——函数入口处指针不该为空、状态机不该出现非法状态,这些是程序员的责任,assert 合适。外部输入,包括用户输入、网络报文、文件内容,必须用返回码或异常处理,任何编译配置下都要执行。规范的可靠性章节和断言章节是分开写的,这个分章本身就在提示这条边界。
/* 正例:断言守内部不变量,返回码守外部输入 */ int ProcessPacket(const Packet *pkt, size_t len) { assert(pkt != NULL); /* 内部约定:调用方保证非空 */ if (len > MAX_PKT_LEN) { /* 外部输入:必须显式检查 */ return ERR_LEN_TOO_LARGE; /* 定义 NDEBUG 后这句依然执行 */ } /* ... */ return OK; }assert在NDEBUG定义后展开为空,if不受影响。两者混用时,发布版就只剩一半的防御,出问题的往往正是被关掉的那一半。
5.6 全局状态让代码没法单独测
现象:想给某个函数写单元测试,发现它依赖一个全局配置、一个静态计数器、一个单例连接,测试之间互相污染,跑第二遍结果就不一样。
原因:全局变量和静态状态把「函数的输入」从参数列表扩散到了整个进程。规范里「避免或少用全局变量」不只是模块耦合问题,它直接决定可测试性——不可注入的依赖,测试时就没法替换成桩,只能启动整个进程跑集成测试。
解决:把依赖显式传进来,配置作为参数或者构造时注入;确实需要全局的,收敛到少数几个明确的位置,并提供测试时可重置的入口。可测试性不是写完代码再补测试工具,是设计阶段就要留出的接缝,留晚了改不动。
注意:这几类问题的共同点是「条文记住了,条文想防的东西没记住」。对着检查表打勾只能拦住表面,真正该问的是这一条规则在防哪一种具体的失败。
6. 设计取舍:用可判定的规则换掉不可判定的争论
这套规范最值得学的不是具体数字,是它承认了「有些事机器能判,有些事只有人能判」,并用两级约束把这个边界画了出来。80 列、禁 TAB、花括号独占一行,这些能被脚本检查的条款做成硬规则,成本极低、收益确定;「保持代码简明」「避免过分技巧」这类只能靠人判断的,做成建议和原则,不假装能强制执行。承认边界比假装万能更难,也更有用。
第二个取舍是「统一」优先于「最优」。4 空格不一定比 2 空格好看,80 列也不一定适合所有场景,但一个团队用同一套数字,比每个人用自己认为最好的数字,总成本低得多。规范的价值来自一致性,不来自每个条款的绝对正确。这一点在跨团队协作时尤其明显:两个人对着屏幕讲代码,格式一致时注意力全在逻辑上,格式不一致时视线要先花几秒钟解析布局。
第三个取舍是显式优于隐式。花括号要写、类型要进名字、依赖按顺序排、断言和错误处理要分开,这些规则都在把「读者需要推断的东西」变成「写者必须写出来的东西」。写的人多花一点力气,读的人少花很多力气,而代码被读的次数远多于被写的次数,这笔账怎么算都是赚的。
验证方法可以很轻。拿一个存量模块,跑一遍机械检查,统计行宽、TAB、花括号三类违规的密度;再抽十个函数,看注释有没有解释「为什么」而不是复述语句;最后挑其中最复杂的一个,试着给它写单元测试,看能不能不启动整个进程。三个动作分别对应布局、注释、可测试性,半小时就能判断这套规范在这个代码库上落地到了什么程度,以及该从哪一条开始补。
从那以后我每次接手一个老模块,都会先跑这三步,再决定是照搬规范还是先做局部改造。规范不是拿来背的,是拿来当尺子量差距的,量完就知道力气该往哪使。希望帮到你。
本文还有配套的精品资源,点击获取