跳转至

代码规范

参考资料

  • 单元测试的艺术
  • Clean Code
  • UNIX 编程艺术
  • Clean Architecture
  • 代码大全 2
  • Google C++ Style Guide

单元测试的艺术

  • 单元测试不依赖于外部环境, 使用存根提供数据
  • 自动化 + 快速
  • 使用模拟对象验证交互 (高级断言)
  • 每个单元测试只测试一个行为
    • 最好只有一个模拟对象 / 一个断言
    • 一个行为大于一个方法
  • 测试命名 行为名_状态(测试内容)_预期行为

Clean Code

  • 不要布尔参数 / 枚举参数 (一个函数只做一件事)
  • 错误处理就是一件事, 不要把他和业务逻辑混在一起
  • 对象不要被操作属性, 直接被操作的叫数据结构
    • 经常添加数据类型使用面向对象
    • 经常添加操作使用面向过程
  • 德墨忒尔法则: 只和直接的朋友交流
    • 反例: a.getB().getC().doSomething()
    • 改进: a.doSomethingOnC(), 在方法里可以开火车, 被让使用者开就行
  • 使用别人的代码是请建立一个抽象层, 以防止依赖外部库的变化
  • FIRST 测试原则
    • Fast: 快速运行
    • Independent: 独立, 不依赖其他测试
    • Repeatable: 可重复
    • Self-validating: 自我验证, 有明确的通过 / 失败标准
    • Timely: 及时编写, 在编写代码前或同时编写测试代码
  • 内聚性: 一个类的所有成员变量都被所有方法使用
    • 否则考虑拆分类
  • 懒汉式单例模式应该交给依赖注入框架处理, 否则考虑饿汉式

UNIX 编程艺术

  • 哲学
    • 组合优于集成
    • 将知识封装在数据中
    • 错误源于复杂的容错机制
  • 用通信来共享内存, 而不是用共享内存来通信
  • 声明式编程优于命令式编程
    • 让程序直接处理声明式数据

Clean Architecture

  • 结构化编程禁止 goto 是为了程序的可推导性
  • 面向对象编程利用多态让源码依赖方向与控制流相反 (只依赖抽象)
  • 函数式编程启发我们将逻辑按副作用分离
    • 不可变组件执行纯函数逻辑
    • 可变组件通过特殊的事务机制保护状态变更
    • 只存储事务记录而不存储最终状态 (只存储增量, 避免并发冲突)
  • 组件
    • 职责相同的类放在同一个组件中
    • 不要有依赖环
    • 依赖关系必须要指向更稳定的方向 (依赖难以变化的组件)
    • 组件的抽象化程度应该与其稳定性保持一致 (难以变化的组件应该更抽象)
  • 同心圆模型
    • 最内层: 实体 (业务对象)
    • 用例层: 应用业务规则
    • 接口适配层: 框架 / UI / 数据库等接口
    • 基础设施层: 实现接口适配层的具体技术细节
    • 源码中的依赖关系必须只指向同心圆的内层

代码大全 2

  • 防御性编程
    • 程序的输入处建立防线, 之后的代码可以假设输入是正确的
    • 类似 UNIX 哲学, 错误应该立刻崩溃

Google C++ Style Guide

Google C++ 风格指南

背景

C++ 是 Google 许多开源项目使用的主要开发语言之一。正如每个 C++ 程序员所知,该语言具有许多强大的特性,但这种强大性也带来了复杂性,这反过来又可能使代码更容易出错,更难阅读和维护。

本指南的目标是通过详细描述编写 C++ 代码的应该做和不应该做的事情来管理这种复杂性。这些规则的存在是为了在允许程序员有效地使用 C++ 语言特性的同时,保持代码库的可管理性。

风格,也称为可读性,是我们用来称呼管理我们 C++ 代码的惯例。术语“风格”有点用词不当,因为这些惯例涵盖的范围远不止源代码格式。

Google 开发的大多数开源项目都符合本指南中的要求。

请注意,本指南不是 C++ 教程:我们假设读者熟悉该语言。

风格指南的目标

我们为什么要有这份文档?

我们认为本指南应该服务于几个核心目标。这些是支配所有单独规则的基本为什么。通过将这些理念置于首位,我们希望能够为讨论奠定基础,并使我们的更广泛社区更清楚地了解规则为何存在以及为何做出特定决策。如果您了解每条规则所服务的目的,那么每个人都应该更清楚地知道何时可以放弃某条规则(有些可以),以及更改指南中某条规则需要什么样的论据或替代方案。

目前我们认为的风格指南目标如下:

  • 风格规则应该有足够的价值

一条风格规则所带来的好处必须足够大,才能证明要求我们所有工程师记住它的合理性。好处是相对于没有这条规则的代码库来衡量的,因此,即使是针对一个非常有危害的做法的规则,如果人们无论如何都不太可能这样做,其好处可能仍然很小。这条原则主要解释了我们没有哪些规则,而不是我们有哪些规则:例如,goto 违反了以下许多原则,但它已经非常罕见,因此风格指南没有讨论它。

  • 为读者优化,而不是为作者优化

我们的代码库(以及提交给它的多数单个组件)预计将持续相当长的时间。因此,我们阅读大多数代码的时间将多于编写它的时间。我们明确选择为我们普通软件工程师阅读、维护和调试我们代码库中的代码的体验进行优化,而不是为了编写这些代码时的便利。 “为读者留下痕迹”是这一原则的一个特别常见的子点:当一段代码中发生了令人惊讶或不寻常的事情时(例如,指针所有权的转移),在使用的位置为读者留下文本提示是有价值的(std::unique_ptr 在调用点明确地展示了所有权转移)。

  • 与现有代码保持一致

在我们的整个代码库中一致地使用一种风格,使我们能够专注于其他(更重要)的问题。一致性还有助于自动化:格式化代码或调整 #include 的工具只有在您的代码与工具的期望一致时才能正常工作。在许多情况下,归因于“保持一致”的规则归结为“随便选一个,别再为此烦恼”;允许在这些点上灵活性的潜在价值被人们为之争论的成本所抵消。然而,一致性是有限度的;当没有明确的技术论证,也没有长期方向时,它是一个很好的平局决胜因素。它在局部(每个文件,或对于一组紧密相关的接口)的应用更为强烈。不应将一致性用作在不考虑新风格的优势,或代码库随着时间趋向新风格的趋势的情况下,以旧风格做事的普遍理由。

  • 在适当情况下与更广泛的 C++ 社区保持一致

与外部组织使用 C++ 的方式保持一致,其价值与在我们代码库内保持一致的原因相同。如果 C++ 标准中的一个特性解决了问题,或者某种惯用法被广泛知晓和接受,那就是使用它的理由。然而,有时标准特性和惯用法是有缺陷的,或者设计时没有考虑到我们代码库的需求。在这些情况下(如下所述),限制或禁止标准特性是适当的。在某些情况下,我们倾向于使用本土或第三方库,而不是 C++ 标准中定义的库,这要么是出于感知到的优越性,要么是考虑到将代码库过渡到标准接口的价值不足。

  • 避免令人惊讶或危险的结构

C++ 有一些特性比初看起来更令人惊讶或危险。一些风格指南限制是为了防止陷入这些陷阱。对此类限制的风格指南豁免有很高的门槛,因为豁免这些规则通常会直接危及程序的正确性。

  • 避免我们的普通 C++ 程序员会觉得棘手或难以维护的结构

C++ 有一些特性可能不普遍适用,因为它们给代码带来了复杂性。在广泛使用的代码中,使用更复杂的语言结构可能更容易接受,因为更复杂实现带来的任何好处都会通过使用而广泛倍增,并且在处理代码库的新部分时,无需再次支付理解复杂性的成本。如有疑问,可以通过询问您的项目负责人来寻求对这种类型规则的豁免。这对于我们的代码库尤其重要,因为代码所有权和团队成员资格会随时间变化:即使目前使用某段代码的每个人都理解它,也不能保证几年后仍然如此。

  • 注意我们的规模

对于一个拥有超过 1 亿行代码和数千名工程师的代码库,对于一个工程师来说,一些错误和简化可能会给许多人带来高昂的代价。例如,避免污染全局命名空间特别重要:如果每个人都将东西放入全局命名空间,那么在数亿行代码库中出现名称冲突将很难处理和避免。

  • 必要时让步于优化

性能优化有时是必要和适当的,即使它们与本文档的其他原则冲突。

本文档的目的是在合理的限制下提供最大的指导。一如既往,常识和良好的品味应该占上风。我们特别指的是整个 Google C++ 社区的既定惯例,而不仅仅是您的个人偏好或您团队的偏好。对聪明或不寻常的结构持怀疑态度并避免使用:缺乏禁令并不等同于可以自由使用。请运用您的判断,如果您不确定,请随时向您的项目负责人寻求额外的意见。

C++ 版本

目前,代码应该以 C++20 为目标,即不应使用 C++23 特性。本指南所针对的 C++ 版本将随着时间的推移(积极地)向前推进。

不要使用非标准扩展

在您的项目中使用 C++17 和 C++20 的特性之前,请考虑对其他环境的移植性。

头文件

通常,每个 .cc 文件都应该有一个相关的 .h 文件。有一些常见的例外,例如单元测试和只包含 main() 函数的小型 .cc 文件。

正确使用头文件可以极大地影响代码的可读性、大小和性能。

以下规则将指导您避免使用头文件时的各种陷阱。

自包含头文件

头文件应该是自包含的(可以独立编译)并以 .h 结尾。旨在包含的非头文件应以 .inc 结尾,并应谨慎使用。

所有头文件都应该是自包含的。用户和重构工具不应该需要遵守特殊条件来包含头文件。具体来说,头文件应该有头文件保护并包含它所需的所有其他头文件。

当头文件声明显式函数或模板,并且头文件的客户端将实例化它们时,显式函数和模板也必须在头文件中(直接或在其包含的文件中)有定义。不要将这些定义移动到单独包含的头文件(-inl.h)中;这种做法在过去很常见,但现在不允许了。当模板的所有实例化都发生在单个 .cc 文件中时,无论是由于显式实例化还是因为定义只对该 .cc 文件可访问,模板定义可以保留在该文件中。

在极少数情况下,设计为包含的文件不是自包含的。这些文件通常打算在不寻常的位置包含,例如在另一个文件的中间。它们可能不使用头文件保护,也可能不包含其先决条件。用 .inc 扩展名命名这些文件。请谨慎使用,并尽可能首选自包含头文件。

#define 保护

所有头文件都应该有 #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 语句,而不会破坏客户端。这也适用于相关头文件——如果 foo.cc 使用了来自 bar.h 的符号,即使 foo.h 包含了 bar.hfoo.cc 也应该包含 bar.h

前向声明

尽可能避免使用前向声明。相反,包含您需要的头文件

“前向声明”是对一个实体的声明,但不附带定义。

// 在 C++ 源文件中:
class B;
void FuncInB();
extern int variable_in_b;
ABSL_DECLARE_FLAG(flag_in_b);
  • 前向声明可以节省编译时间,因为 #include 强制编译器打开更多文件并处理更多输入。
  • 前向声明可以节省不必要的重新编译。 #include 可能会因为头文件中的不相关更改而更频繁地强制您的代码重新编译。

  • 前向声明可能会隐藏依赖关系,允许用户代码在头文件更改时跳过必要的重新编译。

  • 相比于 #include 语句,前向声明使得自动化工具难以发现定义该符号的模块。
  • 库的后续更改可能会破坏前向声明。函数和模板的前向声明可能会阻止头文件所有者对其 API 进行原本兼容的更改,例如扩大参数类型、添加具有默认值的模板参数或迁移到新的命名空间。
  • 前向声明来自命名空间 std:: 的符号会导致未定义的行为。
  • 可能很难确定是需要前向声明还是完整的 #include。用前向声明替换 #include 可能会悄悄地改变代码的含义:
// b.h:
struct B {};
struct D : B {};

// good_user.cc:
#include "b.h"
void f(B*);
void f(void*);
void test(D* x) { f(x); }  // 调用 f(B*)

如果用 BD 的前向声明替换了 #include,那么 test() 将调用 f(void*)。 - 前向声明来自一个头文件的多个符号可能比简单地 #include 该头文件更冗长。 - 组织代码以支持前向声明(例如,使用指针成员而不是对象成员)可能会使代码更慢且更复杂。

尝试避免前向声明在其他项目中定义的实体。

在头文件中定义函数

仅当函数定义很短时,才在其声明点将函数定义包含在头文件中。如果定义出于其他原因必须在头文件中,请将其放在文件的内部部分。如果需要使定义对 ODR 安全,请使用 inline 说明符标记它。

在头文件中定义的函数有时被称为“内联函数”,这是一个有点被滥用的术语,指的是几种不同但重叠的情况:

  1. 文本内联符号的定义在其声明处向读者公开。
  2. 在头文件中定义的函数或变量是可展开内联的,因为它的定义可供编译器用于内联展开,这可以生成更高效的目标代码。
  3. ODR-安全的实体不违反“一次定义规则”,这通常需要为在头文件中定义的事物使用 inline 关键字。

虽然函数往往是造成混淆的更常见来源,但这些定义也适用于变量,因此此处的规则也适用。

  • 在文本中内联定义函数可以减少简单函数(如访问器和修改器)的样板代码。
  • 如上所述,头文件中的函数定义可以由于编译器的内联展开而为小型函数生成更高效的目标代码。
  • 函数模板和 constexpr 函数通常需要在声明它们的头文件(但不一定是公共部分)中定义。

  • 在公共 API 中嵌入函数定义会使 API 难以浏览,并给该 API 的读者带来认知负担——函数越复杂,成本越高。

  • 公共定义暴露了充其量无害,通常是无关紧要的实现细节。

仅当函数很短(例如 10 行或更少)时,才在其公开声明处定义。除非出于性能或技术原因必须将较长的函数体放在头文件中,否则将其放在 .cc 文件中。

即使定义必须在头文件中,这也不足以成为将其放在公共部分的原因。相反,定义可以放在头文件的内部部分,例如类中的 private 部分,包含单词 internal 的命名空间中,或像 // Implementation details only below here 这样的注释下方。

一旦定义在头文件中,它就必须是 ODR-安全的,方法是具有 inline 说明符,或者通过作为函数模板或在类体中首次声明时被隐式指定为内联。

template <typename T>
class Foo {
 public:
  int bar() { return bar_; }

  void MethodWithHugeBody();

 private:
  int bar_;
};

// Implementation details only below here

template <typename T>
void Foo<T>::MethodWithHugeBody() {
  ...
}

包含的名称和顺序

按照以下顺序包含头文件:相关头文件、C 系统头文件、C++ 标准库头文件、其他库的头文件、您的项目头文件。

项目的所有头文件都应列为项目源目录的后代,不使用 UNIX 目录别名 .(当前目录)或 ..(父目录)。例如,google-awesome-project/src/base/logging.h 应包含为:

#include "base/logging.h"

仅当库要求您这样做时,才应使用尖括号路径包含头文件。特别是,以下头文件需要尖括号:

  • C 和 C++ 标准库头文件(例如,<stdlib.h><string>)。
  • POSIX、Linux 和 Windows 系统头文件(例如,<unistd.h><windows.h>)。
  • 在极少数情况下,第三方库(例如,<Python.h>)。

dir/foo.ccdir/foo_test.cc 中,它们的主要目的是实现或测试 dir2/foo2.h 中的内容,请按以下顺序组织包含:

  1. dir2/foo2.h
  2. 空行
  3. C 系统头文件,以及任何其他带有 .h 扩展名的尖括号头文件,例如 <unistd.h><stdlib.h><Python.h>
  4. 空行
  5. C++ 标准库头文件(不带文件扩展名),例如 <algorithm><cstddef>
  6. 空行
  7. 其他库的 .h 文件。
  8. 空行
  9. 您的项目自己生成的 .h 文件。

用一个空行分隔每个非空组。

使用首选的顺序,如果相关头文件 dir2/foo2.h 遗漏了任何必要的包含,那么 dir/foo.ccdir/foo_test.cc 的构建将失败。因此,这条规则确保了构建失败首先出现在处理这些文件的人员面前,而不是其他包中无辜的人员面前。

dir/foo.ccdir2/foo2.h 通常在同一个目录中(例如,base/basictypes_test.ccbase/basictypes.h),但有时也可能在不同的目录中。

请注意,像 stddef.h 这样的 C 头文件与其 C++ 对应文件(cstddef)基本可以互换。两种风格都是可以接受的,但最好与现有代码保持一致。

在每个部分中,包含应按字母顺序排列。请注意,旧代码可能不符合此规则,应在方便时修复。

例如,google-awesome-project/src/foo/internal/fooserver.cc 中的包含可能如下所示:

#include "foo/server/fooserver.h"

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

#include <string>
#include <vector>

#include "base/basictypes.h"
#include "foo/server/bar.h"
#include "third_party/absl/flags/flag.h"

例外:

有时,系统特定的代码需要条件包含。此类代码可以将条件包含放在其他包含之后。当然,请保持您的系统特定代码小巧且局部化。示例:

#include "foo/public/fooserver.h"

#ifdef _WIN32
#include <windows.h>
#endif  // _WIN32

作用域

命名空间

除少数例外,将代码放在命名空间中。命名空间应具有基于项目名称及其路径的唯一名称。不要使用 using-directives(例如,using namespace foo)。不要使用内联命名空间。对于未命名的命名空间,请参见内部链接

命名空间将全局作用域细分为不同的、命名的作用域,因此有助于防止全局作用域中的名称冲突。

命名空间提供了一种方法,可以在大型程序中防止名称冲突,同时允许大多数代码使用相当短的名称。

例如,如果两个不同的项目在全局作用域中都有一个类 Foo,这些符号可能会在编译时或运行时冲突。如果每个项目将其代码放在一个命名空间中,project1::Fooproject2::Foo 现在是不会冲突的不同符号,并且每个项目命名空间中的代码可以继续引用 Foo 而无需前缀。

内联命名空间会自动将其名称放入封闭作用域。例如,考虑以下片段:

namespace outer {
inline namespace inner {
  void foo();
}  // namespace inner
}  // namespace outer

表达式 outer::inner::foo()outer::foo() 可以互换。内联命名空间主要用于跨版本的 ABI 兼容性。

命名空间可能令人困惑,因为它们使弄清楚名称所指的定义是哪个的机制复杂化了。

特别是内联命名空间可能令人困惑,因为名称实际上并不局限于它们声明的命名空间。它们仅在作为某个更大的版本管理策略的一部分时才有用。

在某些情况下,需要重复地通过其完全限定名称引用符号。对于深度嵌套的命名空间,这可能会增加很多混乱。

命名空间应按如下方式使用:

  • 遵循命名空间名称的规则。

  • 如给定的示例所示,使用注释终止多行命名空间。

  • 命名空间包裹在包含、gflags 定义/声明和来自其他命名空间的类的前向声明之后,整个源文件。

// 在 .h 文件中
namespace mynamespace {

// 所有声明都在命名空间作用域内。
// 注意没有缩进。
class MyClass {
 public:
  ...
  void Foo();
};

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

// 函数定义在命名空间的作用域内。
void MyClass::Foo() {
  ...
}

}  // namespace mynamespace

更复杂的 .cc 文件可能包含额外的细节,例如标志或 using-declarations。

#include "a.h"

ABSL_FLAG(bool, someflag, false, "a flag");

namespace mynamespace {

using ::foo::Bar;

...code for mynamespace...    // 代码靠在左边距。

}  // namespace mynamespace
  • 要将生成的协议消息代码放在命名空间中,请在 .proto 文件中使用 package 说明符。有关详细信息,请参阅 Protocol Buffer Packages

  • 不要在命名空间 std 中声明任何内容,包括标准库类的前向声明。在命名空间 std 中声明实体是未定义行为,即不可移植。要声明来自标准库的实体,请包含适当的头文件。

  • 您不能使用 using-directive 使所有名称从命名空间中可用。

// 禁止 -- 这会污染命名空间。
using namespace foo;
  • 在头文件的命名空间作用域中,不要使用 Namespace aliases,除非在明确标记为仅供内部使用的命名空间中,因为任何导入到头文件中的命名空间中的内容都会成为该文件导出的公共 API 的一部分。当这些条件不适用时,可以使用命名空间别名,但它们必须具有适当的名称
// 在 .h 文件中,别名不得是单独的 API,或必须隐藏在实现细节中。
namespace librarian {

namespace internal {  // 内部,不是 API 的一部分。
namespace sidetable = ::pipeline_diagnostics::sidetable;
}  // namespace internal

inline void my_inline_function() {
  // 局部于函数。
  namespace baz = ::foo::bar::baz;
  ...
}

}  // namespace librarian
// 在 .cc 文件中删除一些常用名称中不感兴趣的部分。
namespace sidetable = ::pipeline_diagnostics::sidetable;
  • 不要使用内联命名空间。

  • 使用名称中包含 “internal” 的命名空间来记录不应被 API 用户提及的 API 部分。

// 我们不应该在非 absl 代码中使用这个内部名称。
using ::absl::container_internal::ImplementationDetail;

请注意,嵌套的 internal 命名空间中的库之间仍存在冲突风险,因此请为命名空间中的每个库提供一个唯一的内部命名空间,方法是添加库的文件名。例如,gshoe/widget.h 将使用 gshoe::internal_widget 而不是仅仅使用 gshoe::internal

  • 在新代码中首选单行嵌套命名空间声明,但不是必需的。
namespace my_project::my_component {

  ...

}  // namespace my_project::my_component

内部链接

.cc 文件中的定义不需要在文件外部引用时,通过将它们放在未命名的命名空间中或声明它们为 static 来赋予它们内部链接。不要在 .h 文件中使用这些构造。

所有声明都可以通过将它们放在未命名的命名空间中来赋予内部链接。函数和变量也可以通过声明它们为 static 来赋予内部链接。这意味着您声明的任何内容都不能从另一个文件访问。如果不同的文件声明了具有相同名称的内容,那么这两个实体是完全独立的。

鼓励在 .cc 文件中使用内部链接,用于所有不需要在其他地方引用的代码。不要在 .h 文件中使用内部链接。

像命名空间一样格式化未命名的命名空间。在终止注释中,将命名空间名称留空:

namespace {
...
}  // namespace

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

优先将非成员函数放在命名空间中;很少使用完全全局的函数。不要仅仅为了对静态成员进行分组而使用类。类的静态方法通常应与类的实例或类的静态数据密切相关。

非成员和静态成员函数在某些情况下可能很有用。将非成员函数放在命名空间中可以避免污染全局命名空间。

非成员和静态成员函数作为新类的成员可能更有意义,特别是如果它们访问外部资源或具有显著依赖关系。

有时,定义不绑定到类实例的函数很有用。这样的函数可以是静态成员或非成员函数。非成员函数不应依赖外部变量,并且几乎总是存在于命名空间中。不要仅仅为了对静态成员进行分组而创建类;这与仅仅给名称一个共同前缀没有什么不同,而且这种分组通常无论如何都是不必要的。

如果您定义了一个非成员函数,并且只需要在它的 .cc 文件中使用它,请使用内部链接来限制它的作用域。

局部变量

将函数的变量放在尽可能窄的作用域内,并在声明中初始化变量。

C++ 允许您在函数中的任何地方声明变量。我们鼓励您在尽可能局部的作用域中声明它们,并尽可能靠近首次使用。这使得读者更容易找到声明并查看变量的类型及其初始化值。特别是,应该使用初始化而不是声明和赋值,例如:

int i;
i = f();      // 差 -- 初始化与声明分离。
int i = f();  // 好 -- 声明包含初始化。
int jobs = NumJobs();
// 更多代码...
f(jobs);      // 差 -- 声明与使用分离。
int jobs = NumJobs();
f(jobs);      // 好 -- 声明紧接着(或紧密地)跟着使用。
std::vector<int> v;
v.push_back(1);  // 倾向于使用大括号初始化进行初始化。
v.push_back(2);
std::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;  // 我的构造函数和析构函数各被调用一次。
for (int i = 0; i < 1000000; ++i) {
  f.DoSomething(i);
}

静态和全局变量

禁止使用具有静态存储期的对象,除非它们是可平凡析构的。非正式地,这意味着析构函数不执行任何操作,即使考虑到成员和基类的析构函数。更正式地,这意味着该类型没有用户定义或虚析构函数,并且所有基类和非静态成员都是可平凡析构的。静态函数局部变量可以使用动态初始化。不鼓励对静态类成员变量或命名空间作用域的变量使用动态初始化,但在有限的情况下允许;详情见下文。

根据经验法则:如果一个全局变量的声明,单独考虑,可以写成 constexpr,那么它就满足这些要求。

每个对象都具有存储期,这与其生命周期相关。具有静态存储期的对象从初始化点到程序结束时存在。此类对象出现在命名空间作用域中的变量(“全局变量”)、类的静态数据成员,或使用 static 说明符声明的函数局部变量。函数局部静态变量在控制流首次通过其声明时初始化;所有其他具有静态存储期的对象作为程序启动的一部分进行初始化。所有具有静态存储期的对象在程序退出时被销毁(这发生在未连接的线程终止之前)。

初始化可能是动态的,这意味着在初始化期间发生了一些非平凡的事情。(例如,考虑一个分配内存的构造函数,或一个用当前进程 ID 初始化的变量。)另一种初始化是静态初始化。两者并不完全相反:静态初始化总是发生在具有静态存储期的对象上(将对象初始化为给定的常量或由所有字节设置为零的表示),而动态初始化在其后发生,如果需要的话。

全局和静态变量对于大量应用非常有用:命名常量、某些翻译单元内部的辅助数据结构、命令行标志、日志记录、注册机制、后台基础设施等。

使用动态初始化或具有非平凡析构函数的全局和静态变量会产生复杂性,这很容易导致难以发现的错误。动态初始化在翻译单元之间没有顺序,销毁也没有顺序(除了销毁按初始化相反的顺序发生)。当一个初始化引用另一个具有静态存储期的变量时,可能会导致在对象生命周期开始之前(或生命周期结束之后)访问该对象。此外,当程序启动在退出时未连接的线程时,如果对象的析构函数已经运行,这些线程可能会尝试在对象生命周期结束后访问它们。

关于销毁的决定

当析构函数是平凡的时,它们的执行根本不受顺序约束(它们实际上不会“运行”);否则,我们就会面临在对象生命周期结束后访问对象的风险。因此,我们只允许可平凡析构的具有静态存储期的对象。基本类型(如指针和 int)是可平凡析构的,可平凡析构类型的数组也是。请注意,标记为 constexpr 的变量是可平凡析构的。

const int kNum = 10;  // 允许

struct X { int n; };
const X kX[] = {{1}, {2}, {3}};  // 允许

void foo() {
  static const char* const kMessages[] = {"hello", "world"};  // 允许
}

// 允许:constexpr 保证了平凡析构函数。
constexpr std::array<int, 3> kArray = {1, 2, 3};
// 差:非平凡析构函数
const std::string kFoo = "foo";

// 出于同样的原因而差,即使 kBar 是一个引用(规则也适用于生命周期延长的临时对象)。
const std::string& kBar = StrCat("a", "b", "c");

void bar() {
  // 差:非平凡析构函数。
  static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}};
}

请注意,引用不是对象,因此它们不受析构的约束。但是,关于动态初始化的约束仍然适用。特别是,形式为 static T& t = *new T; 的函数局部静态引用是允许的。

关于初始化的决定

初始化是一个更复杂的主题。这是因为我们不仅要考虑类构造函数是否执行,还必须考虑初始化器的评估:

int n = 5;    // 好
int m = f();  // ? (取决于 f)
Foo x;        // ? (取决于 Foo::Foo)
Bar y = g();  // ? (取决于 g 和 Bar::Bar)

除第一个语句外,所有语句都会使我们面临不确定的初始化顺序。

我们在 C++ 标准的正式语言中寻找的概念称为常量初始化。这意味着初始化表达式是一个常量表达式,如果对象是通过构造函数调用初始化的,那么构造函数也必须指定为 constexpr

struct Foo { constexpr Foo(int) {} };

int n = 5;  // 好,5 是一个常量表达式。
Foo x(2);   // 好,2 是一个常量表达式,并且选择的构造函数是 constexpr。
Foo a[] = { Foo(1), Foo(2), Foo(3) };  // 好

常量初始化总是允许的。静态存储期变量的常量初始化应标记为 constexprconstinit。任何未如此标记的非局部静态存储期变量应被假定为具有动态初始化,并非常仔细地审查。

相比之下,以下初始化存在问题:

// 下面使用的一些声明。
time_t time(time_t*);      // 不是 constexpr!
int f();                   // 不是 constexpr!
struct Bar { Bar() {} };

// 有问题的初始化。
time_t m = time(nullptr);  // 初始化表达式不是常量表达式。
Foo y(f());                // 同上
Bar b;                     // 选择的构造函数 Bar::Bar() 不是 constexpr。

不鼓励非局部变量的动态初始化,并且通常是禁止的。但是,如果程序的任何方面不依赖于此初始化相对于所有其他初始化的排序,我们确实允许它。在这些限制下,初始化的排序不会产生可观察到的差异。例如:

int p = getpid();  // 允许,只要没有其他静态变量
                   // 在自己的初始化中使用 p。

允许(并且常见)静态局部变量的动态初始化。

常见模式

  • 全局字符串:如果您需要一个命名的全局或静态字符串常量,请考虑使用 string_view、字符数组或字符指针的 constexpr 变量,指向字符串字面量。字符串字面量已经具有静态存储期,并且通常就足够了。请参阅 TotW #140
  • 映射、集合和其他动态容器:如果您需要一个静态的、固定的集合,例如要搜索的集合或查找表,您不能使用标准库中的动态容器作为静态变量,因为它们具有非平凡的析构函数。相反,请考虑使用平凡类型的简单数组,例如,整数数组的数组(用于“从 int 到 int 的映射”),或对数组(例如,intconst char* 的对)。对于小型集合,线性搜索是完全足够的(并且高效,由于内存局部性);考虑使用来自 absl/algorithm/container.h 的工具进行标准操作。如果需要,请将集合保持排序,并使用二进制搜索算法。如果您确实更喜欢标准库中的动态容器,请考虑使用函数局部静态指针,如下所述。
  • 智能指针(std::unique_ptrstd::shared_ptr):智能指针在销毁期间执行清理,因此被禁止。考虑您的用例是否符合本节中描述的其他模式之一。一个简单的解决方案是使用指向动态分配对象的普通指针,并且永远不删除它(参见最后一项)。
  • 自定义类型的静态变量:如果您需要静态的、您需要自己定义的类型的常量数据,请为该类型提供一个平凡析构函数和一个 constexpr 构造函数。
  • 如果所有其他方法都失败了,您可以通过使用函数局部静态指针或引用来动态创建一个对象并且永远不删除它(例如,static const auto& impl = *new T(args...);)。

thread_local 变量

未在函数内部声明的 thread_local 变量必须使用真正的编译时常量进行初始化,并且必须通过使用 constinit 属性来强制执行此操作。优先使用 thread_local 而不是定义线程局部数据的其他方式。

可以使用 thread_local 说明符声明变量:

thread_local Foo foo = ...;

这样的变量实际上是对象的集合,因此当不同的线程访问它时,它们实际上访问的是不同的对象。thread_local 变量在许多方面与静态存储期变量非常相似。例如,它们可以在命名空间作用域、函数内部或作为静态类成员声明,但不能作为普通类成员声明。

thread_local 变量实例的初始化方式与静态变量非常相似,只是它们必须为每个线程分别初始化,而不是在程序启动时初始化一次。这意味着在函数内部声明的 thread_local 变量是安全的,但其他 thread_local 变量会受到与静态变量相同的初始化顺序问题的影响(以及更多)。

thread_local 变量有一个微妙的销毁顺序问题:在线程关闭期间,thread_local 变量将按照其初始化的相反顺序销毁(这在 C++ 中通常是正确的)。如果由任何 thread_local 变量的析构函数触发的代码引用了该线程上任何已销毁的 thread_local,我们将得到一个特别难以诊断的 use-after-free 错误。

  • 线程局部数据本质上是免受竞态的(因为通常只有一个线程可以访问它),这使得 thread_local 对并发编程很有用。
  • thread_local 是创建线程局部数据的唯一标准支持方式。

  • 访问 thread_local 变量可能会在线程启动或给定线程上首次使用期间触发不可预测和不可控制数量的其他代码的执行。

  • thread_local 变量实际上是全局变量,除了缺乏线程安全外,具有全局变量的所有缺点。
  • thread_local 变量消耗的内存随着运行线程数量的增加而增加(在最坏的情况下),这在一个程序中可能非常大。
  • 数据成员不能是 thread_local,除非它们也是 static
  • 如果 thread_local 变量具有复杂的析构函数,我们可能会遭受 use-after-free 错误。特别是,任何此类变量的析构函数不得(传递地)调用引用任何可能已销毁的 thread_local 的代码。这个属性很难强制执行。
  • 避免全局/静态上下文中 use-after-free 的方法不适用于 thread_local。具体来说,跳过全局和静态变量的析构函数是允许的,因为它们的生命周期在程序关闭时结束。因此,任何“泄漏”都立即由操作系统清理我们的内存和其他资源来管理。相比之下,跳过 thread_local 变量的析构函数会导致与程序生命周期内终止的线程总数成比例的资源泄漏。

类或命名空间作用域的 thread_local 变量必须使用真正的编译时常量进行初始化(即,它们不得有动态初始化)。为了强制执行此操作,类或命名空间作用域的 thread_local 变量必须使用 constinit 进行注释(或 constexpr,但这应该很少见):

   constinit thread_local Foo foo = ...;

函数内部的 thread_local 变量没有初始化问题,但在线程退出时仍有 use-after-free 的风险。请注意,您可以使用函数作用域的 thread_local 来模拟类或命名空间作用域的 thread_local,方法是定义一个公开它的函数或静态方法:

Foo& MyThreadLocalFoo() {
  thread_local Foo result = ComplicatedInitialization();
  return result;
}

请注意,每当线程退出时,thread_local 变量都会被销毁。如果任何此类变量的析构函数引用了任何其他(可能已销毁的)thread_local,我们将遭受难以诊断的 use-after-free 错误。优先使用平凡类型,或可证明在销毁时没有运行用户提供代码的类型,以最小化访问任何其他 thread_local 的可能性。

应优先使用 thread_local 而不是定义线程局部数据的其他机制。

类是 C++ 中代码的基本单元。很自然地,我们广泛使用它们。本节列出了您在编写类时应遵循的主要应该做和不应该做的事情。

在构造函数中执行工作

避免在构造函数中调用虚方法,并避免在无法发出错误信号时进行可能失败的初始化。

可以在构造函数的主体中执行任意初始化。

  • 无需担心类是否已初始化。
  • 通过构造函数调用完全初始化的对象可以是 const,并且可能更容易与标准容器或算法一起使用。

  • 如果工作调用了虚函数,这些调用将不会分派到子类实现。即使您的类目前没有子类,未来的类修改也可能悄悄地引入此问题,从而导致很多混乱。

  • 构造函数没有简单的方法来发出错误信号,除非使程序崩溃(并非总是适当)或使用异常(这是禁止的)。
  • 如果工作失败,我们现在有一个初始化代码失败的对象,因此它可能处于需要 bool IsValid() 状态检查机制(或类似机制)的异常状态,而这很容易忘记调用。
  • 您无法获取构造函数的地址,因此在构造函数中完成的任何工作都不能轻易地移交给例如另一个线程。

构造函数不应调用虚函数。如果适合您的代码,终止程序可能是适当的错误处理响应。否则,请考虑使用 TotW #42 中描述的工厂函数或 Init() 方法。避免在没有影响哪些公共方法可以被调用的其他状态的对象上使用 Init() 方法(这种形式的半构造对象特别难以正确使用)。

隐式转换

不要定义隐式转换。对转换操作符和单参数构造函数使用 explicit 关键字。

隐式转换允许在一个预期不同类型(称为目标类型)的地方使用一种类型(称为源类型)的对象,例如将 int 参数传递给接受 double 参数的函数时。

除了语言定义的隐式转换之外,用户还可以通过向源类型或目标类型的类定义添加适当的成员来定义自己的隐式转换。源类型中的隐式转换由以目标类型命名的类型转换操作符定义(例如,operator bool())。目标类型中的隐式转换由可以将其源类型作为其唯一参数(或唯一的没有默认值的参数)的构造函数定义。

explicit 关键字可以应用于构造函数或转换操作符,以确保它只能在目标类型在使用点是显式的情况下使用,例如使用强制转换。这不仅适用于隐式转换,也适用于列表初始化语法:

class Foo {
  explicit Foo(int x, double y);
  ...
};

void Func(Foo f);
Func({42, 3.14});  // 错误

这种代码在技术上不是隐式转换,但就 explicit 而言,语言将其视为隐式转换。

  • 隐式转换可以通过消除在明显时显式命名类型的需要,使类型更可用和富有表现力。
  • 隐式转换可以作为重载的更简单替代方案,例如当具有 string_view 参数的单个函数取代 std::stringconst char* 的单独重载时。
  • 列表初始化语法是初始化对象的一种简洁且富有表现力的方式。

  • 隐式转换可能会隐藏类型不匹配的错误,其中目标类型与用户的期望不匹配,或者用户没有意识到将发生任何转换。

  • 隐式转换会使代码更难阅读,尤其是在存在重载的情况下,因为它使得实际调用了哪个代码不那么明显。
  • 接受单个参数的构造函数可能会意外地被用作隐式类型转换,即使它们不打算这样做。
  • 当单参数构造函数未标记为 explicit 时,没有可靠的方法来判断它是否旨在定义隐式转换,还是作者只是忘记标记它。
  • 隐式转换可能导致调用点歧义,尤其是在存在双向隐式转换时。这可能是由于两种类型都提供隐式转换,或者单个类型具有隐式构造函数和隐式类型转换操作符。
  • 如果目标类型是隐式的,列表初始化可能会遭受相同的问题,特别是如果列表只有一个元素。

类型转换操作符和可以用单个参数调用的构造函数,必须在类定义中标记为 explicit。作为一个例外,复制和移动构造函数不应该标记为 explicit,因为它们不执行类型转换。

不能用单个参数调用的构造函数可以省略 explicit。接受单个 std::initializer_list 参数的构造函数也应该省略 explicit,以支持复制初始化(例如,MyType m = {1, 2};)。

隐式转换有时对于设计为可互换的类型是必要和适当的,例如当两种类型的对象只是相同底层值的不同表示时。在这种情况下,请联系您的项目负责人以请求豁免此规则。

可复制和可移动类型

一个类的公共 API 必须明确该类是可复制的、仅可移动的,还是既不可复制也不可移动的。如果这些操作对于您的类型是明确和有意义的,则支持复制和/或移动。

可移动类型是可以从临时对象初始化和赋值的类型。

可复制类型是可以从任何其他相同类型的对象初始化或赋值的类型(因此根据定义也是可移动的),但要求源的值不会改变。std::unique_ptr<int> 是一个可移动但不可复制的类型示例(因为源 std::unique_ptr<int> 的值必须在赋值给目标时被修改)。intstd::string 是可移动类型也是可复制类型的示例。(对于 int,移动和复制操作是相同的;对于 std::string,存在比复制成本更低的移动操作。)

对于用户定义的类型,复制行为由复制构造函数和复制赋值操作符定义。移动行为由移动构造函数和移动赋值操作符定义,如果它们存在,否则由复制构造函数和复制赋值操作符定义。

在某些情况下,复制/移动构造函数可以被编译器隐式调用,例如,通过值传递对象时。

可复制和可移动类型的对象可以通过值传递和返回,这使 API 更简单、更安全、更通用。与通过指针或引用传递对象不同,不存在关于所有权、生命周期、可变性等问题的混淆风险,也无需在合同中指定它们。它还防止了客户端和实现之间的非局部交互,这使得它们更容易理解、维护和被编译器优化。此外,此类对象可以与需要按值传递的通用 API 一起使用,例如大多数容器,并且它们允许在例如类型组合方面具有额外的灵活性。

复制/移动构造函数和赋值操作符通常比像 Clone()CopyFrom()Swap() 这样的替代方案更容易正确定义,因为它们可以由编译器生成,无论是隐式生成还是使用 = default。它们简洁,并确​​保所有数据成员都被复制。复制和移动构造函数的效率通常也更高,因为它们不需要堆分配或单独的初始化和赋值步骤,并且它们符合诸如复制省略之类的优化条件。

移动操作允许将资源隐式且高效地从右值对象中转移出来。这在某些情况下允许更清晰的编码风格。

有些类型不需要可复制,为这些类型提供复制操作可能会令人困惑、毫无意义或完全错误。表示单例对象(Registerer)、绑定到特定作用域的对象(Cleanup)或与对象身份密切相关的对象(Mutex)不能有意义地复制。用于多态使用的基类类型的复制操作是危险的,因为使用它们可能导致对象切片。默认或草率实现的复制操作可能是错误的,由此产生的错误可能令人困惑且难以诊断。

复制构造函数是隐式调用的,这使得调用很容易被忽略。这可能会让习惯于按引用传递是常规或强制的语言的程序员感到困惑。它也可能鼓励过度复制,这可能导致性能问题。

每个类的公共接口必须明确该类支持哪些复制和移动操作。这通常应采用在声明的 public 部分显式声明和/或删除适当操作的形式。

具体来说,可复制类应显式声明复制操作,仅可移动类应显式声明移动操作,不可复制/不可移动类应显式删除复制操作。可复制类也可以声明移动操作以支持高效移动。显式声明或删除所有四个复制/移动操作是允许的,但不是必需的。如果您提供了一个复制或移动赋值操作符,您也必须提供相应的构造函数。

class Copyable {
 public:
  Copyable(const Copyable& other) = default;
  Copyable& operator=(const Copyable& other) = default;

  // 隐式移动操作被上面声明的操作抑制了。
  // 您可以显式声明移动操作以支持高效移动。
};

class MoveOnly {
 public:
  MoveOnly(MoveOnly&& other) = default;
  MoveOnly& operator=(MoveOnly&& other) = default;

  // 复制操作被隐式删除,但如果您想,
  // 您可以显式地写出来:
  MoveOnly(const MoveOnly&) = delete;
  MoveOnly& operator=(const MoveOnly&) = delete;
};

class NotCopyableOrMovable {
 public:
  // 不可复制或不可移动
  NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
  NotCopyableOrMovable& operator=(const NotCopyableOrMovable&)
      = delete;

  // 移动操作被隐式禁用,但如果您想,
  // 您可以显式地写出来:
  NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;
  NotCopyableOrMovable& operator=(NotCopyableOrMovable&&)
      = delete;
};

仅当它们很明显时才能省略这些声明/删除:

  • 如果类没有 private 部分,例如 struct 或仅接口的基类,那么可复制性/可移动性可以由任何公共数据成员的可复制性/可移动性确定。
  • 如果基类显然不可复制或不可移动,则派生类自然也不会。一个将这些操作隐式化的仅接口基类不足以使具体子类清晰。
  • 请注意,如果您显式声明或删除了复制的构造函数或赋值操作之一,则另一个复制操作就不明显,必须声明或删除。移动操作也类似。

如果复制/移动的含义对于普通用户来说不清楚,或者如果它会产生意外的成本,则类型不应是可复制/可移动的。对于可复制类型,移动操作严格来说是一种性能优化,并且是潜在的错误和复杂性来源,因此除非它们比相应的复制操作效率显著更高,否则避免定义它们。如果您的类型提供了复制操作,建议您设计您的类,以便这些操作的默认实现是正确的。请记住,像对待任何其他代码一样,审查任何默认操作的正确性。

为了消除切片的风险,倾向于使基类抽象,通过将它们的构造函数设为保护,将它们的析构函数声明为保护,或为它们提供一个或多个纯虚成员函数。倾向于避免从具体类派生。

结构体与类

仅将 struct 用于携带数据的被动对象;其他所有都是 class

structclass 关键字在 C++ 中的行为几乎相同。我们为每个关键字添加了自己的语义含义,因此您应该为您定义的类型使用适当的关键字。

structs 应该用于携带数据,并且可能具有相关常量的被动对象。所有字段必须是公共的。结构体类型本身不得具有暗示不同字段之间关系的不可变性,因为用户直接访问这些字段可能会破坏这些不可变性,但结构体的用户可能对其特定用法有要求和保证。构造函数、析构函数和辅助方法可以存在;但是,这些方法不得要求或强制执行任何不可变性。

如果需要更多的功能或不可变性,或者结构体具有广泛的可见性并预计会发展,那么 class 更合适。如果有疑问,请将其设为 class

为了与 STL 保持一致,您可以对无状态类型使用 struct 而不是 class,例如 traits、模板元函数和一些 functor。

请注意,结构体和类中的成员变量具有不同的命名规则

结构体与对和元组

只要元素可以有有意义的名称,就优先使用 struct 而不是对或元组。

虽然使用对和元组可以避免定义自定义类型的需要,这可能会在编写代码时节省工作,但在阅读代码时,有意义的字段名称几乎总是比 .first.secondstd::get<X> 清晰得多。虽然 C++14 引入的 std::get<Type> 允许通过类型而不是索引访问元组元素(当类型唯一时)有时可以部分缓解这一点,但字段名称通常比类型更清晰和信息更丰富。

对和元组可能适用于通用代码,其中对或元组的元素没有特定含义。它们的使用也可能需要与现有代码或 API 互操作。

继承

组合通常比继承更合适。使用继承时,请使其为 public

当子类从基类继承时,它包含了基类定义的所有数据和操作的定义。“接口继承”是从纯抽象基类(没有状态或已定义方法)继承;所有其他继承都是“实现继承”。

实现继承通过重用基类代码来减少代码大小,因为它特化了现有类型。因为继承是编译时声明,您和编译器可以理解操作并检测错误。接口继承可用于以编程方式强制类公开特定的 API。同样,编译器可以检测错误,在这种情况下,当类没有定义 API 的必要方法时。

对于实现继承,因为实现子类的代码分散在基类和子类之间,所以可能更难理解实现。子类不能覆盖非虚函数,因此子类无法更改实现。

多重继承尤其成问题,因为它通常会带来更高的性能开销(事实上,从单继承到多重继承的性能下降通常可能大于从普通到虚派分的性能下降),并且因为它有可能导致“菱形”继承模式,这种模式容易产生歧义、混淆和直接的错误。

所有继承都应该是 public 的。如果您想进行私有继承,应该将基类的实例作为成员包含。当您不打算支持将它们用作基类时,您可以在类上使用 final

不要过度使用实现继承。组合通常更合适。尝试将继承的使用限制在“is-a”情况:如果可以合理地说 Bar “是一种” Foo,则 Bar 子类化 Foo

protected 的使用限制在可能需要从子类访问的成员函数上。请注意,数据成员应该是 private

使用正好一个 override 或(较少使用)final 说明符显式注释虚函数或虚析构函数的覆盖。声明覆盖时不使用 virtual。理由:标记为 overridefinal 的函数或析构函数如果不是基类虚函数的覆盖,则无法编译,这有助于捕获常见错误。说明符充当文档;如果不存在说明符,读者必须检查相关类的所有祖先以确定函数或析构函数是否是虚的。

允许使用多重继承,但强烈不鼓励使用多重实现继承。

操作符重载

明智地重载操作符。不要使用用户定义字面量。

C++ 允许用户代码使用 operator 关键字声明内置操作符的重载版本,只要其中一个参数是用户定义类型。operator 关键字还允许用户代码使用 operator"" 定义新型字面量,并定义类型转换函数,例如 operator bool()

操作符重载可以通过使用户定义类型表现得与内置类型相同,使代码更简洁和直观。重载操作符是某些操作的惯用名称(例如,==<=<<),并且遵守这些约定可以使用户定义类型更具可读性,并使它们能够与期望这些名称的库互操作。

用户定义字面量是创建用户定义类型对象的一种非常简洁的表示法。

  • 提供一组正确、一致且不令人意外的操作符重载需要一些小心,否则可能会导致混乱和错误。
  • 操作符的过度使用可能导致代码混淆,特别是如果重载操作符的语义不遵循约定。
  • 函数重载的危害同样适用于操作符重载,甚至更多。
  • 操作符重载可能会欺骗我们的直觉,认为昂贵的操作是廉价的内置操作。
  • 查找重载操作符的调用点可能需要一个了解 C++ 语法的搜索工具,而不是例如 grep。
  • 如果您弄错了重载操作符的参数类型,您可能会得到不同的重载而不是编译错误。例如,foo < bar 可能做一件事,而 &foo < &bar 做完全不同的事情。
  • 某些操作符重载本质上是危险的。重载一元 & 可能会导致相同的代码具有不同的含义,具体取决于重载声明是否可见。&&||,(逗号)的重载无法匹配内置操作符的求值顺序语义。
  • 操作符通常在类外部定义,因此存在不同文件引入相同操作符的不同定义的风险。如果两个定义都链接到同一个二进制文件中,这将导致未定义行为,这可能表现为微妙的运行时错误。
  • 用户定义字面量 (UDL) 允许创建新的句法形式,即使对于经验丰富的 C++ 程序员也可能不熟悉,例如 "Hello World"sv 作为 std::string_view("Hello World") 的简写。现有的表示法更清晰,尽管不那么简洁。
  • 由于它们不能进行命名空间限定,UDL 的使用还需要使用 using-directives(我们禁止)或 using-declarations(我们在头文件中禁止,除非导入的名称是相关头文件公开的接口的一部分)。鉴于头文件必须避免 UDL 后缀,我们倾向于避免在头文件和源文件之间存在不同的字面量约定。

仅当操作符重载的含义明显、不令人意外且与相应的内置操作符一致时才定义它。例如,将 | 用作按位或逻辑或,而不是 shell 风格的管道。

仅对您自己的类型定义操作符。更确切地说,在与它们操作的类型相同的头文件、.cc 文件和命名空间中定义它们。这样,无论类型在哪里可用,操作符都可用,从而最小化多重定义的风险。如果可能,避免将操作符定义为模板,因为它们必须满足任何可能的模板参数的此规则。如果您定义了一个操作符,也要定义任何相关的有意义的操作符,并确保它们的定义一致。

倾向于将非修改的二元操作符定义为非成员函数。如果二元操作符定义为类成员,隐式转换将应用于右侧参数,但不应用于左侧参数。如果 a + b 可以编译而 b + a 不能,这将使您的用户感到困惑。

对于值可以进行相等比较的类型 T,定义一个非成员 operator== 并记录何时认为类型 T 的两个值相等。如果在值 t1 小于另一个此类值 t2 时有一个明显的概念,那么您也可以定义 operator<=>,它应该与 operator== 一致。倾向于不对其他比较和排序操作符进行重载。

不要刻意避免定义操作符重载。例如,优先定义 ===<<,而不是 Equals()CopyFrom()PrintTo()。相反,不要仅仅因为其他库期望它们就定义操作符重载。例如,如果您的类型没有自然的排序,但您想将其存储在 std::set 中,请使用自定义比较器而不是重载 <

不要重载 &&||,(逗号)或一元 &。不要重载 operator"",即不要引入用户定义字面量。不要使用其他人提供的任何此类字面量(包括标准库)。

类型转换操作符在隐式转换一节中有所介绍。= 操作符在复制构造函数一节中有所介绍。重载 << 以用于流在一节中有所介绍。另请参阅函数重载的规则,它也适用于操作符重载。

访问控制

将类的数据成员设为 private,除非它们是常量。这简化了对不变性的推理,代价是如果需要,以访问器(通常是 const)形式的一些简单样板代码。

出于技术原因,我们允许在使用 Google Test 时,定义在 .cc 文件中的测试 fixture 类的数据成员是 protected 的。如果测试 fixture 类是在它使用的 .cc 文件之外定义的,例如在 .h 文件中,则将数据成员设为 private

声明顺序

将相似的声明分组在一起,将 public 部分放在前面。

类定义通常应以 public: 部分开始,后跟 protected:,然后是 private:。省略空的部分。

在每个部分中,倾向于将相似种类的声明分组在一起,并倾向于以下顺序:

  1. 类型和类型别名(typedefusingenum、嵌套结构体和类,以及 friend 类型)
  2. (可选,仅适用于结构体)非 static 数据成员
  3. 静态常量
  4. 工厂函数
  5. 构造函数和赋值操作符
  6. 析构函数
  7. 所有其他函数(static 和非 static 成员函数,以及 friend 函数)
  8. 所有其他数据成员(静态和非静态)

不要将大型方法定义内联在类定义中。通常,只有平凡或对性能至关重要,且非常短的方法才可以内联定义。有关更多详细信息,请参阅在头文件中定义函数

函数

输入和输出

C++ 函数的输出自然是通过返回值提供的,有时是通过输出参数(或输入/输出参数)提供的。

优先使用返回值而不是输出参数:它们提高了可读性,并且通常提供相同或更好的性能。请参阅 TotW #176

倾向于通过值返回,如果不行,则通过引用返回。避免返回裸指针,除非它可以为空。

参数要么是函数的输入,要么是函数的输出,要么两者都是。非可选的输入参数通常应该是值或 const 引用,而不可选的输出和输入/输出参数通常应该是引用(不能为空)。通常,使用 std::optional 来表示可选的按值输入,并在非可选形式使用引用时使用 const 指针。使用非 const 指针来表示可选的输出和可选的输入/输出参数。

避免定义需要引用参数的生命周期超过调用持续时间的函数。在某些情况下,引用参数可以绑定到临时对象,从而导致生命周期错误。相反,找到一种方法来消除生命周期要求(例如,通过复制参数),或者通过指针传递保留的参数并记录生命周期和非空要求。有关更多信息,请参阅 TotW 116

在排列函数参数时,将所有仅输入参数放在任何输出参数之前。特别是,不要仅仅因为它们是新的就将新参数添加到函数的末尾;将新的仅输入参数放在输出参数之前。这不是一个硬性规定。既是输入又是输出的参数使情况复杂化,而且,一如既往,与相关函数的一致性可能要求您打破规则。可变参数函数也可能需要不寻常的参数顺序。

编写短函数

倾向于使用短小且集中的函数。

我们承认长函数有时是适当的,因此对函数长度没有硬性限制。如果一个函数超过大约 40 行,请考虑是否可以在不损害程序结构的情况下将其分解。

即使您的长函数现在运行完美,几个月后修改它的人可能会添加新行为。这可能导致难以发现的错误。保持您的函数短小和简单,使其他人更容易阅读和修改您的代码。小函数也更容易测试。

当处理某些代码时,您可能会发现长而复杂的函数。不要害怕修改现有代码:如果使用这样的函数证明很困难,您发现错误难以调试,或者您想在几个不同的上下文中使用它的一部分,请考虑将函数分解成更小、更易于管理的片段。

函数重载

仅当读者在查看调用点时可以对正在发生的事情有一个很好的了解,而无需首先弄清楚正在调用哪个重载时,才使用重载函数(包括构造函数)。

您可以编写一个接受 const std::string& 的函数,并将其重载为另一个接受 const char* 的函数。但是,在这种情况下,请考虑使用 std::string_view

class MyClass {
 public:
  void Analyze(const std::string& text);
  void Analyze(const char* text, size_t textlen);
};

重载可以通过允许同名函数接受不同的参数,使代码更直观。它对于模板化代码可能是必要的,并且对于 Visitor 来说可能很方便。

基于 const 或引用限定的重载可以使实用程序代码更可用、更高效,或两者兼有。有关更多信息,请参阅 TotW #148

如果函数仅通过参数类型重载,读者可能需要理解 C++ 复杂的匹配规则才能知道发生了什么。此外,如果派生类只覆盖函数的部分变体,许多人会对其继承的语义感到困惑。

当变体之间没有语义差异时,您可以重载函数。这些重载可以在类型、限定符或参数数量上有所不同。但是,此类调用的读者不需要知道选择了重载集中的哪个成员,只需要知道正在调用该集合中的某个东西。

为了体现这种统一的设计,优先使用单个、全面的“保护伞”注释来记录整个重载集,并将其放在第一个声明之前。

在读者可能难以将保护伞注释与特定重载联系起来的地方,可以为特定重载添加注释。

默认参数

默认参数允许在非虚函数上使用,前提是默认值保证始终具有相同的值。遵循与函数重载相同的限制,如果默认参数带来的可读性增益没有超过以下缺点,则优先使用重载函数。

通常您有一个使用默认值的函数,但偶尔您想覆盖默认值。默认参数提供了一种简单的方法来做到这一点,而无需为罕见的例外定义许多函数。与重载函数相比,默认参数具有更简洁的语法、更少的样板代码,以及更清晰地划分“必需”和“可选”参数。

默认参数是实现重载函数语义的另一种方式,因此所有不重载函数的原因都适用。

虚函数调用中参数的默认值由目标对象的静态类型确定,并且不能保证给定函数的所有覆盖都声明相同的默认值。

默认参数在每个调用点都会被重新评估,这可能会使生成的代码膨胀。读者也可能期望默认值在声明处是固定的,而不是在每次调用时都变化的。

在存在默认参数的情况下,函数指针是令人困惑的,因为函数签名通常与调用签名不匹配。添加函数重载可以避免这些问题。

禁止在虚函数上使用默认参数,因为它们不能正常工作,以及在指定的默认值根据评估时间可能不会评估为相同值的情况下。(例如,不要写 void f(int n = counter++);。)

在其他一些情况下,默认参数可以显著改善其函数声明的可读性,从而克服上述缺点,因此允许使用它们。如有疑问,请使用重载。

尾随返回类型语法

仅在以下情况下使用尾随返回类型:使用普通语法(前导返回类型)不切实际或可读性差得多。

C++ 允许两种不同的函数声明形式。在较旧的形式中,返回类型出现在函数名称之前。例如:

int Foo(int x);

较新的形式使用函数名称前的 auto 关键字和参数列表后的尾随返回类型。例如,上面的声明可以等效地写成:

auto Foo(int x) -> int;

尾随返回类型位于函数的作用域内。这对于像 int 这样的简单情况没有区别,但对于更复杂的情况,例如在类作用域中声明的类型或根据函数参数编写的类型,这很重要。

尾随返回类型是显式指定lambda 表达式返回类型的唯一方法。在某些情况下,编译器能够推导 lambda 的返回类型,但并非在所有情况下都能推导。即使编译器可以自动推导,有时明确指定它对读者来说会更清晰。

有时,在函数的参数列表已经出现之后指定返回类型会更容易、更具可读性。当返回类型依赖于模板参数时尤其如此。例如:

    template <typename T, typename U>
    auto Add(T t, U u) -> decltype(t + u);

对比

    template <typename T, typename U>
    decltype(declval<T&>() + declval<U&>()) Add(T t, U u);

尾随返回类型语法在 C 和 Java 等类似 C++ 的语言中没有对应物,因此有些读者可能会觉得不熟悉。

现有代码库中有大量的函数声明不会被更改为使用新语法,因此现实的选择是仅使用旧语法或混合使用两者。使用单个版本对于风格的统一性更好。

在大多数情况下,继续使用较旧的函数声明风格,其中返回类型放在函数名称之前。仅在需要时(例如 lambda)或通过将类型放在函数的参数列表之后,它可以让您以更具可读性的方式编写类型的情况下,才使用新的尾随返回类型形式。后一种情况应该很少见;它主要出现在相当复杂的模板代码中,而这在大多数情况下是不鼓励的

Google 特有的魔法

我们使用各种技巧和实用程序来使 C++ 代码更健壮,以及我们使用 C++ 的各种方式可能与您在其他地方看到的有所不同。

所有权和智能指针

倾向于为动态分配的对象设置单一、固定的所有者。倾向于使用智能指针转移所有权。

“所有权”是一种用于管理动态分配内存(和其他资源)的簿记技术。动态分配对象的所有者是负责确保在不再需要时将其删除的对象或函数。所有权有时可以共享,在这种情况下,最后一个所有者通常负责删除它。即使所有权不共享,它也可以从一段代码转移到另一段代码。

“智能”指针是行为类似于指针的类,例如通过重载 *-> 操作符。某些智能指针类型可用于自动化所有权簿记,以确保履行这些责任。std::unique_ptr 是一种智能指针类型,表示对动态分配对象的排他所有权;当 std::unique_ptr 超出作用域时,该对象将被删除。它不能复制,但可以移动以表示所有权转移。std::shared_ptr 是一种智能指针类型,表示对动态分配对象的共享所有权。std::shared_ptr 可以复制;对象的所有权在所有副本之间共享,当最后一个 std::shared_ptr 被销毁时,对象将被删除。

  • 没有某种所有权逻辑,管理动态分配的内存几乎是不可能的。
  • 转移对象的所有权可能比复制它更便宜(如果复制它甚至可能的话)。
  • 转移所有权可能比“借用”指针或引用更简单,因为它减少了在两个用户之间协调对象生命周期的需要。
  • 智能指针可以通过使所有权逻辑明确、自我文档化和明确无误来提高可读性。
  • 智能指针可以消除手动所有权簿记,从而简化代码并排除大量错误。
  • 对于 const 对象,共享所有权可以是深度复制的简单且高效的替代方案。

  • 所有权必须通过指针(无论是智能指针还是普通指针)表示和转移。指针语义比值语义更复杂,尤其是在 API 中:您不仅要担心所有权,还要担心别名、生命周期和可变性等问题。

  • 值语义的性能成本通常被高估了,因此所有权转移的性能优势可能无法证明其可读性和复杂性成本是合理的。
  • 转移所有权的 API 强制其客户使用单一的内存管理模型。
  • 使用智能指针的代码在资源释放发生的位置不那么明确。
  • std::unique_ptr 使用移动语义表示所有权转移,这可能很复杂,并可能使某些程序员感到困惑。
  • 共享所有权可能是一个诱人的选择,可以替代仔细的所有权设计,从而模糊系统的设计。
  • 共享所有权需要运行时显式的簿记,这可能很昂贵。
  • 在某些情况下(例如,循环引用),具有共享所有权的对象可能永远不会被删除。
  • 智能指针不是普通指针的完美替代品。

如果动态分配是必要的,倾向于将所有权保留在分配它的代码中。如果其他代码需要访问该对象,请考虑向其传递副本,或传递指针或引用而不转移所有权。倾向于使用 std::unique_ptr 使所有权转移明确。例如:

std::unique_ptr<Foo> FooFactory();
void FooConsumer(std::unique_ptr<Foo> ptr);

不要在没有非常好的理由的情况下设计您的代码来使用共享所有权。一个这样的原因是避免昂贵的复制操作,但您应该只在性能优势显著且底层对象不可变(即 std::shared_ptr<const Foo>)的情况下这样做。如果您确实使用共享所有权,倾向于使用 std::shared_ptr

永远不要使用 std::auto_ptr。相反,使用 std::unique_ptr

cpplint

使用 cpplint.py 来检测风格错误。

cpplint.py 是一个工具,它读取源文件并识别许多风格错误。它并不完美,既有假阳性也有假阴性,但它仍然是一个有价值的工具。

有些项目有关于如何从其项目工具运行 cpplint.py 的说明。如果您贡献的项目没有,您可以单独下载 cpplint.py

其他 C++ 特性

右值引用

仅在下面列出的某些特殊情况下使用右值引用。

右值引用是一种只能绑定到临时对象的引用类型。语法类似于传统的引用语法。例如,void f(std::string&& s); 声明了一个参数为 std::string 右值引用的函数。

当令牌 '&&' 应用于函数参数中的非限定模板参数时,应用特殊的模板参数推导规则。这种引用称为转发引用。

  • 定义移动构造函数(接受类类型的右值引用的构造函数)使得可以移动值而不是复制它。例如,如果 v1 是一个 std::vector<std::string>,那么 auto v2(std::move(v1)) 可能会导致一些简单的指针操作,而不是复制大量数据。在许多情况下,这可以显著提高性能。
  • 右值引用使得实现可移动但不可复制的类型成为可能,这对于没有合理的复制定义但您仍然希望将它们作为函数参数传递、放入容器等情况很有用。
  • std::move 对于有效使用某些标准库类型是必要的,例如 std::unique_ptr
  • 使用右值引用令牌的转发引用使得编写一个将参数转发给另一个函数的通用函数包装器成为可能,并且无论其参数是否是临时对象和/或 const 都可以工作。这称为“完美转发”。

  • 右值引用尚未被广泛理解。像引用折叠和转发引用的特殊推导规则这样的规则有些晦涩难懂。

  • 右值引用经常被滥用。在期望参数在函数调用后具有有效的指定状态,或者没有执行移动操作的签名中使用右值引用是违反直觉的。

不要使用右值引用(或将 && 限定符应用于方法),除非在以下情况:

  • 您可以使用它们来定义移动构造函数和移动赋值操作符(如可复制和可移动类型中所述)。
  • 您可以使用它们来定义 &&-限定的方法,这些方法逻辑上“消费” *this,使其处于不可用或空状态。请注意,这仅适用于方法限定符(位于函数签名闭合括号之后);如果您想“消费”一个普通函数参数,请优先通过值传递它。
  • 您可以使用转发引用与 std::forward 结合使用,以支持完美转发。
  • 您可以使用它们来定义对重载,例如一个接受 Foo&&,另一个接受 const Foo&。通常首选的解决方案是简单地通过值传递,但有时一对重载函数可以提供更好的性能,例如如果函数有时不消费输入。一如既往:如果您为了性能而编写更复杂的代码,请确保您有证据证明它确实有帮助。

友元

我们允许使用 friend 类和函数,但要适度。

友元通常应在同一文件中定义,以便读者不必在另一个文件中查找对类私有成员的使用。friend 的常见用途是让 FooBuilder 类成为 Foo 的友元,以便它可以正确构造 Foo 的内部状态,而不会向外界公开此状态。在某些情况下,将单元测试类设为其测试类的友元可能很有用。

友元扩展了类封装的边界,但不会破坏它。在某些情况下,这比当您只想让另一个类访问它时将成员设为 public 更好。但是,大多数类应该仅通过其公共成员与其他类交互。

异常

我们不使用 C++ 异常。

  • 异常允许应用程序的高级层决定如何处理深度嵌套函数中的“不可能发生”的故障,而无需错误代码的模糊和容易出错的簿记。
  • 异常被大多数其他现代语言使用。在 C++ 中使用它们会使其与 Python、Java 和其他人熟悉的 C++ 更一致。
  • 一些第三方 C++ 库使用异常,在内部关闭它们使得与这些库集成变得更加困难。
  • 异常是构造函数失败的唯一方法。我们可以通过工厂函数或 Init() 方法来模拟这一点,但这分别需要堆分配或新的“无效”状态。
  • 异常在测试框架中非常方便。

  • 当您向现有函数添加 throw 语句时,您必须检查它的所有传递调用者。他们要么必须至少做出基本的异常安全保证,要么必须永远不捕获异常,并且对程序终止感到满意。例如,如果 f() 调用 g() 调用 h(),并且 h 抛出了一个 f 捕获的异常,那么 g 必须小心,否则它可能无法正确清理。

  • 更普遍的是,异常使程序控制流难以通过查看代码来评估:函数可能会在您不期望的地方返回。这会导致可维护性和调试困难。您可以通过一些关于何时何地可以使用异常的规则来最小化这种成本,但代价是开发人员需要知道和理解更多的东西。
  • 异常安全需要 RAII 和不同的编码实践。需要大量的支持机制才能轻松编写正确的异常安全代码。此外,为了避免要求读者理解整个调用图,异常安全代码必须将写入持久状态的逻辑隔离到一个“提交”阶段。这将既有好处也有成本(也许在您被迫混淆代码以隔离提交的地方)。允许异常将迫使我们总是支付这些成本,即使它们不值得。
  • 打开异常会向生成的每个二进制文件添加数据,从而增加编译时间(可能略微增加)并可能增加地址空间压力。
  • 异常的可用性可能会鼓励开发人员在不适合或从不安全的情况下抛出它们。例如,无效的用户输入不应导致抛出异常。我们需要使风格指南更长以记录这些限制!

从表面上看,使用异常的好处大于成本,尤其是在新项目中。然而,对于现有代码,引入异常对所有依赖代码都有影响。如果异常可以传播到新项目之外,那么将新项目集成到现有的无异常代码中也会变得有问题。因为 Google 大多数现有的 C++ 代码都没有准备好处理异常,所以采用生成异常的新代码相对困难。

鉴于 Google 的现有代码不容忍异常,使用异常的成本比在新项目中的成本要高一些。转换过程将缓慢且容易出错。我们不认为异常的可用替代方案,例如错误代码和断言,会带来显著的负担。

我们反对使用异常的建议不是基于哲学或道德理由,而是基于实际理由。因为我们希望在 Google 使用我们的开源项目,而且如果这些项目使用异常,这样做就很困难,所以我们也需要建议在 Google 开源项目中避免使用异常。如果一切从头再来,情况可能会有所不同。

此禁令也适用于与异常处理相关的特性,例如 std::exception_ptrstd::nested_exception

对于 Windows 代码,此规则有一个例外(并非双关语)。

noexcept

在有用和正确时指定 noexcept

noexcept 说明符用于指定函数是否会抛出异常。如果异常从标记为 noexcept 的函数中逃逸,程序将通过 std::terminate 崩溃。

noexcept 操作符执行编译时检查,如果表达式声明不抛出任何异常,则返回 true。

  • 将移动构造函数指定为 noexcept 在某些情况下可以提高性能,例如,如果 T 的移动构造函数是 noexcept,则 std::vector<T>::resize() 会移动而不是复制对象。
  • 在启用了异常的环境中,在函数上指定 noexcept 可以触发编译器优化,例如,如果编译器知道由于 noexcept 说明符不会抛出异常,则不必生成用于堆栈展开的额外代码。

  • 在遵循本指南并禁用了异常的项目中,很难确保 noexcept 说明符是正确的,也很难定义正确性甚至意味着什么。

  • 很难,如果不是不可能,撤销 noexcept,因为它消除了调用者可能依赖的保证,其方式难以检测。

当它对性能有用时,您可以使用 noexcept,如果它准确地反映了您的函数的预期语义,即如果异常以某种方式从函数体内部抛出,则它表示一个致命错误。您可以假设移动构造函数上的 noexcept 具有有意义的性能优势。如果您认为在其他函数上指定 noexcept 具有显著的性能优势,请与您的项目负责人讨论。

如果异常完全禁用(即,大多数 Google C++ 环境),则优先使用无条件的 noexcept。否则,使用带有简单条件的条件 noexcept 说明符,其评估结果仅在函数可能抛出的少数情况下为 false。测试可能包括检查所涉及的操作是否可能抛出的类型 traits(例如,std::is_nothrow_move_constructible 用于移动构造对象),或者检查分配是否可能抛出(例如,absl::default_allocator_is_nothrow 用于标准默认分配)。请注意,在许多情况下,异常的唯一可能原因是分配失败(我们相信移动构造函数不应抛出,除非是由于分配失败),并且在许多应用程序中,将内存耗尽视为致命错误而不是程序应尝试从中恢复的异常情况是适当的。即使对于其他潜在的故障,您也应该优先考虑接口简单性,而不是支持所有可能的异常抛出场景:例如,不要编写依赖于哈希函数是否可以抛出的复杂 noexcept 子句,而是简单地记录您的组件不支持哈希函数抛出,并使其无条件地 noexcept

运行时类型信息 (RTTI)

避免使用运行时类型信息 (RTTI)。

RTTI 允许程序员在运行时查询对象的 C++ 类。这是通过使用 typeiddynamic_cast 完成的。

RTTI 的标准替代方案(如下所述)需要修改或重新设计相关的类层次结构。有时,此类修改是不可行或不合需要的,特别是在广泛使用或成熟的代码中。

RTTI 在某些单元测试中可能很有用。例如,它在工厂类的测试中很有用,其中测试必须验证新创建的对象具有预期的动态类型。它对于管理对象与其模拟之间的关系也很有用。

在考虑多个抽象对象时,RTTI 很有用。考虑

bool Base::Equal(Base* other) = 0;
bool Derived::Equal(Base* other) {
  Derived* that = dynamic_cast<Derived*>(other);
  if (that == nullptr)
    return false;
  ...
}

在运行时查询对象的类型通常意味着设计问题。需要在运行时知道对象的类型通常表明您的类层次结构的设计存在缺陷。

不受约束地使用 RTTI 会使代码难以维护。它可能导致散布在代码中的基于类型的决策树或 switch 语句,所有这些都必须在进行进一步更改时进行检查。

RTTI 具有合法的用途,但容易被滥用,因此在使用时必须小心。您可以在单元测试中自由使用它,但在其他代码中尽可能避免使用它。特别是,在使用 RTTI in new code 之前三思。如果您发现自己需要编写基于对象的类而行为不同的代码,请考虑使用以下 RTTI 替代方案之一来查询类型:

  • 虚方法是根据特定子类类型执行不同代码路径的首选方式。这会将工作放在对象本身内。
  • 如果工作属于对象外部,而是在一些处理代码中,请考虑使用双分派解决方案,例如 Visitor 设计模式。这允许对象本身外部的工具使用内置类型系统确定类的类型。

当程序的逻辑保证基类的给定实例实际上是特定派生类的一个实例时,可以在对象上自由使用 dynamic_cast。通常可以使用 static_cast 作为此类情况下的替代方案。

基于类型的决策树强烈表明您的代码走错了方向。

if (typeid(*data) == typeid(D1)) {
  ...
} else if (typeid(*data) == typeid(D2)) {
  ...
} else if (typeid(*data) == typeid(D3)) {
...

当向类层次结构添加额外的子类时,这样的代码通常会中断。此外,当子类的属性发生变化时,很难找到并修改所有受影响的代码段。

不要手动实现类似 RTTI 的变通方法。反对 RTTI 的论点同样适用于带有类型标签的类层次结构等变通方法。此外,变通方法掩盖了您的真实意图。

强制转换

使用 C++ 风格的强制类型转换,例如 static_cast<float>(double_value),或大括号初始化用于算术类型的转换,例如 int64_t y = int64_t{1} << 42。不要使用像 (int)x 这样的强制转换格式,除非强制转换是 void。只有当 T 是类类型时,您才可以使用像 T(x) 这样的强制转换格式。

C++ 引入了与 C 不同的强制转换系统,它区分了强制转换操作的类型。

C 强制转换的问题在于操作的歧义;有时您正在进行转换(例如,(int)3.5),有时您正在进行强制转换(例如,(int)"hello")。大括号初始化和 C++ 强制转换通常有助于避免这种歧义。此外,C++ 强制转换在搜索它们时更可见。

C++ 风格的强制转换语法冗长且笨拙。

通常,不要使用 C 风格的强制转换。相反,当需要显式类型转换时,使用这些 C++ 风格的强制转换。

  • 使用大括号初始化来转换算术类型(例如,int64_t{x})。这是最安全的方法,因为如果转换可能导致信息丢失,代码将无法编译。语法也很简洁。
  • 当显式转换为类类型时,使用函数式强制转换;例如,优先使用 std::string(some_cord) 而不是 static_cast<std::string>(some_cord)
  • 使用 absl::implicit_cast 安全地向上转换类型层次结构,例如将 Foo* 强制转换为 SuperclassOfFoo* 或将 Foo* 强制转换为 const Foo*。C++ 通常会自动执行此操作,但在某些情况下需要显式向上转换,例如使用 ?: 操作符。
  • 使用 static_cast 作为执行值转换的 C 风格强制转换的等效项,当您需要显式地将指针从一个类向上转换为其超类,或者当您需要显式地将指针从超类强制转换为子类时。在后一种情况下,您必须确保您的对象实际上是子类的实例。
  • 使用 const_cast 来删除 const 限定符(参见 const)。
  • 使用 reinterpret_cast 对指针类型进行不安全的转换,转换为整数和其他指针类型,包括 void*。仅当您知道自己在做什么并且理解别名问题时才使用此方法。另外,考虑解引用指针(不进行强制转换)并使用 std::bit_cast 来强制转换结果值。
  • 使用 std::bit_cast 来解释具有相同大小的不同类型的值的原始位(类型双关),例如将 double 的位解释为 int64_t

有关使用 dynamic_cast 的指南,请参阅 RTTI 章节

在适当的情况下使用流,并坚持“简单”的用法。仅对表示值的类型重载 << 以进行流式传输,并且只写入用户可见的值,而不是任何实现细节。

流是 C++ 中的标准 I/O 抽象,例如标准头文件 <iostream>。它们在 Google 代码中被广泛使用,主要用于调试日志记录和测试诊断。

<<>> 流操作符提供了一个易于学习、可移植、可重用和可扩展的格式化 I/O API。相比之下,printf 甚至不支持 std::string,更不用说用户定义的类型了,而且很难可移植地使用。printf 也迫使您在众多略有不同的函数版本中进行选择,并浏览数十个转换说明符。

流通过 std::cinstd::coutstd::cerrstd::clog 提供对控制台 I/O 的一流支持。C API 也有,但由于需要手动缓冲输入而受到阻碍。

  • 流格式化可以通过改变流的状态来配置。这种改变是持久的,因此您的代码的行为可能会受到流的整个先前历史的影响,除非您竭尽全力将其恢复到已知状态,而其他代码可能已更改了它。用户代码不仅可以修改内置状态,还可以通过注册系统添加新的状态变量和行为。
  • 由于上述问题、流式代码中代码和数据的混合方式,以及操作符重载的使用(它可能选择与您预期不同的重载),很难精确控制流输出。
  • 通过 << 操作符链构建输出的做法阻碍了国际化,因为它将词序硬编码到代码中,并且流对本地化的支持是有缺陷的
  • 流 API 是微妙而复杂的,因此程序员必须在使用它方面积累经验才能有效地使用它。
  • 解决 << 的许多重载对于编译器来说成本极高。当在大型代码库中普遍使用时,它可能占用高达 20% 的解析和语义分析时间。

仅当流是完成工作的最佳工具时才使用它们。通常,当 I/O 是临时的、局部的、人类可读的,并且面向其他开发人员而不是最终用户时,就是这种情况。与您周围的代码以及整个代码库保持一致;如果您的工具已有一个用于您的问题的既定工具,请改用该工具。特别是,日志记录库通常是诊断输出比 std::cerrstd::clog 更好的选择,并且 absl/strings 或等效库中的库通常是比 std::stringstream 更好的选择。

避免使用流进行面向外部用户或处理不受信任数据的 I/O。相反,查找并使用适当的模板库来处理国际化、本地化和安全强化等问题。

如果您确实使用流,请避免流 API 的有状态部分(错误状态除外),例如 imbue()xalloc()register_callback()。使用显式格式化函数(例如 absl::StreamFormat())而不是流操作符或格式化标志来控制格式化细节,例如数字基数、精度或填充。

仅当您的类型表示一个值,并且 << 写入该值的人类可读字符串表示时,才重载 << 作为您类型的流操作符。避免在 << 的输出中暴露实现细节;如果您需要打印对象内部以进行调试,请改用命名函数(名为 DebugString() 的方法是最常见的约定)。

前增量和前减量

使用增量和减量操作符的前缀形式(++i),除非您需要后缀语义。

当变量递增(++ii++)或递减(--ii--)并且表达式的值未使用时,必须决定是前增量(递减)还是后增量(递减)。

后缀增量/减量表达式的值计算为它被修改之前的值。这可能导致代码更紧凑但更难阅读。前缀形式通常更具可读性,效率从不低于后者,而且可能更高效,因为它不需要复制操作之前的值。

在 C 中,即使表达式值未使用,也形成了使用后增量的传统,特别是在 for 循环中。

使用前缀增量/减量,除非代码明确需要后缀增量/减量表达式的结果。

const 的使用

在 API 中,只要有意义,就使用 constconstexpr 是某些 const 用法的更好选择。

变量和参数可以加上关键字 const,以表示变量不会改变(例如,const int foo)。类函数可以有 const 限定符,以表示函数不会改变类成员变量的状态(例如,class Foo { int Bar(char c) const; };)。

更容易理解变量是如何使用的。允许编译器进行更好的类型检查,并且可以想象,生成更好的代码。帮助人们确信程序的正确性,因为他们知道他们调用的函数在修改变量方面是有限制的。帮助人们知道在多线程程序中使用哪些函数是安全的,无需锁。

const 是病毒式的:如果将 const 变量传递给函数,该函数必须在其原型中具有 const(否则变量将需要 const_cast)。在调用库函数时,这可能是一个特殊问题。

我们强烈建议在 API 中(即在函数参数、方法和非局部变量上)使用 const,只要它是有意义和准确的。这提供了关于操作可以改变哪些对象的一致且经过编译器验证的文档。拥有一个一致且可靠的方法来区分读取和写入对于编写线程安全代码至关重要,并且在许多其他上下文中也很有用。特别是:

  • 如果函数保证不会修改通过引用或指针传递的参数,则相应的函数参数应分别为 const 引用(const T&)或 const 指针(const T*)。
  • 对于按值传递的函数参数,const 对调用者没有影响,因此不建议在函数声明中使用。请参阅 TotW #109
  • 声明方法为 const,除非它们改变对象的逻辑状态(或允许用户修改该状态,例如通过返回非 const 引用,但这很少见),或者它们不能安全地并发调用。

在局部变量上使用 const 既不鼓励也不反对。

类的所有 const 操作都应该是安全的,可以相互并发调用。如果不可行,则必须将该类清楚地记录为“非线程安全”。

const 放在哪里

有些人倾向于 int const* foo 形式而不是 const int* foo。他们认为这更具可读性,因为它更一致:它保持了 const 总是跟随它描述的对象的规则。然而,在很少有深层嵌套指针表达式的代码库中,这种一致性论点不适用,因为大多数 const 表达式只有一个 const,并且它应用于底层值。在这种情况下,没有一致性需要维护。将 const 放在前面可以说更具可读性,因为它遵循英语中的将“形容词”(const)放在“名词”(int)之前的习惯。

话虽如此,虽然我们鼓励将 const 放在前面,但我们不要求这样做。但要与你周围的代码保持一致!

constexprconstinitconsteval 的使用

使用 constexpr 来定义真正的常量或确保常量初始化。使用 constinit 来确保非常量变量的常量初始化。

一些变量可以声明为 constexpr,以表示变量是真正的常量,即在编译/链接时固定。一些函数和构造函数可以声明为 constexpr,这使得它们可以用于定义 constexpr 变量。函数可以声明为 consteval,以将其使用限制在编译时。

使用 constexpr 可以定义带有浮点表达式而不是仅仅是字面量的常量;定义用户定义类型的常量;以及定义带有函数调用的常量。

过早地将某些内容标记为 constexpr 可能会导致迁移问题,如果稍后必须降级的话。目前对 constexpr 函数和构造函数中允许的内容的限制可能会导致这些定义中出现晦涩的变通方法。

constexpr 定义可以更可靠地指定接口的常量部分。使用 constexpr 来指定真正的常量和支持其定义的函数。consteval 可用于不得在运行时调用的代码。避免使函数定义复杂化以支持它们与 constexpr 一起使用。不要使用 constexprconsteval 来强制内联。

整数类型

在内置的 C++ 整数类型中,唯一使用的是 int。如果程序需要不同大小的整数类型,请使用来自 <stdint.h> 的确切宽度整数类型,例如 int16_t。如果您有一个值可能大于或等于 2^31,请使用 64 位类型,例如 int64_t。请记住,即使您的值对于 int 来说永远不会太大,它也可能用于可能需要更大类型的中间计算。如有疑问,请选择更大的类型。

C++ 没有为像 int 这样的整数类型指定确切的大小。当代架构上的常见大小是 short 为 16 位,int 为 32 位,long 为 32 或 64 位,long long 为 64 位,但不同的平台会做出不同的选择,特别是对于 long

声明的统一性。

C++ 中整数类型的大小可能因编译器和架构而异。

标准库头文件 <stdint.h> 定义了像 int16_tuint32_tint64_t 等类型。当您需要保证整数的大小时,您应该始终优先使用这些类型,而不是 shortunsigned long long 等。倾向于省略这些类型的 std:: 前缀,因为额外的 5 个字符不值得增加混乱。在内置整数类型中,应仅使用 int。在适当的情况下,欢迎您使用标准类型别名,例如 size_tptrdiff_t

我们经常使用 int,用于我们知道不会太大的整数,例如循环计数器。对这些事情使用普通的 int。您应该假设 int 至少是 32 位,但不要假设它超过 32 位。如果您需要 64 位整数类型,请使用 int64_tuint64_t

对于我们知道可能“大”的整数,使用 int64_t

您不应使用像 uint32_t 这样的无符号整数类型,除非有合法的理由,例如表示位模式而不是数字,或者您需要定义 modulo 2^N 的溢出。特别是,不要使用无符号类型来表示数字永远不会是负数。相反,对此使用断言。

如果您的代码是一个返回大小的容器,请确保使用可以容纳您的容器任何可能用法的类型。如有疑问,请使用更大的类型而不是更小的类型。

在转换整数类型时要小心。整数转换和提升可能导致未定义行为,从而导致安全错误和其他问题。

关于无符号整数

无符号整数适用于表示位字段和模运算。由于历史原因,C++ 标准也使用无符号整数来表示容器的大小——标准机构的许多成员认为这是一个错误,但目前实际上不可能修复。无符号算术不模拟简单整数的行为,而是由标准定义为模拟模算术(在溢出/下溢时环绕),这意味着编译器无法诊断出一大类错误。在其他情况下,定义的行为会阻碍优化。

话虽如此,混合整数类型的符号性会造成同样大的问题。我们能提供的最佳建议:尝试使用迭代器和容器而不是指针和大小,尽量不要混合符号性,以及尽量避免无符号类型(除了表示位字段或模算术)。不要仅仅为了断言变量是非负的而使用无符号类型。

浮点类型

在内置的 C++ 浮点类型中,唯一使用的是 floatdouble。您可以假设这些类型分别表示 IEEE-754 binary32 和 binary64。

不要使用 long double,因为它会给出不可移植的结果。

架构可移植性

编写架构可移植的代码。不要依赖于特定于单个处理器的 CPU 特性。

  • 在打印值时,使用类型安全的数字格式化库,例如 absl::StrCatabsl::Substituteabsl::StrFormatstd::ostream,而不是 printf 系列函数。
  • 在将结构化数据移入或移出您的进程时,使用 Protocol Buffers 等序列化库对其进行编码,而不是复制内存中的表示。
  • 如果您需要将内存地址作为整数处理,请将它们存储在 uintptr_t 中,而不是 uint32_tuint64_t 中。
  • 根据需要使用大括号初始化来创建 64 位常量。例如:
int64_t my_value{0x123456789};
uint64_t my_mask{uint64_t{3} << 48};
  • 使用可移植的浮点类型;避免 long double

  • 使用可移植的整数类型;避免 shortlonglong long

预处理器宏

避免定义宏,尤其是在头文件中;优先使用内联函数、枚举和 const 变量。用项目特定的前缀命名宏。不要使用宏来定义 C++ API 的部分。

宏意味着您看到的代码与编译器看到的代码不同。这可能会引入意外的行为,尤其因为宏具有全局作用域。

当宏用于定义 C++ API 的部分时,它们带来的问题尤其严重,对于公共 API 更是如此。当开发人员错误地使用该接口时,编译器发出的每个错误消息现在都必须解释宏如何形成接口。重构和分析工具在更新接口方面面临更大的困难。因此,我们明确禁止以这种方式使用宏。例如,避免以下模式:

class WOMBAT_TYPE(Foo) {
  // ...

 public:
  EXPAND_PUBLIC_WOMBAT_API(Foo)

  EXPAND_WOMBAT_COMPARISONS(Foo, ==, <)
};

幸运的是,宏在 C++ 中不像在 C 中那样必要。不要使用宏来内联对性能至关重要的代码,而是使用内联函数。不要使用宏来存储常量,而是使用 const 变量。不要使用宏来“缩写”长变量名,而是使用引用。不要使用宏来条件编译代码......嗯,根本不要这样做(当然,除了用于防止头文件重复包含的 #define 保护)。它使测试变得更加困难。

宏可以做其他技术无法做到的事情,而且您确实在代码库中看到了它们,尤其是在较低级别的库中。而且它们的一些特殊功能(如字符串化、连接等)不能通过语言本身获得。但在使用宏之前,请仔细考虑是否有非宏的方法可以实现相同的结果。如果您需要使用宏来定义接口,请联系您的项目负责人以请求豁免此规则。

以下使用模式将避免宏的许多问题;如果您使用宏,请尽可能遵循它:

  • 不要在 .h 文件中定义宏。
  • 在使用宏之前 #define 它们,并在之后立即 #undef 它们。
  • 不要仅仅在用您自己的宏替换现有宏之前 #undef 它;相反,选择一个可能唯一的名称。
  • 尽量不要使用扩展为不平衡 C++ 结构的宏,或者至少记录好这种行为。
  • 倾向于不使用 ## 生成函数/类/变量名。

从头文件导出宏(即,在头文件中定义它们而不 #undef 它们,直到头文件结束)是极力不鼓励的。如果您从头文件导出宏,它必须具有全局唯一的名称。为此,它必须使用一个由您的项目命名空间名称组成的前缀命名(但要大写)。

0 和 nullptr/NULL

对指针使用 nullptr,对字符使用 '\0'(而不是 0 字面量)。

对于指针(地址值),使用 nullptr,因为它提供了类型安全性。

对于空字符,使用 '\0'。使用正确的类型可以使代码更具可读性。

sizeof

倾向于 sizeof(varname) 而不是 sizeof(type)

当您获取特定变量的大小时,使用 sizeof(varname)。如果有人现在或将来更改变量类型,sizeof(varname) 将适当地更新。您可以对与任何特定变量无关的代码使用 sizeof(type),例如管理外部或内部数据格式的代码,其中适当 C++ 类型的变量不方便。

MyStruct data;
memset(&data, 0, sizeof(data));
memset(&data, 0, sizeof(MyStruct));
if (raw_size < sizeof(int)) {
  LOG(ERROR) << "compressed record not big enough for count: " << raw_size;
  return false;
}

类型推导(包括 auto

仅当类型推导能使不熟悉该项目的读者更容易理解代码,或者如果它使代码更安全时,才使用类型推导。不要仅仅为了避免编写显式类型的不便而使用它。

在 C++ 中,有几种情况下允许(甚至要求)类型由编译器推导,而不是在代码中显式写出:

可以调用函数模板而无需显式模板参数。编译器从函数参数的类型推导出这些参数:

template <typename T>
void f(T t);

f(0);  // Invokes f<int>(0)

变量声明可以使用 auto 关键字代替类型。编译器从变量的初始化器推导出类型,遵循与具有相同初始化器的函数模板参数推导相同的规则(只要您不使用大括号而不是圆括号)。

auto a = 42;  // a is an int
auto& b = a;  // b is an int&
auto c = b;   // c is an int
auto d{42};   // d is an int, not a std::initializer_list<int>

auto 可以用 const 限定,并且可以用作指针或引用类型的一部分,并且(自 C++17 起)可以用作非类型模板参数。这种语法的一个罕见变体使用 decltype(auto) 而不是 auto,在这种情况下,推导出的类型是应用 decltype 到初始化器的结果。

auto(和 decltype(auto))也可以用于代替函数返回类型。编译器从函数体中的 return 语句推导出返回类型,遵循与变量声明相同的规则:

auto f() { return 0; }  // The return type of f is int

Lambda 表达式返回类型可以以相同的方式推导,但这由省略返回类型触发,而不是由显式 auto 触发。令人困惑的是,函数的尾随返回类型语法也在返回类型位置使用 auto,但这不依赖于类型推导;它只是显式返回类型的替代语法。

lambda 表达式可以在其一个或多个参数类型的位置使用 auto 关键字。这使得 lambda 的调用操作符成为一个函数模板而不是一个普通函数,每个 auto 函数参数都有一个单独的模板参数:

// Sort `vec` in decreasing order
std::sort(vec.begin(), vec.end(), [](auto lhs, auto rhs) { return lhs > rhs; });

Lambda 捕获可以具有显式初始化器,可用于声明全新的变量,而不仅仅是捕获现有变量:

[x = 42, y = "foo"] { ... }  // x is an int, and y is a const char*

此语法不允许指定类型;相反,它是使用 auto 变量的规则推导出来的。

请参阅下文

使用 auto 声明元组、结构体或数组时,您可以为单个元素指定名称,而不是为整个对象指定名称;这些名称称为“结构化绑定”,整个声明称为“结构化绑定声明”。此语法无法指定封闭对象或单个名称的类型:

auto [iter, success] = my_map.insert({key, value});
if (!success) {
  iter->second = value;
}

auto 也可以用 const&&& 限定,但请注意,这些限定符在技术上适用于匿名元组/结构体/数组,而不是单个绑定。确定绑定类型的规则非常复杂;结果往往不令人意外,只是绑定类型通常不会是引用,即使声明声明了引用(但它们通常仍会表现得像引用)。

(这些摘要省略了许多细节和注意事项;有关更多信息,请参阅链接。)

  • C++ 类型名称可能很长且笨拙,尤其是当它们涉及模板或命名空间时。
  • 当 C++ 类型名称在单个声明或小代码区域内重复时,重复可能不会提高可读性。
  • 有时让类型被推导更安全,因为这避免了意外复制或类型转换的可能性。

当类型是显式时,C++ 代码通常更清晰,尤其是当类型推导将依赖于来自遥远代码部分的信息时。在像下面这样的表达式中:

auto foo = x.add_foo();
auto i = y.Find(key);

如果 y 的类型不是很清楚,或者如果 y 在很多行之前就已声明,那么结果类型可能就不明显了。

程序员必须理解类型推导何时会或不会产生引用类型,否则他们会得到他们不想要的副本。

如果推导出的类型用作接口的一部分,那么程序员可能会更改其类型,而只打算更改其值,从而导致比预期更激进的 API 更改。

基本规则是:仅在使代码更清晰或更安全时使用类型推导,不要仅仅为了避免编写显式类型的不便而使用它。在判断代码是否更清晰时,请记住您的读者不一定在您的团队中,也不熟悉您的项目,因此您和您的审阅者认为不必要的混乱的类型通常会为其他人提供有用的信息。例如,您可以假设 make_unique<Foo>() 的返回类型是显而易见的,但 MyWidgetFactory() 的返回类型可能不是。

这些原则适用于所有形式的类型推导,但细节有所不同,如下面的部分所述。

函数模板参数推导

函数模板参数推导几乎总是可以的。类型推导是与函数模板交互的预期默认方式,因为它允许函数模板像无限组普通函数重载一样工作。因此,函数模板几乎总是被设计成模板参数推导是清晰和安全的,或者不会编译。

局部变量类型推导

对于局部变量,您可以使用类型推导使代码更清晰,方法是消除明显或不相关的类型信息,以便读者可以专注于代码中有意义的部分:

std::unique_ptr<WidgetWithBellsAndWhistles> widget =
    std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2);
absl::flat_hash_map<std::string,
                    std::unique_ptr<WidgetWithBellsAndWhistles>>::const_iterator
    it = my_map_.find(key);
std::array<int, 6> numbers = {4, 8, 15, 16, 23, 42};
auto widget = std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2);
auto it = my_map_.find(key);
std::array numbers = {4, 8, 15, 16, 23, 42};

类型有时包含有用信息和样板代码的混合,例如上面示例中的 it:很明显类型是一个迭代器,在许多上下文中容器类型甚至键类型都不相关,但值的类型可能有用。在这种情况下,通常可以使用显式类型定义局部变量,以传达相关信息:

if (auto it = my_map_.find(key); it != my_map_.end()) {
  WidgetWithBellsAndWhistles& widget = *it->second;
  // Do stuff with `widget`
}

如果类型是模板实例,并且参数是样板代码但模板本身提供信息,则可以使用类模板参数推导来抑制样板代码。然而,这样做实际上能提供有意义好处的情况非常罕见。请注意,类模板参数推导也受到单独的风格规则的约束。

如果可以使用更简单的选项,则不要使用 decltype(auto);因为它是一个相当晦涩的特性,所以在代码清晰度方面成本很高。

返回类型推导

仅当函数体具有非常少量的 return 语句和很少的其他代码时,才使用返回类型推导(对于函数和 lambda),因为否则读者可能无法一眼看出返回类型是什么。此外,仅当函数或 lambda 具有非常窄的作用域时才使用它,因为具有推导返回类型的函数不定义抽象边界:实现就是接口。特别是,头文件中的公共函数几乎不应该具有推导的返回类型。

参数类型推导

lambda 的 auto 参数类型应谨慎使用,因为实际类型由调用 lambda 的代码确定,而不是由 lambda 的定义确定。因此,显式类型几乎总是更清晰,除非 lambda 在定义它的位置附近显式调用(以便读者可以轻松地看到两者),或者 lambda 传递给一个众所周知的接口,以至于显然会调用哪个参数(例如,上面的 std::sort 示例)。

Lambda 初始化捕获

初始化捕获受更具体的风格规则约束,该规则在很大程度上取代了类型推导的一般规则。

结构化绑定

与其他形式的类型推导不同,结构化绑定实际上可以为读者提供额外的信息,方法是为较大对象的元素提供有意义的名称。这意味着结构化绑定声明可能会在可读性方面带来净改进,即使在 auto 不会的情况下也是如此。结构化绑定在对象是一对或元组时特别有益(如上面的 insert 示例),因为它们最初没有有意义的字段名称,但请注意,您通常不应该使用对或元组,除非像 insert 这样的预先存在的 API 强制您这样做。

如果正在绑定的对象是一个结构体,那么提供更特定于您的用法的名称有时会很有帮助,但请记住,这也可能意味着这些名称对您的读者来说不如字段名称易于识别。我们建议使用注释来指示底层字段的名称,如果它与绑定的名称不匹配,请使用与函数参数注释相同的语法:

auto [/*field_name1=*/bound_name1, /*field_name2=*/bound_name2] = ...

与函数参数注释一样,这可以使工具能够检测您是否弄错了字段的顺序。

类模板参数推导

仅对已明确选择支持类模板参数推导的模板使用它。

类模板参数推导(通常缩写为“CTAD”)发生在变量使用命名模板的类型声明时,并且未提供模板参数列表(甚至没有空尖括号):

std::array a = {1, 2, 3};  // `a` is a std::array<int, 3>

编译器使用模板的“推导指南”(可以是显式或隐式)从初始化器推导出参数。

显式推导指南看起来像带有尾随返回类型的函数声明,只是没有前导 auto,并且函数名称是模板的名称。例如,上面的示例依赖于 std::array 的此推导指南:

namespace std {
template <class T, class... U>
array(T, U...) -> std::array<T, 1 + sizeof...(U)>;
}

主模板中的构造函数(与模板特化相反)也隐式定义了推导指南。

当您声明依赖于 CTAD 的变量时,编译器使用构造函数重载解析的规则选择一个推导指南,并且该指南的返回类型成为变量的类型。

CTAD 有时可以允许您从代码中省略样板代码。

从构造函数生成的隐式推导指南可能具有不希望的行为,或者完全不正确。这对于在引入 CTAD 之前编写的构造函数来说尤其成问题,因为这些构造函数的作者无法知道(更不用说修复)他们的构造函数将为 CTAD 带来的任何问题。此外,添加显式推导指南来修复这些问题可能会破坏依赖于隐式推导指南的任何现有代码。

CTAD 也遭受与 auto 相同的许多缺点,因为它们都是从变量的初始化器推导变量的全部或部分类型的机制。CTAD 确实比 auto 更能为读者提供信息,但它也不会给读者一个明显的线索表明信息已被省略。

除非模板的维护者已明确选择支持 CTAD 的使用,方法是提供至少一个显式推导指南(std 命名空间中的所有模板也被假定为已选择加入),否则不要对给定模板使用 CTAD。如果可用,应通过编译器警告强制执行此操作。

CTAD 的使用也必须遵循类型推导的一般规则。

指定初始化器

仅以其 C++20 兼容形式使用指定初始化器。

指定初始化器是一种语法,允许通过显式命名其字段来初始化聚合(“普通旧结构体”):

  struct Point {
    float x = 0.0;
    float y = 0.0;
    float z = 0.0;
  };

  Point p = {
    .x = 1.0,
    .y = 2.0,
    // z will be 0.0
  };

显式列出的字段将按指定进行初始化,其他字段将以与传统聚合初始化表达式 Point{1.0, 2.0} 中相同的方式进行初始化。

指定初始化器可以使聚合表达式方便且高度可读,特别是对于字段顺序不如上面 Point 示例那么简单的结构体。

虽然指定初始化器长期以来一直是 C 标准的一部分,并作为扩展得到 C++ 编译器的支持,但在 C++20 之前它们不受 C++ 支持。

C++ 标准中的规则比 C 和编译器扩展中的规则更严格,要求指定初始化器以与它们在结构体定义中出现的顺序相同的顺序出现。因此,在上面的示例中,根据 C++20,初始化 x 然后 z 是合法的,但初始化 y 然后 x 是不合法的。

仅以与 C++20 标准兼容的形式使用指定初始化器:初始化器以与相应字段在结构体定义中出现的顺序相同的顺序。

Lambda 表达式

在适当的情况下使用 lambda 表达式。当 lambda 将逃逸当前作用域时,优先使用显式捕获。

lambda 表达式是创建匿名函数对象的一种简洁方式。当将函数作为参数传递时,它们通常很有用。例如:

std::sort(v.begin(), v.end(), [](int x, int y) {
  return Weight(x) < Weight(y);
});

它们进一步允许从封闭作用域捕获变量,可以通过名称显式捕获,也可以使用默认捕获隐式捕获。显式捕获要求列表中的每个变量都是一个值或引用捕获:

int weight = 3;
int sum = 0;
// Captures `weight` by value and `sum` by reference.
std::for_each(v.begin(), v.end(), [weight, &sum](int x) {
  sum += weight * x;
});

默认捕获隐式捕获 lambda 主体中引用的任何变量,如果使用了任何成员,则包括 this

const std::vector<int> lookup_table = ...;
std::vector<int> indices = ...;
// Captures `lookup_table` by reference, sorts `indices` by the value
// of the associated element in `lookup_table`.
std::sort(indices.begin(), indices.end(), [&](int a, int b) {
  return lookup_table[a] < lookup_table[b];
});

变量捕获也可以具有显式初始化器,可用于按值捕获仅可移动变量,或用于普通引用或值捕获未处理的其他情况:

std::unique_ptr<Foo> foo = ...;
[foo = std::move(foo)] () {
  ...
}

此类捕获(通常称为“初始化捕获”或“通用 lambda 捕获”)实际上不必从封闭作用域“捕获”任何内容,甚至不必具有来自封闭作用域的名称;此语法是定义 lambda 对象成员的完全通用方式:

[foo = std::vector<int>({1, 2, 3})] () {
  ...
}

具有初始化器的捕获的类型使用与 auto 相同的规则推导。

  • Lambda 比定义函数对象以传递给 STL 算法的其他方式要简洁得多,这可以提高可读性。
  • 适当使用默认捕获可以消除冗余,并突出显示与默认值不同的重要例外。
  • Lambda、std::functionstd::bind 可以组合用作通用回调机制;它们使得编写接受绑定函数作为参数的函数变得容易。

  • Lambda 中的变量捕获可能是悬空指针错误的来源,特别是如果 lambda 逃逸当前作用域。

  • 按值默认捕获可能具有误导性,因为它们不能防止悬空指针错误。按值捕获指针不会导致深层复制,因此它通常具有与按引用捕获相同的生命周期问题。当按值捕获 this 时,这尤其令人困惑,因为 this 的使用通常是隐式的。
  • 捕获实际上声明了新变量(无论捕获是否具有初始化器),但它们看起来与 C++ 中的任何其他变量声明语法都不相似。特别是,没有变量类型的位置,甚至没有 auto 占位符(尽管初始化捕获可以间接指示它,例如通过强制转换)。这使得识别它们为声明变得困难。
  • 初始化捕获本质上依赖于类型推导,并且遭受与 auto 相同的许多缺点,另外一个问题是语法甚至没有提示读者正在发生推导。
  • Lambda 的使用可能会失控;非常长的嵌套匿名函数会使代码难以理解。

  • 在适当的情况下使用 lambda 表达式,并按照下面所述进行格式化。

  • 如果 lambda 可能会逃逸当前作用域,则优先使用显式捕获。例如,不要使用:

{
  Foo foo;
  ...
  executor->Schedule([&] { Frobnicate(foo); })
  ...
}
// 差!lambda 使用对 `foo` 的引用和可能的 `this`(如果 `Frobnicate`
// 是成员函数)可能在粗略检查时并不明显。如果 lambda 在
// 函数返回后调用,那将是错误的,因为 `foo`
// 和封闭对象可能已被销毁。

优先编写:

{
  Foo foo;
  ...
  executor->Schedule([&foo] { Frobnicate(foo); })
  ...
}
// 更好 - 如果 `Frobnicate` 是成员
// 函数,编译将失败,并且更清楚的是 `foo` 被危险地
// 按引用捕获。
  • 仅当 lambda 的生命周期明显短于任何潜在的捕获时,才使用按引用默认捕获([&])。

  • 仅作为为短 lambda 绑定少量变量的手段使用按值默认捕获([=]),其中捕获的变量集一目了然,并且不会隐式捕获 this。(这意味着出现在非静态类成员函数中并在其主体中引用非静态类成员的 lambda 必须显式或通过 [&] 捕获 this。)倾向于不编写带有按值默认捕获的长而复杂的 lambda。

  • 仅使用捕获来实际捕获封闭作用域中的变量。不要使用带有初始化器的捕获来引入新名称,或实质性地改变现有名称的含义。相反,以传统方式声明一个新变量,然后捕获它,或者避免 lambda 简写并显式定义一个函数对象。

  • 有关指定参数和返回类型的指南,请参阅类型推导一节。

模板元编程

避免复杂的模板编程。

模板元编程指的是一系列技术,它们利用 C++ 模板实例化机制是图灵完备的,并且可用于在类型域中执行任意编译时计算的事实。

模板元编程允许使用类型安全和高性能的极其灵活的接口。如果没有它,像 GoogleTeststd::tuplestd::function 和 Boost.Spirit 这样的工具将是不可能的。

模板元编程中使用的技术对于除了语言专家之外的任何人来说通常都是晦涩难懂的。以复杂方式使用模板的代码通常难以阅读,并且难以调试或维护。

模板元编程通常会导致极其糟糕的编译时错误消息:即使接口很简单,当用户做错事时,复杂的实现细节也会变得可见。

模板元编程通过使重构工具的工作更加困难来干扰大规模重构。首先,模板代码在多个上下文中展开,很难验证转换在所有上下文中是否都有意义。其次,一些重构工具使用仅表示模板展开后代码结构的 AST。很难自动追溯到需要重写的原始源构造。

模板元编程有时允许比没有它可能更清晰、更容易使用的接口,但它也常常是过度聪明的诱惑。最好在少数低级组件中使用它,其中额外的维护负担分散在大量用途上。

在使用模板元编程或其他复杂的模板技术之前三思;考虑一下您的团队的普通成员是否能够充分理解您的代码,以便在您转向另一个项目后对其进行维护,或者非 C++ 程序员或随意浏览代码库的人是否能够理解错误消息或跟踪他们想要调用的函数的流程。如果您正在使用递归模板实例化或类型列表或元函数或表达式模板,或者依赖于 SFINAE 或 sizeof 技巧来检测函数重载解析,那么您很有可能已经做得太过分了。

如果您使用模板元编程,您应该期望投入大量的精力来最小化和隔离复杂性。您应该尽可能将元编程隐藏为实现细节,以便面向用户的头文件是可读的,并且您应该确保棘手的代码有特别好的注释。您应该仔细记录代码的使用方式,并且您应该说明“生成”的代码是什么样子的。特别注意编译器在用户犯错时发出的错误消息。错误消息是您的用户界面的一部分,您的代码应根据需要进行调整,以便错误消息从用户的角度来看是可理解和可操作的。

概念和约束

有限地使用概念。一般来说,概念和约束应仅用于在 C++20 之前会使用模板的情况。避免在头文件中引入新的概念,除非头文件被标记为库内部。不要定义不受编译器强制执行的概念。优先使用约束而不是模板元编程,并避免使用 template<Concept T> 语法;相反,使用 requires(Concept<T>) 语法。

concept 关键字是一种新的机制,用于为模板参数定义要求(例如类型特性或接口规范)。requires 关键字提供了对模板施加匿名约束并在编译时验证约束是否满足的机制。概念和约束通常一起使用,但也可以独立使用。

  • 概念允许编译器在涉及模板时生成更好的错误消息,这可以减少混乱并显著改善开发体验。
  • 概念可以减少定义和使用编译时约束所需的样板代码,通常可以增加结果代码的清晰度。
  • 约束提供了一些难以用模板和 SFINAE 技术实现的功能。

  • 与模板一样,概念会使代码显著复杂且难以理解。

  • 概念语法可能令读者困惑,因为概念在用法上看起来类似于类类型。
  • 概念,尤其是在 API 边界,会增加代码耦合、刚性和僵化。
  • 概念和约束可以复制函数体中的逻辑,导致代码重复和增加维护成本。
  • 概念模糊了其底层合同的真实来源,因为它们是独立的命名实体,可以在多个位置使用,所有这些都与彼此独立演变。这可能导致陈述的和暗示的要求随着时间的推移而分歧。
  • 概念和约束以新颖且不明显的方式影响重载解析。
  • 与 SFINAE 一样,约束使大规模重构代码变得更加困难。

应优先使用标准库中预定义的概念,而不是类型特性,当存在等效的概念时。(例如,如果在使用 C++20 之前会使用 std::is_integral_v,那么在 C++20 代码中应该使用 std::integral。)类似地,优先使用现代约束语法(通过 requires(Condition))。避免遗留模板元编程结构(例如 std::enable_if<Condition>)以及 template<Concept T> 语法。

不要手动重新实现任何现有概念或特性。例如,使用 requires(std::default_initializable<T>) 而不是 requires(requires { T v; }) 或类似。

新的 concept 声明应该很少见,并且仅在库内部定义,以便它们不暴露在 API 边界。更普遍地,不要在您不会在 C++17 中使用其遗留模板等效项的情况下使用概念或约束。

不要定义重复函数体或施加从阅读代码主体或结果错误消息中看不出微不足道或不明显的要求的概念。例如,避免以下情况:

template <typename T>     // 差 - 冗余且收益微不足道
concept Addable = std::copyable<T> && requires(T a, T b) { a + b; };
template <Addable T>
T Add(T x, T y, T z) { return x + y + z; }

相反,倾向于将代码保留为普通模板,除非您可以证明概念在该特定情况下会带来显着改进,例如在深度嵌套的或不明显的要求的结果错误消息中。

概念应该可以通过编译器进行静态验证。不要使用任何主要好处来自语义(或以其他方式未强制执行)约束的概念。未在编译时强制执行的要求应改用其他机制(例如注释、断言或测试)施加。

C++20 模块

不要使用 C++20 模块。

C++20 引入了“模块”,这是一种新的语言特性,旨在作为头文件文本包含的替代方案。它引入了三个新关键字来支持这一点:moduleexportimport

模块是 C++ 编写和编译方式的重大转变,我们仍在评估它们未来如何适应 Google 的 C++ 生态系统。此外,它们目前没有得到我们的构建系统、编译器和其他工具的良好支持,需要进一步探索编写和使用它们的最佳实践。

协程

仅通过经您的项目负责人批准的库使用 C++20 协程。

C++20 引入了协程:可以暂停和稍后恢复执行的函数。它们对于异步编程特别方便,可以在其中提供比传统基于回调的框架进行实质性改进。

与其他大多数编程语言(Kotlin、Rust、TypeScript 等)不同,C++ 不提供协程的具体实现。相反,它要求用户实现自己的可等待类型(使用promise 类型),它决定了协程参数类型、协程如何执行,并允许在执行的不同阶段运行用户定义的代码。

  • 协程可用于实现适合特定任务的安全高效的库,例如异步编程。
  • 协程在语法上与非协程函数几乎相同,这使得它们比替代方案更具可读性。
  • 高度的定制使得可以将更详细的调试信息插入到协程中,而不是替代方案。

  • 没有标准的协程 promise 类型,并且每个用户定义的实现可能在某些方面都是唯一的。

  • 由于返回类型、promise 类型中的各种可定制钩子和编译器生成代码之间的负载交互,协程语义非常难以从阅读用户代码中推导出来。
  • 协程的许多可定制方面引入了大量的陷阱,尤其是在悬空引用和竞态条件方面。

总而言之,设计高质量和可互操作的协程库需要大量困难的工作、仔细的思考和广泛的文档。

仅使用已获准在项目范围内使用的协程库。不要自己编写 promise 或可等待类型。

Boost

仅使用来自 Boost 库集合的已批准库。

Boost 库集合是同行评审、免费、开源 C++ 库的流行集合。

Boost 代码通常质量非常高,可移植性广,并填补了 C++ 标准库中的许多重要空白,例如类型特性和更好的绑定器。

一些 Boost 库鼓励可能妨碍可读性的编码实践,例如元编程和其他高级模板技术,以及过于“函数式”的编程风格。

为了保持所有可能阅读和维护代码的贡献者的高可读性水平,我们只允许 Boost 特性的一部分经批准的子集。目前,以下库是允许的:

我们正在积极考虑将其他 Boost 特性添加到列表中,因此此列表将来可能会扩展。

禁止使用的标准库特性

Boost 一样,一些现代 C++ 库功能鼓励的编码实践会妨碍可读性——例如,通过删除可能有助​​于读者的经过检查的冗余(例如类型名称),或通过鼓励模板元编程。其他扩展复制了通过现有机制可用的功能,这可能导致混乱和转换成本。

以下 C++ 标准库特性不得使用:

  • 编译时有理数 (<ratio>),因为担心它与更模板化的接口风格相关联。
  • <cfenv><fenv.h> 头文件,因为许多编译器不能可靠地支持这些特性。
  • <filesystem> 头文件,因为它没有足够的支持进行测试,并且存在固有的安全漏洞。

非标准扩展

除非另有说明,否则不得使用 C++ 的非标准扩展。

编译器支持不是标准 C++ 一部分的各种扩展。此类扩展包括 GCC 的 __attribute__、内在函数,例如 __builtin_prefetch 或 SIMD、#pragma、内联汇编、__COUNTER____PRETTY_FUNCTION__、复合语句表达式(例如,foo = ({ int x; Bar(&x); x })、可变长度数组和 alloca(),以及 “Elvis 操作符a?:b

  • 非标准扩展可能提供标准 C++ 中不存在的有用功能。
  • 只能使用扩展指定对编译器重要的性能指导。

  • 非标准扩展不能在所有编译器中工作。使用非标准扩展会降低代码的可移植性。

  • 即使它们在所有目标编译器中都受支持,这些扩展通常也没有得到很好的指定,并且编译器之间可能存在细微的行为差异。
  • 非标准扩展向语言添加了读者必须知道的功能才能理解代码。
  • 非标准扩展需要额外的工作才能跨架构移植。

不要使用非标准扩展。您可以使用使用非标准扩展实现的移植性包装器,只要这些包装器由指定的项目范围的移植性头文件提供。

别名

公共别名是为了 API 用户的利益,应该清楚地记录。

有几种方法可以创建作为其他实体的别名的名称:

using Bar = Foo;
typedef Foo Bar;  // But prefer `using` in C++ code.
using ::other_namespace::Foo;
using enum MyEnumType;  // Creates aliases for all enumerators in MyEnumType.

在新代码中,using 优于 typedef,因为它提供了与 C++ 其余部分更一致的语法,并且适用于模板。

像其他声明一样,在头文件中声明的别名是该头文件公共 API 的一部分,除非它们在函数定义中、类的私有部分或在明确标记的内部命名空间中。这些区域或 .cc 文件中的别名是实现细节(因为客户端代码无法引用它们),并且不受此规则的限制。

  • 别名可以通过简化冗长或复杂的名称来提高可读性。
  • 别名可以通过在一个地方命名 API 中重复使用的类型来减少重复,这可能使以后更改类型更容易。

  • 当放置在客户端代码可以引用它们的头文件中时,别名会增加该头文件 API 中的实体数量,从而增加了其复杂性。

  • 客户端可以很容易地依赖公共别名的意外细节,从而使更改变得困难。
  • 可能会诱惑创建一个仅供实现使用的公共别名,而没有考虑它对 API 或可维护性的影响。
  • 别名可能会产生名称冲突的风险。
  • 别名可能会通过给熟悉的构造一个不熟悉的名称来降低可读性。
  • 类型别名可能会创建不明确的 API 合同:不清楚别名是否保证与其别名的类型相同、具有相同的 API,或者仅在指定的窄方式下可用。

不要仅仅为了在实现中节省输入而将别名放在您的公共 API 中;只有当您打算让您的客户使用它时才这样做。

在定义公共别名时,记录新名称的意图,包括它是否保证始终与其当前别名的类型相同,或者是否预期有限的兼容性。这让用户知道他们是否可以将类型视为可替代的,或者是否必须遵循更具体的规则,并且可以帮助实现保留更改别名的一定程度的自由。

不要在您的公共 API 中放置命名空间别名。(另请参阅命名空间)。

例如,这些别名记录了它们在客户端代码中的预期使用方式:

namespace mynamespace {
// Used to store field measurements. DataPoint may change from Bar* to some internal type.
// Client code should treat it as an opaque pointer.
using DataPoint = ::foo::Bar*;

// A set of measurements. Just an alias for user convenience.
using TimeSeries = std::unordered_set<DataPoint, std::hash<DataPoint>, DataPointComparator>;
}  // namespace mynamespace

这些别名没有记录预期的用途,其中一半不适用于客户端用途:

namespace mynamespace {
// 差:这些都没有说明它们应该如何使用。
using DataPoint = ::foo::Bar*;
using ::std::unordered_set;  // 差:仅为局部方便
using ::std::hash;           // 差:仅为局部方便
typedef unordered_set<DataPoint, hash<DataPoint>, DataPointComparator> TimeSeries;
}  // namespace mynamespace

但是,函数定义、类的 private 部分、明确标记的内部命名空间和 .cc 文件中的局部方便别名是允许的:

// 在 .cc 文件中
using ::foo::Bar;

Switch 语句

如果不是以枚举值为条件,switch 语句应始终具有 default 情况(在枚举值的情况下,如果未处理任何值,编译器将警告您)。如果 default 情况永远不应执行,请将其视为错误。例如:

switch (var) {
  case 0: {
    ...
    break;
  }
  case 1: {
    ...
    break;
  }
  default: {
    LOG(FATAL) << "Invalid value in switch statement: " << var;
  }
}

从一个 case 标签落空到另一个 case 标签必须使用 [[fallthrough]]; 属性进行注释。[[fallthrough]]; 应放置在发生落空到下一个 case 标签的执行点。一个常见的例外是没有中间代码的连续 case 标签,在这种情况下不需要注释。

switch (x) {
  case 41:  // 这里不需要注释。
  case 43:
    if (dont_be_picky) {
      // Use this instead of or along with annotations in comments.
      [[fallthrough]];
    } else {
      CloseButNoCigar();
      break;
    }
  case 42:
    DoSomethingSpecial();
    [[fallthrough]];
  default:
    DoSomethingGeneric();
    break;
}

包容性语言

在所有代码中,包括命名和注释,使用包容性语言,避免其他程序员可能觉得不尊重或冒犯的术语(例如“master”和“slave”、“blacklist”和“whitelist”或“redline”),即使这些术语也有表面上的中性含义。同样,使用性别中立的语言,除非您指代特定的人(并使用他们的代词)。例如,对于性别未指定的人,使用 “they”/“them”/“their”(即使是单数),对于软件、计算机和其他非人物体,使用 “it”/“its”。

命名

最重要的一致性规则是那些管理命名的规则。名称的风格立即告诉我们被命名实体是什么:类型、变量、函数、常量、宏等,而无需我们搜索该实体的声明。我们大脑中的模式匹配引擎在很大程度上依赖于这些命名规则。

关于命名的风格规则是相当武断的,但我们认为在这个领域中,一致性比个人偏好更重要,所以无论您觉得它们是否合理,规则就是规则。

对于下面的命名规则,一个“单词”是任何您在英语中会写但没有内部空格的东西。要么所有单词都是小写,单词之间用下划线连接(“snake_case”),要么单词是混合大小写,每个单词的首字母大写(“camelCase”或“PascalCase”)。

选择名称

给事物起名称,使新读者,即使是与所有者不在同一团队的人,也能理解它们的用途或意图。不要担心节省水平空间,因为让新读者立即理解您的代码更为重要。

考虑名称将使用的上下文。即使名称在远离使其可用的代码的地方使用,它也应该具有描述性。但是,名称不应通过重复立即上下文中存在的信息来分散读者的注意力。通常,这意味着描述性应与名称的可见性范围成比例。在头文件中声明的自由函数可能应该提及头文件的库,而局部变量可能不应该解释它在哪个函数中。

尽量减少使用项目外部人员可能不知道的缩写(尤其??是首字母缩略词和首字母缩写)。不要通过删除单词中的字母来缩写。使用缩写时,倾向于将其作为单个“单词”大写,例如 StartRpc() 而不是 StartRPC()。根据经验,如果一个缩写在 Wikipedia 中列出,那么它可能是可以的。请注意,某些广为人知的缩写是允许的,例如用于循环索引的 i 和用于模板参数的 T

您最常看到的名称不像大多数名称;少量“词汇”名称被广泛重用,因此它们始终在上下文中。这些名称往往很短,甚至是缩写,它们的完整含义来自明确的长期文档,而不是仅仅来自对其定义和名称内的单词的注释。例如,absl::Status 在 devguide 中有一个专门的页面,记录了它的正确使用。您可能不会经常定义新的词汇名称,但如果您定义了,请进行额外的设计审查,以确保所选名称在广泛使用时效果良好。

class MyClass {
 public:
  int CountFooErrors(const std::vector<Foo>& foos) {
    int n = 0;  // Clear meaning given limited scope and context
    for (const auto& foo : foos) {
      ...
      ++n;
    }
    return n;
  }
  // Function comment doesn't need to explain that this returns non-OK on
  // failure as that is implied by the `absl::Status` return type, but it
  // might document behavior for some specific codes.
  absl::Status DoSomethingImportant() {
    std::string fqdn = ...;  // Well-known abbreviation for Fully Qualified Domain Name
    return absl::OkStatus();
  }
 private:
  const int kMaxAllowedConnections = ...;  // Clear meaning within context
};
class MyClass {
 public:
  int CountFooErrors(const std::vector<Foo>& foos) {
    int total_number_of_foo_errors = 0;  // Overly verbose given limited scope and context
    for (int foo_index = 0; foo_index < foos.size(); ++foo_index) {  // Use idiomatic `i`
      ...
      ++total_number_of_foo_errors;
    }
    return total_number_of_foo_errors;
  }
  // A return type with a generic name is unclear without widespread education.
  Result DoSomethingImportant() {
    int cstmr_id = ...;  // Deletes internal letters
  }
 private:
  const int kNum = ...;  // Unclear meaning within broad scope
};

文件名

文件名应全部小写,可以包含下划线(_)或短划线(-)。遵循您的项目使用的约定。如果没有一致的局部模式可遵循,则优先使用 "_"。

可接受的文件名示例:

  • my_useful_class.cc
  • my-useful-class.cc
  • myusefulclass.cc
  • myusefulclass_test.cc // _unittest and _regtest are deprecated.

C++ 文件应具有 .cc 文件名扩展名,头文件应具有 .h 扩展名。依赖于在特定点进行文本包含的文件应以 .inc 结尾(另请参阅有关自包含头文件的章节)。

不要使用已经存在于 /usr/include 中的文件名,例如 db.h

通常,使您的文件名非常具体。例如,使用 http_server_logs.h 而不是 logs.h。一个非常常见的情况是有一对着文件,例如 foo_bar.hfoo_bar.cc,定义了一个名为 FooBar 的类。

类型名称

类型名称以大写字母开头,每个新单词的首字母大写,没有下划线:MyExcitingClassMyExcitingEnum

所有类型(类、结构体、类型别名、枚举和类型模板参数)的名称都具有相同的命名约定。类型名称应以大写字母开头,每个新单词的首字母大写。没有下划线。例如:

// class and structs
class UrlTable { ...
class UrlTableTester { ...
struct UrlTableProperties { ...

// typedefs
typedef hash_map<UrlTableProperties*, std::string> PropertiesMap;

// using aliases
using PropertiesMap = hash_map<UrlTableProperties*, std::string>;

// enums
enum class UrlTableError { ...

概念名称

概念名称遵循与类型名称相同的规则。

变量名称

变量(包括函数参数)和数据成员的名称是 snake_case(全部小写,单词之间用下划线连接)。类的(但不是结构体的)数据成员另外具有尾随下划线。例如:a_local_variablea_struct_data_membera_class_data_member_

常见变量名称

例如:

std::string table_name;  // OK - snake_case.
std::string tableName;   // 差 - 混合大小写。

类数据成员

类的数据成员,无论是静态还是非静态,都像普通的非成员变量一样命名,但带有一个尾随下划线。静态常量类成员是例外,它们应遵循命名常量的规则。

class TableInfo {
 public:
  ...
  static const int kTableVersion = 3;  // OK - constant naming.
  ...

 private:
  std::string table_name_;             // OK - underscore at end.
  static Pool<TableInfo>* pool_;       // OK.
};

结构体数据成员

结构体的数据成员,无论是静态还是非静态,都像普通的非成员变量一样命名。它们没有类中数据成员所具有的尾随下划线。

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

有关何时使用结构体而不是类的讨论,请参阅结构体与类

常量名称

声明为 constexprconst,并且其值在程序持续时间内固定的变量,以“k”开头,后跟混合大小写命名。在极少数情况下,如果不能使用大小写进行分隔,可以使用下划线作为分隔符。例如:

const int kDaysInAWeek = 7;
const int kAndroid8_0_0 = 24;  // Android 8.0.0

所有具有静态存储期(即静态和全局变量,有关详细信息,请参阅存储期)的此类变量都应以这种方式命名,包括那些是静态常量类数据成员和模板中模板的不同实例化可能具有不同值的变量。对于其他存储类的变量,此约定是可选的,例如自动变量;否则应用通常的变量命名规则。例如:

void ComputeFoo(absl::string_view suffix) {
  // Either of these is acceptable.
  const absl::string_view kPrefix = "prefix";
  const absl::string_view prefix = "prefix";
  ...
}
void ComputeFoo(absl::string_view suffix) {
  // 差 - ComputeFoo 的不同调用会给 kCombined 不同的值。
  const std::string kCombined = absl::StrCat(kPrefix, suffix);
  ...
}

函数名称

通常,函数遵循 PascalCase:以大写字母开头,每个新单词的首字母大写。

AddTableEntry()
DeleteUrl()
OpenFileOrDie()

相同的命名规则适用于作为 API 的一部分公开且意图看起来像函数的类和命名空间作用域常量,因为它们是对象而不是函数的事实是一个不重要的实现细节。

访问器和修改器(get 和 set 函数)可以像变量一样命名,采用 snake_case。这些通常对应于实际的成员变量,但这不是必需的。例如,int count()void set_count(int count)

命名空间名称

命名空间名称是 snake_case(全部小写,单词之间用下划线连接)。

在为命名空间选择名称时,请注意,在命名空间之外的头文件中使用名称时,必须完全限定名称,因为通常禁止使用非限定别名

顶级命名空间必须是全局唯一且可识别的,因此每个命名空间都应由单个项目或团队拥有,名称基于该项目或团队的名称。通常,命名空间中的所有代码都应位于一个或多个与命名空间同名的目录。

嵌套命名空间应避免使用众所周知的顶级命名空间的名称,尤其是 stdabsl,因为在 C++ 中,嵌套命名空间不保护与其他命名空间中的名称的冲突(请参阅 TotW #130)。

枚举器名称

枚举器(对于作用域和无作用域枚举)应像常量一样命名,而不是像一样命名。也就是说,使用 kEnumName 而不是 ENUM_NAME

enum class UrlTableError {
  kOk = 0;
  kOutOfMemory,
  kMalformedInput,
};
enum class AlternateUrlTableError {
  OK = 0,
  OUT_OF_MEMORY = 1,
  MALFORMED_INPUT = 2,
};

直到 2009 年 1 月,风格是将枚举值命名为。这导致了枚举值和宏之间的名称冲突问题。因此,改为倾向于常量风格命名。新代码应使用常量风格命名。

模板参数名称

模板参数应遵循其类别的命名风格:类型模板参数应遵循命名类型的规则,非类型模板参数应遵循命名变量常量的规则。

宏名称

您真的不打算定义宏吗?如果您确实定义了,它们就像这样:MY_MACRO_THAT_SCARES_SMALL_CHILDREN_AND_ADULTS_ALIKE

请参阅宏的描述;一般不应使用宏。但是,如果绝对需要,则应使用全部大写和下划线命名,并带有项目特定的前缀。

#define MYPROJECT_ROUND(x) ...

别名2

别名的名称遵循与任何其他新名称相同的原则,应用于定义别名的上下文,而不是原始名称出现的上下文。

命名规则的例外

如果您正在命名与现有 C 或 C++ 实体(或通过互操作的 Rust 实体)类似的东西,那么您可以遵循现有的命名约定方案。

  • bigopen()

函数名称,遵循 open() 的形式

  • uint

typedef

  • bigpos

structclass,遵循 pos 的形式

  • sparse_hash_map

类似 STL 的实体;遵循 STL 命名约定

  • LONGLONG_MAX

一个常量,如 INT_MAX

注释

注释对于保持我们的代码可读性至关重要。以下规则描述了您应该注释什么以及在哪里注释。但请记住:虽然注释非常重要,但最好的代码是自我文档化的。给类型和变量起有意义的名称比使用晦涩的名称然后必须通过注释来解释要好得多。

编写注释时,请为您的受众编写:下一个需要理解您的代码的贡献者。大方一点——下一个可能就是您!

注释风格

使用 ///* */ 语法,只要您保持一致。

虽然两种语法都是可以接受的,但 // 更为常见。在您注释的方式和使用的风格上保持一致。

文件注释

每个文件都以许可样板开头。

如果源文件(例如 .h 文件)声明了多个面向用户的抽象(常见函数、相关类等),则包含一个注释来描述这些抽象的集合。包含足够的细节,以便未来的作者知道什么不适合那里。但是,关于单个抽象的详细文档应与这些抽象一起,而不是在文件级别。

例如,如果您为 frobber.h 编写了文件注释,则不需要在 frobber.ccfrobber_test.cc 中包含文件注释。另一方面,如果您在 registered_objects.cc 中编写了一组没有关联头文件的类,您必须在 registered_objects.cc 中包含文件注释。

法律声明和作者行

每个文件都应包含许可样板。为项目使用的许可证选择适当的样板(例如,Apache 2.0、BSD、LGPL、GPL)。

如果您对带有作者行的文件进行了重大更改,请考虑删除作者行。新文件通常不应包含版权声明或作者行。

结构体和类注释

每个不明显的类或结构体声明都应附带一个描述其用途和如何使用的注释。

// Iterates over the contents of a GargantuanTable.
// Example:
//    std::unique_ptr<GargantuanTableIterator> iter = table->NewIterator();
//    for (iter->Seek("foo"); !iter->done(); iter->Next()) {
//      process(iter->key(), iter->value());
//    }
class GargantuanTableIterator {
  ...
};

类注释

类注释应向读者提供足够的信息,让他们知道如何以及何时使用该类,以及正确使用该类所需的任何额外考虑。如果类有任何同步假设,请记录下来。如果类的实例可以被多个线程访问,请特别注意记录围绕多线程使用的规则和不变性。

类注释通常是用于演示类的简单且集中的用法的小代码片段的好地方。

当足够分离时(例如,.h.cc 文件),描述类使用的注释应与其接口定义放在一起;关于类操作和实现的注释应与类方法的实现一起。

函数注释

声明注释描述函数的使用(当它不明显时);函数定义处的注释描述操作。

函数声明

几乎每个函数声明都应该在其前面立即有注释,描述函数的作用以及如何使用它。仅当函数简单且明显时(例如,类明显属性的简单访问器)才能省略这些注释。在 .cc 文件中声明的私有方法和函数不例外。函数注释应该以此函数为隐式主语编写,并且应该以动词短语开头;例如,“Opens the file”,而不是“Open the file”。通常,这些注释不描述函数如何执行其任务。相反,这应该留给函数定义中的注释。

在函数声明处的注释中提及的事物类型:

  • 输入和输出是什么。如果在“反引号”中提供了函数参数名称,则代码索引工具可能能够更好地呈现文档。
  • 对于类成员函数:对象是否在方法调用持续时间之外记住引用或指针参数。这对于构造函数的指针/引用参数来说非常常见。
  • 对于每个指针参数,是否允许为空以及如果为空会发生什么。
  • 对于每个输出或输入/输出参数,该参数所处的状态会发生什么(例如,状态是附加还是覆盖?)。
  • 如果函数的使用方式对性能有任何影响。

这是一个例子:

// Returns an iterator for this table, positioned at the first entry
// lexically greater than or equal to `start_word`. If there is no
// such entry, returns a null pointer. The client must not use the
// iterator after the underlying GargantuanTable has been destroyed.
//
// This method is equivalent to:
//    std::unique_ptr<Iterator> iter = table->NewIterator();
//    iter->Seek(start_word);
//    return iter;
std::unique_ptr<Iterator> GetIterator(absl::string_view start_word) const;

但是,不要不必要地冗长或陈述完全明显的内容。

在注释函数覆盖时,专注于覆盖本身的具体细节,而不是重复被覆盖函数的注释。在许多这些情况下,覆盖不需要额外的文档,因此不需要注释。

在注释构造函数和析构函数时,请记住阅读您的代码的人知道构造函数和析构函数是做什么用的,因此像“销毁此对象”这样的注释没有用处。记录构造函数对其参数做什么(例如,它们是否获取指针的所有权),以及析构函数做什么清理。如果这很琐碎,只需跳过注释。析构函数没有头注释是很常见的。

函数定义

如果函数执行其任务的方式有任何技巧,函数定义应该有解释性注释。例如,在定义注释中,您可以描述您使用的任何编码技巧,概述您经历的步骤,或解释您为什么选择以这种方式实现函数而不是使用可行的替代方案。例如,您可能提及为什么它必须在前??半部分获取锁,但为什么在后半部分不需要。

请注意,您应该只是重复函数声明(在 .h 文件或任何地方)中给出的注释。简要地重述函数的作用是可以的,但注释的重点应该放在它是如何做的。

变量注释

通常,变量的实际名称应该具有足够的描述性,以很好地了解变量的用途。在某些情况下,需要更多的注释。

类数据成员2

每个类数据成员(也称为实例变量或成员变量)的用途必须清楚。如果存在类型和名称未清楚表达的任何不变性(特殊值、成员之间的关系、生命周期要求),则必须对其进行注释。但是,如果类型和名称足够(int num_events_;),则不需要注释。

特别是,添加注释以描述哨兵值的存在和含义,例如 nullptr 或 -1,当它们不明显时。例如:

private:
 // Used to bounds-check table accesses. -1 means
 // that we don't yet know how many entries the table has.
 int num_total_entries_;

全局变量

所有全局变量都应该有一个注释,描述它们是什么、它们的用途是什么,以及(如果不清楚)为什么它们需要是全局的。例如:

// The total number of test cases that we run through in this regression test.
const int kNumTestCases = 6;

实现注释

在您的实现中,您应该在棘手、不明显、有趣或重要的代码部分中添加注释。

解释性注释

棘手或复杂的代码块应该在它们之前有注释。

函数参数注释

当函数参数的含义不明显时,请考虑以下补救措施之一:

  • 如果参数是字面常量,并且相同的常量以一种默认它们是相同的方式在多个函数调用中使用,则应使用命名常量使该约束显式化,并保证它成立。
  • 考虑更改函数签名,用 enum 参数替换 bool 参数。这将使参数值自我描述。
  • 对于具有多个配置选项的函数,考虑定义一个单独的类或结构体来保存所有选项,并传递它的一个实例。这种方法有几个优点。在调用点通过名称引用选项,这澄清了它们的含义。它还减少了函数参数数量,这使得函数调用更容易阅读和编写。作为一个额外的好处,当您添加另一个选项时,您不必更改调用点。
  • 用命名变量替换大型或复杂的嵌套表达式。
  • 作为最后的手段,使用注释在调用点澄清参数含义。

考虑以下示例:

// What are these arguments?
const DecimalNumber product = CalculateProduct(values, 7, false, nullptr);

对比:

ProductOptions options;
options.set_precision_decimals(7);
options.set_use_cache(ProductOptions::kDontUseCache);
const DecimalNumber product =
    CalculateProduct(values, options, /*completion_callback=*/nullptr);

注意事项

不要陈述明显的事实。特别是,不要字面上描述代码的作用,除非对于一个非常了解 C++ 的读者来说,行为是不明显的。相反,提供更高级别的注释,描述代码为什么这样做,或者使代码自我描述。

比较以下内容:

// Find the element in the vector.  <-- 差:明显!
if (std::find(v.begin(), v.end(), element) != v.end()) {
  Process(element);
}

与此:

// Process "element" unless it was already processed.
if (std::find(v.begin(), v.end(), element) != v.end()) {
  Process(element);
}

自我描述的代码不需要注释。上面示例中的注释将是明显的:

if (!IsAlreadyProcessed(element)) {
  Process(element);
}

标点、拼写和语法

注意标点、拼写和语法;阅读写得好的注释比写得差的注释更容易。

注释应该像叙事文本一样可读,具有正确的??写和标点。在许多情况下,完整的句子比句子片段更具可读性。较短的注释,例如代码行末尾的注释,有时可以不那么正式,但您应该与您的风格保持一致。

尽管让代码审阅者指出您使用了逗号而不是分号可能会令人沮丧,但源代码保持高水平的清晰度和可读性非常重要。正确的标点、拼写和语法有助于实现该目标。

TODO 注释

对于临时、短期解决方案或足够好但不完美的代码,使用 TODO 注释。

TODO 应包含大写的字符串 TODO,后跟有关 TODO 所引用的问题具有最佳上下文的人员或问题的错误 ID、姓名、电子邮件地址或其他标识符。

推荐的风格是(按偏好顺序):

// TODO: bug 12345678 - Remove this after the 2047q4 compatibility window expires.
// TODO: example.com/my-design-doc - Manually fix up this code the next time it's touched.
// TODO(bug 12345678): Update this list after the Foo service is turned down.
// TODO(John): Use a "\*" here for concatenation operator.

如果您的 TODO 形式为“在未来的某个日期做某事”,请确保您要么包含一个非常具体的日期(“Fix by November 2005”),要么包含一个非常具体的事件(“Remove this code when all clients can handle XML responses.”)。

格式

编码风格和格式是相当武断的,但如果每个人都使用相同的风格,项目就会更容易遵循。个人可能不同意格式规则的每个方面,并且可能需要一些时间来适应某些规则,但重要的是所有项目贡献者都遵循风格规则,以便他们都能轻松阅读和理解彼此的代码。

为了帮助您正确格式化代码,我们为 emacs 创建了一个设置文件

行长度

代码中的每一行文本最多应为 80 个字符长。

我们承认这个规则是有争议的,但太多现有代码已经遵守它,我们认为一致性很重要。

支持这条规则的人认为,强迫他们调整窗口大小是很不礼貌的,而且没有必要使用更长的东西。有些人习惯于并排有多个代码窗口,因此无论如何都没有空间来加宽窗口。人们在设置工作环境时假设了一个特定的最大窗口宽度,而 80 列一直是传统标准。为什么要改变它?

变革的支持者认为,更宽的行可以使代码更具可读性。80 列的限制是对 20 世纪 60 年代大型机的守旧追溯;现代设备有宽屏幕,可以轻松显示更长的行。

80 个字符是最大值。

如果一行是以下情况,它可能超过 80 个字符:

  • 无法在不损害可读性、剪切和粘贴的便利性或自动链接的情况下拆分的注释行——例如,如果一行包含一个示例命令或一个超过 80 个字符的字面 URL。
  • 无法轻松在 80 列处换行的字符串字面量。这可能是因为它包含 URI 或其他语义关键的部分,或者字面量包含嵌入式语言,或者它是一个新行具有意义的多行字面量,例如帮助消息。在这些情况下,拆分字面量会降低可读性、可搜索性、点击链接的能力等。除了测试代码外,此类字面量应出现在靠近文件顶部的命名空间作用域。如果像 Clang-Format 这样的工具不识别不可拆分的内容,请根据需要禁用该工具周围的内容。

(我们必须在这些字面量的可用性/可搜索性与围绕它们的代码的可读性之间取得平衡。) - 一个 #include 语句。 - 一个头文件保护。 - 一个 using-declaration。

非 ASCII 字符

非 ASCII 字符应该很少见,并且必须使用 UTF-8 格式。

您不应该在源代码中硬编码面向用户的文本,甚至是英语,因此非 ASCII 字符的使用应该很少见。但是,在某些情况下,将此类单词包含在代码中是适当的。例如,如果您的代码解析来自外部来源的数据文件,则将这些数据文件中使用的非 ASCII 字符串硬编码为分隔符可能是适当的。更常见的是,单元测试代码(无需本地化)可能包含非 ASCII 字符串。在这种情况下,您应该使用 UTF-8,因为这是大多数能够处理不只是 ASCII 的工具都能理解的编码。

十六进制编码也是可以的,并且在它增强可读性时受到鼓励——例如,"\xEF\xBB\xBF",或者更简单地,"\uFEFF" 是 Unicode 零宽度非换行空格字符,如果作为纯 UTF-8 包含在源代码中将是不可见的。

如果可能,避免使用 u8 前缀。它在 C++20 中与 C++17 相比具有显著不同的语义,产生 char8_t 而不是 char 的数组,并且在 C++23 中将再次更改。

您不应该使用 char16_tchar32_t 字符类型,因为它们用于非 UTF-8 文本。出于类似的原因,您也不应该使用 wchar_t(除非您正在编写与 Windows API 交互的代码,该 API 广泛使用 wchar_t)。

空格与制表符

只使用空格,一次缩进 2 个空格。

我们使用空格进行缩进。不要在代码中使用制表符。您应该将编辑器设置为在您点击 Tab 键时发出空格。

函数声明和定义

返回类型与函数名称在同一行,如果参数适合,则参数在同一行。换行不适合单行的参数列表,就像您在函数调用中换行参数一样。

函数看起来像这样:

ReturnType ClassName::FunctionName(Type par_name1, Type par_name2) {
  DoSomething();
  ...
}

如果您的文本太多,无法放在一行上:

ReturnType ClassName::ReallyLongFunctionName(Type par_name1, Type par_name2,
                                             Type par_name3) {
  DoSomething();
  ...
}

或者如果连第一个参数都放不下:

ReturnType LongClassName::ReallyReallyReallyLongFunctionName(
    Type par_name1,  // 4 space indent
    Type par_name2,
    Type par_name3) {
  DoSomething();  // 2 space indent
  ...
}

需要注意的几点:

  • 选择好的参数名称。
  • 仅当参数在函数的定义中未使用时,才能省略参数名称。
  • 如果返回类型和函数名称不能放在单行上,则在它们之间换行。
  • 如果在函数声明或定义的返回类型之后换行,则不要缩进。
  • 左圆括号始终与函数名称在同一行。
  • 函数名称和左圆括号之间永远没有空格。
  • 圆括号和参数之间永远没有空格。
  • 左花括号始终在函数声明的最后一行的末尾,而不是下一行的开头。
  • 右花括号要么在单独的最后一行上,要么与左花括号在同一行上。
  • 右圆括号和左花括号之间应该有一个空格。
  • 如果可能,所有参数都应该对齐。
  • 默认缩进是 2 个空格。
  • 换行的参数有 4 个空格缩进。

上下文明显的未使用参数可以省略名称:

class Foo {
 public:
  Foo(const Foo&) = delete;
  Foo& operator=(const Foo&) = delete;
};

可能不明显的未使用参数应在函数定义中注释掉变量名称:

class Shape {
 public:
  virtual void Rotate(double radians) = 0;
};

class Circle : public Shape {
 public:
  void Rotate(double radians) override;
};

void Circle::Rotate(double /*radians*/) {}
// 差 - 如果有人想稍后实现,不清楚变量的含义。
void Circle::Rotate(double) {}

属性和扩展为属性的宏出现在函数声明或定义的开头,在返回类型之前:

  ABSL_ATTRIBUTE_NOINLINE void ExpensiveFunction();
  [[nodiscard]] bool IsOk();

Lambda 表达式2

像格式化任何其他函数一样格式化参数和主体,像其他逗号分隔列表一样格式化捕获列表。

对于按引用捕获,不要在与号(&)和变量名之间留出空格。

int x = 0;
auto x_plus_n = [&x](int n) -> int { return x + n; }

短 lambda 可以内联写成函数参数。

absl::flat_hash_set<int> to_remove = {7, 8, 9};
std::vector<int> digits = {3, 9, 1, 8, 4, 7, 1};
digits.erase(std::remove_if(digits.begin(), digits.end(), [&to_remove](int i) {
               return to_remove.contains(i);
             }),
             digits.end());

浮点字面量

浮点字面量应始终具有小数点,两侧都有数字,即使它们使用指数表示法。如果所有浮点字面量都采用这种熟悉的形式,可读性会得到提高,因为这有助于确保它们不会被误认为是整数??面量,并且指数表示法的 E/e 不会被误认为是十六进制数字。用整数??面量初始化浮点变量是可以的(假设变量类型可以精确地表示该整数),但请注意,指数表示法的数字永远不是整数字面量。

float f = 1.f;
long double ld = -.5L;
double d = 1248e6;
float f = 1.0f;
float f2 = 1.0;  // 也是 OK
float f3 = 1;    // 也是 OK
long double ld = -0.5L;
double d = 1248.0e6;

函数调用

要么将调用全部写在一行上,要么在圆括号处换行参数,要么在新行上以四个空格缩进开始参数并以该 4 个空格缩进继续。在没有其他考虑因素的情况下,使用最少的行数,包括在适当的情况下将多个参数放在每行上。

函数调用具有以下格式:

bool result = DoSomething(argument1, argument2, argument3);

如果参数不能全部放在一行上,则应将它们分解成多行,每后续行与第一个参数对齐。不要在左圆括号后或右圆括号前添加空格:

bool result = DoSomething(averyveryveryverylongargument1,
                          argument2, argument3);

参数可以选择全部放在后续行上,并带有四个空格缩进:

if (...) {
  ...
  ...
  if (...) {
    bool result = DoSomething(
        argument1, argument2,  // 4 space indent
        argument3, argument4);
    ...
  }

将多个参数放在一行上以减少调用函数所需的行数,除非存在特定的可读性问题。有些人认为严格按照每行一个参数的格式更具可读性,并简化了参数的编辑。但是,我们优先考虑读者而不是编辑参数的便利性,并且大多数可读性问题都可以通过以下技术更好地解决。

如果在一行中包含多个参数会因为构成某些参数的表达式的复杂性或令人困惑的性质而降低可读性,请尝试创建以描述性名称捕获这些参数的变量:

int my_heuristic = scores[x] * y + bases[x];
bool result = DoSomething(my_heuristic, x, y, z);

或者将令人困惑的参数放在它自己的行上,并加上解释性注释:

bool result = DoSomething(scores[x] * y + bases[x],  // Score heuristic.
                          x, y, z);

如果仍然存在某个参数在其自己的行上更具可读性的情况,则将其放在其自己的行上。决定应该特定于变得更具可读性的参数,而不是一般政策。

有时参数形成一个对可读性很重要的结构。在这些情况下,请随意根据该结构格式化参数:

// Transform the widget by a 3x3 matrix.
my_widget.Transform(x1, x2, x3,
                    y1, y2, y3,
                    z1, z2, z3);

大括号初始化列表格式

完全按照您将函数调用格式化在其位置的方式格式化大括号初始化列表。

如果大括号列表跟在一个名称后面(例如,类型或变量名称),则格式化为 {} 是带有该名称的函数调用的圆括号。如果没有名称,则假定为零长度名称。

// Examples of braced init list on a single line.
return {foo, bar};
functioncall({foo, bar});
std::pair<int, int> p{foo, bar};

// When you have to wrap.
SomeFunction(
    {"assume a zero-length name before {"},
    some_other_function_parameter);
SomeType variable{
    some, other, values,
    {"assume a zero-length name before {"},
    SomeOtherType{
        "Very long string requiring the surrounding breaks.",
        some, other, values},
    SomeOtherType{"Slightly shorter string",
                  some, other, values}};
SomeType variable{
    "This is too long to fit all in one line"};
MyType m = {  // Here, you could also break before {.
    superlongvariablename1,
    superlongvariablename2,
    {short, interior, list},
    {interiorwrappinglist,
     interiorwrappinglist2}};

循环和分支语句

从高层次来看,循环或分支语句包含以下组成部分

  • 一个或多个语句关键字(例如,ifelseswitchwhiledofor)。
  • 一个条件或迭代说明符,位于圆括号内。
  • 一个或多个受控语句,或受控语句块。

对于这些语句:

  • 语句的组成部分之间应用单个空格分隔(而不是换行)。
  • 在条件或迭代说明符内部,在每个分号和下一个标记之间放置一个空格(或换行),除非该标记是闭合圆括号或另一个分号。
  • 在条件或迭代说明符内部,不要在左圆括号后或右圆括号前放置空格。
  • 将任何受控语句放在块内(即,使用花括号)。
  • 在受控块内部,紧接在左花括号后放置一个换行,并在右花括号前放置一个换行。
if (condition) {                   // 好 - 圆括号内没有空格,花括号前有空格。
  DoOneThing();                    // 好 - 两空格缩进。
  DoAnotherThing();
} else if (int a = f(); a != 3) {  // 好 - 闭合花括号在新行上,else 在同一行上。
  DoAThirdThing(a);
} else {
  DoNothing();
}

// 好 - 相同的规则适用于循环。
while (condition) {
  RepeatAThing();
}

// 好 - 相同的规则适用于循环。
do {
  RepeatAThing();
} while (condition);

// 好 - 相同的规则适用于循环。
for (int i = 0; i < 10; ++i) {
  RepeatAThing();
}
if(condition) {}                   // 差 - `if` 后缺少空格。
else if ( condition ) {}           // 差 - 圆括号与条件之间有空格。
else if (condition){}              // 差 - `{` 前缺少空格。
else if(condition){}               // 差 - 缺少多个空格。

for (int a = f();a == 10) {}       // 差 - 分号后缺少空格。

// 差 - `if ... else` 语句没有花括号。
if (condition)
  foo;
else {
  bar;
}

// 差 - `if` 语句太长而不能省略花括号。
if (condition)
  // Comment
  DoSomething();

// 差 - `if` 语句太长而不能省略花括号。
if (condition1 &&
    condition2)
  DoSomething();

出于历史原因,我们允许上述规则有一个例外:如果结果是整个语句出现在单行上(在这种情况下,闭合圆括号和受控语句之间有一个空格)或两行上(在这种情况下,闭合圆括号后有一个换行,并且没有花括号),则可以省略受控语句的花括号或花括号内的换行。

// OK - fits on one line.
if (x == kFoo) { return new Foo(); }

// OK - braces are optional in this case.
if (x == kFoo) return new Foo();

// OK - condition fits on one line, body fits on another.
if (x == kBar)
  Bar(arg1, arg2, arg3);

此例外不适用于像 if ... elsedo ... while 这样的多关键字语句。

// 差 - `if ... else` 语句缺少花括号。
if (x) DoThis();
else DoThat();

// 差 - `do ... while` 语句缺少花括号。
do DoThis();
while (x);

仅当语句简短时才使用此风格,并考虑具有复杂条件或受控语句的循环和分支语句使用花括号可能更具可读性。某些项目要求始终使用花括号。

switch 语句中的 case 块可以有花括号,也可以没有,具体取决于您的偏好。如果您确实包含花括号,则应按如下所示放置它们。

switch (var) {
  case 0: {  // 2 space indent
    Foo();   // 4 space indent
    break;
  }
  default: {
    Bar();
  }
}

空循环体应使用空的花括号对或带有 continue 且没有花括号,而不是单个分号。

while (condition) {}  // 好 - `{}` 表示没有逻辑。
while (condition) {
  // Comments are okay, too
}
while (condition) continue;  // 好 - `continue` 表示没有逻辑。
while (condition);  // 差 - 看起来像 `do-while` 循环的一部分。

指针和引用表达式和类型

句点或箭头周围没有空格。指针操作符没有尾随空格。

以下是格式正确的指针和引用表达式的示例:

x = *p;
p = &x;
x = r.y;
x = r->y;

请注意:

  • 访问成员时,句点或箭头周围没有空格。
  • 指针操作符在 *& 之后没有空格。

当引用指针或引用时(变量声明或定义、参数、返回类型、模板参数等),不得在星号/与号之前放置空格。使用空格分隔类型和声明的名称(如果存在)。

// These are fine.
char* c;
const std::string& str;
int* GetPointer();
std::vector<char*>  // Note no space between '*' and '>'

允许(如果不同寻常)在同一声明中声明多个变量,但如果其中任何一个具有指针或引用修饰,则禁止这样做。此类声明很容易被误读。

// Fine if helpful for readability.
int x, y;
int x, *y;  // Disallowed - no & or * in multiple declaration
int *x, *y;  // Disallowed - no & or * in multiple declaration
int *x;  // Disallowed - & or * must be left of the space
char * c;  // 差 - * 两边都有空格
const std::string & str;  // 差 - & 两边都有空格

布尔表达式

当您有一个长于标准行长度的布尔表达式时,请在如何分解行方面保持一致。

在此示例中,逻辑 AND 操作符始终位于行尾:

if (this_one_thing > this_other_thing &&
    a_third_thing == a_fourth_thing &&
    yet_another && last_one) {
  ...
}

请注意,在此示例中,当代码换行时,两个 && 逻辑 AND 操作符都在行尾。这在 Google 代码中更常见,尽管也允许在行首换行所有操作符。随意酌情插入额外的圆括号,因为如果使用得当,它们对提高可读性非常有帮助,但要小心过度使用。另请注意,您应该始终使用标点操作符,例如 &&~,而不是单词操作符,例如 andcompl

返回值

不要不必要地用圆括号包围 return 表达式。

return expr; 中使用圆括号,仅在您在 x = expr; 中使用它们的地方。

return result;                  // 在简单情况下没有圆括号。
// Parentheses OK to make a complex expression more readable.
return (some_long_condition &&
        another_condition);
return (value);                // 您不会写 var = (value);
return(result);                // return is not a function!

变量和数组初始化

您可以选择 =、()、{};以下都是正确的:

int x = 3;
int x(3);
int x{3};
std::string name = "Some Name";
std::string name("Some Name");
std::string name{"Some Name"};

在具有 std::initializer_list 构造函数的类型上使用大括号初始化列表 {...} 时要小心。非空braced-init-list 优先选择 std::initializer_list 构造函数,只要可能。请注意,空大括号 {} 是特殊的,如果可用,将调用默认构造函数。要强制使用非 std::initializer_list 构造函数,请改用圆括号而不是大括号。

std::vector<int> v(100, 1);  // 一个包含 100 个项的向量:全部是 1。
std::vector<int> v{100, 1};  // 一个包含 2 个项的向量:100 和 1。

此外,大括号形式阻止了整数类型的缩小。这可以防止某些类型的编程错误。

int pi(3.14);  // OK -- pi == 3.
int pi{3.14};  // Compile error: narrowing conversion.

预处理器指令

开始预处理器指令的井号应始终位于行的开头。

即使预处理器指令位于缩进代码的主体中,指令也应从行的开头开始。

// 好 - 指令在行首
  if (lopsided_score) {
#if DISASTER_PENDING      // 正确 -- 从行首开始
    DropEverything();
# if NOTIFY               // OK 但不是必需的 -- # 后有空格
    NotifyClient();
# endif
#endif
    BackToNormal();
  }
// 差 - 缩进的指令
  if (lopsided_score) {
    #if DISASTER_PENDING  // 错误! "#if" 应该在行首
    DropEverything();
    #endif                // 错误! 不要缩进 "#endif"
    BackToNormal();
  }

类格式

publicprotectedprivate 部分的顺序,每个缩进一个空格。

类定义的基本格式(缺少注释,有关需要哪些注释的讨论,请参阅类注释)是:

class MyClass : public OtherClass {
 public:      // Note the 1 space indent!
  MyClass();  // Regular 2 space indent.
  explicit MyClass(int var);
  ~MyClass() {}

  void SomeFunction();
  void SomeFunctionThatDoesNothing() {
  }

  void set_some_var(int var) { some_var_ = var; }
  int some_var() const { return some_var_; }

 private:
  bool SomeInternalFunction();

  int some_var_;
  int some_other_var_;
};

需要注意的事项:

  • 任何基类名称应与子类名称在同一行上,受 80 列限制。
  • public:protected:private: 关键字应缩进一个空格。
  • 除了第一次出现,这些关键字之前应有一个空行。此规则在小型类中是可选的。
  • 在这些关键字之后不要留空行。
  • public 部分应放在第一位,后跟 protected,最后是 private 部分。
  • 有关在这些部分的每个部分中排序声明的规则,请参阅声明顺序

构造函数初始化列表

构造函数初始化列表可以全部放在一行上,也可以后续行缩进四个空格。

初始化列表的可接受格式是:

// When everything fits on one line:
MyClass::MyClass(int var) : some_var_(var) {
  DoSomething();
}

// If the signature and initializer list are not all on one line,
// you must wrap before the colon and indent 4 spaces:
MyClass::MyClass(int var)
    : some_var_(var), some_other_var_(var + 1) {
  DoSomething();
}

// When the list spans multiple lines, put each member on its own line
// and align them:
MyClass::MyClass(int var)
    : some_var_(var),             // 4 space indent
      some_other_var_(var + 1) {  // lined up
  DoSomething();
}

// As with any other code block, the close curly can be on the same
// line as the open curly, if it fits.
MyClass::MyClass(int var)
    : some_var_(var) {}

命名空间格式

命名空间的内容不缩进。

命名空间不添加额外的缩进级别。例如,使用:

namespace {

void foo() {  // Correct.  No extra indentation within namespace.
  ...
}

}  // namespace

不要在命名空间内缩进:

namespace {

  // Wrong!  Indented when it should not be.
  void foo() {
    ...
  }

}  // namespace

水平空格

水平空格的使用取决于位置。永远不要在行尾放置尾随空格。

一般

int i = 0;  // Two spaces before end-of-line comments.

void f(bool b) {  // Open braces should always have a space before them.
  ...
int i = 0;  // Semicolons usually have no space before them.
// Spaces inside braces for braced-init-list are optional.  If you use them,
// put them on both sides!
int x[] = { 0 };
int x[] = {0};

// Spaces around the colon in inheritance and initializer lists.
class Foo : public Bar {
 public:
  // For inline function implementations, put spaces between the braces
  // and the implementation itself.
  Foo(int b) : Bar(), baz_(b) {}  // No spaces inside empty braces.
  void Reset() { baz_ = 0; }  // Spaces separating braces from implementation.
  ...

添加尾随空格可能会给编辑同一文件的其他人带来额外的工作,就像删除现有尾随空格一样。所以,不要引入尾随空格。如果您已经在更改该行,请将其删除,或者在单独的清理操作中进行(最好在没有其他人处理该文件时)。

循环和条件

if (b) {          // Space after the keyword in conditions and loops.
} else {          // Spaces around else.
}
while (test) {}   // There is usually no space inside parentheses.
switch (i) {
for (int i = 0; i < 5; ++i) {
// Loops and conditions may have spaces inside parentheses, but this
// is rare.  Be consistent.
switch ( i ) {
if ( test ) {
for ( int i = 0; i < 5; ++i ) {
// For loops always have a space after the semicolon.  They may have a space
// before the semicolon, but this is rare.
for ( ; i < 5 ; ++i) {
  ...

// Range-based for loops always have a space before and after the colon.
for (auto x : counts) {
  ...
}
switch (i) {
  case 1:         // No space before colon in a switch case.
    ...
  case 2: break;  // Use a space after a colon if there's code after it.

操作符

// Assignment operators always have spaces around them.
x = 0;

// Other binary operators usually have spaces around them, but it's
// OK to remove spaces around factors.  Parentheses should have no
// internal padding.
v = w * x + y / z;
v = w*x + y/z;
v = w * (x + z);

// No spaces separating unary operators and their arguments.
x = -5;
++x;
if (x && !y)
  ...

模板和强制类型转换

// No spaces inside the angle brackets (< and >), before
// <, or between >( in a cast
std::vector<std::string> x;
y = static_cast<char*>(x);

// No spaces between type and pointer.
std::vector<char*> x;

垂直空格

谨慎使用垂直空格;不必要的空行使整体代码结构更难看清。仅在有助于读者理解结构的地方使用空行。

不要在缩进已经提供清晰界定(例如在代码块的开始或结束)的地方添加空行。使用空行将代码分成紧密相关的块,类似于散文中的段落分隔。在一个语句或声明中,通常只插入换行以保持在行长度限制内,或将注释附加到内容的仅一部分。

规则的例外

上面描述的编码约定是强制性的。但是,像所有好的规则一样,它们有时也有例外,我们在这里讨论。

现有的不符合规范的代码

在处理不符合此风格指南的代码时,您可以偏离规则。

如果您发现自己正在修改根据本指南以外的规范编写的代码,您可能需要偏离这些规则,以保持与该代码中的局部约定一致。如果您对此有疑问,请询问原始作者或目前负责该代码的人员。请记住,一致性也包括局部一致性。

Windows 代码

Windows 程序员已经形成了自己的一套编码约定,主要源自 Windows 头文件和其他 Microsoft 代码中的约定。我们希望让任何人都能轻松理解您的代码,因此我们为在任何平台上编写 C++ 的每个人都提供了一套单一的指南。

值得重申一些如果您习惯了流行的 Windows 风格可能会忘记的指南:

  • 不要使用匈牙利命名法(例如,将整数命名为 iNum)。使用 Google 命名约定,包括 .cc 扩展名用于源文件。
  • Windows 定义了许多自己的基本类型同义词,例如 DWORDHANDLE 等。在调用 Windows API 函数时,使用这些类型是完全可以接受和鼓励的。即便如此,也要尽可能接近底层的 C++ 类型。例如,使用 const TCHAR* 而不是 LPCTSTR
  • 使用 Microsoft Visual C++ 编译时,将编译器设置为警告级别 3 或更高,并将所有警告视为错误。
  • 不要使用 #pragma once;相反,使用标准的 Google 包含保护。包含保护中的路径应相对于您的项目树的顶部。
  • 事实上,不要使用任何非标准扩展,例如 #pragma__declspec,除非您绝对必须这样做。允许使用 __declspec(dllimport)__declspec(dllexport);但是,您必须通过宏(例如 DLLIMPORTDLLEXPORT)使用它们,以便如果他们共享代码,可以轻松禁用这些扩展。

但是,在 Windows 上,我们偶尔需要打破一些规则:

  • 通常我们强烈不鼓励使用多重实现继承;但是,在使用 COM 和一些 ATL/WTL 类时是必需的。您可以使用多重实现继承来实现 COM 或 ATL/WTL 类和接口。
  • 尽管您不应该在自己的代码中使用异常,但它们在 ATL 和一些 STL 中被广泛使用,包括 Visual C++ 附带的 STL。使用 ATL 时,您应该定义 _ATL_NO_EXCEPTIONS 以禁用异常。您应该调查是否也可以在 STL 中禁用异常,但如果不能,则可以在编译器中打开异常。 (请注意,这只是为了让 STL 编译。您仍不应自己编写异常处理代码。)
  • 使用预编译头的通常方法是在每个源文件的顶部包含一个头文件,通常名称类似于 StdAfx.hprecompile.h。为了使您的代码更容易与其他项目共享,请避免显式包含此文件(precompile.cc 中除外),并使用 /FI 编译器选项自动包含该文件。
  • 资源头文件(通常命名为 resource.h 并且只包含宏)不需要符合这些风格指南。