Skip to content

willzli/cpp_standards

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

C++代码规范

[TOC]


前言

本规范在 Google C++ 代码规范的基础上,根据腾讯实际情况进行了调整和补充,尤其是增加了适合 UE4 引擎的编码规范。 参与规范修订的同事,来自各个 BG 推举的 C++ 专家代表,具体参见规范分工与负责人;

每项规范内容,给出了要求等级,其定义为:

  • 必须(Mandatory):用户必须采用;
  • 推荐(Preferable):用户理应采用,但如有特殊情况,可以不采用;
  • 可选(Optional):用户可参考,自行决定是否采用。

本规范默认以精简模式显示,要深入了解,请点击查看具体规范项的▶ 详细信息部分。

本文档仅供腾讯产品开发内部使用,禁止对外提供、商用或分发。


0. 原则和目标

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

我们认为本规范应该有几个核心目标,这些目标是制定每条具体规则背后的根本原因。我们希望通过强调这些目标,为讨论打下基础,并使我们更清楚地了解为什么要制定这些规则以及为什么要做出特定的决定。如果你理解了每条规则的背后的目标,也会更清楚了解什么时候可以豁免某条规则(有些规则可以),以及改变规范中的某条规则需要提供什么样的依据或替代方案。

目前我们代码规范的目标如下:

  • 规则应当充分发挥作用

    规则的好处必须足够大,以便让我们所有的工程师都能记住它。这个好处是相对于我们在没有这个规则的情况下所能得到的代码库来衡量的,所以对于虽然非常有害但是人们已经普遍不用的做法专门制定规则的收益就会很小。这个原则主要解释规范中为何没有某条规则,而不是解释已有的规则:例如,goto 违反了下面的许多原则,但已经很少见了,所以代码规范没有讨论它。

  • 为读者而不是作者优化

    我们的代码库(包括大量的基础组件)都会持续使用相当长的时间。因此,阅读大部分代码的时间将会比编写这些代码的时间多很多。我们明确选择优化软件工程师阅读、维护和调试代码库中代码的体验,而不是编写这些代码时的轻松程度。比如,“为读者留下线索”就是这一原则中一个特别常见的子点:当一段代码中发生了令人惊讶或不寻常的事情时(例如,指针所有权的转移),在调用点为读者留下文字提示就很有价值(比如 std::unique_ptr 就在调用点明确地展示了所有权转移)。

  • 保持代码库的一致性

    在我们的代码库中始终如一地使用一种风格,可以让我们专注于其他(更重要的)问题。一致性还更有利于自动化:只有当你的代码与工具的期望一致时,格式化你的代码或调整你的 #includes 的工具才能正常工作。在很多情况下,“保持一致”的规则归结为“选一个就好,不要再费心了”;在这些点上允许灵活性的潜在价值抵不过人们为之争论不休所付出的代价。

  • 在适当的时候与更广泛的 C++ 社区保持一致

    与其他组织使用 C++ 的方式保持一致,与我们代码库内的一致性有着同样的价值。如果 C++ 标准中的某个特性解决了一个问题,或者某个惯用法广为人知并被接受,就有理由允许使用。然而,有时标准特性和惯用法是有缺陷的,或者只是在设计时没有考虑到我们代码库的需求。在这些情况下(如下所述),限制或禁止标准特性是合适的。在某些特殊情况下,我们会倾向于使用一个自制的或第三方的库,而不是 C++ 标准中定义的库,这可能是这些库更好用,或者是代码库迁移到标准接口的收益不足够大。

  • 避免令人惊讶或危险的用法

    C++ 中有一些比人们一眼就能想到的更令人惊讶或更危险的特性。一些代码规范限制是为了防止陷入这些陷阱。代码规范对这类限制的豁免有很高的要求,因为豁免这类规则往往有直接损害程序正确性的风险。

  • 避免那些我们普通的 C++ 程序员会觉得棘手或难以维护的用法

    C++ 有一些功能可能在一般情况下并不合适,因为它们会给代码带来复杂性。在广泛使用的基础代码中,使用较复杂的语言结构可能更容易被接受,因为更复杂的实现所带来的好处会因为被广泛使用而放大,而且在处理代码库的新增部分时,不需要再次付出理解复杂性的代价。当有疑问时,可以通过询问你的项目负责人来寻求对这类规则的豁免。这一点对于我们的代码库特别重要,因为代码所有权和团队成员会随着时间的推移而变化:即使目前使用某段代码的每个人都理解它,但这种理解并不能保证几年后还能保持。

  • 注意代码库和团队规模

    在上亿行的代码库和数千名工程师的情况下,一个工程师的错误或者懒省事可能会成为很多工程师的代价。比如避免污染全局命名空间就尤为重要:在一个上亿行的代码库中,如果每个人都把东西放到全局命名空间中,名字冲突就很难避免,也很难处理。

  • 必要时让步于优化

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

本文档的目的是在合理的限制下提供最大限度的指导。一如既往,应以常识和良好的品味为准。我们所指的是整个公司 C++ 社区的既定惯例,而不仅仅是你个人或你团队的喜好。对小聪明的或不寻常的用法持怀疑态度:没有禁止不等于就是允许。使用你的判断力,如果你不确定,请毫不犹豫地询问你的项目负责人以获得更多的意见。


1. C++版本

当前,代码应针对 C++ 17,即不应该使用 C++ 2a 功能。本指南所针对的 C++ 版本将随发布时间的推移(积极地)发展。

不要使用非标准扩展

在项目中使用 C++ 14 和 C++ 17的功能之前,请先考虑好可移植性问题。

返回目录


2. 头文件

通常,每一个 .cc 文件都有一个对应的 .h 文件。也有一些常见例外,如单元测试和只包含 main() 函数的小型 .cc 文件。 PS:考虑到公司历史原因,还存在大量的.cpp文件,以下如无特殊说明,.cc文件泛指.cc.cpp文件。

正确使用头文件可令代码在可读性、文件大小和性能上大为改观。

下面的规则将引导你规避使用头文件时的各种陷阱。

2.1.【必须】Self-contained 头文件

头文件应该是自给自足( self-contained,可自编译),以 .h 结尾。禁止分离出 -inl.h 头文件的做法。 特地用来包含的非头文件应当谨慎使用,并以 .inc 做扩展名。

详细信息
  • 头文件要能够做到自足,使得用户在包含这个头文件时不需要遵守特殊的条件。具体说来,一个头文件要有头文件保护,并且包含了它所需要的所有其他头文件。比如你的头文件中用到了 std::string,那么就需要自己包含 <string>,而不应当依赖使用者先包含了 <string> 才能包含你的头文件。确保头文件是自足的,能够简化使用者的负担,也方便通过重构工具对头文件列表进行增加、删除、重新排序等操作。通过在 .cc 实现文件或者测试文件中把自己模块对应的头文件放在最开头包含,可以让编译器来检查是否已经做到了自足,参见#include 的路径及顺序

  • 最好将模板和内联函数的定义与其声明放置在同一文件中。凡是有用到这些构造的 .cc 文件,都必须包含对应的头文件,否则程序可能会在构建时链接失败。如果声明和定义不在同一个文件中,声明应可传递的包含在定义中。过去有一种常见的做法是将这些定义移到单独的 -inl.h 文件中,然后在头文件的末尾包含这个文件,使得头文件中只包含声明,看起来更整洁一些,但现在不再允许这种做法。

  • 有个例外:如果某模板为所有相关的模板参数都做了显式实例化,或着是某个类的私有实现细节,那么允许它只定义在实例化该模板的 .cc 文件中。这样有助于减少代码膨胀,提高编译和链接速度。

  • 在极少数情况下,有些包含文件会被特地设计成非自足的。这样的文件通常被包含在不寻常的位置,例如作为文本片段插入到代码某处。这种情况下可能没有头文件保护,也可能不包括其他先决条件。这样的文件要用 .inc 扩展名以示区别。要谨慎使用这种方式,尽可能使用自足的头文件。

2.2.【必须】 头文件保护

所有头文件都应该使用 #define#pragma once 来防止头文件被多重包含。其中#define的命名格式当是: <PROJECT>_<PATH>_<FILE>_H_

#pragma once 示例如下:

#pragma once
...

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

#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif // FOO_BAR_BAZ_H_
详细信息
  • #define 的方式依赖于宏名不能冲突。所以不仅可以保证同一个文件不被包含多次,也可以保证内容完全相同的两个文件不会被重复包含。命名中要求包含全路径的原因是避免不同头文件因宏名冲突而导致问题难定位。非全路径方式发生宏名冲突时会导致头文件存在,编译器却报找不到声明的错误,这种问题难以跟进和解决,相对而言宏名冲突更容易定位和排查。
  • #pragma once 已被编译器普遍支持,其是由编译器提供保证,同一个文件不会被编译多次。此处所说的“同一个文件”是指绝对路径相同的文件,当同一份文件放置在多个绝对路径下时,会提示宏名冲突。

2.3.【推荐】避免使用前置声明

尽可能地避免使用前置声明。使用 #include 包含需要的头文件。

详细信息

定义:

前置声明(forward declaration)是类、函数和模板的纯粹声明,没伴随着其定义。

优点:

  • 前置声明能够节省编译时间,多余的 #include 会迫使编译器展开更多的文件,处理更多的输入。
  • 前置声明能够节省不必要的重新编译的时间。#include 使代码因为头文件中无关的改动而被重新编译多次。

缺点:

  • 前置声明隐藏了依赖关系,头文件改动时,用户的代码会跳过必要的重新编译过程。

  • 前置声明可能会被库的后续更改所破坏,前置声明函数或模板有时会妨碍头文件开发者变动其api。例如扩大形参类型,加个自带默认参数的模板形参等等。

  • 前置声明来自命名空间 std:: 的符号( symbol )时,其行为未定义。

  • 很难判断什么时候该用前置声明,什么时候该用 #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); }  // calls f(B*)

    如果 #includeBD 的前置声明替代,test() 就会调用 f(void*)

  • 前置声明了不少来自头文件的符号( symbol )时,就会比单单一行的 include 冗长。

  • 仅仅为了能前置声明而重构代码(比如用指针成员代替对象成员)会使代码变得更慢更复杂。

结论:

  • 尽量避免前置声明那些定义在其他项目中的实体。
  • 函数:尽量使用 #include
  • 类模板:优先使用 #include

至于什么时候包含头文件,参见 #include的路径及顺序

2.4.【推荐】内联函数

只有当函数只有 10 行甚至更少时才将其定义为内联函数。

详细信息

定义:

当函数被声明为内联函数之后,编译器会将其内联展开,而不是按通常的函数调用机制进行调用。

优点:

只要内联的函数体较小,内联该函数可以令目标代码更加高效。对于存取函数以及其它函数体比较短,性能关键的函数,鼓励使用内联。

缺点:

滥用内联将导致程序变得更慢。内联可能使目标代码量或增或减,这取决于内联函数的大小。内联非常短小的存取函数通常会减少代码大小,但内联一个相当大的函数将戏剧性的增加代码大小。现代处理器由于更好的利用了指令缓存,小巧的代码往往执行更快。

结论:

一个较为合理的经验准则是,不要内联超过 10 行的函数。谨慎对待析构函数,析构函数往往比其表面看起来要更长,因为有隐含的成员和基类析构函数被调用!

另一个实用的经验准则:内联那些包含循环或 switch 语句的函数常常是得不偿失 (除非在大多数情况下,这些循环或 switch 语句从不被执行)。

重要的是知道,有些函数即使声明为内联函数,也不一定总会被编译器内联。例如:虚函数和递归函数一般不会被正常内联。

  • 通常,递归函数不应该声明成内联函数。主要是由于递归调用栈的展开不像循环那么简单,比如递归层数在编译时可能是未知的,最重要的是大多数编译器都不支持内联递归函数。
  • 使用虚函数内联的主要原因是想把它的函数体放在类定义中,为了方便,或者是记录其行为,例如精短的存取函数。

2.5.【必须】#include 的路径及顺序

使用标准的头文件包含顺序可增强可读性,避免隐藏依赖:相关头文件C 库C++ 库其他库的 .h本项目内的 .h

项目内头文件应按照项目源代码目录树结构排列,避免使用 UNIX 特殊的快捷目录. (当前目录) 或 .. (上级目录)。例如,our-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. 空行
  3. C 系统头文件
  4. 空行
  5. C++ 标准库头文件
  6. 空行
  7. 其他库的 .h文件
  8. (可选)空行
  9. 本项目内 .h文件

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

详细信息

这种优先的顺序排序保证当 dir2/foo2.h 遗漏某些必要的库时,dir/foo.ccdir/foo_test.cc 的构建会立刻中止。因此这一条规则可确保首先将构建中断的信息显示给维护这些文件的人员,而不是维护其他文件的无辜人员。

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

请注意,C 头文件(如 stddef.h )与 C++ 头文件(如 cstddef )本质上是可以互换的。两种风格都是可以接受的,但请与现有代码保持一致。

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

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

举例来说,our-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 ),这些代码可以放到其它包含之后。当然,您的平台特定代码也要够简练且独立,比如:

#include "foo/public/fooserver.h"

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

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

返回目录


3. 作用域

3.1.【必须】命名空间

除了少数例外,代码应该处在命名空间中。命名空间的名称应当基于项目名或相对路径。 不要在头文件的全局作用域使用 using 指令(也就是 using namespace foo)。 禁止使用内联命名空间(inline namespace)。 推荐在源文件内使用匿名命名空间或 static 声明,参见下一条规范。

详细信息

定义:

命名空间将全局作用域细分为独立的,具名的作用域,可有效防止全局作用域的命名冲突。

优点:

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

例如,如果两个不同的项目在全局范围内都有一个 Foo 类,则这些符号可能在编译时或运行时发生冲突。如果每个项目将代码置于不同命名空间中,project1::Fooproject2::Foo 作为不同符号自然不会冲突。

内联命名空间会自动把内部的标识符放到外层作用域,比如:

namespace X {
inline namespace Y {
void foo();
}  // namespace Y
}  // namespace X

X::Y::foo()X::foo()彼此可代替。内联命名空间主要用来保持跨版本的 ABI(Application Binary Interface)兼容性。

缺点:

命名空间具有迷惑性,因为它们使得区分两个相同命名所指代的定义更加困难。

内联命名空间很容易令人迷惑,毕竟其内部的成员不再受其声明所在命名空间的限制。内联命名空间只在大型版本控制里有用。

有时候不得不多次引用某个定义在许多嵌套命名空间里的实体,使用完整的命名空间会导致代码的冗长。

在头文件中使用匿名空间导致违背 C++ 的唯一定义原则 (One Definition Rule(ODR))。

结论:

根据下文将要提到的策略合理使用命名空间:

  • 遵守命名空间命名中的规则;

  • 像之前的几个例子中一样,在命名空间的最后注释出命名空间的名字;

  • 用命名空间把文件包含,gflags 的声明/定义,以及类的前置声明以外的整个源文件封装起来,以区别于其它命名空间:

    // .h 文件
    namespace mynamespace {
    
    // 所有声明都置于命名空间中
    // 注意不要使用缩进
    class MyClass {
      public:
      ...
      void Foo();
    };
    
    } // namespace mynamespace
    // 源文件
    namespace mynamespace {
    
    // 函数定义都置于命名空间中
    void MyClass::Foo() {
      ...
    }
    
    } // namespace mynamespace

    更复杂的源文件包含更多、更复杂的细节,比如 gflags 或 using声明。

    #include "a.h"
    
    DEFINE_FLAG(bool, someflag, false, "dummy flag");
    
    namespace a {
    
    ...code for a...                // 左对齐
    
    } // namespace a
  • 不要在命名空间 std 内声明任何东西,包括标准库的类前置声明。在 std 命名空间声明实体是未定义的行为,会导致不可移植。声明标准库下的实体,需要包含对应的头文件。

  • 不应该在头文件的全局作用域中使用 using 指示 引入整个命名空间的标识符号。

    // 在头文件的全局作用域中
    // 禁止 —— 污染命名空间
    using namespace foo;

    在头文件的局部作用域或者实现文件(cc 或者 cpp 文件)中,允许适当使用。例如:

    // 引入 gmock 命名空间里的成员,减少繁杂的代码。
    using namespace testing;
    
    EXPECT_CALL(foo, DoThis(AllOf(Gt(5),
                                  Ne(10))));
    
    // The first argument must not contain sub-string "blah".
    EXPECT_CALL(foo, DoThat(Not(HasSubstr("blah")),
                            NULL));

    如果不用 using namespace 的话,代码会很冗长,反而不清晰。而如果用多个 using 声明(using testing::AllOf 等)的方式,则在代码变更时需要调整其声明列表,不利于维护。 但是不要仅仅为了减少输入而滥用 using namespace,增加名字冲突的潜在风险。

  • 不要在头文件中使用 命名空间别名除非显式标记内部命名空间使用。因为任何在头文件中引入的命名空间都会成为公开API的一部分。

    // 在 .cc 中使用别名缩短常用的命名空间
    namespace baz = ::foo::bar::baz;
    // 在 .h 中使用别名缩短常用的命名空间
    namespace librarian {
    namespace impl {  // 仅限内部使用
    namespace sidetable = ::pipeline_diagnostics::sidetable;
    }  // namespace impl
    
    inline void my_inline_function() {
      // 限制在一个函数中的命名空间别名
      namespace baz = ::foo::bar::baz;
      ...
    }
    }  // namespace librarian
  • 禁止用内联命名空间

3.2.【必须】匿名命名空间和静态变量

在源文件中定义一个不需要被外部引用的变量时,可以将它们放在匿名命名空间或声明为 static 。但是不要在头文件中这么做。

详细信息

定义:

所有置于匿名命名空间的声明都具有内部链接性,函数和变量可以经由声明为 static 拥有内部链接性,这意味着你在这个文件中声明的这些标识符都不能在另一个文件中被访问。即使两个文件声明了完全一样名字的标识符,它们所指向的实体实际上是完全不同的。

结论:

推荐、鼓励在源文件中对于不需要在其他地方引用的标识符使用内部链接性声明,但是不要在头文件中使用。

匿名命名空间的声明和具名的格式相同,在最后注释上 namespace :

namespace {
...
}  // namespace

3.3.【必须】非成员函数、静态成员函数和全局函数

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

详细信息

优点:

某些情况下,非成员函数和静态成员函数是非常有用的,将非成员函数放在命名空间内可避免污染全局作用域。

缺点:

将非成员函数和静态成员函数作为新类的成员或许更有意义,当它们需要访问外部资源或具有重要的依赖关系时更是如此。

结论:

有时,把函数的定义同类的实例脱钩是有益的,甚至是必要的。这样的函数可以被定义成静态成员或是非成员函数。非成员函数不应依赖于外部变量,应尽量置于某个命名空间内。 相比单纯为了封装若干不共享任何静态数据的静态成员函数而创建类,不如使用 namespaces 。举例而言,对于头文件 myproject/foo_bar.h ,应当使用:

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

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

如果你必须定义非成员函数,又只是在源文件中使用它,可使用匿名命名空间或静态非成员函数(如 static int Foo() { ... } )限定其作用域。

3.4.【推荐】局部变量

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

详细信息

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);
}

3.5.【必须】静态和全局变量

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

用通俗的话讲,这意味着析构函数不会执行任何操作,甚至也不会调用成员变量和基类的析构函数。用正式的术语说,这意味着该类型没有用户自定义的或者虚的析构函数,并且所有基类和非静态成员也都是可平凡析构的。

函数内的静态局部变量可以使用动态初始化。不鼓励使用动态初始化的静态类成员变量或命名空间范围内的变量,但在有限的情况下允许使用,详情请参见下文描述。

经验法则:对于全局变量,只要其声明是 constexpr 的,就已经满足了这些要求。

详细信息

定义:

每个对象都有一个和其生存期相关的存储期。具有静态存储期的对象从其初始化到程序结束一直存在。这些对象包括命名空间作用域的变量(“全局变量”),类的静态成员变量或函数内的静态局部变量。

除了函数静态局部变量在第一次执行到它们的声明时初始化外,所有其他具有静态存储期的对象都在程序启动时进行初始化。所有具有静态存储期的对象都在程序退出时被销毁,不会自动等待其他线程执行完成。

初始化可以是动态的,这意味着在初始化期间会发生一些非平凡的事情。(例如,在构造函数中分配内存,或用当前进程的 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++ 中用 const 定义的变量并非都是真正的常量。上述示例中,m 即使加上 const 修饰,其值依然不是常量表达式。

因此我们需要引入 C++ 标准中常量初始化的概念。这意味着初始化表达式需要是一个(真正的)常量表达式,为了准确地表达这个概念,C++11 引入了 constexpr 关键字,可以用于变量和函数。如果对象是通过调用构造函数的方式初始化的,则构造函数也必须为 constexpr

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

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

常量初始化总是允许的。静态存储期变量的常量初始化应当使用 constexpr 标记。任何未标记的非局部静态存储期变量都应假定具有动态初始化,并应非常仔细地评审。

相比之下,下面的初始化则是有问题的:

// 一些下面会用到的声明
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

平凡析构的静态局部变量的动态初始化则是允许的(也很常见)。

常用模式

  • 全局字符串:如果需要全局或静态字符串常量,请考虑使用简单的字符数组或指向字符串字面量的 char* 指针。 字符串字面量已经具有静态存储期,通常就够用了。从 C++17 开始,还可以使用 constexpr std::string_view
  • 各种 Map,Set 和其他动态容器:如果你需要一个静态的固定容器(例如要搜索的集合或查找表),不能将标准库中的动态容器用作静态变量,因为它们具有非平凡的析构函数。 作为替代,可以考虑使用平凡类型的简单数组,例如,一个 int 数组的数组(用于“从 intint 的映射”),或 pair 的数组(例如,intconst char*pair)。 对于较小的集合,线性搜索就足够了(并且由于内存的局部性,也比较高效)。如有可能的话,保持集合有序,并使用二分查找算法。如果您确实更喜欢标准库中的动态容器,请考虑使用下述的函数局部静态指针。
  • 智能指针(unique_ptrshared_ptr):智能指针在析构时会执行清理,因此也禁止使用。先考虑您的用例是否适合采用本节中描述的其他模式。一种简单的解决方案是使用指向动态分配对象的普通指针,并且永远不要删除它(请参阅最后一条)。
  • 自定义类型的静态变量:如果需要用自定义类型的静态常量数据,该类型要有平凡析构函数和 constexpr 构造函数,使得可以使用 constexpr 定义对象。
  • 如果所有其他方法均不行,则可以动态创建不删除的对象。做法是使用函数局部的静态普通指针或引用(例如,static const auto& impl = *new T(args...);),大部分内存泄漏检查工具都支持忽略这种情况。

3.6.【必须】thread_local 变量

非函数内定义的 thread_local 变量必须初始化为编译时常量。 相比于其他用于定义线程本地数据的机制,应该优先使用 thread_local

详细信息

从C++11开始,变量允许使用 thread_local 修饰符:

thread_local Foo foo = ...;

当定义了这样的变量后,不同的线程获取到的对象是不同的。 thread_local 变量在很多方面跟静态存储周期变量类似。比如,它们都可以在命名空间、函数内、或者作为静态成员变量,但是不能作为普通的成员变量。 thread_local 变量实例初始化必须在每个线程独立初始化而不是在程序启动时,除此之外跟静态变量类似。这意味着在函数体内定义thread_local 变量是线程安全的,但是在其他地方定义的 thread_local 变量跟静态变量的初始化顺序是一致的(还有更多场景也有类似行为)。 thread_local 变量实例当线程结束时销毁,所以销毁顺序跟静态变量不同。

  • 线程局部数据对竞争是天生安全的(因为常规情况下只有一个线程能访问到),这对并发编程很有用。
  • thread_local 是创建线程数据唯一标准的方式。
  • 访问 thread_local 变量可能会触发意料之外的数量不可控制的代码执行。
  • thread_local 变量也是全局变量,因此除了线程安全外,具有全局变量的所有弊端。
  • thread_local 变量消耗的存储空间大小跟线程个数成正比,这意味着在极端场景下,可能会消耗非常大的内存。
  • 普通的成员变量不能定义为 thread_local
  • thread_local 可能不如某些编译器内置支持有效。

函数内定义的 thread_local 变量没有安全顾虑,因此可以不受限制地使用。比如你可以用函数体内的 thread_local 变量去模拟类或命名空间作用域的效果:

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

除了在函数内部定义外的 thread_local 变量必须初始化为编译时常量(确保它们不能动态初始化)。 相比于其他用于定义线程本地数据的机制,应该优先使用 thread_local

返回目录


4. 类

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

4.1.【必须】构造函数的职责

不要在构造函数中调用虚函数,也不要在无法报出错误时进行可能失败的初始化。

详细信息

定义

在构造函数中可以进行各种初始化操作。

优点

  • 无需考虑类是否被初始化。
  • 经过构造函数完全初始化后的对象可以为 const 类型,也能更方便地被标准容器或算法使用。

缺点

  • 如果在构造函数内调用了自身的虚函数,这类调用是不会重定向到子类的虚函数实现的。即使当前没有子类化实现,将来仍是隐患。
  • 在没有使程序崩溃或者使用异常(因为已经被禁用了)的条件下,构造函数很难上报错误。
  • 如果执行失败,会返回一个初始化失败的对象,这个对象有可能进入不正常的状态,常常需要使用 bool IsValid() 或类似的机制才能检查出来,但这样的方式容易被疏忽,应避免这种两阶段的初始化方式。
  • 构造函数的地址是无法被取得的,因此,由构造函数完成的工作是无法以简单的方式交给其他线程的。
  • 如果有人创造该类型的全局变量,构造函数将先 main() 一步被调用,这有可能破坏构造函数中隐含的假设条件,比如gflags尚未初始化。

结论

构造函数不允许调用虚函数,如果对象需要进行有意义的(non-trivial)初始化,请考虑使用明确的 Init() 函数或使用工厂模式;这里建议优先考虑工厂模式,因为使用 Init() 函数则需要对状态进行判断,即未初始化,已初始化,以及初始化失败,如果遗漏判断,且对象没有初始化成功,使用这种半构造的对象则会导致程序错误。当初始化失败时,如果适用,则最合适的处理办法是直接终止程序,从而避免继续使用错误的对象。应该尽量避免以是否使用 Init() 函数或者函数返回是否失败来决定使用是否调用某些公共函数,因为这样的半构造对象不容易准确的使用。

4.2.【必须】隐式类型转换

不要定义隐式类型转换。对于转换运算符和单参数构造函数,请使用 explicit 关键字。

详细信息

定义

隐式类型转换允许一个类型( 源类型)的对象用于需要另一种类型( 目的类型)的位置,例如,将一个 int 类型的参数传递给需要 double 类型的函数。

除了语言所定义的隐式类型转换,用户还可以在类定义时定义自己需要的转换。在源类型中定义隐式类型转换,可以通过目的类型名的类型转换运算符实现(例如 operator bool())。在目的类型中定义隐式类型转换,则通过以源类型作为其唯一参数(或唯一无默认值的参数)的构造函数实现。

explicit 关键字可以用于构造函数或(在 C++11 引入)类型转换运算符,以保证只有当目的类型在调用点被显式写明时才能进行类型转换,例如使用 cast。这不仅作用于隐式类型转换,还能作用于 C++11 的列表初始化语法:

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

void Func(Foo f);

此时下面的代码是不允许的:

Func({42, 3.14});  // Error

从技术上说,以上并非隐式类型转换,但是语言标准认为这是 explicit 应当限制的行为。

优点

  • 有时目的类型名是一目了然的,通过避免显式地写出类型名,隐式类型转换可以让一个类型的可用性和表达性更强。
  • 隐式类型转换可以简单地取代函数重载。
  • 在初始化对象时,列表初始化语法是一种简洁明了的写法。

缺点

  • 隐式类型转换会隐藏类型不匹配的错误。有时,目的类型并不符合用户的期望,甚至用户根本没有意识到发生了类型转换。
  • 隐式类型转换会让代码难以阅读,尤其是在有函数重载的时候,因为这时很难判断到底是哪个函数被调用。
  • 单参数构造函数有可能会被无意地用作隐式类型转换。
  • 如果单参数构造函数没有加上 explicit 关键字,读者无法判断这一函数究竟是要作为隐式类型转换,还是作者忘了加上 explicit 标记。
  • 并没有明确的方法用来判断哪个类应该提供类型转换,这会使得代码变得含糊不清。
  • 如果目的类型是隐式指定的,那么列表初始化会出现和隐式类型转换一样的问题,尤其是在列表中只有一个元素的时候。

结论

在类型定义中,类型转换运算符和单参数构造函数都应当用 explicit 进行标记。一个例外是,拷贝和移动构造函数不应当被标记为 explicit`, 因为它们并不执行类型转换。对于设计目的就是用于对其他类型进行透明包装的类来说,隐式类型转换有时是必要且合适的。这时应当联系项目组长并说明特殊情况。

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

4.3.【必须】可拷贝类型和可移动类型

如果需要,就让你的类型支持拷贝/移动。否则,把隐式产生的拷贝和移动函数禁用。

详细信息

定义

可拷贝类型允许对象在初始化时得到来自相同类型的另一对象的值,或在赋值时被赋予相同类型的另一对象的值,同时不改变源对象的值。对于用户定义的类型,拷贝操作一般通过拷贝构造函数与拷贝赋值运算符定义。string 类型就是一个可拷贝类型的例子。

可移动类型允许对象在初始化时得到来自相同类型的临时对象的值,或在赋值时被赋予相同类型的临时对象的值(因此所有可拷贝对象也是可移动的)。std::unique_ptr<int> 就是一个可移动但不可复制的对象的例子。对于用户定义的类型,移动操作一般是通过移动构造函数和移动赋值运算符实现的。

拷贝/移动构造函数在某些情况下会被编译器隐式调用。例如,通过传值的方式传递对象。

优点

可移动及可拷贝类型的对象可以通过传值的方式进行传递或者返回,这使得 API 更简单,更安全也更通用。与传指针和引用不同,这样的传递不会造成所有权,生命周期,可变性等方面的混乱,也就没必要在协议中予以明确。这同时也防止了客户端与实现在非作用域内的交互,使得它们更容易被理解与维护。这样的对象可以和需要传值操作的通用 API一起使用,例如大多数容器。

一般来说,拷贝/移动构造函数与赋值操作要比它们的各种替代方案(如 Clone()CopyFrom()Swap())更容易定义,因为它们能通过编译器产生(隐式或通过 = default)。这种方式很简洁,也保证所有数据成员都会被复制。拷贝/移动构造函数一般也更高效,因为它们不需要堆的分配或者是单独的初始化和赋值步骤,同时,对于类似省略不必要的拷贝这样的优化也更加合适。

移动操作允许隐式且高效地将源数据转移出右值对象。这有时能让代码风格更加清晰。

缺点

许多类型都不需要拷贝,为它们提供拷贝操作会让人迷惑,也显得荒谬而不合理。单件类型(Registerer),与特定的作用域相关的类型(Cleanup),与其他对象实体紧耦合的类型(Mutex)从逻辑上来说都不应该提供拷贝操作。为基类提供拷贝 / 赋值操作是有害的,因为在使用它们时会造成对象切割 。默认的或者随意的拷贝操作实现可能是不正确的,这往往导致令人困惑并且难以诊断出的错误。

因为拷贝构造函数是隐式调用的,所以很容易被忽略。这会让人迷惑,尤其是对某些程序员(他们所用的语言约定或强制要求传引用)来说更是如此。这会在一定程度上鼓励过度拷贝,从而导致性能问题。

结论

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

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

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;

4.4.【必须】结构体 VS. 类

仅对承载数据的被动对象使用 struct,其它一概使用 class

详细信息

说明

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

struct 用来定义承载数据的被动式对象,也可以包含相关的常量,但除了存取数据成员之外,没有别的函数功能。存取功能是通过直接访问成员变量,而不是访问函数。结构体不得具有暗示不同字段之间关系的不变式,因为直接用户访问这些字段可能会破坏这些不变式。除了构造函数,析构函数,Initialize()Reset() 等类似的用于设定数据成员的函数外,不能提供其它功能的函数。

如果需要更多的功能或者不变式,class 更适合. 如果拿不准,就用 class

为了和 STL 保持一致,对于无状态的类型,可以使用 struct 代替 class,比如 traits,模板元编程以及一些仿函数。

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

4.5.【必须】继承

使用组合常常比使用继承更合理。如果使用继承的话,定义为 public 继承。

详细信息

定义

当子类继承基类时,子类包含了父基类所有数据及操作的定义。C++ 实践中,继承主要用于两种场合:实现继承,子类继承父类的实现代码;接口继承,子类仅继承父类的方法名称。

优点

通过原封不动的复用基类代码,实现继承减少了代码量。由于继承是在编译时声明,程序员和编译器都可以理解相应操作并发现错误。从编程角度而言,接口继承是用来强制类输出特定的 API。在类没有实现这其中某些必须的 API 时,编译器同样会发现并报告错误。

缺点

对于实现继承,由于子类的实现代码散布在父类和子类之间,要理解其实现变得更加困难。子类不能重写父类的非虚函数,当然也就不能修改其实现。基类也可能定义了一些数据成员,因此还必须区分基类的实际布局。

结论

所有继承必须是 public 的。如果你想使用私有继承,你应该替换成把基类的实例作为成员对象的方式。

不要过度使用实现继承。组合常常更合适一些,尽量做到只在"是一个"的情况下使用继承:如果 Bar 的确"是一个" FooBar 才能继承 Foo

必要的话,析构函数声明为 virtual。如果你的类有虚函数,则析构函数也应该为虚函数。

对于可能被子类访问的成员函数,不要过度使用 protected 关键字。注意,数据成员都必须是私有的

对于重载的虚函数或虚析构函数,使用 override,或(较不常用的)final 关键字显式地进行标记。较早(早于 C++11)的代码可能会使用 virtual 关键字作为不得已的选项。因此,在声明重载时,请使用 overridefinalvirtual 的其中之一进行标记。标记为 overridefinal 的析构函数如果不是对基类虚函数的重载的话,编译会报错,这有助于捕获常见的错误。这些标记起到了文档的作用,如果省略这些关键字,代码阅读者不得不检查所有父类,以判断该函数是否是虚函数。

允许将多重继承用于一次或多次接口继承(参见面向接口的编程)。但是我们强烈不建议使用多重实现继承

// path/to/http_request.h
// 接口类
class HttpRequestDelegate {
 public:
  virtual void HttpRquestSucceeded(Response* response, GfData* data) = 0;
  virtual void HttpRquestFailed(NetworkError error, Response* response) = 0;
  virtual void HttpRquestCancelled() = 0;
};
// path/to/some_user_logic.cc
#include "path/to/http_request.h"

// 一个实现继承,一个接口继承
class SomeUserLogic : public SomeBaseObject, public HttpRequestDelegate {
 public:
  void DownloadSomeData() {
    auto request = HttpRequest::AllocWithUrl("http://xxxxx");
    request->SetMethod("GET");
    request->SetDelegate(this);
    request->Start();
  }

  // from HttpRequestDelegate
  void HttpRquestSucceeded(Response* response, GfData* data) override;
  void HttpRquestFailed(NetworkError error, Response* response) override;
  void HttpRquestCancelled() override;
};

4.6 【必须】结构体,Pair 和 Tuple

如果可以给字段取有意义的名字,应该优先使用结构体,其次才是 pair 和 tuple。

详细信息

尽管使用 pair 和 tuple 可以省掉自定义结构体的工作,但是在读代码的时候,一个有意义的名字总是比 .first.second 或者 std::get<X>. 更清楚。 当然 C++14 引入了 std::get<Type>,在类型唯一的时候,可以用类型而不是下标访问 tuple 的元素,部分缓解了下标访问的不清晰,但是字段名通常总是比类型更清晰,能提供更多的信息。

当 pair 或 tuple 中的元素没有什么特定含义的时候,pair 和 tuple 是合适的。 和已有代码和 API 的互操作,有时也会要求必须用 pair 或 tuple。

4.7.【必须】运算符重载

除少数特定环境外,不要重载运算符。也不要创建用户定义字面量。

详细信息

定义

C++ 允许用户通过使用 operator 关键字对内建运算符进行重载定义,只要其中一个参数是用户定义的类型。operator 关键字还允许用户使用 operator"" 定义新的字面运算符,并且定义类型转换函数,例如operator bool()

优点

通过使用户自定义类型和内建类型行为表现一致,运算符重载可以使代码更简洁,更符合直觉。 重载运算符对于某些操作来说,是符合传统习惯的命名 (例如 ==, <, =,<<),遵循这些传统习惯,可以让用户定义类型可读性更高。 并且有的库遵循这种传统习惯,希望使用者定义这些重载运算符,自定义类型重载运算符后,也能更好地和这些库进行互操作。

对于创建用户定义类型的对象来说,用户定义字面量是一种非常简洁的标记方法。

缺点

  • 要提供正确, 一致, 不违背使用者预期的运算符重载,需要非常小心。而且如果没达到这些要求, 会导致令使用者非常迷惑,或者产生 Bug。
  • 过度使用运算符重载,会产生难以理解的代码,尤其是当重载的运算符的语义,与传统习惯不符合时。
  • 函数重载的弊端, 运算符重载同样都有。
  • 运算符重载会欺骗我们的直觉,让我们误以为一些昂贵的操作,是便宜高效的内建运算符。
  • 查找调用了重载运算符的代码,需要能感知并解析 C++ 语法的搜索工具,像 grep 这样的基本工具无法胜任。
  • 如果你写错了重载运算符入参的类型,你可能会得到一个完全不同的重载,而无法得到编译器报错。例如: 有可能 foo < bar 执行了某些运算行为,而 &foo < &bar 执行了完全不同的运算行为.
  • 重载某些特定运算符本身就是非常危险易错的。例如,取决于运算符重载的声明对调用方代码是否可见,重载一元运算符 & 在不同的调用代码有完全不同的含义。 重载诸如 &&||, 运算符,其参数运算语义,和内建运算符的语义是不可能等价的(例如不可能实现短路运算)。
  • 重载运算符通常定义在类的外部,所以对于同一运算符,可能不同的文件定义了不同的重载定义,这是有风险的。如果两种定义链接到同一个二进制文件,会导致未定义行为,会表现为难以发现的运行时 bug。
  • 用户定义字面量允许用户创建新的语法形式,这些语法形式即使对有经验的 C++程序员来说都是很陌生的。例如 "Hello World"sv 可以定义成 std::string_view("Hello World") 的简写,尽管前者更简洁,可是常用的后者更清晰。

结论

只有在重载运算符的意义显而易见,其行为不违背使用者预期,并且与对应内建运算符行为一致时,才定义重载运算符。例如,| 可以定义成 “位或运算” 或 “逻辑或” ,而不应定义成 shell 风格的管道。

只应对你自己定义的类型,定义重载运算符。更准确地说,把重载运算符,和它们所操作的类型,定义在同一个头文件中,同一个 .cc 文件中,同一个命名空间中。这样,自定义类型可用的时候,重载运算符也可以使用,尽可能减少重复定义的风险。 如果可能的话,请避免将运算符定义为模板, 因为此时要对任何模板参数类型,遵守前述运算符和类型在定义在同一个头文件的规则。 如果你定义了一个运算符, 应同样定义和其相关有意义的运算符,并且保证这些定义的语义是一致的。例如,如果你重载了 <,那么对所有的比较运算符都进行重载,并且保证对于同一组参数,<>不会同时返回 true

最好将不更改参数的二元运算符,定义为非成员函数。如果一个二元运算符被定义为类成员函数,隐式转换会作用于右侧的参数,却不会作用于左侧。 这会导致出现 a < b能够通过编译,而 b < a 编译不过的情况, 这会让你的用户非常迷惑。

不要为了避免重载运算符而走极端。比如说,应当定义 ===,和 << ,而不是 Equals()CopyFrom()PrintTo()。 反过来说,不要只是为了满足函数库需要而去定义运算符重载。 比如说,如果你的类型没有自然顺序,而你要将它们存入 std::set 中,此时最好还是定义一个自定义的比较函数,而不是重载 < 运算符。

不要重载 &&||, 或一元运算符 &。不要重载 operator"",也就是说, 不要引入用户定义字面量,不要使用其他人提供的用户定义字面量(就算是标准库提供的也不应使用)。

类型转换运算符在隐式类型转换一节有提及。 =运算符在可拷贝类型和可移动类型一节有提及。 运算符 <<一节有提及。同时请参见函数重载一节,其中提到的的规则对运算符重载同样适用。

4.8.【必须】存取控制

所有 数据成员声明为 private , 除非是 static const 常量类型成员 (遵循常量命名规则)。这便于推理追查不变量,代价是有时候有必要引入一些 accessors 函数。

出于技术原因, 我们允许在使用 Google Test时,我们允许测试固件类中的数据成员为 protected

4.9.【必须】声明顺序

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

详细信息

说明

类定义一般应以 public: 段开始,后跟 protected: 段,最后是 private: 段。如果某段是空的可以忽略。

在每一段内,建议将类似的声明放在一起,并且建议以如下的顺序: 类型 (包括typedefusing 和嵌套的结构体与类),常量,工厂函数,构造函数,赋值运算符,析构函数,其它函数,数据成员。

不要将大段的函数定义内联在类定义中。通常,只有那些一目了然的,或性能关键,并且非常短的函数,可以内联在类定义中。参见内联函数一节。

返回目录


5. 函数

5.1.【推荐】输入和输出

推荐优先使用返回值作为函数输出。函数的参数列表排序为:输入参数在前,输出参数在后。

详细信息

说明

返回值

C++ 中的函数返回值天然就是其输出,但有时也会需要通过输出参数(或者输入/输出参数)。

在 C++11 之前,通过引用或者指针方式的输出参数代替返回是一种常见的避免不必要的拷贝的性能优化方式。但是在现代 C++ 代码中,应当优先使用返回值:因为使用返回值作为输出,可以提升代码可读性,与此同时,性能也相同甚至更优。

如果返回值可能不存在,可以考虑返回 std::optional(C++17) 或者 std::unique_ptr

template <typename T>
std::optional<T> TryParse(const std::string_view& s);

// 使用
auto n = TryParse<int>("42");
if (n && *n > 0) {
  // 使用 n
}

如果需要返回多个值,可以考虑通过 std::tuple 或者结构体:

// 不好: 需要通过注释说明输出参数
int Foo(const string& input, /*output only*/ string& output_data) {
  // ...
  output_data = Something(input);
  return status;
}

// 好: 更直观,需要注意,返回 tuple 而不是 struct 时,应当说明每个元素的含义
std::tuple<int, string> Foo(const string& input) {
  // ...
  return std::make_tuple(status, Something(input));
}

直接用下标访问 tuple 的元素不够清晰,为了提高代码的可读性,在 C++11 中,可以通过 std::tie 用具名变量来接收多个返回值:

int status;
std::string reason;
std::tie(status, reason) = Foo("Hello");

这样略有些繁琐并且可能会带来一次拷贝,因此从 C++17 开始,可以用结构化绑定来接收多个返回值,代码也更简洁:

auto [status, reason] = Foo("Hello");

参数类型

函数参数,要么是输入,要么是输出,或二者兼有。输入参数通常应当是值或者是 const 引用,纯输出参数和输入兼输出参数则可以是非 const 指针或者引用。可以用 std::optional 或者 const 指针表示可选输入参数,非 const 指针表示可选输出参数。

当使用 const 引用参数时,应当避免对其生存期的依赖超过本次调用(比如把这个参数的地址或者引用保存了下来供后续使用),因为常量引用参数可以绑定到临时对象。而应该用某些办法消除生存期依赖(比如通过拷贝参数),或者明确通过 const 指针传递并且在文档中说明非空的要求。由于大多数情况之下,输入参数是 const T&,若是 const T* 则说明函数会对输入参数做某种特殊的处理。因此当你将输入参数设置为 const T* 时,应说清楚这么做的具体原因,如果毫无缘由地设置,则会使读者感到困惑。

如前面返回值部分所述,我们应当尽量用返回值代替输出参数,但是有时候可能还是难以避免,特别是需要输入/输出参数的情况。实现输出参数有指针和引用两种方式,并且都存在较大的争议:

  • 在早期的 Google 代码规范以及 Bjarne Stroustrup《C++ 程序设计语言》(第三版 5.5 节)中,都提倡用指针来作为输出参数。其好处是在调用处通常能显式地看出参数传递的方向,有利于提高代码的可读性。但是在实现里使用时,指针的语义更含糊一些,比如无法直接表达不能为空的约束。
  • 一些较新的规范推荐用非 const 引用做非可选输出参数,好处是在实现里使用时更简洁,且不用考虑是否可以为空,还可以把运行期检查提前到编译期间检查进行,但是确实导致在调用点不容易看出参数传递方向,虽然有些 IDE 比如 CLion 能够在代码编辑窗口里显示出来,但是并不普及。

在一些新的编程语言中,对输出参数提供了一些内建的语法支持,比如 C# 的out 和 Rust 的 mut 关键字都要求在不但在定义处,在调用处也需要加上。C++ 中用指针来传递输出参数的用法,可以看做是希望借用调用时接收输出参数时变量名前的要加的 & 运算符来模拟这种语法。但是这种方案并不完美,比如当实参本身就已经是指针类型时,参数前就无法再加& 运算符。

因此考虑到历史代码,这两种方式都可以接受,应当在一个项目内保持一致。

当使用非 const 引用做输出参数时,应当尽量通过函数名体现出会修改这个参数:

void FillResponse(const Request& request, Response& response);

双向的输入/输出参数比单方向的输入和输出参数更容易出错。当使用输入/输出参数时,应当避免用结构体的一部分成员做输入,另一部分成员做输出,拆分成输入、输出两部分会更有可读性。

参数顺序

当进行参数排序时,请将所有的纯输入参数置于所有输出参数之前。特别要指出的是,不要因为参数是新增的,而将其放到参数列表的最后。尽量将新的纯输入参数置于输出参数之前。

但这并不是一个一成不变的规则。输入兼输出参数(通常是类或结构体)常常会打乱规则,与相关函数保持一致,也需要做灵活调整。变参函数通常也需要不寻常的参数顺序。

5.2.【推荐】编写简短函数

推荐编写简短且内聚的函数。

详细信息

说明

不可否认长函数有时是合理的,因此我们并不硬性限制函数的长度。如果一个函数超过了 40 行,则可以思考下,能否在不破坏程序结构的前提之下,对函数进行拆分。

即使一个长函数当前运行地很正常,也可能会有人需要对其进行修改,或者添加一些新的功能,这样可能会产生难以发现的 bug。请尽量编写简短的函数,以便于其他人阅读和修改你的代码。同时,简短的函数也便于测试。

在工作的过程中,你可能会遇到一些冗长复杂的函数。如果发现这些函数使用起来十分困难,例如出现错误难以调试,或者在多个不同的地方都使用了其中的代码片段,则千万不要害怕调整这些已有的代码,请将这些函数拆分得更小、更易管理的单元。

5.3.【必须】函数重载

若要使用函数(包括构造函数)重载,则必须让读者一看调用点就了然于心,而不必花心思查找调用的重载函数到底是哪一个。

详细信息

定义

你可以编写一个参数类型为 const string& 的函数,然后用另一个参数类型为 const char* 的函数对其进行重载。当然,在这个例子中,最好将 std::string 替换成 std::string_view (C++17 新特性)。

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

优点

当函数重载时,允许函数名相同但参数不同,这样使得代码看起来会更直观。重载对于模板化的代码,可能也是必要的,这样使用起来才会更便利。

基于 constref 限定的重载,可能使得程序变得更加高效、实用。(详情可参考 TotW 148

缺点

如果一个函数只通过参数类型的不同来区分各个重载版本,读者可能必须了解复杂的 C++ 匹配规则,才能知道真实调用的版本。此外,如果派生类只重写了重载函数的部分版本,那么人们就容易混淆继承与重载之间的语义。

结论

如果重载后的各个版本的函数之间没有语义差异时,你可以选择重载函数。这些重载体现在参数类型,限定符或者个数上有所区别。不过,务必让阅读者在调用时,无需关注具体调用的是哪个版本,只需知道是其中的某个重载版本被调用了。如果你可以通过单个注释,就能描述清楚所有的重载函数版本集合,则表明这是一个设计良好的重载函数。

5.4.【推荐】缺省参数

只允许在非虚函数中使用缺省参数,且必须保证缺省参数的值始终一致。函数重载所遵循的规则,同样适用于缺省参数。一般情况下都建议使用函数重载,特别是当使用了缺省参数后,对代码可读性的提升,还不能弥补由此引入的缺点的情况下。

详细信息

优点

有些函数,在一般情况下,参数使用默认值即可,但偶尔又需要使用非默认值。此时,你只需通过设置缺省参数,就可以很轻松地达到这个目的,而无需通过定义一堆函数来覆盖这些特例。同函数重载相比,缺省参数的语法更加简洁,并且减少了样板代码,还能够很清晰的辨别出“必填”与“选填”参数。

缺点

实际上,缺省参数是实现函数重载语义的另一种方式,因此所有不应当使用函数重载的地方也适用于缺省参数。

在虚函数中调用的缺省参数的默认值,取决于目标对象的静态类型,但无法保证该函数的所有重写都声明了相同的默认值。

缺省参数在每次调用时都会进行重新计算,这会导致生成的代码膨胀。读者可能也期望缺省参数的默认值在声明完后,就保持不变,而不是在每次调用时发生变化。

缺省参数会干扰函数指针,导致函数签名常与调用的函数签名不匹配。而使用函数重载,则可以避免此类问题。

结论

在虚函数中禁止使用缺省参数,因为可能导致运行不正常。同时,如果每次调用缺省参数的默认值可能不同,且取决于什么时候被调用,那么也应禁止使用缺省参数。(例如,不要写类似 void f(int n = counter++); 这样的代码)

其他情况,如果通过设置缺省参数,在可读性上的提升,足够弥补由此引入的上述缺点,那就可以使用缺省参数。当拿不准时,那就使用函数重载。

5.5.【可选】返回类型后置语法

只有在常规写法(返回类型前置)无法满足要求或者可读性很差的情况之下,才使用返回类型后置。

详细信息

定义

C++ 允许两种不同的函数声明形式。旧声明形式中,是将返回类型置于函数名之前。例如:

int foo(int x);

C++11 后引入了新的声明形式:可以在函数名前使用 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++ 的语言,如 CJava 中,都没有类似的语法,因此对读者来说可能比较陌生。

在已有的代码中,已经存在大量的函数声明,不太可能把它们都用新的语法重写一遍,因此比较现实的做法是只使用旧的语法或者新旧语法混用。但使用单一版本更有利于风格的统一。

结论

在大部分情况下,应当继续使用旧的函数声明形式,即将返回类型置于函数名前。只有在必需的时候(如 lambda 表达式),或者使用后置语法能够大幅提高可读性的时候,才可使用返回类型后置。但需要返回类型后置的场景一般较少见,大多数出现在相当复杂的模板代码中,而我们又不鼓励写这种复杂代码。

返回目录


6. 其他 C++ 特性

6.1.【推荐】右值引用

使用右值引用来:定义移动构造函数与移动赋值操作。作为函数重载的参数,以降低性能开销。支持完美转发(perfect forwarding)。

详细信息

定义:

右值引用是一种只能绑定到临时对象的引用,其语法与传统的引用语法相似。 例如:void f(string&& s);声明了一个参数是一个字符串的右值引用的函数。

此外,当 && 作用于未限定(unqualified)模板参数时,称为前向引用(forwarding reference),此时有特殊的模板参数推导(deduction)规则。

优点:

用于定义移动构造函数(使用类的右值引用进行构造的函数)使得移动一个值而非拷贝值成为可能。 例如, 如果 v1 是一个vector<string>, 则 auto v2(std::move(v1)) 将很可能不再进行大量的数据复制而只是简单地进行指针操作,在某些情况下这将带来大幅度的性能提升。

右值引用能实现可移动但不可拷贝的类型,这一特性对那些在拷贝方面没有实际需求,但有时又需要将它们作为函数参数传递或塞入容器的类型很有用。

要高效率地使用某些标准库类型, 例如:std::unique_ptrstd::move 是必需的。

前向引用使得编写通用的参数转发函数成为可能,无论参数类型是临时对象、常量还是其他类型。这称为完美转发。

缺点:

右值引用是一个相对较新的特性 (由 C++11 引入),尚未被广泛理解。像是引用折叠(reference collapsing)和前向引用这样的特殊推导规则令人难以理解。

结论:

在定义移动构造函数与移动赋值操作时使用右值引用。

结合函数重载的优缺点和对性能的需求来考虑是否提供支持右值引用参数的函数重载。

使用前向引用及 std::forward 来支持完美转发。

6.2.【必须】友元

我们允许合理的使用友元类及友元函数。

详细信息

通常情况下友元应该定义在同一文件内,以避免代码读者需要在其它文件内查找类的私有成员的用途。一种使用友员的通常场景是将 FooBuilder 类声明为 Foo 类的友元,以便FooBuilder 类可以正确构造 Foo 的内部状态,而无需将该状态暴露出来。某些情况下,将一个单元测试类声明成待测类的友元会很方便。

友元扩大了 (但没有打破) 类的封装边界。某些情况下,相对于将类成员声明为public,使用友元是更好的选择,尤其是当你只允许另一个类访问该类的私有成员时,当然,大多数类都只应该通过其提供的公有成员进行互操作。

6.3.【必须】异常

根据项目特点和团队能力决定是否使用 C++ 异常,但必须保持一致。对于不用异常的现存项目,不要引入异常。使用异常时要遵循业界最佳实践,确保异常安全。 基础库和基础组件的客户端库的接口设计要考虑到不用异常的项目。

详细信息

异常一直是 C++ 中争议较大的语言特性,拥护者和反对者都表达出较强的态度,异常主要有以下优点和缺点:

优点:

  • 使用异常使得正常的代码更清晰易读,减少了对错误码的层层传递和检查,也有利于一些性能提升。
  • 异常不能被忽略,能够避免出了问题的代码继续执行(虽然用返回码时,也可以用C++17提供的 nodiscard 来加强检查,但是这一功能推出较晚并不广为人知,并且在接口设计时需要额外的工作,GCC 的 warn_unused_result 属性出现的很早,但是是非标准扩展)。
  • 异常是处理一些特殊函数比如运算符重载和构造函数失败的唯一途径。虽然可以用工厂函数或 Init() 方法以避免在构造函数中抛出异常,但是前者要求在堆上分配内存,后者会导致引入一个新的 "无效"状态。 (C++17 引入的 std::optional 可以避免动态分配内存)。
  • 和普通的错误码相比,异常可以携带更丰富的信息。虽然错误码也可以升级为错误对象来实现类似目的,但是需要付出更多的接口设计和用户教育成本。
  • 异常允许应用程序在高层决定如何处理在深层嵌套函数中“不可能发生”的失败(failures),而不用引入那些含糊且容易出错的错误码。
  • 很多其他现代语言都用异常。引入异常使得 C++ 与 Python、Java 以及其它类 C++ 的语言更加一致。
  • 有些第三方 C++ 库使用异常,禁用异常会导致这些第三方库难以使用。
  • 在一些对错误处理要求不高的场景,比如测试框架中,异常确实很方便。

缺点:

  • 异常缺乏编译期间的检查,异常并非函数签名的一部分,无法得知一个函数是否会抛出异常,会抛出哪些类型的异常(noexcept 也没有编译期检查)。如果缺乏完善而准确的文档,只能靠猜测或者去阅读源代码。
  • 当您在在现有函数中添加 throw 语句时,您必须检查所有调用逻辑。要么让所有调用逻辑统统具备最低限度的异常安全保证,要么让他们从来不捕获这个异常并且欢快的让他中断掉整个程序。举个例子,f() 调用 g()g() 又调用 h(),且 h 抛出的异常被 f 捕获。此时 g 必须的逻辑要十分小心,以避免资源没有被正确释放。
  • 更常见的,异常会使我们在阅读代码时更难判断程序的执行流程:函数也许会在您意料不到的地方返回。这增加了维护和调试的难度。您可以通过规定在何时何地可以使用异常以尽可能规避这些麻烦,但是这又会使开发人员学习理解的成本上升。
  • 异常安全需要 RAII 和不同程度的编码实践。要轻松编写出正确的异常安全代码需要大量的支持机制。更进一步地说,为了避免要求读者理解整个调用图,异常安全的代码必须将写入持久状态的逻辑隔离到“提交”阶段。这既有好处也有代价(因为你也许不得不为了隔离“提交”而使代码逻辑更加模糊不清)。使用异常会迫使我们总是要付出这些代价,即使有时它们并不值得。 GotW #8 展示了正确实现异常安全的艰难挑战。
  • 启用异常会增加所有生成的二进制文件的数据,增加编译时间(或许影响很小),还可能加大地址空间的压力。这对某些内存受限的运行环境是一个不利影响。
  • 允许使用异常会变相鼓励开发者去抛出不合时宜的或者本来就已经没法恢复的异常。例如,无效用户输入不应该抛出异常。我们需要一份更长的文档来列举这些限制。
  • 异常处理带来的性能开销并非可以忽视。现代编译器已经能做到异常不发生时对性能基本没有影响,但是异常发生时的处理则很慢。特别是对于本身很轻量的函数,异常发生时,性能会比正常时慢很多,如果对其调用出现在次数较多的循环中,可能会导致处理能力的严重下降,这对时间敏感的系统的影响很大,也可能导致服务器过载雪崩。如果接口只提供异常方式的错误报告机制,会使使用者在明确希望忽略错误的情况下,也不得不付出性能代价。

结论:

在原始的 Google 代码规范中,由于其内部只有一个统一的代码库,考虑到工程师对异常的掌握情况及对存量代码的影响,所以统一禁用了异常。但是对于腾讯来说,情况并不完全一样,不同项目的代码不在一起,是否已经使用了异常也各不一样。综合考虑利弊,决定如下:

  • 对于现有的没有异常处理的项目,由于引入异常会牵连到所有相关代码。在跟以前未使用异常的代码集成时也将是个麻烦。引入带有会产生异常的新代码时相对来讲非常困难,应该继续保持不用异常。
  • 如果团队对异常的掌握比较好,在权衡利弊后,技术负责人可以决定使用异常。

使用异常时注意以下几点:

  • 制定合适的异常类型体系,只抛出这些类型的对象,不要抛出任意类型(比如整数、字符串之类的)
  • 按值抛出异常,按引用(或者常量引用)捕获异常
  • 不要到处捕获异常,只捕获和处理你能处理的异常
  • 不要用异常来报告代码逻辑错误,应该用 assert 或者类似的机制
  • 不要随意忽略异常,如果确实需要忽略,应当有明确的注释/日志说明
  • 不要用异常来代替正常的控制流,否则不但影响性能还会使人困惑
  • 确保代码的异常安全,比如用 RAII 技术来避免资源泄漏
  • 对于会抛出自定义异常的库,要在文档或者注释中明确说明

对于不用异常的项目:

  • 除了特殊的目标运行环境(比如不依赖操作系统的独立环境)外,不建议通过编译参数(比如 gcc 的 -fno-exceptions)关闭异常, 因为 C++ 标准库和一些第三方库依赖异常来报告错误。
  • 通常不显式处理 C++ 运行库和 C++ 标准库中抛出的异常(例如 bad_allocout_of_range 等)。
  • 如果用到了使用异常来报告错误的第三方库,可以捕获和处理异常,比如转为适当错误码在系统中传播,但是不要在自己的代码中抛出异常。

对于应用广泛的基础组件的客户端库部分,应该确保提供不用异常的接口,以适配大量不用异常的项目。

总之,异常并非简单易用,不是万灵单,也不是适用于所有的情况。要用好异常需要对其进行深入的理解。更多信息请阅读 C++ Core GuidelinesC++ FAQ 的异常和错误处理部分。

6.4.【可选】noexcept限定

在正确且切实有效时可以使用限定符 noexcept。

详细信息

noexcept 限定符用于指定某个函数是否会抛出异常。如果异常从某个被标记为 noexcept 的函数中被抛出,程序会通过 std::terminate 崩溃。

noexcept 运算符会对表达式进行编译期检查。如果一个表达式声明为不会抛出任何异常,则 noexcept 返回 true。

优点

在某些情况下,对移动构造函数使用 noexcept 进行限定能够提升性能。例如,在移动构造函数被限定为 noexcept 的情形下,std::vector::resize() 会对对象进行移动操作而非拷贝。

在允许使用异常的环境中,对函数使用 noexcept 进行限定能够使编译器对其进行优化。例如,如果编译器通过 noexcept 限定符获知函数不能够抛出异常,便无需为栈展开生成额外的代码。

缺点

在禁止使用异常的项目中,很难保证对 noexcept 限定符的使用是正确的,甚至很难定义什么是“正确的使用”。

删除已有的 noexcept 很难甚至不可能的。因为这样的行为消除了函数的调用者所依赖的由 noexcept 所提供的保证,且这种保证很难被检查到。

结论

如果 noexcept 切实地带来了性能提升,并且准确地表达了函数的语义,则允许使用 noexcept。即在这种情况下,如果从函数体中抛出了异常,就表明发生了严重的错误。通常 noexcept 对于移动构造函数能够带来性能上的提升,但如果开发者认为 noexcept 限定符对于其他的某些函数也能够提升性能,则应当与项目负责人进行讨论。

建议在异常被完全禁止的情况下无条件地使用 noexcept。否则,应当使用简单的准则判断是否使用 noexcept,即只通过几个规则就能够判断函数是否有可能抛出异常。这些规则可能包括对相关的操作是否可能抛出异常的类型特性检查(例如,对于移动构造的对象,有 std::is_nothrow_move_constructible 属性),或者在空间分配时可能抛出的异常检查(我们认为移动构造函数不应在空间分配失败时抛出异常)。另外,在很多情况下,代码应当将内存耗尽视作严重错误而非条件性的异常并试图将其恢复。对于其他可能的错误,应当优先考虑接口的简洁性,而非支持尽可能多的异常处理:与其为哈希函数可能抛出的异常编写复杂的 noexcept 语句,不如简单地在文档中将该函数声明为不支持异常的组件,并且无条件地限定为 noexcept。

6.5.【推荐】运行时类型识别

我们禁止使用 RTTI。

详细信息

定义:

RTTI 允许程序员在运行时识别 C++ 类对象的类型。它通过使用 typeid 或者dynamic_cast 实现。

优点:

RTTI 的标准替代 (下面将描述)需要对有问题的类的层级关系进行修改或重构。有时这样的修改并不是我们所想要的,甚至是不可取的,尤其是在一个已经广泛使用的或者成熟的代码中。

RTTI 在某些单元测试中非常有用。比如进行工厂类测试时,用来验证一个新建对象是否为期望的动态类型。RTTI对于管理对象和他们的 mock 对象中间的关系也很有用。

在考虑多个抽象对象时 RTTI 也很有用。例如:

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

缺点:

在运行时判断类型通常意味着设计问题。如果你需要在运行期间确定一个对象的类型,这通常说明你的类的层级关系是有缺陷的。

随意地使用 RTTI 会使你的代码难以维护。它使得基于类型的决策树或者 switch 语句散布在代码各处。如果以后要进行修改,你就必须要全部检查这些逻辑。

结论:

RTTI 有合理的用途但是容易被滥用,因此在使用时请务必注意。在单元测试中可以随意使用 RTTI,但是在其他代码中请尽量避免。尤其是在新代码中,使用 RTTI 前务必三思。如果你的代码需要根据不同的对象类型执行不同的行为的话,请考虑用以下的替代方案来查询类型:

  • 虚函数可以根据特定的子类类型的不同而执行不同代码。这样可以把类型识别的工作交给对象本身去处理。

  • 如果这一工作需要在对象之外完成,可以考虑使用双重分发的方案,例如使用访问者设计模式。这允许对象之外的一个特定的装置去使用内置的类型系统进行类型判断。

如果程序能够保证给定的基类实例实际上都是某个派生类的实例,那么就可以自由使用 dynamic_cast。在这种情况下,通常使用使用 staitc_cast 作为一种替代方案。

基于类型的决策树是一个很强的暗示,它说明你的代码已经偏离正轨了。

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

一旦在类的层级关系中加入新的子类,像这样的代码往往会出现错误。而且,一旦某个子类的属性改变了,你很难找到并修改所有受影响的代码块。 不要去手工实现一个类似 RTTI 的方案。反对 RTTI 的理由同样适用于这些方案,比如带类型标签的类继承体系。而且,这些方案会掩盖你的真实意图。

6.6.【必须】类型转换

使用 C++ 风格的类型转换,如 static_cast<float>(double_value),或者对数字类型的转换使用括号初始化,比如 int64 y = int64{1} << 42。不要使用类似于 int y = (int)xint y = int(x) 等转换方式(不过当你要调用一个构造函数的时候,这是被允许的)。

详细信息

定义:

C++ 采用了有别于 C 的类型转换机制,对转换操作进行了归类。

优点:

C 语言的类型转换问题在于模棱两可的操作;有时是在做强制转换 (如(int)3.5),有时是在做类型转换 (如 (int)"hello")。另外,括号初始化和 C++ 的类型转换通常可以避免这些歧义。并且,C++ 的类型转换在查找时更醒目。

缺点:

C++ 风格的类型转换语法非常的冗余。

结论:

不要使用 C 风格类型转换。在必须使用显示类型转换,应该使用 C++ 风格的类型转换作为替代。

  • 使用括号初始化进行数字类型的转换 (比如 int64{x})。这是最安全的实现方案,因为当转换会造成精度损失,将会导致编译错误。并且这种语法也非常简洁。
  • 使用 static_cast 作为值转换时等价于 C 风格转换的类型转换,或某个类指针需要显示的向上转换为父类指针时,或者当你需要显示的转换一个父类指针到子类指针时。对于后者,你必须保证要转换的对象确实是该子类的实例。
  • 使用 const_cast 去掉 const 限定符。
  • 使用 reinterpret_cast 进行指针类型和其他指针类型或整形之间不安全的相互转换。仅在你对所做一切了然于心时使用。并且,考虑使用 absl::bit_cast 作为替代方案。
  • 使用 absl::bit_cast 进行相同大小的不同类型之间的二进制转换(a type pun),比如将 double 强制转换为 int64。至于 dynamic_cast 参见运行时类型识别

6.7.【必须】流

在合适的时候使用流,并且保持 "简洁"。仅对表达值的类型重载 <<,并且仅写入对用户可见的变量,不要写入任何实现的细节。

详细信息

定义:

流是 C++ 中提供的对标准 I/O 的抽象,也是 <iostream> 头文件中最常被人举例的内容。流在我们的代码中广泛使用,大多数用于调试日志的打印和测试的诊断信息。

优点:

流运算符 <<>> 提供了简单易学、可移植、可复用、可扩展的格式化 I/O 接口。一个明显的区别就是 printf 并不支持 std::string,更不要说用户自定义的类型了,并且这非常的难以移植。同时 printf 迫使你在一系列差别细微的不同版本中选择合适的调用,并且引入了了大量的类型说明符。 流通过 std::cinstd::coutstd::cerrstd::clog 提供了最优的控制台 I/O 支持。C 接口同样可以如此,但是需要手动维护输入内容的缓存,这阻碍了 C 接口提供类似的操作。

缺点:

  • 流格式化可以通过对流的状态进行修改来设置。这些修改是持久化的,所以你的代码逻辑可能会被在流上发生的历史操作影响,除非你每次都将流重新设置为一个已知的状态。用户的代码不仅仅可以修改内部的状态,也同样可以通过注册机制增加新的状态变量或者行为。
  • 由于上述的原因,精确控制流输出的内容是非常困难的,而且代码和数据都可以影响到流的输出,并且流使用了运算符重载 (编译器可能会选择一个非期望的重载)。
  • 在国际化场景下使用流的 << 构建输出并不好,因为他把单词的顺序交由代码处理,并且流支持的本地化功能是有缺陷的。
  • 流的 API 非常的巧妙又复杂,所以程序员必须要有一定的经验才可以有效的使用它。
  • 解析非常多的 << 运算符重载是非常消耗编译器性能的。当在一个大型项目中普遍使用它时,它会消耗接近 20% 的解析和语法分析的时间。

结论:

仅在流是解决当前问题的最好工具时才使用流。典型的使用场景是当 I/O 是格式化的、本地的、对人类阅读友好的,并且以其他开发者为目标而不是终端用户时。与和你相关的代码保持统一,并且和整个代码仓库保持统计;当已经存在一个稳定的工具可以解决你的问题时,直接使用该工具作为替代。尤其是使用日志库输出调试信息相对使用 std::cerr 或者 std::clog 通常是一个更好的选择,同时使用 absl/strings 中的库或者使用其他类似的库通常也是一个相对于使用 std::stringstream 的更好的选择。

不要对外部用户或者不信任的数据使用流 I/O。而应该找到并使用一些合适的库来处理类似于国际化、本地化和安全性的问题。

如果你一定要使用流,不要使用流 API 中关于状态的部分 (除了错误状态),比如 imbue()xalloc()register_callback()。使用显示的格式化函数(详见 absl/strings)而不是流运算符或者格式化标志来控制格式化的细节,比如数字的进制,精度或者填充。

仅当对你的类型是用来表达值的时候才重载 << 运算符,并且 << 应该写出更加容易阅读的表达。不要在 << 输出中暴露内部细节;如果你需要打印对象的内部细节以便调试的话,使用一个单独的函数代替(比如 DebugString())。

6.8.【必须】前置自增和自减

对于迭代器和其他模板对象使用前缀形式(++i)的自增,自减运算符。

详细信息

定义:

对于变量在自增(++ii++)或自减(--ii--)后表达式的值又没有被用到的情况下,需要确定到底是使用前置还是后置的自增(自减)。

优点:

不考虑返回值的话,前置自增(++i)通常要比后置自增(i++)效率更高。因为后置自增(或自减)需要对表达式的值 i 进行一次拷贝。如果 i 是迭代器或其他非数值类型,拷贝的代价是比较大的。既然两种自增方式实现的功能一样,为什么不总是使用前置自增呢?

缺点:

在 C 开发中,当表达式的值未被使用时,传统的做法是使用后置自增,特别是在 for 循环中。有些人觉得后置自增更加易懂,因为这很像自然语言,主语(i)在谓语动词(++)前。

结论:

对简单数值(非对象),两种都无所谓。对迭代器和模板类型,使用前置自增(自减)。

6.9.【必须】const 用法

在 API 里,只要合理,就应该使用 const。在一些场景下,使用 constexprconst 更好。

详细信息

定义:

在声明的变量或参数前加上关键字 const 用于指明变量值不可被篡改(如 const int foo )。类成员函数加上 const 限定符表明该函数不会修改类成员变量的状态(如class Foo { int Bar(char c) const; };)。

优点:

大家更容易理解如何使用变量。编译器可以更好地进行类型检测,也能生成更好的代码。人们对编写正确的代码更加自信,因为他们知道所调用的函数被限定了能或不能修改变量值。即使是在无锁的多线程编程中,人们也知道什么样的函数是安全的。

缺点:

const 有侵入性:如果你向一个函数传入 const 变量,函数原型声明中也必须对应 const 参数(否则变量需要 const_cast 类型转换),在调用库函数时显得尤其麻烦。

结论:

我们强烈建议在 API(如:函数参数、方法、以及非局部变量)中使用 const,只要正确有意义。这能为一个操作可以更改哪些对象提供了编译器验证的保障。拥有一致且可靠的方式来区分读取和写入对于编写线程安全代码至关重要,并且在很多其他场景下也很有用。尤其是:

  • 如果函数不会修改传你入的引用或指针类型参数,该参数应声明为 const
  • 按值传入的函数参数,const 对调用者没有影响,因此不建议在函数声明中使用。为了保持声明和定义一致,也不建议在函数定义中使用。参见 TotW#109
  • 尽可能将方法声明为 const,除非它们改变了对象的逻辑状态(或使用户能够修改状态,例如返回非const引用,但这很少见),否则不能安全地并发调用它们。

不鼓励和也不反对对局部变量使用 const 修饰。

一个类的所有 const 方法应该可以安全地相互并发调用。如果不行,则必须将该类明确记录为“线程不安全”。

const 的位置:

有人喜欢 int const *foo 形式,不喜欢 const int* foo,他们认为前者可读性更好因为更一致:遵循了 const 总位于其描述的对象之后的一致性原则。但是大多数 const 表达式只有一个 const,因此这个一致性原则不适用于几乎没有深度嵌套的指针表达式的代码库。将 const 放在首位更具可读性,因为在自然语言中形容词(const)是在名词(int)之前.

这是说, 我们提倡但不强制 const 在前。但要保持代码的一致性!

6.10.【推荐】constexpr 用法

使用 constexpr 来定义真正的常量,或实现常量初始化。

详细信息

定义:

变量可以被声明成 constexpr 以表示它是真正意义上的常量,即在编译/链接时就固定不变了。函数或构造函数也可以被声明成constexpr,以用来定义 constexpr 变量。

优点:

使用 constexpr 定义浮点型的常量,不用再依赖字面值了;也可以定义用户自定义类型的常量;甚至也可以定义函数调用所返回的常量。

缺点:

若过早把对象标记成 constexpr,将来又要把它改为常规对象时,挺麻烦的;对函数和构造函数中允许行为过早做constexpr 限制可能会导致定义模糊晦涩。

结论:

constexpr 修饰使接口的恒定部分的规范更可靠。使用constexpr修饰真正的常量以及支持其修饰的函数。避免为了使用 constexpr 导致函数定义复杂化。不要使用constexpr来强制内联。

6.11.【推荐】整型

C++ 内建整型中,仅使用 int。 如果程序中需要不同大小的变量,可以使用 <stdint.h> 中定义的精确宽度的整数类型,如 int16_t。如果您的变量可能不小于 2^31(2GiB),就用 64 位整数类型比如 int64_t。 此外要留意,哪怕您的值并不会超出 int 所能够表示的范围,在计算过程中也可能会溢出。所以拿不准时,干脆用更大的类型。

在UE4 C++规范中,对于数据类型有特定的规则,详情见UE4 C++规范

详细信息

定义:

C++ 没有指定整型的大小。通常人们假定 short 是 16 位,int 是 32 位,long 是 32 位,long long 是 64 位。

优点:

保持声明统一。

缺点:

C++ 中整型大小因编译器和体系结构的不同而不同。

结论:

<stdint.h> 定义了 int16_tuint32_tint64_t 等整型,在需要确保整型大小时可以使用它们代替 shortunsigned long long 等。在 C 整型中, 只使用 int。在合适的情况下,推荐使用标准类型如 size_tptrdiff_t

如果已知整数不会太大,我们常常会使用 int,如循环计数。在类似的情况下使用原生类型 int。你可以认为 int 至少为 32 位,但不要认为它会多于 32 位。如果需要 64 位整型,用 int64_tuint64_t

对于大整数,使用 int64_t

不要使用 uint32_t 等无符号整型,除非表示一个位组而不是一个数值,或是定义二进制补码溢出。尤其是不要为了指出数值永不会为负,而使用无符号类型。相反,应该使用断言来保护数据.

如果您的代码涉及容器返回的大小(size),确保其类型足以应付容器各种可能的用法。拿不准时,类型越大越好。

小心整型类型转换和整型提升,总有意想不到的后果。

关于无符号整数:

无符号整数非常适合表示位组和模运算。由于历史原因,C++ 标准还使用无符号整数来表示容器的大小,虽然标准主体的很多成员认为这是一个错误,但实际上目前无法修复。无符号算术不能描述简单整数的行为,而是描述模运算(围绕高位溢出/低位溢出),这一事实意味着编译器无法诊断出大量的错误,也会阻碍优化。

也就是说,有符号和无符号整数类型的混用会引发一大堆问题。我们可以提供的最佳建议是:尝试使用迭代器和容器而不是指针和长度(sizes),尝试不混合有符号性,并避免使用无符号类型(表示位组或模运算除外)。不要仅使用无符号类型来保障变量是非负数。

6.12.【推荐】64 位下的可移植性

代码应该对 64 位和 32 位系统友好。尤其要注意打印、比较和结构对齐的问题。

详细信息
  • 对于某些整数类型,正确的可移植 printf()转换描述符所依赖的宏扩展(来自 的 PRI 宏)非常难用。除非针对特殊情况没有更合理的选择,否则请尝试避免使用甚至升级依赖于 printf 系列的 API。更好的方式是使用类型安全的整数格式库,例如 StrCatSubstitute 进行快速简单转换,或使用 std::ostream

    不幸的是,PRI 宏是标准位宽整数类型(即 int64_tuint64_tint32_tuint32_t 等)间转换的唯一可移植方式。尽可能避免将标准位宽整数类型的参数传递给基于 printf 的 API。请注意,可以使用具有 printf 专用长度修饰符的整数类型,例如 size_t(z)ptrdiff_t(t)maxint_t(j)

  • 请记住,sizeof(void *) != sizeof(int)。如果需要指针大小的整数,请使用 intptr_t

  • 您可能需要注意结构对齐,特别是对于存储在磁盘上的结构。默认情况下,具有 int64_t / uint64_t 成员的任何类/结构在 64 位系统上最终都是 8 字节对齐的。如果要在 32 位和 64 位代码之间共享这样的磁盘数据结构,则需要确保它们在对齐方式上保持一致。大多数编译器都提供了更改结构对齐方式的方法。对于gcc,您可以使用__attribute__((packed))。 MSVC 提供了#pragma pack()__declspec(align())

  • 根据需要使用大括号初始化来创建 64 位常量。例如:

    int64_t my_value{0x123456789};
    uint64_t my_mask{3ULL << 48};

6.13.【必须】预处理宏

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

详细信息

宏意味着你和编译器看到的代码是不同的,这可能会导致异常行为,尤其因为宏具有全局作用域。

值得庆幸的是,C++ 中,宏不像在 C 中那么必不可少。以往用宏展开性能关键的代码,现在可以用内联函数替代。用宏表示常量可被 constconstexpr 变量代替。用宏 "缩写" 长变量名可被引用代替。用宏进行条件编译...这个,千万别这么做,会令测试更加痛苦(#define防止头文件重包含当然是个特例)。

宏可以做一些其他技术无法实现的事情,在一些代码库(尤其是底层库中)可以看到宏的某些特性(如用 # 字符串化,用 ## 连接等等)。但在使用前,仔细考虑一下能不能不使用宏达到同样的目的。

极少数情况下,用宏能够提供极大的便利性,比如:

void PrintLog(const char* file, int line, const char* format, ...);
#define PRINT_LOG(format, ...) PrintLog(__FILE__, __LINE__, format, ##__VA_ARGS__)

PRINT_LOG("hello world");
PRINT_LOG("hello %s", "world");

用宏能够自动获取文件名和行号,如果不用宏就很繁琐了。

下面给出的用法模式可以避免使用宏带来的问题;如果你要用宏,尽可能遵守:

  • 不要在 .h 文件中定义宏。
  • 在马上要使用时才进行 #define,使用后要立即 #undef
  • 不要只是对已经存在的宏使用 #undef,选择一个不会冲突的名称。
  • 不要试图使用展开后会导致 C++ 构造不稳定的宏,不然也至少要附上文档说明其行为。
  • 不要用 ## 处理函数,类和变量的名字。

6.14.【必须】0nullptrNULL

整数用 0,浮点数用 0.0,指针用 nullptrNULL,字符用 '\0'

在 UE4 C++ 规范中也对 nullptr 的使用做了特殊的说明,详情可参见UE4 C++规范

详细信息

整数用 0,浮点数用 0.0,这一点是毫无争议的。

对于指针(地址值),到底是用 0NULL 还是 nullptr

  • C++11 项目用 nullptrnullptr 具备类型安全,而 0NULL 通常都是整型,后者在函数重载和模板类型推导时会出问题。
  • C++03 项目用 NULL,它看起来更像指针。
  • 实际上,一些 C++ 编译器对 NULL 的定义比较特殊,可以输出有用的警告,特别是 sizeof(NULL) 就和 sizeof(0) 不一样。

字符用 '\0',不仅类型正确而且可读性好。

6.15.【必须】sizeof

尽可能用 sizeof(varname) 代替 sizeof(type)

详细信息

使用 sizeof(varname) 的好处是当代码中变量类型改变时会自动更新。

sizeof(type) 适用于不涉及任何变量的代码,比如处理来自外部或内部的数据格式,这时用变量会很不方便。

Struct data;
memset(&data, 0, sizeof(data));

// warning
memset(&data, 0, sizeof(Struct));

if (raw_size < sizeof(int)) {
    LOG(ERROR) << "compressed record not big enough for count: " << raw_size;
    return false;
}

6.16.【推荐】类型推导

推荐使用类型推导仅有2种情况,能够让不熟悉项目代码的人更清晰地读懂代码,或者能够使代码更安全。 仅因为显式写出类型较为繁杂,应避免使用类型推导。

详细信息

有些情况下,C++ 允许(甚至要求)类型由编译器来推导,而不是显式指定类型:

函数模板参数推导

调用函数模板时,可以不用显式指定模板参数,编译器从参数类型自动推导。

template <typename T>
void f(T t);
f(0);  // Invokes f<int>(0)

auto 变量声明

声明变量的时候,可以用 auto 代替变量类型。 变量类型由编译器从变量的初始化表达式推导而来,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 来修饰,并且可以作为指针或引用类型的一部分,但是不能用于模板参数。

在变量声明的场合,若用 decltype(auto) 代替 auto,推导出的类型等价于将 decltype 应用于初始化表达式。

函数返回值类型推导

autodecltype(auto) 也可以用于函数返回类型。返回类型由编译器从函数中的 return 语句推导,推导规则与变量声明一样:

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

Lambda 表达式 的返回类型可以用与上面同样的规则推导出来,但需要省略返回类型而不是显式用 auto。 容易混淆的是,尾置返回也是用 auto 表示函数返回类型,但是并不依赖类型推导,仅是显式指定返回类型的另一种语法。

泛型 Lambda

可以用 auto 代替 lambda 表达式中的一个或多个参数类型。这会导致 lambda 表达式的 operator() 是一个函数模板而不是普通函数,每一个 auto 参数对应一个独立的模板参数:

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

Lambda 初始化捕获

lambda 表达式支持初始化捕获,不仅可以捕获已存在的变量,还可以声明新的变量:

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

这种语法不允许指定变量类型,变量类型是由 auto 变量的推导规则推导而来。

类模板参数推导

请参考 类模板参数推导

结构化绑定

使用 auto 声明 tuplestructarray 时,可以为单个元素指定变量名,而不是为整个对象指定变量名,这些变量名叫做 结构化绑定(structured bindings),整个声明叫做 结构化绑定声明(structured binding declaration)。这种语法无法指定整个对象或者每一个元素的类型。

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

此处的 auto 也可以用 const&&& 来修饰,要注意的是这些修饰从技术上应用于对应的匿名 tuple/struct/array,而不是每一个独立的绑定变量。用来决定每一个绑定变量类型的规则有一点复杂,结果通常都能符合直觉,除了绑定类型通常不会是引用即使声明的时候是用引用(但其实无所谓因为它们通常用起来像引用)。

(这些总结忽略了很多细节和警告,可以从相关链接查看详情)

优点:

  • C++ 类型名有时很冗长,特别是涉及模板或命名空间的时候
  • 当一个 C++ 类型名在一个声明或一小段代码中重复出现的时候,很可能会降低代码可读性
  • 有时候让类型被推导出来会更安全,因为能避免意外的拷贝或类型转换

缺点:

当类型推导依赖别处的代码的时候,显式指定类型会让代码更清晰。像下面的表达式:

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

如果 y 的类型不明确或者 y 的声明远在几百行之外,想知道 i 的类型并非易事。

程序员必须理解 autoauto& 的区别,否则会导致不必要的拷贝。

如果 API 接口中使用了被推导出的类型,程序员对值的改变可能会导致类型改变,这会导致非预期的 API 接口变化。

结论:

最基本的规则是:

仅在能够让代码更清晰或更安全的情况下使用类型推导,并且不要仅仅为了避免编写显式类型的不便。 当判断代码是否更清晰时,记住读代码的人可能不在你的团队中,或不熟悉这个项目,你觉得清晰的代码对其他人可能并不清晰。 比如,可以假设 make_unique<Foo>() 的返回值类型是明确的,但 MyWidgetFactory() 的返回值类型可能不明确。

上述规则适用于所有形式的类型推导,但细节上还有些不同,详见下面的章节。

函数模板参数推导

函数模板的参数推导几乎总是没问题的。当涉及函数模板时,类型推导是可预期的默认行为,因为函数模板看起来就像是有无数个重载的普通函数。因此,函数模板几乎总是经过精心设计,以使函数模板参数推导清晰、安全或无法编译。

局部变量类型推导

对于局部变量,可以用类型推导来消除明显的或不相关的类型信息,这样代码会清晰些,别人阅读代码的时候也容易聚焦到更有意义的部分:

std::unique_ptr<WidgetWithBellsAndWhistles> widget_ptr =
    absl::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, 0> numbers = {4, 8, 15, 16, 23, 42};
auto widget_ptr = absl::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2);
auto it = my_map_.find(key);
std::array numbers = {4, 8, 15, 16, 23, 42};

类型有时能提供有用的信息,例如上面的示例:很明显,it 的类型是一个迭代器,在许多情况下,容器的类型甚至是 key 的类型都无关紧要,但是 value 的类型可能有用。在这种情况下,可以明确写出局部变量的类型来让代码更清晰:

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

如果类型是模板实例,可以使用类模板参数推导来省略模板参数。但是,这种情况带来的好处有限。请注意,类模板参数推导也要遵循类模板参数推导

如果有更简单的方式,不要使用 decltype(auto) ,因为它是一个相当晦涩的特性,代码没那么清晰。

返回值类型推导

仅在以下情况下使用返回值类型推导(适用于函数和 lambda 表达式):

  • 函数体代码较少且只有少量 return 语句,不然无法一眼看出返回类型是什么;
  • 函数作用域比较小,如 static 函数或匿名 lambda 表达式;

定义在头文件中的全局函数永远不要使用返回值类型推导。

参数类型推导

auto 作为 lambda 表达式的形参时要谨慎,因为实际类型取决于调用 lambda 的代码,而不是 lambda 的定义。

因此,为使代码更清晰应当尽量显式指定类型,除非:

  • 调用 lambda 的代码和 lambda 的定义放在一起(很容易看到两者);
  • 将 lambda 传给众所周知的接口,很容易看出是用什么参数调用(例如上面的 std::sort 示例)。

Lambda 初始化捕获

初始化捕获在很大程度上取代了类型推导,详情请参考 Lambda 表达式

结构化绑定

与其它形式的类型推导不同,结构化绑定为一个对象的每个元素指定有意义的变量名,实际上可以给读者提供更多信息。这意味着结构化绑定声明可以提供比显式类型或 auto 更好的可读性。尤其是当对象是一个 pairtuple 时(参考上面的例子),因为他们没有有意义的字段名。要注意的是通常情况下不要使用 pairtuple,除非遇到类似 insert 这种 API 你不得不用。

如果要绑定的对象是一个 struct,提供一个更适合你用法的变量名有时候会比较有用,但请记住其他人读你代码的时候可能没那么清晰。当指定的变量名和字段名不一致时,建议使用注释来标识对应的字段名:

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

与函数参数的注释一样,这有助于工具来检测是否弄错了字段顺序。

6.17.【必须】类模板参数推导

仅当某个类模板明确表示支持类模板参数推导的时候,才使用该特性。

详细信息

在以下情况下会发生类模板参数推导(通常缩写为 CTAD):

  • 所声明的变量类型为模板,且没有提供模板参数(甚至没有空尖括号 <>
std::array a = {1, 2, 3};  // `a` is a std::array<int, 3>

编译器使用类模板的推导指引(deduction guides)从初始化表达式推导出参数类型,推导可以是显式的或隐式的。

显式的推导指引看起来就像有尾置返回类型的函数声明,除了开头没有 auto,函数名字就是模板名字。例如,上面的示例依赖于 std::array 的推导指引:

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

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

当声明一个依赖 CTAD 的变量时,编译器使用构造函数的重载解析规则来选择合适的推导指引,推导指引的返回类型就是变量的类型。

优点:

  • CTAD 有时候允许省略一些样板代码。

缺点:

  • 从构造函数生成的隐式推导指引可能有非预期行为,或完全不正确。对于在 C++17 引入 CTAD 之前编写的构造函数尤其有问题,因为这些作者根本不知道引入 CTAD 后会不会有问题。此外,添加显式的推导指引来修复这些问题可能会破坏现有依赖隐式推导指引的代码。

  • CTAD 还有很多和 auto 相同的缺点,因为他们都是从初始化表达式推导所有或部分变量类型的机制。CTAD 相比 auto 确实给了读者更多信息,但是也没有明显的提示让读者知道部分类型信息被省略了。

结论:

  • 不要使用 CTAD,除非模板的维护者提供至少一个显式的模板指引来支持 CTAD(可以假定 std 命名空间的所有模板都支持)。如果可以的话,应该由编译器强制执行警告。

  • 使用 CTAD 还必须遵循类型推导的通用规则。

6.18.【必须】Lambda 表达式

  • 如果出现以下情况的,优先考虑使用 Lambda 表达式 (CppCoreGuidelines F.50);

    • 需要捕获局部变量的,优先使用 Lambda 表达式,而不要使用 std::bind 或 仿函数;
    • 如果函数需要重载或模板函数的,不要使用 Lambda 表达式;
    • 如果函数仅在局部使用且短小(一眼就可以看出是干什么),优先使用 Lambda 表达式,而不是全局静态函数。
  • 对于局部使用的 Lambda (非常确定的在何时会回调的 Lambda,例如 std::find_if),优先采用按引用捕获非值类型局部变量 (CppCoreGuidelines F.52);

  • 对于非局部使用的 Lambda (不确定何时会调回调 Lambda 或 Lambda 执行顺序和主调方有数据竞争,例如 std::thread),避免采用按引用捕获 (CppCoreGuidelines F.53);

  • 尽量避免使用默认捕获;

    • 如果捕获的 this,禁止使用默认捕获 (CppCoreGuidelines F.53);
    • 如果某个 Lambda 表达式并未捕获任何局部变量,禁止添加默认捕获;
    • 仅在 Lambda 函数体较短,或生命周期比捕获的内容短时,才应该考虑使用默认捕获;
详细信息

定义:

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

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

这允许两种方式捕获闭包作用域的变量:一是使用名字显式捕获,二是使用隐式默认捕获。显式捕获要求列出所有变量,并指出以值或引用的形式捕获:

int weight = 3;
int sum = 0;
// 按值捕获 `weight`,按引用捕获 `sum`。
std::for_each(v.begin(), v.end(), [weight, &sum](int x) {
  sum += weight * x;
});

默认捕获的方式会隐式捕获 Lambda 函数体内引用的所有变量(如果类成员被使用,也会包括 this,当您需要捕获 this时,禁止使用默认捕获):

const std::vector<int> lookup_table = ...;
std::vector<int> indices = ...;
// 按引用捕获 `lookup_table`,按 `lookup_table` 中关联的值,排序 `indices`。
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 捕获的变量可能导致悬垂指针错误,尤其在 lambda 离开当前作用域的场景。
  • 默认按值捕获也可能导致悬垂指针错误。因为捕获指针不会深拷贝,所以和按引用捕获一样,会遇到同样的生命周期问题。尤其在隐式使用 this,导致按值捕获 this 的场景下,使人困惑。
  • 捕获会声明新的变量(不管是不是初始化捕获),但和其他变量声明的语法看起来不一样。尤其是不需要指出变量类型,甚至不用写 auto(即使初始化捕获能间接指出,例如类型转换),从而难以用声明辨别。
  • 初始化捕获基于类型推导,不仅和 auto 有着相同的缺陷,甚至还没有任何类型推导的迹象。
  • Lambda 可能会被滥用;层层嵌套的匿名函数让代码难以理解。

结论:

  • 适当使用 lambda 表达式,并遵守格式规范
  • 如果 lambda 表达式中没有捕获任何变量,禁止添加默认捕获。
  • 我们不推荐使用默认捕获,所有需要捕获的变量都应该在捕获列表中声明是按值捕获或引用捕获。除非遇到以下状况,可以适当考虑使用默认捕获
    • 确定是局部使用的而且函数非常短小精炼的(例如:std::remove_if(vec.begin(), vec.end(), [&](const T &t) { return t.key == compared_key; })
    • 确定 lambda 表达式是非常明确含义的。(如果某个 lambda 表达式经常需要改动导致捕获列表有很强的不确定性,不要使用默认捕获)

之所以提倡避免使用默认捕获原因如下:

  1. 某些时候就算使用默认等值捕获,还是会产生歧义,例如
struct Foo {
  int bar = 0;
  int bas() {
    auto inc = [=]() {
      bar++;  // 这里等效是 this->bar++ this 被 = 定义为默认捕获
      return bar;
    };
    return inc();
  }
};

int main() {
  Foo foo;
  std::cout << foo.bar << " ";
  std::cout << foo.bas() << " ";
  std::cout << foo.bar << std::endl;
}
// 实际结果:0 1 1
// 你以为的:0 1 0

如果修改为,直接显示捕获 this 这样就不会产生歧义了。但很有可能是某位程序人不太了解 = & 的使用场景,而导致直接填上默认捕获,从而使其编译器编译通过。

struct Foo {
  int bar = 0;
  int bas() {
    auto inc = [this]() {
      bar++;  // bar 是 this 的成员变量 this->bar++
      return bar;
    };
    return inc();
  }
};

int main() {
  Foo foo;
  std::cout << foo.bar << " ";
  std::cout << foo.bas() << " ";
  std::cout << foo.bar << std::endl;
}
  1. 对于代码的健壮性而言,不定义默认捕获会让未来的修改者非常小心的使用捕获变量,当在 lambda 包体中添加一个捕获变量时必须考虑到此变量在表达式中声明周期,而不是直接使用了事,如果一个函数很大几率会被未来修改,请不要使用默认捕获。
{
  int a = 100;
  foo([=]() { return a + 1; });
}

之后修改为。

{
  int a = 100;
  std::map<int, int> some_huge_map;
  // 初始化一个大量的 some_huge_map
  foo([=]() { return some_huge_map[a] + 1; });  // 糟糕:没注意到这里是等值拷贝,实际执行会花费大量时间拷贝
}

如果刚开始的第一版就不定义默认捕获,那么最终修改者也会按照下述方式修改

{
  int a = 100;
  std::map<int, int> some_huge_map;
  // 初始化一个大量的 some_huge_map
  foo([a, &some_huge_map]() { return some_huge_map[a] + 1; }); // 噢:我需要想到some_huge_map是按引用拷贝比较好
}
  • 仅在捕获了实际闭包作用域的变量时,才进行捕获。不要使用初始化捕获引入新变量名,或修改已有变量名的含义。应该照常定义新变量,然后捕获它;或是不用 lambda 的简短写法,改用显式的函数对象。
  • 对于参数和返回值规范,请参考类型推导
  • 对于非局部使用的(包括被返回的,在堆上存储的,或者传递给别的线程的)lambda,避免采用按引用捕获

指向局部对象的指针和引用不能超出它们的作用域而存活。按引用捕获的 lambda 恰是另外一种保存指向局部对象的引用的地方,因而当它们(或其副本)存活超出作用域的话,也不应该这样做。如下错误示例

{
  int local = 42;

  // 需要局部对象的引用。
  // 注意,当程序离开作用域时,
  // 局部对象不再存在,因此
  // process() 的调用将带有未定义行为!
  thread_pool.queue_work([&local] { process(local); });
}

您应该修改为

{
  int local = 42;

  // 需要局部对象的副本。
  // 由于为局部变量建立了副本,它将在
  // 函数调用的全部时间内可用。
  thread_pool.queue_work([local] { process(local); });
}

6.19.【推荐】模板元编程

避免使用复杂的模板元编程。

详细信息

定义:

模板元编程是指:利用 C++ 模板实例化机制的图灵完备性,在类型域内进行编译期计算的一系列编程技巧。

优点:

模板元编程能灵活实现类型安全的、高性能的接口。没有它,Google Teststd::tuplestd::function 和 Boost.Spirit 等设施都无法实现。

缺点:

  • 模板元编程所使用的技巧对于 C++ 不熟练的人来说,比较晦涩难懂。复杂的模板代码可读性较差,而且难以调试和维护。
  • 模板元编程常常导致编译错误信息不友好:即使接口很简单,一旦用户遇到错误,复杂的实现细节会出现在错误信息里。
  • 模板元编程会干扰重构工具的大规模重构。首先,模板代码会在很多上下文中展开,很难确保重构全部涉及;其次,有些重构工具只对已经做过模板展开的代码 AST 生效。因此,重构工具对模板原始代码无效,很难找出哪些需要重构。

结论:

  • 模板元编程往往能实现更简洁、更易用的接口,但是有时候也适得其反。最好只在少量的底层组件上使用,因为大量的使用会扩散额外的维护负担。
  • 在使用模板元编程或其他复杂的模板技巧时,请再三考虑:考虑一下你们团队成员的平均水平是否能够读懂并且能够维护你写的模板代码;或者一个非 C++ 程序员和偶尔看一下代码的人,是否能够读懂这些错误信息或者跟踪函数的调用流程。如果你使用递归的模板实例化、类型列表、元函数、表达式模板,或依赖于 SFINAE、sizeof 等技巧检查函数重载,那么这说明你用了太多的模板。
  • 如果使用模板元编程,应该考虑尽可能的最小化并隔离复杂的部分。最好隐藏元编程的实现细节,给用户暴露可读的接口;并确保技巧性代码有详细的注释。注释里应该包含:代码如何使用,以及“生成”的代码是什么样的。还要额外注意:在用户错误使用时,输出更人性化的错误信息。出错信息也是接口的一部分,所以代码应该做到:错误信息易于理解,并且用户能知道如何修改这些错误。

6.20.【可选】Boost

只使用 Boost 中已经被接受的库。

详细信息

定义:

Boost 库集 是一个流行的、经过对等评审的、免费的、开源的 C++ 库集。

优点:

Boost 的代码质量普遍较高,可移植性好,填补了 C++ 标准库很多空白(例如类型萃取和更好的绑定器)。

缺点:

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

结论:

为了保持较高的可读性,易于所有贡献者阅读和维护代码,我们只允许被接受的 Boost 功能的子集。目前允许使用以下库:

我们正在积极考虑增加其它 Boost 特性,所以列表未来可能扩充。

6.21.【推荐】std::hash

不用特化 std::hash 模板。

详细信息

定义:

std::hash<T> 是计算类型 T 散列键值的函数对象,如果不显式指定其他散列函数,就被用于 C++ 11 散列容器。例如,散列映射 std::unordered_map<int, std::string> 使用 std::hash<int> 计算它的键值;而 std::unordered_map<int, std::string, MyIntHash> 用的是 MyIntHash 计算。

std::hash 已经为以下类型定义:所有整型、浮点型、指针、枚举类型和一些标准库类型(例如 stringunique_ptr)。还可以通过模板特化,为自定义类型进行定义。

优点:

std::hash 简单易用,因为不需要为它显式命名。特化 std::hash 是指定类型的散列计算的标准方法,也是教材的内容和新人期望的内容。

缺点:

std::hash 难以特化,因为它需要大量样板代码,而且需要实现两个部分:一是识别散列的输入,二是执行散列算法。类型的作者必须实现前者,但不一定且不需要掌握实现后者的专业知识。这么做风险很高,因为低质量的散列函数可能出现安全漏洞,容易受到散列洪泛攻击

即使是 C++ 专家,正确特化复合类型的 std::hash 也是非常困难的,因为实现不能递归调用数据成员的 std::hash。高质量的散列算法维护着大量的内部状态,而将状态规约到函数返回的 size_t 往往是计算中最慢的部分,所以这个操作不能重复进行。

因此,std::hash 不适用于 std::pairstd::tuple,并且语言不允许我们对其进行扩展。

结论:

仅在 std::hash 可以“开箱即用”的时候使用它,不用为其他类型特化模板。如果需要在散列表中使用 std::hash 不支持的键值,考虑使用旧款的散列容器(例如 hash_map);他们使用其他的默认散列函数,不受影响。

如果使用标准的散列容器,应该为键值类型指定自定义散列函数,例如:

std::unordered_map<MyKeyType, Value, MyKeyTypeHasher> my_map;

咨询类型的作者是否已有能直接使用的散列函数;否则让他们做一个,或是自己做一个。

6.22.【必须】其他 C++ 特性

Boost 中的一样,一些现代的 C++ 扩展也存在鼓励妨碍可读性的编码实践。例如,删除可能对读者有帮助的已检查冗余(例如类型名称),或鼓励模板元编程。其他一些扩展与现有机制可以提供的能力相重复,这可能会导致混乱和转换成本。

因此,除了本文其他的代码规范之外,禁止使用以下 C++ 特性:

  • 编译期有理数(<ratio>),因为会导致接口风格严重依赖于模板。
  • 头文件 <cfenv><fenv.h>,因为许多编译器不能稳定支持。
  • 头文件 <filesystem>,因为没有充分的测试支持,而且有天然的安全风险。

6.23.【推荐】非标准扩展

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

详细信息

定义:

编译器支持许多非标准的 C++ 扩展。例如 GCC 的 __attribute__、内建函数 __builtin_prefetch、结构体指定初始化(例如 Foo f = {.field = 3})、内联汇编代码、__COUNTER____PRETTY_FUNCTION__、复合表达式语句(例如 foo = ({ int x; Bar(&x); x })、变长数组和 alloca(),以及“Elvis 运算符a?:b

优点:

  • 非标准扩展提供了标准 C++ 没有的有用功能。例如有人认为结构体指定初始化比标准的构造函数可读性更强。
  • 对编译器的重要性能指导,只能通过扩展的方式来指定。

缺点:

  • 非标准扩展不适用于所有的编译器,使用非标准扩展会降低代码可移植性。
  • 即使所有目标编译器都支持的扩展,也常常缺乏良好的规范,并且在编译器之间可能存在细微的行为差异。
  • 非标准扩展增加了读者理解代码必须知道的语言特性。

结论:

不要使用非标准扩展。你可以使用用非标准扩展实现的可移植封装,只要这些封装以指定的项目级的可移植的头文件来提供。

6.24.【必须】别名

公共别名能提升 API 易用性,需要有明确的文档记录。

详细信息

定义:

创建其他实体别名的方法有很多:

typedef Foo Bar;
using Bar = Foo;
using other_namespace::Foo;

在新代码中,推荐使用 using 而不是 typedef,因为它能和 C++ 其他语法保持一致,并可以和模板一起使用。

和其他声明一样,头文件中声明的别名是公共 API 的一部分,除非放在函数定义、类的私有部分或显式标记的内部命名空间里。在上述区域或在 .cc 文件中的别名,都属于实现细节(因为用户代码不会引用他们),且不受当前规则约束。

优点:

  • 别名可以化简过长的、复杂的名字,增强可读性。
  • 别名只在一处命名,可以减少 API 多处的重复,使得以后修改类型更简单。

缺点:

  • 用户代码可以引用头文件里的别名,这增加了头文件 API 的实体数量,增加了复杂性。
  • 用户代码可能依赖于公共别名中不打算公开的细节,增加了修改的难度。
  • 可能会创建仅用于实现的公共别名,而不考虑其对 API 的影响,以及可维护性。
  • 别名可能会造成名字冲突的风险。
  • 别名为熟悉的结构起一个陌生的名字,降低可读性。
  • 别名可能导致 API 约定不清晰,不清楚是否保证:别名和它指向的类型相同,具有相同的 API,或是仅用于类型变窄转换。

结论:

不要把仅用于实现的别名放到公共 API 里;公共 API 里只放用户使用的别名。

在定义公共别名时,把新名字的意图写入文档,包括:是否保证始终和当前指向的类型相同,或是否打算使用更有限的兼容性。这让用户知道:是否可以将这些类型视为可替换类型,或者是否必须遵循更特定的规则,并且帮助实现部分保留别名更改的自由度。

不要在你的公共 API 中放置命名空间别名。(参考命名空间

例如,别名的文档指出用户应该如何使用:

namespace mynamespace {
// 存储实地测量的值。DataPoint 可能会从 Bar* 变成其他内部类型。
// 用户代码应该将其视为不透明指针。
using DataPoint = foo::Bar*;

// 一组测量值。仅用于方便使用的别名。
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

然而,只是为了局部方便使用的别名,可以用在函数定义、类的私有部分、显式标记的内部命名空间或 .cc 文件里:

// In a .cc file
using foo::Bar;

6.25.【推荐】所有权与智能指针

动态分配的对象最好有单一且固定的所有者,并通过智能指针传递所有权。

详细信息

定义

代码中直接把所有权传递给其它对象。

智能指针是一个通过重载 *-> 运算符以表现得如指针一样的类。智能指针类型被用来自动化所有权的薄记工作,来确保执行销毁义务到位。 std::unique_ptr是 C++11 新推出的一种智能指针类型,用来表示动态分配出的对象的独一无二的所有权;当 std::unique_ptr离开作用域时,对象就会被销毁。std::unique_ptr 不能被复制,但可以把它移动(move)给新所有者。std::shared_ptr 是一种表示对动态分配对象的共享所有权的智能指针类型。std::shared_ptr 可以被复制,对象的所有权由所有副本共同拥有,最后一个副本被销毁时,对象也会随着被销毁。

优点

  • 如果没有某种所有权逻辑,几乎不可能管理好动态分配的内存。
  • 传递对象的所有权,开销比复制来得小,如果可以复制的话。
  • 传递所有权也比"借用"指针或引用来得简单,毕竟它省去了两个使用者一起协调对象生命周期的需要。
  • 如果所有权逻辑条理,有文档且不紊乱的话,可读性会有很大提升。
  • 智能指针可以通过使所有权逻辑明确,自文档和明确无误来提高代码的可读性。
  • 智能指针可以消除手动所有权簿记,简化代码,避免一大类相关的错误。
  • 对于 const 对象,共享所有权可以是深度复制的一种简单有效的替代方法。

缺点

  • 所有权必须通过指针来表示和传递(不管是智能的还是原生的)。指针语义可要比值语义复杂得许多了,特别是在API里:这时不光要操心所有权,还要顾及别名,生命周期,可变性以及其它大大小小的问题。
  • 其实值语义的开销经常被高估,所以所有权传递带来的性能提升不一定能弥补可读性和复杂度的损失。
  • 如果 API 依赖所有权的传递,就会害得客户端不得不用单一的内存管理模型。
  • 如果使用智能指针,那么资源释放发生的位置就会变得不那么明显。
  • std::unique_ptr 使用 C++11 的移动语义表示所有权转移,该语义相对较新,可能会使某些程序员感到困惑。
  • 共享所有权可能是对细心设计的所有权的诱人替代方案,模糊了系统的设计。
  • 共享所有权需要运行期间的显式的薄记工作,开销可能相当大。
  • 某些情况下(例如循环引用),所有权被共享的对象永远不会被销毁。
  • 智能指针并不能够完全代替原生指针。

结论

如果必须使用动态分配,那么更倾向于将所有权保持在分配者手中。如果其他地方要使用这个对象,最好传递它的拷贝,或者传递一个不用改变所有权的指针或引用。 倾向于使用 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 代替它。

返回目录


7. 命名约定

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

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

7.1.【必须】通用命名规则

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

UE4 C++编码有其特定的命名规范,如类型名称前面要有一个标识类型的前缀,详情可参考UE4 C++命名规范

详细信息

说明

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

正例:

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 表示模板参数。

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

7.2.【必须】文件命名

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

C++ 文件在项目内要统一以 .cc.cpp 结尾,头文件以 .h 结尾。专门插入文本的文件则以 .inc 结尾,参见头文件自足

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

详细信息

说明

可接受的文件命名示例:

  • my_useful_class.cc
  • my-useful-class.cc
  • myusefulclass.cc
  • myusefulclass_test.cc // _unittest_regtest 已弃用。

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

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

7.3.【必须】类型命名

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

详细信息

说明

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

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

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

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

// 枚举
enum UrlTableErrors { ...

7.4.【必须】变量命名

变量 (包括函数参数) 和数据成员名一律小写,单词之间用下划线连接。类的成员变量以下划线结尾,但结构体的就不用,如:a_local_variable, a_struct_data_member, a_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. 类

7.5.【必须】常量命名

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

const int kDaysInAWeek = 7;
详细信息

说明

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

7.6.【必须】函数命名

常规函数使用大小写混合。对类成员变量的取值和设值函数也可以与变量名匹配,使用小写下划线命名,但不强制要求,应在同一个类内保持一致。

详细信息

说明

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

AddTableEntry()
DeleteUrl()
OpenFileOrDie()

(对于类和命名空间作用域中期望像函数一样使用的常量(比如函数对象和函数指针),也适用相同的命名规则。这样的常量虽然实际上是对象,但是用法和函数一样,实际是否是函数只是不重要的实现细节。)

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

7.7.【必须】命名空间命名

命名空间以小写字母命名。顶级命名空间的名字应该基于项目名称。另外,要注意避免嵌套命名空间的名字和常见的顶级命名空间的名字之间发生冲突。

详细信息

说明

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

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

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

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

7.8.【必须】枚举命名

枚举(包括作用域枚举和非作用域枚举)的命名应当和常量保持一致而不是。即使用kEnumName形式命名而不是ENUM_NAME形式。

对于非作用域枚举,还应当将枚举类型作为枚举名的前缀,以减少潜在的命名冲突。

示例如下:

enum class UrlTableErrors {
  kOk = 0,
  kOutOfMemory,
  kMalformedInput,
};

enum UrlParseError{
  kUrlParseErrorOk = 0,
  kUrlParseErrorInvalidCharacter,
  kUrlParseErrorOutOfMemory,
};
详细信息

说明

2021 年 1 月之前,我们允许采用的方式命名枚举值。由于枚举值和宏之间的命名冲突,直接导致了很多问题。由此,这里改为使用常量风格的命名方式。新代码应该仅使用常量风格。但是老代码不强制切换到常量风格,除非宏风格确实会产生编译期问题。

如果使用 C++ 11 之后的编译器开发,优先考虑使用 enum class,它可以提供更强的类型检测,并减少潜在的命名冲突。

关于enum class,在UE4 C++规范中也有相关描述,详情可参考UE4 C++规范

7.9.【必须】宏命名

一般情况下不推荐使用宏,优先考虑使用常量或函数,如果你一定要用,那么像这样命名:MY_MACRO_THAT_SCARES_SMALL_CHILDREN

详细信息

说明

参考预处理宏,通常不应该使用宏。如果不得不用,其命名全部由大写和下划线组成:

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

7.10.【推荐】命名规则的特例

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

说明

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

uinttypedef

bigposstructclass,参照 pos 的形式

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

LONGLONG_MAX:常量,如同 INT_MAX

返回目录


8. 注释

注释虽然写起来很痛苦,但对保证代码可读性至关重要。下面的规则描述了如何注释以及在哪儿注释。当然也要记住:注释固然很重要,但最好的代码应当本身就是文档。有意义的类型名和变量名,要远胜过要用注释解释的含糊不清的名字。

你写的注释是给代码读者看的,也就是下一个需要理解你的代码的人。所以慷慨些吧,下一个读者可能就是你!

8.1.【推荐】注释风格

使用 ///* */, 统一就好。

///* */ 都可以;但 // 常用。要在如何注释及注释风格上确保统一。

8.2.【推荐】文件注释

在每一个文件开头加上版权公告。 文件注释描述了该文件的内容。如果一个文件只声明、或实现、或测试了一个对象, 并且这个对象已经在它的声明处进行了详细的注释,那么就没必要再加上文件注释,除此之外的其他文件都需要文件注释。

详细信息

说明

法律公告和作者信息

参考样式:

// Copyright 2020 Tencent Inc.  All rights reserved.
//
// Author: qq@tencent.com (Emperor Penguin)
//
// 文件注释
// ...

Copyright 后可以加 (c) 变成 Copyright (c),年份是文件的初创年份,更改文件时不要更新。All rights reserved. 也可以另起一行。注意是 All rights(权利)而不是 right。

作者部分体现了你的荣誉,可以有多个,每行一个,如果你对代码做了非琐碎的修改,可以在下面追加自己的名字。如果你对原始作者的文件做了重大修改,请考虑删除原作者信息。

对外开源的每个文件都应该包含许可证引用。为项目选择合适的许可证版本(比如:Apache2.0,BSD,LGPL,GPL)。

文件内容

如果一个 .h 文件声明了多个概念,则文件注释应当对文件的内容做一个大致的说明,同时说明各概念之间的联系。一个一到两行的文件注释就足够了,对于每个概念的详细文档应当放在各个概念中,而不是文件注释中。

不要在 .h.cc 之间复制注释,这样的注释偏离了注释的实际意义。

8.3.【推荐】类注释

每个类的定义都要附带一份注释,描述类的功能和用法,除非它的功能相当明显。

详细信息
// Iterates over the contents of a GargantuanTable.
// Example:
//    GargantuanTableIterator* iter = table->NewIterator();
//    for (iter->Seek("foo"); !iter->done(); iter->Next()) {
//      process(iter->key(), iter->value());
//    }
//    delete iter;
class GargantuanTableIterator {
  ...
};

说明

类注释应当为读者理解如何使用与何时使用类提供足够的信息,同时应当提醒读者在正确使用此类时应当考虑的因素。

如果类有任何同步前提,请用文档说明。

如果该类的实例可被多线程访问,要特别注意文档说明多线程环境下相关的规则和常量使用。 如果你想用一小段代码演示这个类的基本用法或通常用法,放在类注释里也非常合适。

如果类的声明和定义分开了(例如分别放在了 .h.cc 文件中),此时描述类用法的注释应当和接口定义放在一起,描述类的操作和实现的注释应当和实现放在一起。

8.4.【推荐】函数注释

函数声明处的注释描述函数功能;定义处的注释描述函数实现。

详细信息

说明

函数声明

基本上每个函数声明处前都应当加上注释,描述函数的功能和用途。只有在函数的功能简单而明显时才能省略这些注释(例如:简单的取值和设值函数)。注释使用叙述式("Opens the file")而非指令式("Open the file");注释只是为了描述函数,而不是命令函数做什么。通常,注释不会描述函数如何工作,那是函数定义部分的事情。

函数声明处注释的内容:

  • 函数的输入输出。
  • 对类成员函数而言:函数调用期间对象是否需要保持引用参数,是否会释放这些参数。
  • 函数是否分配了必须由调用者释放的空间。
  • 参数是否可以为空指针。
  • 是否存在函数使用上的性能隐患。
  • 如果函数是可重入的,其同步前提是什么。

举例如下:

// Returns an iterator for this table.  It is the client's
// responsibility to delete the iterator when it is done with it,
// and it must not use the iterator once the GargantuanTable object
// on which the iterator was created has been deleted.
//
// The iterator is initially positioned at the beginning of the table.
//
// This method is equivalent to:
//    Iterator* iter = table->NewIterator();
//    iter->Seek("");
//    return iter;
// If you are going to immediately seek to another place in the
// returned iterator, it will be faster to use NewIterator()
// and avoid the extra seek.
Iterator* GetIterator() const;

但也要避免罗罗嗦嗦, 或者对显而易见的内容进行说明.下面的注释就没有必要加上“否则返回 false”, 因为已经暗含其中了:

// Returns true if the table cannot hold any more entries.
bool IsTableFull();

注释函数重载时,注释的重点应该是函数中被重载的部分,而不是简单的重复被重载的函数的注释。多数情况下,函数重载不需要额外的文档,因此也没有必要加上注释。

注释构造/析构函数时,切记读代码的人知道构造/析构函数的功能,所以“销毁这一对象” 这样的注释是没有意义的。你应当注明的是注明构造函数对参数做了什么(例如:是否取得指针所有权)以及析构函数清理了什么。如果都是些无关紧要的内容,直接省掉注释。析构函数前没有注释是很正常的。

函数定义

如果函数的实现过程中用到了很巧妙的方式,那么在函数定义处应当加上解释性的注释。例如:你所使用的编程技巧、实现的大致步骤、或解释如此实现的理由。 举个例子:你可以说明为什么函数的前半部分要加锁而后半部分不需要。

不要.h 文件或其他地方的函数声明处直接复制注释。简要重述函数功能是可以的,但注释重点要放在如何实现上。

8.5.【推荐】变量注释

通常变量名本身足以很好说明变量用途。某些情况下,也需要额外的注释说明。

详细信息

说明

类数据成员

每个类数据成员 (也叫实例变量或成员变量) 都应该用注释说明用途。如果有非变量的参数(例如特殊值、数据成员之间的关系、生命周期等)不能够用类型与变量名明确表达,则应当加上注释。然而,如果变量类型与变量名已经足以描述一个变量,那么就不再需要加上注释。

特别地,如果变量可以接受 NULL-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 tests cases that we run through in this regression test.
const int kNumTestCases = 6;

8.6.【推荐】实现注释

对于代码中巧妙的、晦涩的、有趣的、重要的地方加以注释。

详细信息

说明

代码前注释

巧妙或复杂的代码段前要加注释。比如:

// Divide result by two, taking into account that x
// contains the carry from the add.
for (int i = 0; i < result->size(); i++) {
  x = (x << 8) + (*result)[i];
  (*result)[i] = x >> 1;
  x &= 1;
}

行注释

比较隐晦的地方要在行尾加入注释,在行尾空两格进行注释。比如:

// If we have enough memory, mmap the data portion too.
mmap_budget = max<int64>(0, mmap_budget - index_->length());
if (mmap_budget >= data_size_ && !MmapData(mmap_chunk_bytes, mlock))
  return;  // Error already logged.

注意,这里用了两段注释分别描述这段代码的作用,和提示函数返回时错误已经被记入日志。

如果你需要连续进行多行注释,可以使之对齐获得更好的可读性:

DoSomething();                  // Comment here so the comments line up.
DoSomethingElseThatIsLonger();  // Two spaces between the code and the comment.
{ // One space before comment when opening a new scope is allowed,
  // thus the comment lines up with the following comments and code.
  DoSomethingElse();  // Two spaces before line comments normally.
}
std::vector<string> list{
                    // Comments in braced lists describe the next element...
                    "First item",
                    // .. and should be aligned appropriately.
"Second item"};
DoSomething(); /* For trailing block comments, one space is fine. */

函数参数注释

如果函数参数的意义不明显,考虑用下面的方式进行弥补:

  • 如果参数是一个字面常量,并且这一常量在多处函数调用中被使用,为了让它们保持一致,你应当用一个常量名让这一约定变得更明显,并且保证这一约定不会被打破。
  • 考虑更改函数的声明,让某个 bool 类型的参数变为 enum 类型,这样可以让这个参数的值表达其意义。
  • 如果某个函数有多个配置选项,你可以考虑定义一个类或结构体以保存所有的选项,并传入类或结构体的实例。这样的方法有许多优点,例如这样的选项可以在调用处用变量名引用,这样就能清晰地表明其意义。同时也减少了函数参数的数量,使得函数调用更易读也易写。除此之外,以这样的方式,如果你使用其他的选项,就无需对调用点进行更改。
  • 用具名变量代替大段而复杂的嵌套表达式。
  • 万不得已时,才考虑在调用点用注释阐明参数的意义。

比如下面的示例的对比:

// 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++ 的读者来说代码的行为都是不明显的。要假设读代码的人 C++ 水平比你高,即便他/她可能不知道你的用意:

你所提供的注释应当解释代码 为什么 要这么做和代码的目的,或者最好是让代码自文档化。

比较这样的注释:

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

和这样的注释:

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

自文档化的代码根本就不需要注释。上面例子中的注释对下面的代码来说就是毫无必要的:

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

8.7.【推荐】标点、拼写和语法

注意标点、拼写和语法,写得好的注释易读性更好。

说明

注释的通常写法是包含正确大小写和结尾句号的完整叙述性语句。大多数情况下,完整的句子比句子片段可读性更高。短一点的注释(比如代码行尾注释)可以随意点,但依然要注意风格的一致性。

虽然被别人指出该用分号时却用了逗号多少有些尴尬,但清晰易读的代码还是很重要的。正确的标点、拼写和语法对此会有很大帮助。

8.8.【推荐】TODO 注释

对那些临时的、短期的解决方案或已经够好但仍不完美的代码使用 TODO注释。

TODO 注释要使用全大写的字符串 TODO,在随后的圆括号里写上你的名字、邮件地址、bug ID、其它身份标识和与这一 TODO 相关的 issue。主要目的是让添加注释的人(也是可以请求提供更多细节的人)可根据规范的TODO 格式进行查找。添加 TODO 注释并不意味着你要自己来修正,因此当你加上带有姓名的 TODO 时,一般都是写上自己的名字。

// TODO(kl@gmail.com): Use a "*" here for concatenation operator.
// TODO(Zeke) change this to use relations.
// TODO(bug 12345): remove the "Last visitors" feature

如果加 TODO 是为了在“将来某一天做某事”,可以附上一个非常明确的时间("Fix by November 2005"),或者一个明确的事项("Remove this code when allclients can handle XML responses.")。

返回目录


9. 格式

每个人都可能有自己的代码风格和格式,但如果一个项目中的所有人都遵循同一风格的话,这个项目就能更顺利地进行。每个人未必能同意下述的每一处格式规则,而且其中的不少规则需要一定时间的适应,但整个项目服从统一的编程风格是很重要的,只有这样才能让所有人轻松地阅读和理解代码。

为了帮助你正确地编辑代码,我们提供了一些常见编辑器的配置文件。 如果要统一转换格式上不符合现有规范的旧代码,我们推荐使用 clang-format,这里提供了一个参考配置文件

9.1.【必须】行长度

每一行代码最大字符数为 120。

传统的代码一般限制到 80 列,随着大屏幕设备的普及,有必要适当提高。但是由于代码评审、双侧对比以及代码可读性的需要,依然不宜过高。

将最大字符数放宽到 120,对历史代码是兼容的。对于使用 80/100 字符的历史代码,不用修改,仍然是符合规范的。喜欢 80 字符的团队,可以继续使用 80 字符,还是符合规范的。

详细信息

我们收到过 80/100/120/140/160 等各种方案。因为争议非常大,所以我们进行了投票。根据大家投票结果,得票最高的是 120 字符。所以我们确定选择 120 字符。

优点

120 字符,比 80/100 字符能展示更多的内容,更有利于代码阅读。

缺点

120 字符,在笔记本上或竖屏显示器上进行双侧代码对比时,会超出屏幕宽度。

结论

每一行代码最大字符数为 120。

如果换行会影响可读性,那么注释行可以超过 120 个字符,这样可以方便复制粘贴。 例如,带有命令示例或 URL 的行可以超过 120 个字符。

原始字面量和字面常量可以超出 120 字符。

包含长路径的 #include 语句可以超出 120 字符。

头文件保护可以无视该原则。

9.2.【必须】非 ASCII 字符

尽量不使用非 ASCII 字符,使用时必须使用 UTF-8 编码。 非 ASCII 字符(中文等)只能用于注释当中,含有中文注释的代码文件必须使用 UTF-8 编码保存,不能有BOM头。 出于兼容性考虑,对于使用UTF8编码不能正确工作的 Windows 项目,允许使用UTF8 BOM。

详细信息

说明

即使是英文,也不应将用户界面的文本硬编码到源代码中,因此非 ASCII字符应当很少被用到。特殊情况下可以适当包含此类字符。例如,代码分析外部数据文件时,可以适当硬编码数据文件中作为分隔符的非 ASCII字符串; 更常见的是 (不需要本地化的) 单元测试代码可能包含非 ASCII 字符串。此类情况下,应使用 UTF-8 编码,因为很多工具都可以理解和处理 UTF-8 编码。

十六进制编码也可以,能增强可读性的情况下尤其鼓励 —— 比如"\xEF\xBB\xBF",或者更简洁地写作 u8"\uFEFF",在 Unicode 中是 零宽度无间断 的间隔符号,如果不用十六进制直接放在 UTF-8 格式的源文件中,是看不到的。

使用 u8 前缀把带 uXXXX 转义序列的字符串字面值编码成 UTF-8。不要用在本身就带 UTF-8 字符的字符串字面值上,因为如果编译器不把源代码识别成 UTF-8,输出就会出错。

别用 C++11 的 char16_tchar32_t,它们和 UTF-8 文本没有关系,wchar_t 同理,除非你写的代码要调用 Windows API,后者广泛使用了wchar_t

因为BOM头有兼容性问题,有一些 Unix 工具不支持带BOM头的UTF-8文件,所以代码保存时尽量不要有BOM头。在 Windows 项目中,要求统一使用 Visual C++ 的 /utf-8 选项,可以避免UTF-8的兼容性问题。参考: Set Source and Executable character sets to UTF-8. 如果确实有解决不了的兼容性问题,允许使用UTF-8 BOM。例如: Visual Studio C++ 对UTF-8 依赖操作系统的本地语言设置和项目设置。所以在跨项目使用源码文件时(一般为接口头文件)因为无法控制其他项目的项目设置和编译环境,可能出现将UFT-8编码识别为本地语言编码或将本地语言编码识别为UTF-8编码,从而导致编译错误。以下面的代码为例:

// 这是中文注释
int a = 0;
a += 1;

在编译器无法正确识别中文情况下有可能识别不出中文字符后的换行符,将int a = 0;一行判定为注释上一行的注释内容。在这个例子中因为丢失了变量a的声明而编译出错。 更严重的情况是如下所示的代码

int a = 0;
// 这是中文注释
a += 1;

基于同样的原因a += 1; 这行代码被识别为注释,但仍可以正常编译,开发人员难以察觉问题。因为少编译了一行代码,运行时可能出现无法预料的错误,并且很难调试定位问题。 在有BOM头时,Visual Studio C++ 编译器会以BOM头为准识别编码文件,从而避免出现上述错误。

9.3.【必须】空格还是制表位

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

不要在代码中使用制表符。你应该设置编辑器将制表符转为空格。

UE4 C++规则中对空格和Tab的使用做了额外说明,具体可参见UE4 C++规范

9.4.【必须】函数声明与定义

返回类型和函数名在同一行,参数也尽量放在同一行,如果放不下就对形参分行,分行方式与函数调用 一致。

详细信息

说明

函数看上去像这样:

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(Foo&&);
  Foo(const Foo&);
  Foo& operator=(Foo&&);
  Foo& operator=(const Foo&);
};

未被使用的参数如果其用途不明显的话,在函数定义处将参数名注释起来:

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) {}

属性,和展开为属性的宏,写在函数声明或定义的最前面,即返回类型之前:

MUST_USE_RESULT bool IsOK();

9.5.【必须】Lambda 表达式

Lambda 表达式对形参和函数体的格式化和其他函数一致;捕获列表同理,表项用逗号隔开。

在UE4 C++规范中,对Lambda表达式做了更多的说明,具体可参见UE4 C++规范

详细信息

说明

若用引用捕获,在变量名和 & 之间不留空格。

int x = 0;
auto add_to_x = [&x](int n) { x += n; };

短 lambda 就写得和内联函数一样。

std::set<int> blacklist = {7, 8, 9};
std::vector<int> digits = {3, 9, 1, 8, 4, 7, 1};
digits.erase(std::remove_if(digits.begin(), digits.end(), [&blacklist](int i) {
               return blacklist.find(i) != blacklist.end();
             }),
             digits.end());

9.6. 【推荐】浮点字面常量

浮点字面常量都要带小数点,小数点两边都要有数字,即使它们使用指数表示法。如果所有浮点数都采用这种熟悉的形式,可以提高可读性,不会被误认为是整数,指数符号的E/e不会被误认为十六进制数字。可以使用整数字面常量初始化浮点变量(假设变量类型可以精确地表示该整数),但是请注意,指数表示法中的数字绝不能用整数。

详细信息

错误用法:

float f = 1.f;
long double ld = -.5L;
double d = 1248e6;

正确用法:

float f = 1.0f;
float f2 = 1;   // 也可以用整数字面常量初始化浮点数
long double ld = -0.5L;
double d = 1248.0e6;

9.7.【推荐】函数调用

允许3种格式,在一行内写完函数调用,在圆括号里对参数分行,或者参数另起一行且缩进四格。 原则上,尽可能精简行数,比如把多个参数适当地放在同一行里。

详细信息

说明

函数调用遵循如下形式:

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

如果同一行放不下,可断为多行,后面每一行都和第一个实参对齐,左圆括号后和右圆括号前不要留空格:

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

参数也可以放在次行,缩进四格:

if (...) {
  ...
  ...
  if (...) {
    DoSomething(
        argument1, argument2,  // 4 空格缩进
        argument3, argument4);
  }

除非影响到可读性,尽量把多个参数放在同一行,以减少函数调用所需的行数。有人认为把每个参数都独立成行,不仅更好读,而且方便编辑参数。不过,比起所谓的参数编辑,我们更看重可读性,且后者比较更好办到:

如果一些参数是比较复杂的表达式,会降低可读性,可以通过创建临时变量的方式来描述该表达式,再传递给函数:

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

或者放在单独一行,并补充上注释:

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

如果将参数独立成行,对可读性更有帮助的话,也是可以的。总之,参数的格式处理应当以可读性而非其他作为最重要的原则。

此外,如果一系列参数本身就有一定的结构,可以酌情地按其结构来决定参数格式:

// 通过 3x3 矩阵转换 widget.
my_widget.Transform(x1, x2, x3,
                    y1, y2, y3,
                    z1, z2, z3);

9.8.【推荐】列表初始化格式

函数调用如何格式化,就如何格式化列表初始化

详细信息

说明

如果列表初始化伴随着名字,比如类型或变量名,格式化时将名字视作函数调用名,{} 视作函数调用的括号。如果没有名字,视作名字长度为零。

// 一行列表初始化示范
return {foo, bar};
functioncall({foo, bar});
pair<int, int> p{foo, bar};

// 当不得不断行时
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 = {  // 这里可以在 { 前断行
    superlongvariablename1,
    superlongvariablename2,
    {short, interior, list},
    {interiorwrappinglist,
     interiorwrappinglist2}};

9.9.【必须】条件语句

建议不在圆括号内使用空格。关键字 ifelse 另起一行。

UE4 C++规范中if-else的编码风格可参考UE4 C++规范

详细信息

说明

对基本条件语句通常有两种可以接受的格式。一种是在圆括号和条件之间有空格,另一种没有。优先推荐没有空格的格式。

这两种方式都可以,最重要的是代码风格要 保持一致。如果是在修改原有文件,跟已有格式保持一致。如果是写新的代码,参考目录下或项目中其它文件。

if (condition) {  // 圆括号里没有空格
  ...  // 2 空格缩进
} else if (...) {  // else 与 if 的右括号同一行
  ...
} else {
  ...
}

在圆括号内部加空格的格式:

if ( condition ) {  // 圆括号与空格紧邻 - 不常见
  ...  // 2 空格缩进
} else {  // else 与 if 的右括号同一行
  ...
}

在所有情况下,if 和左圆括号间都要有个空格。右圆括号和左大括号之间也要有个空格:

if(condition)     // 差 - IF 后面没空格
if (condition){   // 差 - { 前面没空格
if(condition){    // 更差
if (condition) {  // 好 - IF 和 { 都与空格紧邻

如果能增强可读性,简短的条件语句允许写在同一行。只有在语句简单并且没有使用 else 子句时允许使用:

if (x == kFoo) return new Foo();
if (x == kBar) return new Bar();

如果语句有 else 分支则不允许:

// 不允许 - 当有 ELSE 分支时 IF 块却写在同一行
if (x) DoThis();
else DoThat();

通常,单行语句不需要使用大括号,如果用也没问题;复杂的条件或循环语句用大括号可读性会更好。也有一些项目要求 if必须总是使用大括号:

if (condition)
  DoSomething();  // 2 空格缩进.

if (condition) {
  DoSomething();  // 2 空格缩进.
}

但如果语句中某个 if-else 分支使用了大括号的话, 其它分支也必须使用:

// 不可以这样子 - IF 有大括号 ELSE 却没有
if (condition) {
  foo;
} else
  bar;

// 不可以这样子 - ELSE 有大括号 IF 却没有
if (condition)
  foo;
else {
  bar;
}
// 只要其中一个分支用了大括号,两个分支都要用上大括号
if (condition) {
  foo;
} else {
  bar;
}

9.10.【必须】循环和开关选择语句

switch 语句可以使用大括号分段,以表明 cases 之间不是连在一起的。 在单语句循环里,括号可用可不用。 空循环体应使用 {}continue

在UE4 C++规范中,Switch语句的编写风格稍有不同,可参见UE4 C++规范

详细信息

说明

switch 语句中的 case 块可以使用大括号也可以不用,取决于个人选择。如果用的话,要按照下文所述的方法。

如果有不满足 case 条件的枚举值,switch 应该总是包含一个 default匹配 (如果有输入值没有 case 去处理, 编译器将给出 warning)。如果default 永远执行不到,简单的使用 assert语句:

switch (var) {
  case 0: {  // 2 空格缩进
    ...      // 4 空格缩进
    break;
  }
  case 1: {
    ...
    break;
  }
  default: {
    assert(false);
  }
}

在单语句循环里,可以不使用括号:

for (int i = 0; i < kSomeNumber; ++i)
  printf("I love you\n");

for (int i = 0; i < kSomeNumber; ++i) {
  printf("I take it back\n");
}

空循环体应使用 {}continue,而不是一个简单的分号。

while (condition) {
  // 反复循环直到条件失效
}
for (int i = 0; i < kSomeNumber; ++i) {}  // 可 - 空循环体
while (condition) continue;  // 可 - contunue 表明没有逻辑
while (condition);  // 差 - 看起来仅仅只是 while/loop 的部分之一

9.11.【必须】指针和引用表达式

句点或箭头前后不要有空格。 (*, &) 作为指针/地址运算符时之后不能有空格。(*, &)在用于声明指针变量或参数时,建议空格后置的写法,前置的也允许,但是不要混用。

详细信息

说明

下面是指针和引用表达式的正确使用范例:

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

注意:

  • 在访问成员时,句点或箭头前后没有空格。
  • 指针运算符 *& 后没有空格。

在声明指针变量或参数时,星号与类型或变量名紧挨都可以:

// 好, 空格后置.
char* c;
const string& str;

// 也允许, 空格前置.
char *c;
const string &str;

贴近类型名的写法则在 C++ 中广为流行,也是 C++ 创始人 Bjarne Stroustrup 推荐的写法,强调指针/引用是类型的一部分,更符合人类的阅读习惯,并且由于我们禁止混合声明值和指针,也不存在误导性。 由于本文档是 C++ 规范,因此更推荐采用这种写法。但是贴近变量名的写法是 C 语言的传统,也符合语言自身的原始语义,因此我们也允许这种写法。

在单个文件内要保持风格一致,所以如果是修改现有文件,要遵照该文件已有的风格。

在有助于提高可读性的前提下,允许一次声明多个关系密切的变量。但是如果类型有任何指针或者引用修饰则不允许,这样的声明很容易导致误读。

// 如果有助于可读性,允许
int width, height;  // 两个同类型的关系密切的变量,比起分开减少一行了代码。
int fd, count;  // 不允许,fd (文件描述符号)的类型虽然恰好是整数,但是其语义和个数完全不同。
int x, *y;  // 不允许 - 在多重声明中不能使用 & 或 *
char * c;  // 差 - * 两边都有空格
const string & str;  // 差 - & 两边都有空格

9.12.【必须】布尔表达式

如果一个布尔表达式超过标准行宽 <line-length>,断行方式要统一。比如,逻辑运算符要么都在行尾,要么都在行首。

详细信息

说明

下例中, 逻辑与 (&&) 运算符总位于行尾:

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

注意,上例的逻辑与 (&&) 运算符均位于行尾。这个格式在我们的代码库里很常见,但把所有运算符放在开头也可以。可以考虑额外插入圆括号,合理使用可以增强可读性。此外,直接用符号形式的运算符,比如 &&~ ,不要用词语形式的 andcompl 等。

9.13.【必须】返回值

不要在 return 表达式里加上非必须的圆括号。

详细信息

说明

只有 x = expr 要加上括号的时候才在 return expr; 里使用括号,比如下面的例子:

return result;                  // 返回值很简单,没有圆括号。
// 可以用圆括号把复杂表达式圈起来,改善可读性。
return (some_long_condition &&
        another_condition);
return (value);                // 毕竟您从来不会写 var = (value);
return(result);                // return 可不是函数!

9.14.【推荐】变量及数组初始化

当用值初始化对象时,用 = 语法;初始化执行主动逻辑时,用 () 语法初始化;以上情况不能编译时,才用不带 ={} 初始化;禁止混用 {} 初始化和 auto

详细信息

说明

C++11 提供了一种称为“统一初始化语法”的新语法,该语法期望能统一所有各种初始化样式,避免“烦人的解析”,并避免意外的窄化类型转换。

当某个类型有 std::initializer_list 构造函数时,非空列表初始化会优先调用 std::initializer_list,可能和期望不一致。

vector<int> v(100, 1);  // 内容为 100 个 1 的向量。
vector<int> v{100, 1};  // 只有两个向量,内容为 100 和 1。

此外,列表初始化不允许整型类型的四舍五入。这可以用来避免一些类型上的编程失误。

int pi(3.14);  // OK -- pi == 3。
int pi{3.14};  // 编译错误:缩窄转换。

但是这个功能也能借助一些编译器选项做到。

决定:

以下情况的初始化使用类似赋值的语法(带上等于号(=)):

  • 直接使用预期的文字值(例如 int,float 或 std::string 值)
  • 智能指针(例如 std::shared_ptr,std::unique_ptr)
  • 容器(std::vector,std::map等)
  • 执行结构初始化或进行复制构造
int x = 2;
std::string foo = "Hello World";
std::vector<int> v = {1, 2, 3};
std::unique_ptr<Matrix> matrix = NewMatrix(rows, cols);
MyStruct x = {true, 5.0};
MyProto copied_proto = original_proto;

而不是:

//
int x{2};
std::string foo{"Hello World"};
std::vector<int> v{1, 2, 3};
std::unique_ptr<Matrix> matrix{NewMatrix(rows, cols)};
MyStruct x{true, 5.0};
MyProto copied_proto{original_proto};

当初始化执行了某些主动逻辑时而不是简单的值的组合时,使用传统的构造函数语法(圆括号(())):

Frobber frobber(size, &bazzer_to_duplicate);
std::vector<double> fifty_pies(50, 3.14);  // 创建有 50 个 3.14 的数组

而不是

//

// 可能是调用初始化列表的构造函数,或者两个参数的普通构造函数。
Frobber frobber{size, &bazzer_to_duplicate};

// 期望产生的 `vector` 含有 `50` 个 `3.14`,实际生成的却只包含 `50` 和 `3.14` 两个值。
std::vector<double> fifty_pies{50, 3.14};

仅当以上情况不能编译时,才使用没有 ={} 初始化语法:

class Foo {
 public:
  Foo(int a, int b, int c) : array_{a, b, c} {}

 private:
  int array_[5];
  // 需要用 {} 是因为该类型的此构造函数标记为 `explicit` 并且不可拷贝。
  EventManager em{EventManager::Options()};
};

禁止混用 {}auto,例如:

//
auto x{1};
auto y = {2};  // 这实际上是一个 std::initializer_list<int>!

更多信息,请阅读 ToTW 88

9.15.【必须】预处理指令

预处理指令不要缩进,从行首开始。即使预处理指令位于缩进代码块中,指令也应从行首开始。

详细信息

说明

// 好 - 指令从行首开始
  if (lopsided_score) {
#if DISASTER_PENDING      // 正确 - 从行首开始
    DropEverything();
# if NOTIFY               // 非必要 - # 后跟空格
    NotifyClient();
# endif
#endif
    BackToNormal();
  }
// 差 - 指令缩进
  if (lopsided_score) {
    #if DISASTER_PENDING  // 差 - "#if" 应该放在行开头
    DropEverything();
    #endif                // 差 - "#endif" 不要缩进
    BackToNormal();
  }

9.16.【必须】类格式

访问控制块的声明依次序是 public:protected:private:, 每个都缩进1个空格。

详细信息

说明

类声明 (下面的代码中缺少注释),参考类注释的基本格式如下:

class MyClass : public OtherClass {
 public:      // 注意有一个空格的缩进
  MyClass();  // 标准的两空格缩进
  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_;
};

注意事项:

  • 所有基类名应在<line-length>限制下,尽量与子类名放在同一行。
  • 关键词 public:, protected:, private: 要缩进 1 个空格。
  • 除第一个关键词 (一般是 public) 外,其他关键词前要空一行。如果类比较小的话也可以不空。
  • 这些关键词后不要保留空行。
  • public 放在最前面,然后是 protected,最后是 private
  • 关于声明顺序的规则请参考声明顺序 <declaration-order> 一节。

9.17.【必须】构造函数初始值列表

构造函数初始化列表放在同一行或按四空格缩进并排多行。

详细信息

说明

下面两种初始值列表方式都可以接受:

// 如果所有变量能放在同一行:
MyClass::MyClass(int var) : some_var_(var) {
  DoSomething();
}

// 如果不能放在同一行,
// 必须置于冒号后,并缩进 4 个空格
MyClass::MyClass(int var)
    : some_var_(var), some_other_var_(var + 1) {
  DoSomething();
}

// 如果初始化列表需要置于多行,
// 将每一个成员放在单独的一行,并逐行对齐
MyClass::MyClass(int var)
    : some_var_(var),             // 4 space indent
      some_other_var_(var + 1) {  // lined up
  DoSomething();
}

// 如果合适,右大括号 } 可以和左大括号 { 放在同一行
MyClass::MyClass(int var)
    : some_var_(var) {}

9.18.【必须】命名空间格式

命名空间内容不缩进。

详细信息

说明

命名空间不要增加额外的缩进层次,例如:

namespace {

void foo() {  // 正确,命名空间内没有额外的缩进
  ...
}

}  // namespace

不要在命名空间内缩进:

namespace {

  // 错,缩进多余了
  void foo() {
    ...
  }

}  // namespace

声明嵌套命名空间时,每个命名空间都独立成行。

namespace foo {
namespace bar {

9.19.【必须】水平留白

水平留白的使用取决于代码的位置。永远不要在行尾添加没意义的留白。

说明

通用

void f(bool b) {  // 左大括号前总是有空格
  ...
int i = 0;  // 分号前不加空格
// 列表初始化中大括号内的空格是可选的,
// 如果加了空格,那么两边都要加上
int x[] = { 0 };
int x[] = {0};

// 继承与初始化列表中的冒号前后恒有空格
class Foo : public Bar {
 public:
  // 对于单行函数的实现,在大括号和函数实现之间加上空格
  Foo(int b) : Bar(), baz_(b) {}  // 大括号里面是空的话,不加空格
  void Reset() { baz_ = 0; }  // 用空格把大括号与实现分开
  ...

添加冗余的留白会给其他人编辑时造成额外负担,因此,行尾不要留空格。如果确定一行代码已经修改完毕,将多余的空格去掉;或者在专门清理空格时去掉(尤其是在没有其他人在处理这件事的时候)。

循环和条件语句

if (b) {          // if 条件语句和循环语句关键字后均有空格
} else {          // else 前后有空格
}
while (test) {}   // 圆括号内部不紧邻空格
switch (i) {
for (int i = 0; i < 5; ++i) {

// 循环和条件语句的圆括号内可以有空格,
// 但这种情况很少,要保持一致
switch ( i ) {
if ( test ) {
for ( int i = 0; i < 5; ++i ) {

// 循环里内分号后恒有空格,分号前可以加个空格
for ( ; i < 5 ; ++i) {
switch (i) {
  case 1:         // switch case 的冒号前无空格
    ...
  case 2: break;  // 如果冒号后有代码,加个空格

运算符

// 赋值运算符前后总是有空格
x = 0;

// 其它二元运算符前后也恒有空格,对于表达式的子式可以不加空格
// 圆括号内部没有紧邻空格
v = w * x + y / z;
v = w*x + y/z;
v = w * (x + z);

// 在参数和一元运算符之间不加空格
x = -5;
++x;
if (x && !y)
  ...

模板和转换

// 尖括号(< 和 >) 不与空格紧邻,< 前没有空格,> 和 ( 之间也没有空格
vector<string> x;
y = static_cast<char*>(x);

// 在类型与指针运算符之间也可以留空格,但要保持一致
vector<char *> x;

9.20.【必须】垂直留白

垂直留白越少越好。

详细信息

说明

这不仅仅是规则而是原则问题: 不在万不得已,不要使用空行,尤其是:两个函数定义之间的空行不要超过 2 行,函数体首尾不要留空行,函数体中也不要随意添加空行。

基本原则是: 一屏显示的代码越多,程序的控制流越容易理解。当然,过于密集的代码块和过于疏松的代码块同样难看,这取决于你的判断,但通常是垂直留白越少越好。

下面的规则可以让加入的空行更有效:

  • 函数体内开头或结尾的空行可读性微乎其微。
  • 在多重 if-else 块里加空行或许有点可读性。

返回目录


10. 规则特例

前面说明的编程习惯基本都是强制性的. 但所有优秀的规则都允许例外, 这里就是探讨这些特例.

10.1. 现有不合规范的代码

对于现有不符合既定编程风格的代码可以网开一面。

当你修改使用其他风格的代码时,为了与代码原有风格保持一致可以不使用本指南约定。如果不放心,可以与代码原作者或现在的负责人员商讨。 记住:一致性 也包括原有的一致性。

10.2. Windows 代码

我们希望任何人都可以顺利读懂你的代码,所以针对所有平台的 C++ 编程只给出一个单独的指南。在不违反本规范的前提下,可以遵循 Windows 上的惯例。

说明

虽然 Windows 系统上有一套源于 Windows 头文件和其它 Microsoft 代码的影响广泛的编码约定,但是为了保持一致性,方便阅读代码,我们在 Windows 上的编码风格上也整体遵守本代码规范,这儿有必要重申一下某些你可能会忘记的指南:

  • 不要使用匈牙利命名法(比如把整型变量命名成 iNum)。使用本规范的命名约定,比如不要给类名加前缀(例如 C)。
  • Windows 定义了很多原生类型的同义词 , 如 DWORD, HANDLE 等等. 在调用 Windows API 时这是完全可以接受甚至鼓励的。即使如此, 还是尽量使用原有的 C++ 类型, 例如使用 const TCHAR * 而不是 LPCTSTR
  • 使用 Microsoft Visual C++ 进行编译时, 将警告级别设置为 3 或更高, 并将所有警告(warnings)当作错误(errors)处理。
  • 除非万不得已, 不要使用任何非标准的扩展, 如 #pragma__declspec. 使用 __declspec(dllimport)__declspec(dllexport) 是允许的, 但必须通过宏来使用, 比如 DLLIMPORTDLLEXPORT, 这样其他人在分享使用这些代码时可以很容易地禁用这些扩展。

然而,在 Windows 上仍然有一些我们偶尔需要违反的规则:

  • 在使用 COM 和 ATL/WTL 类时,为了实现 COM 或 ATL/WTL 类/接口,你可能不得不使用多重实现继承
  • 通常为了利用预编译头文件,每个源文件的开头都会包含一个名为 StdAfx.hprecompile.h 的文件。为了使代码方便与其他项目共享,请避免显式包含此文件 (除了在 precompile.cc 中),使用 /FI 编译器选项以自动包含该文件。
  • 资源头文件通常命名为 resource.h 且只包含宏,这一文件不需要遵守本代码规范。

在不违反本规范的前提下,可以遵循 Windows 上的惯例。

返回目录


11. UE4 C++规范说明

为保持一致性,UE4 游戏引擎相关的代码风格基于 UE4 代码规范

11.1.【必须】命名规范

  • 名称的每个单词首字母要大写(比如变量或类型名),单词之间通常不使用下划线,比如Health和UPrimitiveComponent是符合规范的,但lastMouseCoordinates或delta_coordinates不符合。

  • 类型名称前面要有一个标识类型的前缀,用于和变量的定义区分,例如FSKin是类型名,而Skin是FSKin的一个实例。

    • 模板类以T开头,如TMap
    • 继承自UObject的类以U开头,如UMoviePlayerSettings
    • 继承自AActor的类以A开头,如APlayerCameraManager
    • 继承自SWidget的类以S开头,如SCompoundWidget
    • 抽象接口类以I开头,如INavNodeInterface
    • 枚举以E开头,如EAccountType
    • 布尔变量以小写字母b开头,例如bPendingDestruction
    • 大部分其他类以F开头,如FVector
  • 类型和变量名一般是名词,用于清晰描述类型或变量的含义,如FVector Velocity

  • 函数或方法名应使用动词来描述该方法的作用,例如GetSize()

  • 对于返回值类型为布尔值的函数,命名时最好以类似于Is或Should开头来定义,例如IsVisible()、ShouldClearBuffer()

  • 虽然不是强制要求,但建议在以引用传递并且在函数内会被修改的形参前面加上“Out”前缀,这样能够清晰表明函数会修改传入的参数

  • 如果一个In或Out参数是布尔类型,那么需要在In/Out前面增加“b”前缀,如bOutResult

11.2.【必须】数据类型

在UE4中,建议使用如下定义的数据类型,确保跨平台编译时数据的一致性。

  • bool表示布尔值(不能想当然的判断bool的大小),注意并不是大写的BOOL

  • TCHAR表示字符类型(不能想当然的判断TCHAR的大小)

  • int8/uint8分别表示有符号和无符号字节,大小为1个字节

  • int16/uint16分别表示有符号和无符号的短整数,大小为2个字节

  • int32/uint32分别表示有符号和无符号整数,大小为4个字节

  • int64/uint64分别表示有符号和无符号整数,大小为8个字节

  • float表示单精度浮点数,大小为4个字节

  • double表示双精度浮点数,大小为8个字节

  • PTRINT表示与指针同样大小的整数(不能想当然的判断PTRINT的大小)

  • 字符串类最好使用UE中已定义的FString、FText、FName、TCHAR等

  • 容器类请使用UE中已定义的容器类,如TArray、TMap、TSet等,标准C++容器类最好不要使用

  • 文件相关操作,请使用UE封装好的类,比如FFileManagerGeneric,如果需要序列化操作,则可以使用FArchive等

  • 对于C++标准的int和unsigned int类型,在不同平台上占用的字节数可能不同,在占用字节数不重要的代码段中是允许使用的

11.3.【推荐】代码格式

  • 大括号 大括号不建议和代码占同一行,而是另起一行,示例:
if (bThing)
{
	return;
}
  • if-else if-else语句的每一个代码块都必须位于大括号内,示例:
if (PointA.X > 0)
{
	return PointA + PointB;
}
else
{
	return PointA - PointB;
}

对于多重if语句,遵循每个else if与首个if的缩进对齐,示例:

if (PointA.X > 0)
{
	return PointA + PointB;
}
else if (PointA.X == 0)
{
	return PointB;
}
else
{
	return PointA - PointB;
}
  • Switch语句 对于switch语句,如果多个case条件都执行相同的代码块,则需要明确在每个case处添加falls-through注释,表示继续向下执行。其他情况下,每个case都应以break或其他可改变程序控制流的语句结束,比如return或continue。通常情况下,switch、case最后都会有一个默认的default分支,也就是以break语句结束,防止在default之后添加新的case,示例:
switch (condition)
{
	case 1:
		...
		// falls through
	case 2:
		...
		break;
	case 3:
		...
		return;
	default:
		break;
}
  • Tab与缩进
    • 按照执行代码块缩进
    • 对每一行开头的空白使用Tab键而不是空格键。Tab大小设置为4个字符。但有时空格键也是必需的,而且也可以使用空格键来保持代码对齐
    • 如果同时使用C#编写代码,那也要用Tab键而不是空格键。出于习惯考虑,因为如果经常在C#和C++之间切换,需要使用同样的Tab配置。Visual Studio默认对C#代码文件使用空格键,所以要记得在开发Unreal Engine代码时修改这一选项

11.4.【推荐】一般风格

  • 类的构造函数中初始化成员变量的顺序最好与类中声明成员变量的顺序一致,否则有可能导致编译器警告
  • 减少变量的依赖距离,即把变量的声明和定义放在第一次使用的地方。可以在代码段的最前面初始化变量,但是不要等到几百行之后再去使用
  • 尽量解决所有的编译器警告,一般而言,编译器警告都表示该处代码可能会存在问题,需要仔细确认
  • 在文件结尾留一行空行,所有的.cpp和.h文件都应该留一行空行以便于更好的兼容gcc编译器
  • 不要直接将float类型隐式转化为int32类型,因为这个操作效率不高,而且也不是所有的编译器都支持。如果需要转换,可使用appTrunc()函数,这个函数能保证所有编译器都支持,而且执行速度也比较快
  • 请不要提交调试代码,将调试代码和其他代码混在一起会让代码可读性变差
  • 确保对字符串变量使用TEXT()宏。如果没有使用,那么对于从字符串构造FString的代码会产生不必要的转码处理
  • 不要在循环里执行冗余的重复操作。建议把一些通用的表达式操作从循环中移出来以避免冗余的计算。某些场景下也可以使用static来避免一些冗余的跨函数调用的全局操作,例如使用一个字符串来构造FName的操作
  • 使用中间变量来简化复杂的条件表达式。如果将较为复杂的条件表达式拆分为多个使用中间变量来表示的表达式,会更容易理解,也不容易出错,示例:
if ((Blah->BlahP->WindowExists->Etc && Stuff) &&
    !(bPlayerExists && bGameStarted && bPlayerStillHasPawn &&
    IsTuesday())))
{
    DoSomething();
}

应当使用如下的方式来替代,这样看起来可读性会好很多:

const bool bIsLegalWindow = Blah->BlahP->WindowExists->Etc && Stuff;
const bool bIsPlayerDead = bPlayerExists &&
					  bGameStarted &&
					  bPlayerStillHasPawn &&
					  IsTuesday();
if (bIsLegalWindow && !bIsPlayerDead)
{
    DoSomething();
}
  • 指针和引用的声明应当只有一个空格,位于指针或引用符号的右边。这样做的好处是很容易就可以在代码中查找某种类型的指针或引用,示例:
// 这样定义指针
FShaderType* Type;

// 而不是这样定义
FShaderType *Type;
FShaderType * Type;
  • 禁止使用Shadowed Variable,如下的成员函数包含三个可用的“Count”变量,这种写法是禁止的
class FSomeClass
{
public:
	void Func(const int32 Count)
	{
		for(int32 Count = 0; Count != 10; ++Count)
		{
			// Use Count
		}
	}

private:
	int32 Count;
};
  • 蓝图中使用变量前一定要加Valid判断,尤其是在Tick函数中
  • 不要在代码中硬编码字符串
  • 如果希望一直持有UObject对象,则必须添加UPROPERTY标记,让UE引擎能够正确标识引用,不至于该UObject被GC回收
  • 对于UE引擎中的字符编码转换系列宏,如下面这些:
   TCHAR_TO_ANSI
   ANSI_TO_TCHAR
   TCHAR_TO_WCHAR
   WCHAR_TO_TCHAR
   TCHAR_TO_UTF8
   UTF8_TO_TCHAR

必须只能用作函数参数使用,不能将结果赋值给其他对象,否则会由于临时变量被释放导致不可预知的问题,如:

// 正确用法
SomeApi(TCHAR_TO_ANSI(SomeUnicodeString));

// 错误用法
const char* SomePointer = TCHAR_TO_ANSI(SomeUnicodeString);

11.5.【推荐】C++ 11与现代语言语法

​ UE引擎支持绝大部分C++编译器编译,但在使用编译器兼容特性的时候是非常谨慎的。除了下面列出的已经支持的现代C++编译器特性,其他依赖于特定编译器的语言特性尽量不要使用,除非在有必要的情况下,并最好封装在预处理宏或条件判断语句之中。

  • static_assert 可以用于编译时断言(Compile-time Assertion)功能
  • override和final 这两个关键字不但可以用,而且推荐使用
  • nullptr 在任何场景下,都应该使用nullptr,而不是C风格的NULL,但是有一个例外,在C++/CX构建时(如Xbox One),nullptr实际上是托管的空引用类型。这时除了在类型上或某些模板实例化时,大部分场景下原生C++都是和nullptr兼容的,所以考虑到兼容性,此时应该使用TYPE_OF_NULLPTR宏,而不是更常用的decltype(nullptr)
  • auto关键字 大部分情况下,都必须清晰的指明变量初始化时的类型。除了以下几种场景外,最好不要在C++代码中使用auto。可以使用auto的场景:
    • 将Lambda表达式和变量绑定的时候可以使用,这是因为Lambda不能显式的声明类型名
    • 用作迭代器变量的时候可以使用,但只能用在当迭代器的类型名称很冗长,影响到代码可读性的时候
    • 在模板代码中使用,尤其是当表达式的类型不太容易辨别的时候
  • Range Based For 基于范围的For循环能够让代码看起来更简洁,也更易于维护。需要注意的是,对于TMap迭代器,以前的Key()和Value()函数变成现在TPair的Key和Value值,示例:
TMap<FString, int32> MyMap;

// Old style
for (auto It = MyMap.CreateIterator(); It; ++It)
{
    UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), It.Key(), *It.Value());
}

// New style
for (TPair<FString, int32>& Kvp : MyMap)
{
    UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), Kvp.Key, *Kvp.Value);
}

同样,标准迭代器类型也有类似新写法,示例:

// Old style
for (TFieldIterator<UProperty> PropertyIt(InStruct, EFieldIteratorFlags::IncludeSuper); 		PropertyIt; ++PropertyIt)
{
    UProperty* Property = *PropertyIt;
    UE_LOG(LogCategory, Log, TEXT("Property name: %s"), *Property->GetName());
}

// New style
for (UProperty* Property : TFieldRange<UProperty>(InStruct,    EFieldIteratorFlags::IncludeSuper))
{
    UE_LOG(LogCategory, Log, TEXT("Property name: %s"), *Property->GetName());
}
  • Lambda与匿名函数 现在所有的编译器都已经支持Lambda表达式,但用法上仍然需要多考虑。最佳的Lambda表达式长度不要过长,两三条语句就可以,尤其是用在一些更大的表达式或语句上下文的时候,比如在通用算法中用作断言(Predicate)时。
// Find first Thing whose name contains the word "Hello"
    Thing* HelloThing = ArrayOfThings.FindByPredicate([](const Thing& Th){ return Th.GetName().Contains(TEXT("Hello")); });

    // Sort array in reverse order of name
    AnotherArray.Sort([](const Thing& Lhs, const Thing& Rhs){ return Lhs.GetName() > Rhs.GetName(); });

除此之外,还有一些是需要注意的:

  • 以引用或值传递捕获变量时,如果Lambda不在被捕获的变量上下文中执行,那么可能导致无效引用,示例:
void Func()
{
 int32 Value = GetSomeValue();

 // Lots of code

 AsyncTask([&]()
 {
     // Value 变量在这里无效
     for (int Index = 0; Index != Value; ++Index)
     {
         // ...
     }
 });
}
  • 使用值传递捕捉的时候,要考虑性能问题,避免不必要的拷贝,示例:
void Func()
{
    int32 ValueToFind = GetValueToFind();

    // The lambda takes a copy of ArrayOfThings because it is accidentally captured by [=]  when it was only meant to capture ValueToFind
    FThing* Found = ArrayOfThings.FindByPredicate(
        [=](const FThing& Thing)
        {
            return Thing.Value == ValueToFind && Thing.Index < ArrayOfThings.Num();
        }
    );
}
  • 如果在Lambda表达式中引用了成员变量,那么会隐式的自动捕捉this对象指针,即使是[=] 也是如此,示例:
void FStruct::Func()
{
    int32 Local = 5;
    Member = 5;

    auto Lambda = [=]()
    {
        UE_LOG(LogTest, Log, TEXT("Local: %d, Member: %d"), Local, Member);
    };

    Local = 100;
    Member = 100;

    Lambda(); // Logs "Local: 5, Member: 100"
}
  • 对于规模较大的Lambda,或者返回其他函数调用结果时,最好明确声明Lambda返回值类型,示例:
// Without the return type here, the return type is unclear
auto Lambda = []() -> FMyType
{
    return SomeFunc();
};
  • 强类型枚举 建议用枚举类(Enum Class)来代替旧式的命名空间枚举,包括一般的枚举定义和UENUM定义,示例:
// Old enum
UENUM()
namespace EThing
{
    enum Type
    {
        Thing1,
        Thing2
    };
}
// New enum
UENUM()
enum class EThing : uint8
{
    Thing1,
    Thing2
};

UPROPERTY类型的定义上也是一样,只要是uint8类型,都可以代替TEnumAsByte<>定义,示例:

// Old property
UPROPERTY()
TEnumAsByte<EThing::Type> MyProperty;

// New property
UPROPERTY()
EThing MyProperty;

用作标志位定义的枚举类可以使用一个新的宏ENUM_CLASS_FLAGS(EnumType)来自动定义位操作,示例:

enum class EFlags
{
    None  = 0x00,
    Flag1 = 0x01,
    Flag2 = 0x02,
    Flag3 = 0x04
};

ENUM_CLASS_FLAGS(EFlags)

由于编程语言的限制,在判断条件真假的时候不能这么用,所以建议所有的标志枚举类型都应当有一个名为’None’的枚举,其值为0,示例:

// Old
if (Flags & EFlags::Flag1)

// New
if ((Flags & EFlags::Flag1) != EFlags::None)
  • 默认成员初始化 默认的成员初始化用于在类的内部定义成员变量的默认值,例如:
UCLASS()
class UTeaOptions : public UObject
{
    GENERATED_BODY()
public:
    UPROPERTY()
    int32 MaximumNumberOfCupsPerDay = 10;
    UPROPERTY()
    float CupWidth = 11.5f;
    UPROPERTY()
    FString TeaType = TEXT("Earl Grey");
    UPROPERTY()
    EDrinkingStyle DrinkingStyle = EDrinkingStyle::PinkyExtended;
};

这样的代码有如下好处:

  1. 在不同的构造函数中不需要重复编写初始化代码

  2. 初始化顺序和声明顺序不会混淆

  3. 成员的类型、属性标志和默认值都在一个地方,有利于可读性和可维护性

但是,这样也有一些缺点:

  1. 任何对默认值的修改都会导致重新编译所有引用的文件

  2. 头文件的修改是无法在引擎的Patch版本中做到的,所以这种风格有可能会限制原本可以Patch修复的一些场景

  3. 有些对象是无法用这种方法进行初始化的,例如基类、UObject的子对象、前向类型声明的指针、来源于构造函数参数的值以及需要通过多个步骤进行初始化的成员

  4. 如果是把一些初始化放到头文件当中,剩下的一部分放到.cpp文件的构造函数中,这样反而会降低代码的可读性和可维护性

  • 使用using关键字定义类型别名 在C++98/03中一般使用typedef关键字进行类型别名的声明。在C++ 11以后更建议使用using关键字进行类型别名的声明,除了能够提高代码的可读性之外,还可以定义模板别名。例如:
/* The base type of whole set iterators. */
template<bool bConst, bool bRangedFor = false>
class TBaseIterator
{
    …
};

using TRangedForConstIterator = TBaseIterator<true, true>;
using TRangedForIterator      = TBaseIterator<false, true>;

返回目录


12. 规范检查与辅助工具

12.1. Cpplint

使用 cpplint.py 检查风格错误。

cpplint.py 是一个用来分析源文件,能检查出多种风格错误的工具。 它不并完美,甚至还会漏报和误报,但它仍然是一个非常有用的工具。在行尾加// NOLINT,或在上一行加 // NOLINTNEXTLINE,可以忽略报错。 如果你项目没有提供,你可以下载cpplint.py

12.2 常见编辑器的配置文件

为了帮助你正确地编辑代码,我们提供了一些常见编辑器的配置文件

12.3 自动格式转换工具

如果要统一转换格式不符合现有规范的旧代码,我们提供了代码格式化工具


13. 结束语

运用常识和判断力,并且保持一致

编辑代码时,花点时间看看项目中的其它代码,并熟悉其风格。如果其它代码中 if 语句使用空格,那么你也要使用。如果其中的注释用星号 (*)围成一个盒子状,那么你同样要这么编写注释。

代码规范的重点在于提供一个通用的编程规范,这样大家可以把精力集中在实现内容而不是表现形式上。我们展示的是一个总体的的风格规范,但局部风格也很重要。如果你在一个文件中新加的代码和原有代码风格相去甚远,这就破坏了文件本身的整体美观,也打乱读者在阅读代码时的节奏,所以要尽量避免。

好了,关于编码风格写的够多了;代码本身才更有趣。尽情享受吧!

About

C++ standard specification

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published