C++ 编程规范(一)


C++ 编程规范(一)

本文是我对于Google 开源项目风格指南——中文版的学习笔记,在学习的过程中搬运和整理出了这篇文章。

前言

编程规范是一个特别重要的问题。

关于编程规范、代码风格相关的教程,网上已经特别丰富,本文仅做一个简单的介绍,期望读者阅读本文后能够对如何规范的用C++语言进行编程有一个基本的认知。

如果想要更详细的介绍,可以参考:Google 开源项目风格指南——中文版

头文件

通常每一个 .cc 文件都有一个对应的 .h 文件。也有一些常见例外,如单元测试代码和只包含 main() 函数的 .cc 文件。正确使用头文件可令代码在可读性、文件大小和性能上大为改观。

#define 保护

这其实是非常容易被忽视的一点,因为很多时候我们都习惯于用编译器创建头文件,而创建的头文件里面通常直接就包含了”#define保护”。

我们可能不知道为什么会有它,也可能很少去考虑它的作用,一旦我们哪天遇到了“特殊情况”——比如在终端用vim创建头文件,大概率就忘记有#ifndef......#define......#endif......这么一回事了。

所有头文件都应该有 #define 保护来防止头文件被多重包含,命名格式当是:<PROJECT>_<PATH>_<FILE>_H_

为保证唯一性,头文件的命名应该基于所在项目源代码树的全路径。例如,项目 foo 中的头文件 foo/src/bar/baz.h 可按如下方式保护:

#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif // FOO_BAR_BAZ_H_

#include 的路径及顺序

使用标准的头文件包含顺序可增强可读性,避免隐藏依赖。

推荐的 #include 顺序为:相关头文件,C 库,C++ 库,其他库的 .h,本项目内的 .h。

项目内头文件应按照项目源代码目录树结构排列,避免使用 UNIX 特殊的快捷目录 .(当前目录)或 ..(上级目录)。例如,google-awesome-project/src/base/logging.h 应该按如下方式包含:

#include "base/logging.h"

又如,dir/foo.ccdir/foo_test.cc 的主要作用是实现或测试 dir2/foo2.h 的功能,foo.cc 中包含头文件的次序如下:

  1. dir2/foo2.h(优先位置,详情如下)
  2. C 系统文件
  3. C++ 系统文件
  4. 其他库的 .h 文件
  5. 本项目内 .h 文件

这种优先的顺序排序保证当 dir2/foo2.h 遗漏某些必要的库时, dir/foo.ccdir/foo_test.cc 的构建会立刻中止。因此这一条规则保证维护这些文件的人们首先看到构建中止的消息而不是维护其他包的人们。

dir/foo.ccdir2/foo2.h 通常位于同一目录下(如 base/basictypes_unittest.ccbase/basictypes.h),但也可以放在不同目录下。

按字母顺序分别对每种类型的头文件进行二次排序是不错的主意。注意较老的代码可不符合这条规则,要在方便的时候改正它们。

您所依赖的符号(symbols)被哪些头文件所定义,您就应该包含(include)哪些头文件,前置声明(forward declarations)情况除外。比如您要用到 bar.h 中的某个符号,哪怕您所包含的 foo.h 已经包含了 bar.h,也照样得包含 bar.h,除非 foo.h 有明确说明它会自动向您提供 bar.h 中的 symbol。不过,凡是 cc 文件所对应的「相关头文件」已经包含的,就不用再重复包含进其 cc 文件里面了,就像 foo.cc 只包含 foo.h 就够了,不用再管后者所包含的其它内容。

举例来说,google-awesome-project/src/foo/internal/fooserver.cc 的包含次序如下:

#include "foo/public/fooserver.h" // 优先位置

#include <sys/types.h>
#include <unistd.h>

#include <hash_map>
#include <vector>

#include "base/basictypes.h"
#include "base/commandlineflags.h"
#include "foo/public/bar.h"

例外:

有时,平台特定(system-specific)代码需要条件编译(conditional includes),这些代码可以放到其它 includes 之后。当然,您的平台特定代码也要够简练且独立,比如:

#include "foo/public/fooserver.h"

#include "base/port.h"  // For LANG_CXX11.

#ifdef LANG_CXX11
#include <initializer_list>
#endif  // LANG_CXX11

头文件中应该写什么内容?

参考另外一篇文章:C++中的头文件

作用域

命名空间

使用具名的命名空间时,其名称可基于项目名或相对路径。比如:

// .h 文件
namespace mynamespace {

// 所有声明都置于命名空间中
// 注意不要使用缩进
class MyClass {
    public:
    ...
    void Foo();
};

} // namespace mynamespace
// .cc 文件
namespace mynamespace {

// 函数定义都置于命名空间中
void MyClass::Foo() {
    ...
}

} // namespace mynamespace

禁止使用 using 指示(using-directive)。

// 禁止 —— 污染命名空间
using namespace foo;

禁止使用内联命名空间(inline namespace)。

// 内联命名空间很容易令人迷惑,毕竟其内部的成员不再受其声明所在命名空间的限制。
// 内联命名空间只在大型版本控制里有用。
namespace X {
inline namespace Y {
void foo();
}  // namespace Y
}  // namespace X

非成员函数、静态成员函数和全局函数

使用静态成员函数或命名空间内的非成员函数,尽量不要用裸的全局函数。

将一系列函数直接置于命名空间中,不要用类的静态方法模拟出命名空间的效果,类的静态方法应当和类的实例或静态数据紧密相关。

推荐:

namespace myproject {
namespace foo_bar {
void Function1();
void Function2();
}  // namespace foo_bar
}  // namespace myproject

不推荐:

namespace myproject {
class FooBar {
 public:
  static void Function1();
  static void Function2();
};
}  // namespace myproject

定义在同一编译单元的函数,被其他编译单元直接调用可能会引入不必要的耦合和链接时依赖;静态成员函数对此尤其敏感。可以考虑提取到新类中,或者将函数置于独立库的命名空间内。

如果你必须定义非成员函数,又只是在 .cc 文件中使用它,可使用匿名 2.1. 命名空间static 链接关键字(如 static int Foo() {...})限定其作用域。

局部变量

将函数变量尽可能置于最小作用域内,并在变量声明时进行初始化。

C++ 允许在函数的任何位置声明变量。我们提倡在尽可能小的作用域中声明变量,离第一次使用越近越好。这使得代码浏览者更容易定位变量声明的位置,了解变量的类型和初始值。特别是,应使用初始化的方式替代声明再赋值,比如:

int i;
i = f(); // 坏——初始化和声明分离
int j = g(); // 好——初始化时声明
vector<int> v;
v.push_back(1); // 用花括号初始化更好
v.push_back(2);
vector<int> v = {1, 2}; // 好——v 一开始就初始化

属于 ifwhilefor 语句的变量应当在这些语句中正常地声明,这样子这些变量的作用域就被限制在这些语句中了,举例而言:

while (const char* p = strchr(str, '/')) str = p + 1;

例外:

有一个例外,如果变量是一个对象,每次进入作用域都要调用其构造函数,每次退出作用域都要调用其析构函数,这会导致效率降低。

// 低效的实现
for (int i = 0; i < 1000000; ++i) {
    Foo f;                  // 构造函数和析构函数分别调用 1000000 次!
    f.DoSomething(i);
}

在循环作用域外面声明这类变量要高效的多:

Foo f;                      // 构造函数和析构函数只调用 1 次
for (int i = 0; i < 1000000; ++i) {
    f.DoSomething(i);
}

静态和全局变量

禁止定义静态储存周期非POD变量,禁止使用含有副作用的函数初始化POD全局变量,因为多编译单元中的静态变量执行时的构造和析构顺序是未明确的,这将导致代码的不可移植。

禁止使用类的 静态储存周期 变量:由于构造和析构函数调用顺序的不确定性,它们会导致难以发现的 bug 。不过 constexpr 变量除外,毕竟它们又不涉及动态初始化或析构。

静态生存周期的对象,即包括了全局变量,静态变量,静态类成员变量和函数静态变量,都必须是原生数据类型 (POD : Plain Old Data):即 int,char 和 float,以及 POD 类型的指针、数组和结构体。

静态变量的构造函数、析构函数和初始化的顺序在 C++ 中是只有部分明确的,甚至随着构建变化而变化,导致难以发现的 bug。所以除了禁用类类型的全局变量,我们也不允许用函数返回值来初始化 POD 变量,除非该函数(比如 getenv()getpid() )不涉及任何全局变量。函数作用域里的静态变量除外,毕竟它的初始化顺序是有明确定义的,而且只会在指令执行到它的声明那里才会发生。

同一个编译单元内是明确的,静态初始化优先于动态初始化,初始化顺序按照声明顺序进行,销毁则逆序。不同的编译单元之间初始化和销毁顺序属于未明确行为(unspecified behaviour)。

同理,全局和静态变量在程序中断时会被析构,无论所谓中断是从 main() 返回还是对 exit() 的调用。析构顺序正好与构造函数调用的顺序相反。但既然构造顺序未定义,那么析构顺序当然也就不定了。比如,在程序结束时某静态变量已经被析构了,但代码还在跑——比如其它线程——并试图访问它且失败;再比如,一个静态 string 变量也许会在一个引用了前者的其它变量析构之前被析构掉。

改善以上析构问题的办法之一是用 quick_exit() 来代替 exit() 并中断程序。它们的不同之处是前者不会执行任何析构,也不会执行 atexit() 所绑定的任何 handlers。如果您想在执行 quick_exit() 来中断时执行某 handler(比如刷新 log),您可以把它绑定到 _at_quick_exit()。如果您想在 exit()quick_exit() 都用上该 handler,都绑定上去。

综上所述,我们只允许 POD 类型的静态变量,即完全禁用 vector (使用 C 数组替代) 和 string (使用 const char [])。

如果您确实需要一个 class 类型的静态或全局变量,可以考虑在 main() 函数或 pthread_once() 内初始化一个指针且永不回收。注意只能用 raw 指针,别用智能指针,毕竟后者的析构函数涉及到上文指出的不定顺序问题。

上文提及的静态变量泛指静态生存周期的对象,包括: 全局变量,静态变量,静态类成员变量,以及函数静态变量.

类是 C++ 中代码的基本单元。显然,它们被广泛使用。本节列举了在写一个类时的主要注意事项。

构造函数的职责

构造函数不允许调用虚函数。如果代码允许,直接终止程序是一个合适的处理错误的方式。否则,考虑用 Init() 方法或工厂函数。

构造函数不得调用虚函数,或尝试报告一个非致命错误。如果对象需要进行有意义的 (non-trivial) 初始化,考虑使用明确的 Init() 方法或使用工厂模式。

Avoid Init() methods on objects with no other states that affect which public methods may be called (此类形式的半构造对象有时无法正确工作)。

隐式类型转换

在类型定义中,类型转换运算符和单参数构造函数都应当用 explicit 进行标记。一个例外是,拷贝和移动构造函数不应当被标记为 explicit,因为它们并不执行类型转换。

对于设计目的就是用于对其他类型进行透明包装的类来说,隐式类型转换有时是必要且合适的。这时应当联系项目组长并说明特殊情况。

不能以一个参数进行调用的构造函数不应当加上 explicit。接受一个 std::initializer_list 作为参数的构造函数也应当省略 explicit,以便支持拷贝初始化()例如 MyType m = {1, 2};)。

可拷贝类型和可移动类型

如果需要就让你的类型可拷贝 / 可移动。作为一个经验法则,如果对于你的用户来说这个拷贝操作不是一眼就能看出来的,那就不要把类型设置为可拷贝。如果让类型可拷贝,一定要同时给出拷贝构造函数和赋值操作的定义,反之亦然。如果让类型可移动,同时移动操作的效率高于拷贝操作,那么就把移动的两个操作(移动构造函数和赋值操作)也给出定义。如果类型不可拷贝,但是移动操作的正确性对用户显然可见,那么把这个类型设置为只可移动并定义移动的两个操作。

如果定义了拷贝/移动操作,则要保证这些操作的默认实现是正确的。记得时刻检查默认操作的正确性,并且在文档中说明类是可拷贝的且/或可移动的。

class Foo {
 public:
  Foo(Foo&& other) : field_(other.field) {}
  // 差,只定义了移动构造函数,而没有定义对应的赋值运算符.

 private:
  Field field_;
};

由于存在对象切割的风险,不要为任何有可能有派生类的对象提供赋值操作或者拷贝 / 移动构造函数(当然也不要继承有这样的成员函数的类)。如果你的基类需要可复制属性,请提供一个 public virtual Clone() 和一个 protected 的拷贝构造函数以供派生类实现。

如果你的类不需要拷贝 / 移动操作,请显式地通过在 public 域中使用 = delete 或其他手段禁用之。

// MyClass is neither copyable nor movable.
MyClass(const MyClass&) = delete;
MyClass& operator=(const MyClass&) = delete;

结构体 VS. 类

仅当只有数据成员时使用 struct, 其它一概使用 class

在 C++ 中 structclass 关键字几乎含义一样。我们为这两个关键字添加我们自己的语义理解,以便为定义的数据类型选择合适的关键字。

struct 用来定义包含数据的被动式对象,也可以包含相关的常量,但除了存取数据成员之外,没有别的函数功能。并且存取功能是通过直接访问位域,而非函数调用。除了构造函数,析构函数,Initialize()Reset()Validate() 等类似的用于设定数据成员的函数外,不能提供其它功能的函数。

如果需要更多的函数功能,class 更适合。如果拿不准,就用 class

为了和 STL 保持一致,对于仿函数等特性可以不用 class 而是使用 struct

注意:类和结构体的成员变量使用不同的 命名规则

函数

输入和输出

我们倾向于按值返回, 否则按引用返回。 避免返回指针, 除非它可以为空。

编写简短函数

我们倾向于编写简短,凝练的函数。

我们承认长函数有时是合理的,因此并不硬性限制函数的长度。如果函数超过 40 行,可以思索一下能不能在不影响程序结构的前提下对其进行分割。

即使一个长函数现在工作的非常好,一旦有人对其修改,有可能出现新的问题,甚至导致难以发现的 bug。使函数尽量简短,以便于他人阅读和修改代码。

在处理代码时,你可能会发现复杂的长函数。不要害怕修改现有代码:如果证实这些代码使用 / 调试起来很困难,或者你只需要使用其中的一小段代码,考虑将其分割为更加简短并易于管理的若干函数。

引用参数

所有按引用传递的参数必须加上 const

void Foo(const string &in, string *out);

事实上这在 Google Code 是一个硬性约定:输入参数是值参或 const 引用,输出参数为指针。输入参数可以是 const 指针,但决不能是非 const 的引用参数,除非特殊要求,比如 swap()

有时候,在输入形参中用 const T* 指针比 const T& 更明智。比如:

  • 可能会传递空指针。
  • 函数要把指针或对地址的引用赋值给输入形参。

总而言之,大多时候输入形参往往是 const T&。若用 const T* 则说明输入另有处理。所以若要使用 const T*,则应给出相应的理由,否则会使得读者感到迷惑。

命名约定

最重要的一致性规则是命名管理。命名的风格能让我们在不需要去查找类型声明的条件下快速地了解某个名字代表的含义:类型,变量,函数,常量,宏,等等,甚至。我们大脑中的模式匹配引擎非常依赖这些命名规则。

命名规则具有一定随意性,但相比按个人喜好命名,一致性更重要,所以无论你认为它们是否重要,规则总归是规则。

通用命名规则

函数命名,变量命名,文件命名要有描述性;少用缩写。

尽可能使用描述性的命名,别心疼空间,毕竟相比之下让代码易于新读者理解更重要。不要用只有项目开发者能理解的缩写,也不要通过砍掉几个字母来缩写单词。

int price_count_reader;    // 无缩写
int num_errors;            // "num" 是一个常见的写法
int num_dns_connections;   // 人人都知道 "DNS" 是什么
int n;                     // 毫无意义.
int nerr;                  // 含糊不清的缩写.
int n_comp_conns;          // 含糊不清的缩写.
int wgc_connections;       // 只有贵团队知道是什么意思.
int pc_reader;             // "pc" 有太多可能的解释了.
int cstmr_id;              // 删减了若干字母.

注意,一些特定的广为人知的缩写是允许的,例如用 i 表示迭代变量和用 T 表示模板参数。

模板参数的命名应当遵循对应的分类:类型模板参数应当遵循 类型命名 的规则,而非类型模板应当遵循 变量命名 的规则。

文件命名

文件名要全部小写,可以包含下划线 (_) 或连字符 (-),依照项目的约定。如果没有约定,那么 “_” 更好。

可接受的文件命名示例:

  • my_useful_class.cc

  • my-useful-class.cc

  • myusefulclass.cc

  • myusefulclass_test.cc // _unittest_regtest 已弃用。

    C++ 文件要以 .cc 结尾,头文件以 .h 结尾。专门插入文本的文件则以 .inc 结尾,参见 头文件自足

不要使用已经存在于 /usr/include 下的文件名(即编译器搜索系统头文件的路径),如 db.h

通常应尽量让文件名更加明确。http_server_logs.h 就比 logs.h 要好。定义类时文件名一般成对出现,如 foo_bar.hfoo_bar.cc,对应于类 FooBar

内联函数必须放在 .h 文件中。如果内联函数比较短,就直接放在 .h 中。

类型命名

类型名称的每个单词首字母均大写,不包含下划线:MyExcitingClassMyExcitingEnum

所有类型命名 —— 类,结构体,类型定义 (typedef),枚举,类型模板参数 —— 均使用相同约定,即以大写字母开始,每个单词首字母均大写,不包含下划线。例如:

// 类和结构体
class UrlTable { ...
class UrlTableTester { ...
struct UrlTableProperties { ...

// 类型定义
typedef hash_map<UrlTableProperties *, string> PropertiesMap;

// using 别名
using PropertiesMap = hash_map<UrlTableProperties *, string>;

// 枚举
enum UrlTableErrors { ...

变量命名

变量(包括函数参数)和数据成员名一律小写,单词之间用下划线连接。类的成员变量以下划线结尾,但结构体的就不用,如:a_local_variablea_struct_data_membera_class_data_member_

普通变量命名

string table_name;  // 好 - 用下划线.
string tablename;   // 好 - 全小写.

string tableName;  // 差 - 混合大小写

类数据成员

不管是静态的还是非静态的,类数据成员都可以和普通变量一样,但要接下划线。

class TableInfo {
  ...
 private:
  string table_name_;  // 好 - 后加下划线.
  string tablename_;   // 好.
  static Pool<TableInfo>* pool_;  // 好.
};

结构体变量

不管是静态的还是非静态的,结构体数据成员都可以和普通变量一样,不用像类那样接下划线:

struct UrlTableProperties {
  string name;
  int num_entries;
  static Pool<UrlTableProperties>* pool;
};

结构体与类的使用讨论,参考 结构体 vs. 类

常量命名

声明为 constexprconst 的变量,或在程序运行期间其值始终保持不变的,命名时以 “k” 开头,大小写混合。例如:

const int kDaysInAWeek = 7;

所有具有静态存储类型的变量(例如静态变量或全局变量, 参见 存储类型)都应当以此方式命名。对于其他存储类型的变量,如自动变量等,这条规则是可选的。如果不采用这条规则,就按照一般的变量命名规则。

函数命名

常规函数使用大小写混合,取值和设值函数则要求与变量名匹配:MyExcitingFunction()MyExcitingMethod()my_exciting_member_variable()set_my_exciting_member_variable()

一般来说,函数名的每个单词首字母大写(即 “驼峰变量名” 或 “帕斯卡变量名”),没有下划线。对于首字母缩写的单词,更倾向于将它们视作一个单词进行首字母大写(例如,写作 StartRpc() 而非 StartRPC())。

AddTableEntry()
DeleteUrl()
OpenFileOrDie()

(同样的命名规则同时适用于类作用域与命名空间作用域的常量, 因为它们是作为 API 的一部分暴露对外的, 因此应当让它们看起来像是一个函数, 因为在这时, 它们实际上是一个对象而非函数的这一事实对外不过是一个无关紧要的实现细节。)

取值和设值函数的命名与变量一致。一般来说它们的名称与实际的成员变量对应, 但并不强制要求。例如 int count()void set_count(int count)

命名空间命名

命名空间以小写字母命名。最高级命名空间的名字取决于项目名称。要注意避免嵌套命名空间的名字之间和常见的顶级命名空间的名字之间发生冲突。

顶级命名空间的名称应当是项目名或者是该命名空间中的代码所属的团队的名字。命名空间中的代码, 应当存放于和命名空间的名字匹配的文件夹或其子文件夹中。

注意 不使用缩写作为名称 的规则同样适用于命名空间。命名空间中的代码极少需要涉及命名空间的名称, 因此没有必要在命名空间中使用缩写。

要避免嵌套的命名空间与常见的顶级命名空间发生名称冲突。由于名称查找规则的存在, 命名空间之间的冲突完全有可能导致编译失败。尤其是, 不要创建嵌套的 std 命名空间。建议使用更独特的项目标识符 (websearch::indexwebsearch::index_util) 而非常见的极易发生冲突的名称 (比如 websearch::util)。

对于 internal 命名空间, 要当心加入到同一 internal 命名空间的代码之间发生冲突 (由于内部维护人员通常来自同一团队, 因此常有可能导致冲突)。在这种情况下, 请使用文件名以使得内部名称独一无二 (例如对于 frobber.h, 使用 websearch::index::frobber_internal)。

枚举命名

枚举的命名应当和 常量 一致: kEnumName 或是 ENUM_NAME

单独的枚举值应该优先采用 常量 的命名方式。但 方式的命名也可以接受。枚举名 UrlTableErrors (以及 AlternateUrlTableErrors) 是类型, 所以要用大小写混合的方式。

enum UrlTableErrors {
    kOK = 0,
    kErrorOutOfMemory,
    kErrorMalformedInput,
};
enum AlternateUrlTableErrors {
    OK = 0,
    OUT_OF_MEMORY = 1,
    MALFORMED_INPUT = 2,
};

2009 年 1 月之前, 我们一直建议采用 的方式命名枚举值。由于枚举值和宏之间的命名冲突, 直接导致了很多问题。由此, 这里改为优先选择常量风格的命名方式。新代码应该尽可能优先使用常量风格。但是老代码没必要切换到常量风格, 除非宏风格确实会产生编译期问题。

宏命名

你并不打算 使用宏, 对吧? 如果你一定要用, 像这样命名: MY_MACRO_THAT_SCARES_SMALL_CHILDREN

参考 预处理宏; 通常 不应该 使用宏。如果不得不用, 其命名像枚举命名一样全部大写, 使用下划线:

#define ROUND(x) ...#define PI_ROUNDED 3.0

命名规则的特例

如果你命名的实体与已有 C/C++ 实体相似, 可参考现有命名策略.

bigopen(): 函数名, 参照 open() 的形式

uint: typedef

bigpos: structclass, 参照 pos 的形式

sparse_hash_map: STL 型实体; 参照 STL 命名约定

LONGLONG_MAX: 常量, 如同 INT_MAX


文章作者: Immortalqx
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Immortalqx !
评论
  目录