avatar

c++ google style.md

google c++ style

标签 : c++


一、头文件

1.1 self-contained

1
头文件应该要自给自足,也就是说可以被当作第一个文件被引进

1.2 #define保护

define 定义命名基于路径,例如foo文件夹中的foo/src/bar/baz.h

1
2
3
#ifndef FOO_BAR_BAZ_H
#define FOO_BAR_BAZ_H
#endif FOO_BAR_BAZ_H

1.3 前置声明

    尽量不要使用前置声明

定义:

指仅仅是类 函数 模板的纯粹声明,而没有伴随这其定义

优点:

  • 前置声明能够节省编译时间,过多的include会导致展开过多的文件
  • 避免当include内容修改时,导致重新编译

缺点

  • 前置声明隐藏了依赖关系,当头文件改动时,会跳过必要的编译过程[1]
  • 前置声明可能会被库的后续更改所破坏。前置声明函数或模板有时会妨碍头文件开发者变动其 API. 例如扩大形参类型,加个自带默认参数的模板形参等等
  • 前置声明来自命名空间 std:: 的 symbol 时,其行为未定义
  • 很难判断什么时候该用前置声明,什么时候该用 #include 。极端情况下,用前置声明代替 includes 甚至都会暗暗地改变代码的含义:
1
2
3
4
5
6
7
8
// 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*)

如果#includeD B 替代,那么test()会调用f(void) [2]

  • 过多的前置声明会导致冗长
  • 仅仅为了能前置声明而重构代码(比如用指针成员代替对象成员)会使代码变得更慢更复杂.

结论:

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

1.4 内联函数

定义:

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

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

缺点:

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

结论:

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

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

有些函数即使声明为内联的也不一定会被编译器内联, 这点很重要; 比如虚函数和递归函数就不会被正常内联. 通常, 递归函数不应该声明成内联函数.(YuleFox 注: 递归调用堆栈的展开并不像循环那么简单, 比如递归层数在编译时可能是未知的, 大多数编译器都不支持内联递归函数). 虚函数内联的主要原因则是想把它的函数体放在类定义内, 为了图个方便, 抑或是当作文档描述其行为, 比如精短的存取函数.

1.5 #include的路径和顺序

避免使用特殊路径如:..,.,google-awesome-project/src/base/logging.h 应该按如下方式包含:

1
#include "base/logging.h"

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

  1. dir2/foo.h
  2. c系统文件
  3. c++系统文件
  4. 其他库中.h文件
  5. ASDASD阿三本项目内其他.h文件
    所依赖的符号(symbols)被那些头文件依赖,就要去include,即使头文件被已经引入的头文件所依赖

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

1
2
3
4
5
6
7
8
#include "foo/public/fooserver.h" // 优先位置
#include <sys/types.h> //c系统
#include <unistd.h>
#include <hash_map> //c++
#include <vector>
#include "base/basictypes.h" //本项目
#include "base/commandlineflags.h"
#include "foo/public/bar.h"

译者笔记

  • 函数参数可以按照一定顺序书写,如相同类型一起
  • .,..不要使用
  • 类内部的函数一般会自动内联,不打算内联的函数不要定义在头文件中
  • #include中插入空行以分割相关头文件, C 库, C++ 库, 其他库的 .h 和本项目内的 .h 是个好习惯

不理解的点

二、作用域

2.1 命名空间

    鼓励在 .cc 文件内使用匿名命名空间或 static 声明. 使用具名的命名空间时, 其名称可基于项目名或相对路径. 禁止使用 using 指示(using-directive)。禁止使用内联命名空间(inline namespace)。

定义:

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

优点:

和java等语言的作用相同,在类的分离下进一步划分了作用域
内联命名空间会自动把内部标识符放到外部

1
2
3
4
5
namespace X {
inline namespace Y {
void foo();
} // namespace Y
} // namespace X

X:Y:foo()X:foo()相同

结论:

  • 在命名空间的最后注释出命名空间的名字
1
2
3
4
5
6
7
8
9
10
// .h 文件
namespace mynamespace {
// 所有声明都置于命名空间中
// 注意不要使用缩进
class MyClass {
public:
...
void Foo();
};
} // namespace mynamespace
  • 不要再std中声明任何
  • 不要使用using全局
  • 禁止使用内联命名空间

2.2 匿名命名空间和静态变量

tip:

    在`.cc`文件中定义一个不需要被外部引用(extern)的变量,可以将其放在匿名空间或者声明为`static`,但是不要在`.h`文件中这么做

定义:

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

结论:

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

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

1
2
3
namespace {
...
} // namespace

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

tip:

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

优点:

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

结论

若静态不使用类内部静态成员,那么相比定义为静态函数,更应该使用命名空间,如:对于myproject/foo_bar.h,应当使用:

1
2
3
4
5
6
namespace myproject {
namespace foo_bar {
void Function1();
void Function2();
} // namespace foo_bar
} // namespace myproject

而非:

1
2
3
4
5
6
7
namespace myproject {
class FooBar {
public:
static void Function1();
static void Function2();
};
} // namespace myproject

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

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

tip:

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

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

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

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

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

注意:当局部变量为对象的时候,重复进入退出作用域是非常低效的:[3]

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

在外部循环更加高效:

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

2.5 静态和全局变量

tip:

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

静态变量的构造函数 析构函数 以及初始化顺序在c++只是部分明确.禁止使用静态类类型,禁止使用涉及全局变量的函数返回值来初始化变量,局部静态变量除外,因为静态局部变量的初始化是确定[5]

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

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

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

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

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

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

译者注:

  • 匿名空间避免命名冲突,限定作用域,避免直接使用using污染命名空间
  • 尽量不要使用全局函数和全局变量,考虑作用域和命名空间显示,尽量单独形成编译单元
  • 多线程中的全局变量(含静态成员变量)不要使用class类型,也就是只能使用POD类型

三、类

3.1构造函数的职责

不要再构造函数中调用虚函数

缺点:

  • 如果在构造函数中调用了自身的虚函数,此类调用不会重定向到子类虚函数实现
  • 当程序没有崩溃或者使用异常的情况下,构造函数很难向上报错
  • 若执行失败,会得到初始化失败的对象,该对象可能进入不正常状态,必须使用bool isValid()或类似的机制才能检查出来
  • 构造函数的地址是无法被取得的,也就是说构造函数完成的工作无法以简单的方式交给其他线程

结论:

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

3.2 隐式类型转换

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

缺点:

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

结论:

在类型定义中,类型转换运算符和单参数构造函数都应该添加explicit,但是拷贝移动构造函数不需要,因为它们并不执行类型转换
不能以一个参数进行调用的构造函数不应当加上 explicit. 接受一个 std::initializer_list 作为参数的构造函数也应当省略 explicit, 以便支持拷贝初始化 (例如 MyType m = {1, 2};) .

3.3 可拷贝类型和可移动类型

总述:

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

定义:

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

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

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

优点:

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

缺点:

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

拷贝构造函数是隐式调用的, 也就是说, 这些调用很容易被忽略. 这会让人迷惑, 尤其是对那些所用的语言约定或强制要求传引用的程序员来说更是如此. 同时, 这从一定程度上说会鼓励过度拷贝, 从而导致性能上的问题.

关于对象切割,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
class A {
public:
A() {
std::cout << "A construction" << std::endl;
}

A(A &a) { std::cout << "copy"<<std::endl; }

// A&operator=(A a)= delete;
virtual void test() {
std::cout << "a_fun"<<std::endl;
}
};

class B : public A {
public:
B() {
std::cout << "B construction" << std::endl;
}

void test() override {
std::cout << "b_fun"<<std::endl;
}
};

void fun(A a) {
a.test();
}
void fun2(A &a)
{
a.test();
}
int main() {
A a;
B b;
// a = b;
fun(b);
fun2(b);
}
//output:
A construction
A construction
B construction
copy
a_fun
b_fun
  • 关于值传递编译器就会调用拷贝构造函数,在语法判断过程中会决定此条语句能否通过,这里我要表达的意思是构造器不过用来初始化的函数而已
  • 当直接使用赋值函数时,默认的赋值函数只是修改了对象内部的属性,如a=b,a对象任然是a对象,可以通过&去查看,而那些默认传递引用的语言如java语言,通过所谓的值传递,就会导致对象覆盖
  • 当指针或者引用通过函数调用时,就会发生如java的值传递,此时临时变量就是指针或者引用本身而没有出现对象构造,也就是说此时多态的发生才是符合逻辑的
  • 当指针和引用使用赋值,其行为和普通变量的赋值是相同的

结论:

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

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

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

private:
Field field_;
};

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

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

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

3.4 结构体vs类

####总述

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

####说明

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

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

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

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

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

3.5 继承

总述:

使用组合(设计模式这本书值得一看),常常比使用继承更加合理,如果使用继承的话,定义为public继承

定义:

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

优点:

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

缺点:

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

结论:

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

不要过度使用实现继承. 组合常常更合适一些. 尽量做到只在 “是一个” (“is-a”, YuleFox 注: 其他 “has-a” 情况下请使用组合) 的情况下使用继承: 如果 Bar 的确 “是一种” Foo, Bar 才能继承 Foo.

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

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

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

3.6多重继承

总述:

真正需要用到多重实现继承的情况少之又少. 只在以下情况我们才允许多重继承: 最多只有一个基类是非抽象类; 其它基类都是以 Interface 为后缀的 纯接口类.

优点:

相比单继承 (见 继承, 多重实现继承可以复用更多的代码.

缺点:

真正需要用到多重 实现 继承的情况少之又少. 有时多重实现继承看上去是不错的解决方案, 但这时你通常也可以找到一个更明确, 更清晰的不同解决方案
###3.7. 接口

总述:

接口是指满足特定条件的类, 这些类以 Interface 为后缀 (不强制).

定义:

当一个类满足以下要求时, 称之为纯接口:

  • 只有纯虚函数 (“=0”) 和静态函数 (除了下文提到的析构函数).
  • 没有非静态数据成员.
  • 没有定义任何构造函数. 如果有, 也不能带有参数, 并且必须为 protected.
  • 如果它是一个子类, 也只能从满足上述条件并以 Interface 为后缀的类继承.

接口类不能被直接实例化, 因为它声明了纯虚函数. 为确保接口类的所有实现可被正确销毁, 必须为之声明虚析构函数 (作为上述第 1 条规则的特例, 析构函数不能是纯虚函数). 具体细节可参考 Stroustrup 的 The C++ Programming Language, 3rd edition 第 12.4 节.

优点:

以 Interface 为后缀可以提醒其他人不要为该接口类增加函数实现或非静态数据成员. 这一点对于 多重继承 尤其重要. 另外, 对于 Java 程序员来说, 接口的概念已是深入人心.

缺点:

Interface 后缀增加了类名长度, 为阅读和理解带来不便. 同时, 接口属性作为实现细节不应暴露给用户.

结论

只有在满足上述条件时, 类才以 Interface 结尾, 但反过来, 满足上述需要的类未必一定以 Interface 结尾.

3.8运算符重载

总述:

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

优点:

重载运算符可以让代码更简洁易懂, 也使得用户定义的类型和内建类型拥有相似的行为. 重载运算符对于某些运算来说是符合符合语言习惯的名称 (例如 ==, <, =, <<), 遵循这些语言约定可以让用户定义的类型更易读, 也能更好地和需要这些重载运算符的函数库进行交互操作.

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

缺点:

  • 要提供正确, 一致, 不出现异常行为的操作符运算需要花费不少精力, 而且如果达不到这些要求的话, 会导致令人迷惑的 Bug.
  • 过度使用运算符会带来难以理解的代码, 尤其是在重载的操作符的语义与通常的约定不符合时.
    函数重载有多少弊端, 运算符重载就至少有多少.
  • 运算符重载会混淆视听, 让你误以为一些耗时的操作和操作内建类型一样轻巧.
  • 对重载运算符的调用点的查找需要的可就不仅仅是像 grep 那样的程序了, 这时需要能够理解 C++ 语法的搜索工具.
  • 如果重载运算符的参数写错, 此时得到的可能是一个完全不同的重载而非编译错误. 例如: foo < bar 执行的是一个行为, 而 &foo < &bar 执行的就是完全不同的另一个行为了.
  • 重载某些运算符本身就是有害的. 例如, 重载一元运算符 & 会导致同样的代码有完全不同的含义, 这取决于重载的声明对某段代码而言是否是可见的. 重载诸如 &&, || 和 , 会导致运算顺序和内建运算的顺序不一致.
  • 运算符从通常定义在类的外部, 所以对于同一运算, 可能出现不同的文件引入了不同的定义的风险. 如果两种定义都链接到同一二进制文件, 就会导致未定义的行为, 有可能表现为难以发现的运行时错误.
  • 用户定义字面量所创建的语义形式对于某些有经验的 C++ 程序员来说都是很陌生的.

结论:

只有在意义明显, 不会出现奇怪的行为并且与对应的内建运算符的行为一致时才定义重载运算符. 例如, | 要作为位或或逻辑或来使用, 而不是作为 shell 中的管道.

只有对用户自己定义的类型重载运算符. 更准确地说, 将它们和它们所操作的类型定义在同一个头文件中, .cc 中和命名空间中. 这样做无论类型在哪里都能够使用定义的运算符, 并且最大程度上避免了多重定义的风险. 如果可能的话, 请避免将运算符定义为模板, 因为此时它们必须对任何模板参数都能够作用. 如果你定义了一个运算符, 请将其相关且有意义的运算符都进行定义, 并且保证这些定义的语义是一致的. 例如, 如果你重载了 <, 那么请将所有的比较运算符都进行重载, 并且保证对于同一组参数, <> 不会同时返回 true.

建议不要将不进行修改的二元运算符定义为成员函数. 如果一个二元运算符被定义为类成员, 这时隐式转换会作用域右侧的参数却不会作用于左侧. 这时会出现 a < b 能够通过编译而 b < a 不能的情况, 这是很让人迷惑的.

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

不要重载 &&, ||, , 或一元运算符 &. 不要重载 operator"", 也就是说, 不要引入用户定义字面量.

3.9 存取控制

将 所有 数据成员声明为 private, 除非是 static const 类型成员 (遵循 常量命名规则). 处于技术上的原因, 在使用 Google Test 时我们允许测试固件类中的数据成员为 protected.

3.10 声明顺序

总述:

将相似的声明防在一起,public部分放在最前边

说明:

类定义一般应以 public: 开始, 后跟 protected:, 最后是 private:. 省略空部分.

在各个部分中, 建议将类似的声明放在一起, 并且建议以如下的顺序: 类型 (包括 typedef, using 和嵌套的结构体与类), 常量, 工厂函数, 构造函数, 赋值运算符, 析构函数, 其它函数, 数据成员.

不要将大段的函数定义内联在类定义中. 通常,只有那些普通的, 或性能关键且短小的函数可以内联在类定义中. 参见 内联函数 一节.

译者笔记

  • 不再构造函数中做过多的逻辑相关初始化
  • 编译器提供的默认构造器不会对变量进行初始化(此处应该是说符合用户要求的值),当主动添加构造器后,默认构造器不会被编译器再添加
  • 为了避免隐式转换,将单参数构造器声明为explicit
  • 为避免拷贝构造函数, 赋值操作的滥用和编译器自动生成, 可将其声明为 private 且无需实现 或使用delete(c11)
  • 仅仅作为数据载体时使用struct
  • 组合 > 实现继承 > 接口继承 > 私有继承, 子类重载的虚函数也要声明 virtual 关键字, 虽然编译器允许不这样做;
  • 避免多继承
  • 接口使用Interface为后缀命名,除了提供带实现的虚析构函数,静态成员外,其他均为纯虚函数[13]
  • 为降低复杂性, 尽量不重载操作符, 模板, 标准类中使用时提供文档说明
  • 存取函数一般内联在头文件中
  • 声明次序: public -> protected -> private;

四、函数

4.1 参数顺序

总述:

函数的顺序为,输入在前,输出在后

说明:

C/C++ 中的函数参数或者是函数的输入, 或者是函数的输出, 或兼而有之. 输入参数通常是值参或 const 引用, 输出参数或输入/输出参数则一般为非 const 指针. 在排列参数顺序时, 将所有的输入参数置于输出参数之前. 特别要注意, 在加入新参数时不要因为它们是新参数就置于参数列表最后, 而是仍然要按照前述的规则, 即将新的输入参数也置于输出参数之前.

这并非一个硬性规定. 输入/输出参数 (通常是类或结构体) 让这个问题变得复杂. 并且, 有时候为了其他函数保持一致, 你可能不得不有所变通.

4.2编写简短函数

总述:

尽量编写简短,凝练的函数

说明:

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

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

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

4.3引用参数

总述:

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

定义

在 C 语言中, 如果函数需要修改变量的值, 参数必须为指针, 如 int foo(int *pval). 在 C++ 中, 函数还可以声明为引用参数: int foo(int &val).

优点

定义引用参数可以防止出现 (*pval)++ 这样丑陋的代码. 引用参数对于拷贝构造函数这样的应用也是必需的. 同时也更明确地不接受空指针.

缺点

容易引起误解, 因为引用在语法上是值变量却拥有指针的语义.

结论:

函数参数列表中,所有的引用参数都必须是const

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

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

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

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

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

4.4函数重载

总述:

若要使用函数重载, 则必须能让读者一看调用点就胸有成竹, 而不用花心思猜测调用的重载函数到底是哪一种. 这一规则也适用于构造函数.

定义:

1
2
3
4
5
class MyClass {
public:
void Analyze(const string &text);
void Analyze(const char *text, size_t textlen);
};

缺点:

如果函数单靠不同的参数类型而重载 (acgtyrant 注:这意味着参数数量不变), 读者就得十分熟悉 C++ 五花八门的匹配规则, 以了解匹配过程具体到底如何. 另外, 如果派生类只重载了某个函数的部分变体, 继承语义就容易令人困惑.

结论:

如果打算重载一个函数, 可以试试改在函数名里加上参数信息. 例如, 用 AppendString()AppendInt() 等, 而不是一口气重载多个 Append(). 如果重载函数的目的是为了支持不同数量的同一类型参数, 则优先考虑使用 std::vector 以便使用者可以用 列表初始化 指定参数.[15]

4.5缺省参数

总述

只允许在非虚函数中使用,却必须保证缺省参数的值始终一致.

优点:

有些函数一般情况下使用默认参数, 但有时需要又使用非默认的参数. 缺省参数为这样的情形提供了便利, 使程序员不需要为了极少的例外情况编写大量的函数. 和函数重载相比, 缺省参数的语法更简洁明了, 减少了大量的样板代码, 也更好地区别了 “必要参数” 和 “可选参数”.

缺点:

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

虚函数调用的缺省参数取决于目标对象的静态类型,此时无法保证给定函数的所有重载声明的都是同样的缺省参数.

缺省参数是在每个调用点都要进行重新求值的, 这会造成生成的代码迅速膨胀. 作为读者, 一般来说也更希望缺省的参数在声明时就已经被固定了, 而不是在每次调用时都可能会有不同的取值.

缺省参数会干扰函数指针, 导致函数签名与调用点的签名不一致. 而函数重载不会导致这样的问题

结论:

对于虚函数, 不允许使用缺省参数, 因为在虚函数中缺省参数不一定能正常工作. 如果在每个调用点缺省参数的值都有可能不同, 在这种情况下缺省函数也不允许使用. (例如, 不要写像 void f(int n = counter++); 这样的代码.)

在其他情况下, 如果缺省参数对可读性的提升远远超过了以上提及的缺点的话, 可以使用缺省参数. 如果仍有疑惑, 就使用函数重载.

4.6函数返回类型后置语句

总述:

只有在常规写法不便的时候,才会采用后置返回类型

定义:

C++11 引入了这一新的形式. 现在可以在函数名前使用 auto 关键字, 在参数列表之后后置返回类型. 例如:

1
auto foo (int x)->int;

优点:

后置返回类型显示地制定lambda表达式的返回值的唯一方式,某些情况下编译器可以自动推断出lambda返回值类,但并且所有情况都可以,所以主动声明.

1
template <class T, class U> auto add(T t, U u) -> decltype(t + u);

对比

1
2
template <class T, class U> decltype(declval<T&>() + declval<U&>()) add(T t, U u);
//此处使用的decltype(t+v) 只能去理解为t+v之后的类型

缺点:

后置返回类型相对来说是非常新的语法, 而且在 C 和 Java 中都没有相似的写法, 因此可能对读者来说比较陌生.

在已有的代码中有大量的函数声明, 你不可能把它们都用新的语法重写一遍. 因此实际的做法只能是使用旧的语法或者新旧混用. 在这种情况下, 只使用一种版本是相对来说更规整的形式.

结论:

在大部分情况下, 应当继续使用以往的函数声明写法, 即将返回类型置于函数名前. 只有在必需的时候 (如 Lambda 表达式) 或者使用后置语法能够简化书写并且提高易读性的时候才使用新的返回类型后置语法. 但是后一种情况一般来说是很少见的, 大部分时候都出现在相当复杂的模板代码中, 而多数情况下不鼓励写这样 复杂的模板代码.

五、其他c++特性


  1. 此处的表示编译过程我不是非常了解 ↩︎

  2. 这个例子的原因我没看懂 ↩︎

  3. 这里就要明白作用域的范围, 文件 函数 {} 都是作用范围标识 ↩︎

  4. 原生数据类型 (POD : Plain Old Data): 即 int, char 和 float, 以及 POD 类型的指针、数组和结构体。 ↩︎

  5. 此部分说明要通过实践来理解,理解为静态类类型在程序中不是你确定构造和析构,具体见静态储存周期 ↩︎

  6. 这种情况应该是见过 ↩︎

  7. 要了解智能指针相关 ↩︎

  8. 此处是指通过静态函数来构建对象,并且在该函数中进行对象检查,并向上层提出是否发生错误 ↩︎

  9. c11中可以通过delete关键字禁止函数,否则应该要使用private权限 ↩︎

  10. c++多态是通过引用 以及指针进行语法调用的,通过值则会导致对象切割 ↩︎

  11. 如果想要进行子类重写父类方法,那么父类方法必须是virtual修饰,当父类和子类函数签名相同时,这两个方式实质上并非是override关系 ↩︎

  12. 该操作符重载使用在如 class A{bool operator bool(){code…}},if(a),此时就会调用该函数 ↩︎

  13. 当类中存在纯虚函数时,该类不能被实例化,理解为java的抽象类 ↩︎

  14. 这里对于逻辑处理的函数来说,如果都是由if /else等组成的低级代码,其长度是由于编写者水平引起的,还是本身就是这样? ↩︎

  15. 此处表示可变参数不如使用vector,可变参数在c++的实现方式并不是数组 ↩︎

  16. 完全可以将缺省参数 type var=x 这个条语句作为函数内部代码, 这样就和不含缺省的函数形成了重载关系,也就是说java只是在语法上不支持所谓的缺省 ↩︎

文章作者: fancylight
文章链接: https://www.fancylight.top/2018/09/03/c++%20google%20style/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 博客
打赏
  • 微信
    微信
  • 支付寶
    支付寶

评论