C++ 编码规范简介

2022-10-07
9 min read

本文是我参考其他规范文档并结合自己日常编码习惯而总结的文章。因为个人水平优先,文章中不合理的地方,还请指正

编码目标

代码是写给人看的,不是写给机器看的,只是计算机可以执行而已 —— Donald Knuth

  1. 以人为本,优先为读者优化代码。读代码的时间远比写代码的时间要多
  2. 保持一致的编码风格。团队合作,所有人都使用相同的思维模式,效率较高
  3. 极端情况可让步于性能。一些核心代码,为了性能可以适当牺牲可读性

编码原则

  1. DRY,Dont Repeat Yourself。不要出现重复的代码,重复的内容请使用函数进行封装
  2. SOLID,单一职责(S)、开闭原则(O)、最小知识原则(L)、接口隔离(I)、依赖倒置(D)
    1. S,一个函数和类只做一件事,复杂的事情组合简单的函数和类实现。一些规范里限制函数的行数也有这方面的考虑(大部分代码规范限制一个函数可以在一屏内展示,大约 100 行)。例如函数内部一些大的循环可以整理成一个单一职能的函数,使用函数名告知读者函数的功能
    2. O,代码结构上要为未来修改与添加留一些冗余,新功能的添加对已有代码的影响要足够小。S 是开闭的前提,过多职能的函数与类难以修改与扩充
      1. 设计的开始我们并不知道未来是否会添加新的功能,所以可以使用简单的实现方法。但 S 在任何时候都应该满足,满足 S 的代码修改时会更方便
    3. L,无论是函数或者类,依赖第三方内容越少,修改起来就越方便(被动修改的内容较少)。L 也表示里氏替换原则,本文略过
      1. 最小知识原则的一个工程实践是封装,例如使用函数封装全局变量的访问可以避免全局变量与其他模块之间的强耦合,封装隐藏了全局变量的细节(例如真实类型),后续对全局变量的优化也不会造成大范围的变动
      2. 代码结构也可以应用这个原则,例如头文件应尽可能自包含且足够小,这样可减少头文件修改对其他 h/cpp 的影响
      3. 软件设计的一个核心是解耦,而解耦的一个原则是 L,L 可以用来解释绝大部分设计原则与模式。例如高内聚低耦合,就是尽量减少函数、类或者 lib 对外的信息量,降低学习与使用难度
    4. I,接口隔离是 L 的一种表现形式,函数或者类不应该依赖其不使用的函数或者类。可以使用代理类(proxy)连接不同模块与功能
    5. D,面向接口编程
  3. CLEAN,高内聚低耦合
    1. 内聚(Cohesive)/松耦合(Loosely Coupled)/封装(Encapsulated)/自包含(Assertive)/无冗余(Nonredundant)

参考资料

  1. 本文未提及的内容请以 Google C++ 风格指南 为准。可以使用 clang-formatAstyle 等工具实现代码的自动化格式
  2. 《重构:改善既有代码的设计》,代码高可读性技巧手册
  3. 《大规模c++程序设计》,如何规范与组织大规模 C++ 项目
  4. 《C++ Core Guidelines》,大佬们的经验与规范
  5. 其他:奇虎 360 编码规范 /

规范

命名规则

  1. 函数命名、变量命名、文件命名要有描述性,少用缩写
  2. 文件名要全部小写,可以包含下划线 “_” 。C++ 文件在项目内要统一以 .cc.cpp 结尾,头文件以 .h 结尾
  3. 类型名称的每个单词首字母均大写,不包含下划线:MyExcitingClass,MyExcitingEnum
  4. 变量 (包括函数参数) 和数据成员名一律小写,单词之间用下划线连接。类的成员变量以下划线结尾,但结构体的就不用
  5. 声明为 constexprconst 的变量,或在程序运行期间其值始终保持不变的,命名时以 “k” 开头,大小写混合,例如:const int kDaysInAWeek = 7;
  6. 常规函数使用大小写混合(或者与已有规则保持一致)。对类成员变量的取值和设值函数也可以与变量名匹配,使用小写下划线命名,但不强制要求,应在同一个类内保持一致
  7. 命名空间以小写字母命名。顶级命名空间的名字应该基于项目名称。另外,要注意避免嵌套命名空间的名字和常见的顶级命名空间的名字之间发生冲突
  8. 枚举(包括作用域枚举和非作用域枚举)的命名应当和常量保持一致而不是宏。即使用 kEnumName 形式命名
  9. 一般情况下不推荐使用宏,优先考虑使用常量或函数,如果你一定要用,那么像这样命名:MY_MACRO_SMALL

头文件

  1. 项目中尽量使用相同的文件后缀,例如 .h/.cc/.cpp
  2. 包含守卫尽量使用 C++11 标准提出的 #pragma once
  3. 减少头文件中的信息量,例如通过定义接口实现模块之间的解耦
  4. 只内联简单的小函数

头文件依赖

为了减少编译耗时,也依据最小知识原则(L),头文件中包含的信息应该尽可能少

  1. 每个头文件都应该包含尽可能少的内容,但应该是完备的(即当前头是可独立编译的)
    1. 头文件被很多 CPP 文件包含,一旦修改了头,就会触发其他 cpp 的重新编译。更多的内容被修改的几率就越大
  2. 包含过多内容的头文件,编译器在进行预编译时需要更多时间载入其他头中的信息
  3. 可以减少编译耗时的方法:前置声明、Pimpl 等。但不应该过度使用这些技巧
// 只包含当前头文件需要的内容,cpp 文件需要的内容由 cpp 自行包含
#include <db_interface.h>
#include <string>

namespace Test {
    // 第三方模块中修改了 DB 的实现,或者提供了不同类型的 DB 实现
    // 包含当前头文件的项目只需要重新链接,而不需要编译包含当前头文件的 CPP,减少了依赖编译耗时
    std::shared_ptr<DB> get_db(const std::string& db_type); 
    //DB& get_db(const std::string& db_type); // 单例 DB 可以这样
} // namespace Test

可以参考 Google 开源的高性能 KV 数据库 LevelDB 中对相关技巧的使用,例如:BytewiseComparator

虽然前置声明能减少编译耗时,但编译相关实现时依旧需要引用相关定义文件。前置声明容易造成未引入定义文件而编译失败的问题,影响工作效率,所以前置声明应尽量少用。合理的定义接口可以最大化避免前置声明的使用

include 顺序及路径

  1. include 非标准库文件时应该包含库的相对路径,以减少歧义。大的项目中存在同名的头文件,但不同 lib 的路径不同,头文件中引入路径可以减少冲突的风险
  2. 最先包含的头文件应该是你自己写的头文件,这样做可以检查自己写的头文件是否是自包含且完备的
  3. 最后引入第三方头是为了避免第三方头定义一些古怪的内容(例如宏),影响其他头文件。标准库一般没有奇怪的内容,不会影响三方库,所以标准库的引入放在自定义头和第三方头之间。如果同时引入了 C 和 C++ 头文件,将 C 的头放在 C++ 头之前

示例如下:

#include "path/to/own/header.h"

#include <c_std_header>

#include <cpp_std_header>

#include "path/to/third_part/header.h"

...

作用域

  1. 每个项目或者模块都应该有自己的全局命名空间,避免项目内容与其他模块出现同名冲突
  2. 头文件中严禁使用 using namespace name 开启任何命名空间。头文件将影响所有包含当前头文件的 h/cpp 文件,禁止在头文件中打开 namespace 可以降低冲突风险
  3. 优先使用匿名命名空间替换 cpp 文件中的 static 变量,但不要在头文件中这样做
  4. 使用命名空间中的非成员函数替换 class 中的 static 函数。class 中的 static 函数增加了 class 中的信息,命名空间中的非成员函数(Free functions)往往有着更好的灵活性。例如一些辅助性的工具可以写在 classa_utils.h 文件中,将非必须工具从 class 定义中剥离
  5. 尽量不要直接暴露全局变量与函数,以避免全局变量、函数污染命名空间。请将函数与变量隐藏在某个命名空间下

静态与全局变量

禁止使用具有静态存储期的对象,除非它们是可平凡析构

所谓的静态存储期,指的是和进程生命周期等长的变量,全局/局部 static 变量在进程载入内存的时候就存在了,直到进程从内存中卸载才会消失;所谓的平凡析构指的是对象的析构函数和对象内的成员对象的析构函数不做任何事情。一个只包含 int/double/float 的结构体是可平凡析构的,但一个包含 string 的对象就不是可平凡析构的,因为当前对象析构时成员 string 对象会删除其指向的堆字符串

禁止使用全局 static 是因为 static 对象的构造和析构是没有固定顺序的,相互依赖的 static 对象将造成系统的不稳定,很有可能造成重复析构等异常现象。如果类的析构不做任何事情,则可避免析构顺序造成的异常

全局对象可以考虑使用懒汉形式的单例模式,使用函数封装对全局对象的访问,此时全局对象的创建顺序是可控的。单例模式需要动态创建不删除的对象,做法是使用函数局部的静态普通指针或引用(例如,static const auto& impl = *new T(args...);),大部分内存泄漏检查工具都支持忽略这种情况

如果能确认全局对象(例如容器)之间没有很强的相关性,放松对智能指针的约束也不是不可以(主要是方便),示例如下:

std::shared_ptr<ClassA> get_global_classa() {
    static std::shared_ptr<ClassA> class_a_sptr = std::make_shared(...);
    
    return class_a_sptr;
}

可读性

参数个数

一个函数的参数不应过多,参数过多维护起来会比较麻烦,很容易出错

  1. 函数参数之间应该是正交的,即参数列表中的参数不能通过组合其他参数获得
  2. 减少入参个数,一个比较常见的办法是构造一个包含参数的结构体,将参数的构造分离出来

函数长度与注释

代码文件中行的长度以 Google 规范为准:小于等于 80 个字符,适当放宽也可以(比如 120,可以一屏显示),但同一个模块应该保持一致

函数功能应该明确,一个函数应该只做一件事,组合简单的函数以实现复杂的功能。笔记本屏幕一般能显示 35 行左右,建议函数行数小于 70,降低认知难度。减少函数长度的技巧示例:

  1. 将大的循环拆分成小的功能函数
  2. 使用查表替换过长的 if/else 和 switch
  3. 其他,略

代码中尽量减少文本注释,使用函数名完成注释的功能。对于快速迭代的代码,大部分人不愿意去维护冗长的注释文本,随着时间的推移,慢慢的注释和函数的功能将有较大的偏差,造成读者的误解。一个比较好的原则:

如果你觉得需要对一段代码进行文本注释,请将这段代码封装成一个函数并使用函数名描述函数的功能

类大小

成员函数过多的类一般意味着冗余,减少类大小的方法:

  1. 单一职责,减少类的功能可以减少其向外提供的方法数量
  2. 类的成员函数必须是正交的,即任何一个成员函数都不能使用其他函数组合实现
  3. 将非必要功能转移到 utils 文件中。向外提供接口时常提供一个 class 定义文件,包含 class 必需的方法;还有一个辅助文件 utils,包含一些功能性的方法

Rule Of 5

Rule Of 5 也称为 Big5,是对 C++ 构造函数规范的简称。C++ 构造函数规则比较繁琐,容易误用,所以一般要求:

要么一个构造函数也不提供,全部由编译器生成,要么显式声明所有构造函数

C++ 的构造函数有:拷贝构造函数、移动构造函数、拷贝赋值、移动赋值和析构函数。C++ 构造函数规则比较复杂,例如使用 default/delete 显示定义拷贝相关构造将默认禁用移动相关构造,反之亦然。其他细节可以参考: 《C++ Move Semantics》 / 《Effective Modern C++》 /

类型

  1. 尽可能多的使用 const 修饰符
  2. 推荐使用 C++ 风格的类型转换,例如 static_cast ,如此可方便检索与修改
  3. 禁止使用运行时类型检查(RTTI)。RTTI 允许程序员在运行时识别 C++ 类对象的类型。它通过使用 typeid 或者dynamic_cast 实现。禁止使用 dynamic_cast
  4. 如果可以给字段取有意义的名字,应该优先使用结构体,其次才是 pair 和 tuple

其他

多线程等(略)

异常

非性能相关代码可以使用异常,但严禁使用异常处理业务逻辑,异常只能用来处理异常事件

  1. 异常可以更清晰的分离业务逻辑和错误逻辑,使代码更为清晰,适当使用异常是合理的
  2. C++ 异常处理效率比正常流程慢一个数量级(主要问题在于异常系统的全局锁),只要不触发异常,包含异常和不包含异常的代码,性能、可执行文件大小差距在 5% 左右
  3. 可以使用第三方工具同时返回函数出参和异常信息,例如:Boost.LEAFstd::expected

文件编码

文件编码强制要求为 UTF-8

变长数组和 alloca

因为变长数组alloca 暂时(C++11)还不是 C++ 的标准,不同编译器的支持和实现程度不同,所以禁止使用。必要时可以考虑使用 arena

std::size_t arr_sz = caluc_sz();

// 数组的长度在执行期间计算,即为变长数组。变长数组是 C99 标准,但不是 C++ 标准
double tmp_data[arr_sz]; 

尽量不使用宏,必要时要非常谨慎。尽量以内联函数,枚举和常量代替宏