360代码规范五:type

class

类的非常量数据成员均应该是private

类的数据成员均应设为 private,对外统一由成员函数提供访问方法,且应避免返回 private 成员的非常量引用或指针。

将类的所有接口都实现为成员函数,由成员函数按指定逻辑读写数据,以便保证有效地改变对象状态。良好的接口设计会对代码的职责进行合理划分,显著提升可维护性。理想状态下,当有错误需要修正或有功能需要调整时,只改动相关接口的实现即可,调用接口的代码不需要改动,从而将改动降到最低。这种设计的基础便是将数据设为 private,只能由本类的成员函数访问,否则数据可被各个模块随意读写,当有一处需要改动时,很难控制其影响范围。

常量数据成员不可被改变,所以可不受本规则约束。

示例:

1
2
3
4
5
6
struct A {
int *p, n; // Non-compliant

A(int n): p(new int[n]), n(n) {}
~A() { delete[] p; }
};

例中类的数据成员 p 指向动态分配的内存区域,n 记录区域大小,p 和 n 之间存在紧密的逻辑关系,这种内在关系应由成员函数统一维护,不暴露给类的使用者,这便是面向对象的封装理念,也是 C++ 语言的核心理念之一。

应改为:

1
2
3
4
5
6
7
8
9
10
class A {
int *p, n; // Compliant

public:
A(int n): p(new int[n]), n(n) {}
~A() { delete[] p; }

int* begin() { return p; } // Interfaces for members
int* end() { return p + n; }
};

这样数据成员不能被外界直接访问,成员之间的关系也不会被随意打破,显著提升可维护性。

类的非常量数据成员不应定义为 protected

protected 数据成员在派生类中仍可被随意读写,破坏了封装理念。

本规则是 ID_nonPrivateData 的特化,关于封装的进一步讨论可参见 ID_nonPrivateData。

常量数据成员不可被改变,所以可不受本规则约束。

示例:

1
2
3
4
5
class A {
....
protected:
int data; // Non-compliant
};

应改为由接口访问:

1
2
3
4
5
6
7
class A {
....
protected:
int access_data(); // Interfaces for data
private:
int data; // Compliant
};

类不应既有public数据成员又有private数据成员

类的设计应遵循:

  • 成员之间没有依赖关系,且都可以随意被读写时,则都应声明为 public
  • 成员之间有依赖关系,或成员的状态会影响到整个对象的状态时,则都应声明为 private

否则应对类进行改造或拆分。

面向对象的封装理念更倾向于将所有数据成员都设为 private,由成员函数按指定逻辑控制每个成员的读写方法,以供外部访问,对代码的职责进行有效地划分,从而提高可维护性并降低风险,关于封装的进一步讨论可参见 ID_nonPrivateData。

常量数据成员不可被改变,所以可不受本规则约束。

示例:

1
2
3
4
5
6
7
class A {  // Non-compliant
public:
int n;
....
private:
int d;
};

应改为:

1
2
3
4
5
6
7
class A {  // Compliant
public:
int method_for_n();
....
private:
int n, d;
};

有虚函数的基类应具有虚析构函数

为了避免意料之外的资源泄漏,有虚函数的基类都应该具有虚析构函数。

通过基类指针析构派生类对象时,如果基类没有虚析构函数会导致标准未定义的行为,无法正确执行派生类的析构函数。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A {
public:
A() = default;
~A() = default; // Non-compliant, missing ‘virtual’
virtual int foo() = 0;
};

class B: public A {
int *m, n; // New resource

public:
B(int s): m(new int[s]), n(s) {}
~B() { delete[] m; }
int foo() override { return n; }
};

A* p = new B(10);
....
delete p; // Undefined behavior, may leak

由于基类 A 的析构函数不是虚函数,delete p 只调用了基类析构函数,派生类对象的资源没有得到释放。

例外:

1
2
3
4
5
class C {
....
protected:
~C(); // Compliant
};

如果有意阻止外界通过基类指针析构对象,如析构函数是 protected,可不受本规则限制。

避免多重继承自同一非虚基类

当派生类有多个基类,这些基类又派生自同一非虚基类时,派生类对象会持有该非虚基类的多个实例,造成逻辑和存储上的冗余。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct A {
int i = 0;
};

class B: public A {};
class C: public A {};
class D: public B, public C {}; // Non-compliant

void foo(D& d) {
d.i = 1; // Compile error
d.B::i = 1; // Odd
d.C::i = 1; // Odd
}

在 D 类对象 d 中,基类 A 的成员 i 有两个不同的实例,d 不能直接访问 i,只能通过 d.B::i 或 d.C::i 这种怪异的方式访问。

将共同的基类设为虚基类可以解决这种问题:

1
2
3
4
5
6
7
class B: virtual public A {};
class C: virtual public A {};
class D: public B, public C {}; // Compliant

void foo(D& d) {
d.i = 1; // OK
}

注意,直接将虚基类指针转为派生类指针会导致标准未定义的行为,如:

1
2
3
4
void bar(A* a) {
B* p = (B*)a; // Undefined behavior
....
}

这种转换一般不会通过编译,但标准并未要求编译器必须阻止这种转换,改用 dynamic_cast 可解决这些问题:

1
2
3
4
void bar(A* a) {
B* p = dynamic_cast<B*>(a); // OK
....
}

存在析构函数或拷贝赋值运算符时,不应缺少拷贝构造函数

三个紧密相关的函数:

  1. 拷贝构造函数
  2. 拷贝赋值运算符
  3. 析构函数

当这三个函数中的任何一个函数被定义时,说明对象在资源管理等方面有特定的需求,其他两个函数也需要被定义,否则难以适应各种应用场景,易产生意料之外的错误,这种规则称为“Rule of three)”。

如果缺少某个函数,编译器会生成相关默认函数,但其特定需求不会被实现。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class A   // Non-compliant
{
int* p = new int[8];
public:
~A() {
delete[] p;
}
}; // Missing copy constructor and assignment operator

void foo()
{
A a;
A b(a); // Shallow copy
....
} // Double free

void bar(A& a, A& b)
{
a = b; // Memory leak
}

例中的类有析构函数,但没有拷贝构造函数和拷贝赋值运算符,只能进行变量值的复制,使多个对象的资源指针指向同一块内存区域,导致重复释放和内存泄漏,所以应定义拷贝构造函数和拷贝赋值运算符重新分配内存并复制数据。

同理,在遵循 C++11 及之后标准的代码中:

  1. 拷贝构造函数
  2. 拷贝赋值运算符
  3. 析构函数
  4. 移动构造函数
  5. 移动赋值运算符

当定义了这五个函数中的任何一个函数时,其他四个函数也需要定义,详见 ID_violateRuleOfFive。

避免重复实现由默认拷贝、移动、析构函数完成的功能

当类只负责成员对象的包装或组合而没有特殊的复制、移动、析构需求时,不应定义下列函数:

  1. 拷贝构造函数
  2. 拷贝赋值运算符
  3. 析构函数
  4. 移动构造函数
  5. 移动赋值运算符

应由编译器生成相关默认函数,否则会产生多余的代码,增加维护成本,这种规则称为“Rule of zero”。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A {
string a, b;

public:
A(const A& rhs): a(rhs.a), b(rhs.b) { // Redundant
}
A& operator = (const A& rhs) { // Redundant
a = rhs.a;
b = rhs.b;
return *this;
}
~A() { // Redundant
}
};

例中的类只涉及字符串对象的组合,复制、移动和析构可交由成员对象完成,其拷贝构造函数、赋值运算符以及析构函数是多余的,应该去掉,编译器会进行更好地处理。

可接受一个参数的构造函数需用 explicit 关键字限定

为了避免意料之外的类型转换,可接受一个参数的构造函数应该用 explicit 关键字限定。

示例:

1
2
3
4
5
6
7
8
9
10
11
class String {
public:
String(int capacity); // Non-compliant, missing ‘explicit’
....
};

void foo(const String&);

int bar() {
foo(100); // Can be compiled, but very odd
}

由于 String 类的构造函数接受一个 int 型参数,foo(100) 相当于将 100 隐式转为 String 类的对象,这种隐式转换是怪异的,也往往意味着意料之外的错误。

应改为:

1
2
3
4
5
class String {
public:
explicit String(int capacity); // Compliant
....
};

这样 foo(100) 这种写法便不会通过编译。

例外:

1
2
3
4
5
6
class String {
public:
String(const String&); // Explicit or not depends on your design intent
String(String&&); // ditto
....
};

拷贝、移动构造函数可不受本规则约束,如果将拷贝、移动构造函数声明为 explicit 则无法再按值传递参数或按值返回对象。在类的接口设计中,应尽量减少隐式转换以避免不易察觉的问题。

重载的类型转换运算符需用explicit关键字限定

为了避免意料之外的类型转换,重载的类型转换运算符需用 explicit 关键字限定。

示例:

1
2
3
4
5
6
7
8
9
10
struct A {
....
operator char*(); // Non-compliant
};

A foo();

char* bar() {
return foo(); // Invalid address returned
}

例中 foo 返回临时对象,类型转换运算符被隐式调用,然而当 bar 返回后,临时对象被销毁,返回的指针是无效的。

将类型转换运算符用 explicit 关键字限定,有问题的代码便不会通过编译:

1
2
3
4
struct A {
....
explicit operator char*(); // Compliant
};

在类的接口设计中,应尽量减少隐式转换以避免不易察觉的问题。

不要过度使用explicit

对类的拷贝、移动以及不接受 1 个参数的构造函数一般不用 explicit 限定,否则有损代码的易用性和可扩展性。

示例:

1
2
3
4
5
6
7
class A {
public:
explicit A(const A&); // In general, ‘explicit’ is not required
explicit A(A&&); // Ditto
explicit A(int, int); // Ditto
....
};

当类的拷贝、移动构造函数被 explicit 限定时,无法再按值传递参数或按值返回对象,当不接受 1 个参数的构造函数被 explicit 限定时,无法再用初始化列表定义临时对象,如下代码将无法通过编译:

1
2
3
4
5
6
7
void foo(A);
void bar(const A&);

A a(1, 2);

foo(a); // Compile error
bar({3, 4}); // Compile error

带模板的赋值运算符不应与拷贝或移动赋值运算符混淆

带模板的赋值运算符不应与拷贝或移动赋值运算符混淆,存在带模板的赋值运算符时应明确声明拷贝和移动赋值运算符。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class A   // Non-compliant, missing copy and move assignment operators
{
int* dat; // Need deep copy

public:
A();
~A();
template <class T>
A& operator = (const T& a) { // Not a copy assignment operator
return do_copy(a.dat);
}
template <class T>
A& operator = (T&& a) { // Not a move assignment operator
return do_move(a.dat);
}
};

void foo(A& x, A& y)
{
x = y; // Not a deep copy
}

设例中的类需要深拷贝,标准规定即使带模板的赋值运算符在功能上可以满足拷贝或移动赋值运算符的需求,也不能作为拷贝或移动赋值运算符,故其拷贝和移动赋值运算符仍然是默认的,无法完成深拷贝以及正确的数据移动。

应明确声明拷贝和移动赋值运算符:

1
2
3
4
5
6
7
class A   // Compliant
{
....
A& operator = (const A&);
A& operator = (A&&);
....
};

带模板的构造函数不应与拷贝或移动构造函数混淆

带模板的构造函数不应与拷贝或移动构造函数混淆,存在带模板的构造函数时应明确声明拷贝和移动构造函数。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class A   // Non-compliant, missing copy and move constructors
{
int* dat; // Need deep copy

public:
A();
~A();
template <class T> A(const T& a) { // Not a copy constructor
do_copy(a.dat);
}
template <class T> A(T&& a) { // Not a move constructor
do_move(a.dat);
}
};

void foo(A& x)
{
A y(x); // Not a deep copy
....
}

设例中的类需要深拷贝,标准规定即使带模板的构造函数在功能上可以满足拷贝或移动构造函数的需求,也不能作为拷贝或移动构造函数,故其拷贝和移动构造函数仍然是默认的,无法完成深拷贝以及正确的数据移动。

应明确声明拷贝和移动构造函数:

1
2
3
4
5
6
7
class A   // Compliant
{
....
A(const A&);
A(A&&);
....
};

抽象类禁用拷贝和移动赋值运算符

抽象类只能作为基类,没有独立的对象,调用拷贝或移动赋值运算符会造成数据不完整。

示例:

1
2
3
4
5
6
7
8
9
10
struct A {
virtual ~A() = 0;
A& operator = (const A&); // Non-compliant
A& operator = (A&&); // Non-compliant
....
};

void foo(A& x, A& y) {
x = y; // Incomplete assignment
}

例中 foo 函数的参数只能是 A 的派生类对象,派生类对象调用基类的拷贝赋值运算符会得到不完整的复制结果。

应改为:

1
2
3
4
5
6
struct A {
virtual ~A() = 0;
A& operator = (const A&) = delete; // Compliant
A& operator = (A&&) = delete; // Compliant
....
};

将抽象类的拷贝和移动赋值运算符设为 =delete 或 private,可在编译期阻止不完整的复制和移动。

数据成员的数量应在规定范围之内

类或联合体的数据成员过多意味着一个逻辑或功能单位承担了过多的职责,违反了模块化设计理念,是难以维护的。

示例:

1
2
3
4
5
6
7
8
9
10
11
class C
{
// ... 3000 members ...
// Who has the courage to read?
};

union U
{
// ... 3000 members ...
// Here is hell!
};

数据成员之间的填充数据不应被忽视

成员之间存在填充数据,且没有声明对齐方式时,填充数据的长度是由实现定义的,这种数据不应在不同的环境之间传输,而且应注意成员的声明顺序,避免由填充数据造成的空间浪费。

关于填充数据的具体组织方式,详见“内存对齐”。

示例:

1
2
3
4
5
6
struct T {
int8_t a;
int32_t b;
} obj;

recv(sockfd, &obj, sizeof obj, flags); // Non-compliant

例中成员 a 和 b 之间存在填充数据,但没有声明对齐方式,直接在网络上传输这种类型的对象是不符合要求的,如果发送端的对齐方式与接收端不一致就会造成混乱。

应在发送端和接收端统一声明对齐方式:

1
2
3
4
struct alignas(4) T {   // Or use _Alignas in C
int8_t a;
int32_t b;
};

注意,敏感数据可能会残留在填充数据中,所以当存储或传输对象前有必要清理填充数据的值,如:

1
2
3
4
T obj;
memset(&obj, 0, sizeof(obj)); // Required
....
fwrite(&obj, sizeof(obj), 1, fp);

常量成员函数不应返回数据成员的非常量指针或引用

如果常量成员函数返回数据成员的非常量指针或引用,既打破了常量限定,又违反了封装理念,属于不良实现方式。

本规则是 ID_qualifierCastedAway 的特化。

示例:

1
2
3
4
5
6
7
8
9
10
class A
{
int i;

public:
int& foo() const {
return (int&)i; // Non-compliant
}
....
};

类成员应按 public、protected、private 的顺序声明

类成员统一按 public、protected、private 的顺序声明,有利于提高可读性。

示例:

1
2
3
4
5
6
7
8
9
10
11
class A   // Bad
{
private:
int baz();

public:
int foo();

protected:
int bar();
};

供外部使用的 public 成员应作为重点写在前面,其次是 protected 成员,private 成员应写在最后:

1
2
3
4
5
6
7
8
9
10
11
class A   // Good
{
public:
int foo();

protected:
int bar();

private:
int baz();
};

存在构造、析构或虚函数的类不应采用 struct 关键字

简单结构体应采用 struct 关键字,具有封装或多态等特性的类应采用 class 关键字,以便提高可读性。

示例:

1
2
3
4
5
6
7
8
9
10
11
struct A {     // Compliant
int x, y;
};

struct B { // Non-compliant
B();
~B();

private:
int x, y;
};

enum

同类枚举项的值不应相同

枚举项用于标记不同的事物,名称不同但值相同的枚举项往往意味着错误。

示例:

1
2
3
4
5
enum Color {
red = 1,
yellow = 2,
blue = 2, // Non-compliant, see ‘yellow’
};

例中三个枚举项应分别表示三种颜色,但 blue 与 yellow 的值相同会造成逻辑错误。

又如:

1
2
3
4
5
6
enum Fruit {
apple,
pear,
grape,
favorite = grape, // Non-compliant
};

例中 Fruit 定义了三种水果,而 favorite 表示最喜欢的水果,与其他枚举项不是同一层面的概念,不应聚为一类。

应采用更结构化的方式:

1
2
3
4
5
6
7
enum Fruit {
apple, pear, grape
};

Fruit favorite () {
return grape;
}

合理初始化各枚举项

合理初始化各枚举项,只应从下列方式中选择一种:

  • 全不初始化
  • 只初始化第一个
  • 全部初始化为不同的值

示例:

1
2
3
4
5
6
enum Color {
red,
blue,
green,
yellow = 2 // Non-compliant
};

应改为:

1
2
3
4
5
6
enum Color {
red,
blue,
green,
yellow // Compliant
};

不应使用匿名枚举类型

匿名枚举声明相当于在当前作用域定义常量,但类型不够明确。

示例:

1
enum { rabbit = 0xAA, carrot = 1234 };  // Non-compliant

如果无法确定枚举类型的名称,也意味着各枚举项不应聚为一类。

应改为:

1
2
const int rabbit = 0xAA;  // Compliant
const int carrot = 1234; // Compliant

用enum class取代enum

传统 C 枚举没有有效的类型和作用域控制,极易造成类型混淆和名称冲突,在 C++ 代码中建议改用 enum class。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
enum E {      // Non-compliant
e0 = 0,
e1 = 1,
e2 = -1
};

E foo();

void bar() {
if (foo()) { // ‘e1’ or ‘e2’??
....
}
}

传统 C 枚举值与 int 等类型可以随意转换,如果 e0 和 e2 表示某种错误情况,e1 表示正确情况,那么 bar 函数中对 foo 返回值的判断就是错误的,这也是一种常见问题,C++11 提出了 enum class 的概念加强了类型检查,提倡在新项目中尽量使用 enum class。

应改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum class E {   // Compliant
e0 = 0,
e1 = 1,
e2 = -1
};

void bar() {
if (foo() == E::e1) { // OK
....
}
if (foo()) { // Compile error, cannot cast the enum class casually
....
}
}

Union

联合体禁用非基本类型的对象

因为联合体成员之间共享内存地址,所以成员具有构造或析构函数时会导致混乱。

C++98/03 禁止具有拷贝构造函数或析构函数的对象出现在联合体中,C++11 解除了这条禁令,但在语言层面上不保障正确性,相当于把问题抛给了用户。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
union U {
int i;
string s; // Non-compliant

U(int x): i(x) {
}

U(const char* x) {
new(&s) string(x);
}

~U() {
s.~string();
}
};

U u(1);
u.s = "abc"; // No error, no warning, just crash

示例代码在某些环境中会崩溃,原因是没能正确区分对象当前持有的类型,执行了错误的构造或析构过程。

正确的做法是在类中用一个成员变量记录当前持有的类型,再将匿名联合体与类的构造函数以及析构函数相关联,从而根据当前持有的类型正确地初始化或销毁对象。

禁用在类之外定义的联合体

联合体各成员共享存储地址,易引发意料之外的错误。如果一定要使用联合体,需对其进行一定的封装,避免对成员的错误访问。

不应出现:

  • 在命名空间作用域内定义的联合体
  • 在类中定义的具有 public 访问权限的联合体

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
union U {      // Non-compliant, global union
....
};

class A {
public:
union { // Non-compliant, public union
....
};
};

class B {
public:
.... // Interfaces about the union
private:
union { // Compliant, the union is under control
....
};
};

类的 public 数据成员本来就违反了封装原则,如果这种数据成员又处于联合体中,会进一步增加风险。

禁用联合体

联合体的问题主要有:

  • 无法只通过对象获取当前有效的成员
  • 访问不同的成员相当于不安全的类型转换
  • 对非基本类型的成员造成构造和析构的混乱
  • 不能作为基类

这些问题在本质上是对类型理念的破坏,面向对象的程序设计应避免使用联合体。

示例:

1
2
3
4
5
6
7
8
union U {    // Non-compliant
int i;
char c;
};

U u;
u.i = 1000;
cout << u.c << '\n'; // Equivalent to a cast without any restrictions

例中对 u.c 的访问也相当于一种没有任何限制的类型转换。

在 C++ 代码中建议用 std::variant 或 std::any 取代联合体:

1
2
3
4
std::variant<int, char> u;
u = 1000;
cout << get<int>(u) << '\n'; // OK
cout << get<char>(u) << '\n'; // Throw ‘std::bad_variant_access’

std::variant 可以有效记录对象当前持有的类型,如果以不正确的类型访问对象会及时抛出异常。

本规则比 ID_forbidNakedUnion 更严格,针对所有联合体。