c++

C++ Core Guidelines学习

"cpp/c"

Posted by Bill on July 14, 2021

1. 背景

2. Philosophy

2.1 P.1: Express ideas directly in code

意思是在代码里面表达想法。当然我们工作的时候可以通过文档,注释等对代码进行解释,但是如果将想要表达的意图蕴含在代码里会让开发者更容易看懂,比如文档中的例子:

不好的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void f(vector<string>& v)
{
    string val;
    cin >> val;
    // ...
    int index = -1;                    // bad, plus should use gsl::index
    for (int i = 0; i < v.size(); ++i) {
        if (v[i] == val) {
            index = i;
            break;
        }
    }
    // ...
}

好的例子:

1
2
3
4
5
6
7
8
void f(vector<string>& v)
{
    string val;
    cin >> val;
    // ...
    auto p = find(begin(v), end(v), val);  // better
    // ...
}

两个例子的目标是都是一致的,但明显后者在语义上来看更直观。额外以一个例子对比下find的效率,并加入一个set的例子. :

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include<iostream>
#include<algorithm>
#include<vector>
#include<set>
#include<climits>

#include <stdio.h>
#include <sys/time.h>
#include <unistd.h>

using namespace std; class Time {
 public:
  Time(){}
  ~Time(){}

  void start()
  {
    gettimeofday(&tv1,nullptr);
  }

  int end()
  {
    gettimeofday(&tv2,nullptr);
    return (1000000*(tv2.tv_sec - tv1.tv_sec) + (tv2.tv_usec - tv1.tv_usec));
  }
 private:
  struct timeval tv1, tv2;
};

void fun1(vector<int>& v, int val)
{
    auto p = find(begin(v), end(v), val);  // better
    cout<<"fun1 val = "<<*p<<endl;
}

void fun2(vector<int>& v, int val)
{
    int index = -1;                    // bad, plus should use gsl::index
    for (int i = 0; i < v.size(); ++i) {
        if (v[i] == val) {
            index = i;
            break;
        }
    }
    cout<<"fun2 val = "<<v[index]<<endl;
}

void fun3(set<int>& v, int val)
{
    auto p = v.find(val);
    cout<<"fun3 val = "<<*p<<endl;
}

int main() {
  Time time;
  vector<int> input;
  set<int> sinput;
  const int counts = 1<<25;
  for (int i = 0; i<=counts; i++) {
    input.push_back(i);
    sinput.insert(i);
  }
  time.start();
  fun1(input, counts);
  cout<<" func1 cost "<<time.end()<<endl;;
  time.start();
  fun2(input, counts);
  cout<<" func2 cost "<<time.end()<<endl;
  time.start();
  fun3(sinput, counts);
  cout<<" func3 cost "<<time.end()<<endl;
}

输出:

1
2
3
4
5
6
fun1 val = 33554432
 func1 cost 221844
fun2 val = 33554432
 func2 cost 186391
fun3 val = 33554432
 func3 cost 8

可以找到的值都一致,但效率上却还是有区别的。std::find和遍历找的方式的时间复杂度都是O(n),但set的是基于平衡树的方式找数据,得出来的速度也是很快。当然这是一个例子, 在不影响效率的前提下,应当选择更容易读懂的方式。

2.2 P.2: Write in ISO Standard C++

在某些环境中,C++扩展是必须的,但是并不是所有环境支持这些扩展的,建议控制这些扩展的使用,可以在扩展之上再通过封装一层接口层,用于控制是否使用这些扩展。以便在不支持这些扩展的系统环境中也能够正常使用。虽然扩展很方便,也在很多不同的编译器有相应的实现,但是由于没有严格定义的语义,导致可能会在不同的平台中有不同的表现。所以还是使用ISO标准的C++会更可靠。

2.3 P.3: Express intent

写代码除非通过函数名,注释等表明是要做什么的,否则根本不知道它是要干什么的。以如下一个例子为例:

1
2
3
4
gsl::index i = 0;
while (i < v.size()) {
    // ... do something with v[i] ...
    }

这是一段典型的看了不能明确其目标的代码,首先i被暴露出来了,就会有被误用的可能性,且i的生命周期比循环要更长,也就不好明确代码的用意是什么。假如不需要修改元素,更好的写法是:

1
for (const auto& x : v) { /* do something with the value of x */ }

由于不需要修改元素,可以加上const来防止被修改。读者看了这段代码就会明白,这只是一个单纯的循环来获取值。假如需要改值,可以这样写:

1
for (auto& x : v) { /* modify x */ }

有时候使用有名算法会更好,可以使用for_each的写法, 第三个参数可以加上具体的算法函数,可以让读者更清楚想要实现的目的。如:

1
2
for_each(v, [](int x) { /* do something with the value of x */ });
for_each(par, v, [](int x) { /* do something with the value of x */ });

为了表达代码的意图,可以通过构建的方式,来展示,如下面这个画线的例子,前者明显比较难懂,一堆参数放在那里,但后者以两点作为传参,就很清晰明了。

1
2
draw_line(int, int, int, int);  // obscure
draw_line(Point, Point);        // clearer

2.4 P.4: Ideally, a program should be statically type safe

一个程序应该保证是静态类型安全, 它的意思是,只要通过了静态类型检查,就能够保证程序没有问题。但是这是不可能的事情,可能会引发问题的是包括:

  • unions联合体
  • casts强制转换
  • array decay数组名转化为指针
  • range erros
  • narrowing conversions

unions为例,它可以包含多种类型不同的成员且占用相同的地址, 如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdio.h>
union data{
    int n;
    char ch;
    double f;
};
int main() {
    union data test = {123};
    test.ch = 'b';
    printf("%d %c\n", test.n, test.ch);
    printf("%p %p\n", &test.n, &test.ch);
}

输出:

1
2
98 b
0x7ffc3f21eb00 0x7ffc3f21eb00

可以看出,由于ch被设置为字符’b’了,所以之前的成员变量n也会被覆盖,因为union用的是相同地址。虽然这样并不会在编译阶段检查出错误,但是一旦调用了其余类型不同的成员时,就会导致数据异常。

C++17出现的variant可以被看做是类型安全的union,可以容纳不同类型的值,但一次只能容纳一种类型。如下例子:

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
#include <variant>
#include <string>
#include <cassert>
#include <iostream>

int main()
{
    std::variant<int, float> v, w;
    v = 42; // v contains int
    int i = std::get<int>(v);
    assert(42 == i); // succeeds
    w = std::get<int>(v);
    w = std::get<0>(v); // same effect as the previous line
    w = v; // same effect as the previous line

//  std::get<double>(v); // error: no double in [int, float],编译阶段已经报错
//  std::get<3>(v);      // error: valid index values are 0 and 1,编译阶段已经报错

    try //假如调用了float,也会抛出异常
    {
        std::get<float>(w); // w contains int, not float: will throw
    }
    catch (const std::bad_variant_access& ex)
    {
        std::cout << ex.what() << '\n';
    }
}

casts的情况也容易理解,upcast和downcast,前者是安全的,但后者是非安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Parent {
public:
  void sleep() {}
};

class Child: public Parent {
private:
  std::string classes[10];
public:
  void gotoSchool(){}
};

int main( )
{
  Parent *pParent = new Parent;
  Parent *pChild = new Child;

  Child *p1 = (Child *) pParent;  // #1
  p1->gotoSchool();
  Parent *p2 = (Child *) pChild;  // #2
  p2->sleep();
  return 0;
}

前者的downcast转换是非安全的,因为它将一个基类的地址传给了子类,所以在代码中就会期待基类对象能够有子类对象的属性,比如这里的gotoSchool函数,但实际上基类对象是没有的。

array decay,通常数组可以通过sizeof(array)/sizeof(数组类型)来获取数组长度信息,但如果将数组传入到其他的块作用域下,就没办法通过这样来获取长度信息。

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
#include<iostream>
#include<string>
using namespace std;

// Driver function to show Array decay
// Passing array by value
void aDecay(int *p)
{
    // Printing size of pointer
    cout << "Modified size of array is by "
        " passing by value: ";
    cout << sizeof(p) << endl;
}

// Function to show that array decay happens
// even if we use pointer
void pDecay(int (*p)[7])
{
    // Printing size of array
    cout << "Modified size of array by "
        "passing by pointer: ";
    cout << sizeof(p) << endl;
}

int main()
{
    int a[7] = {1, 2, 3, 4, 5, 6, 7,};

    // Printing original size of array
    cout << "Actual size of array is: ";
    cout << sizeof(a) <<endl;

    // Passing a pointer to array as an array is always passed by reference
    aDecay(a);

    // Calling function by pointer
    pDecay(&a);

    return 0;
}

输出:

1
2
3
Actual size of array is: 28
Modified size of array is by  passing by value: 8
Modified size of array by passing by pointer: 8

2.5 P.5: Prefer compile-time checking to run-time checking

即优先选择编译的检查,而非运行时的检查,这样就不需要写一些运行时的错误处理了(机智). 如文中给出的一个例子,对Int进行左移,但由于Int是alias,有可能会导致溢出后仍然大于0的情形:

1
2
3
4
5
6
// Int is an alias used for integers
int bits = 0;         // don't: avoidable code
for (Int i = 1; i; i <<= 1)
    ++bits;
if (bits < 32)
    cerr << "Int too small\n";

但只要适时加入静态检查,就可以避免这种未知行为的产生:

1
2
// Int is an alias used for integers
static_assert(sizeof(Int) >= 4);    // do: compile-time check

当然更好的方法不是使用这种Int的别名,而是直接用int32_t这种明确的类型。

Guideline还举了一个例子,如使用C风格的方式,容易超出下标导致运行时发生越界的段错误:

1
2
3
4
5
void read(int* p, int n);   // read max n integers into *p

int a[100];
read(a, 1000);    // bad, off the end
better

使用span可以解决这类问题,在编译阶段就可以检查出来。

1
2
3
void read(span<int> r); // read into the range of integers r
int a[100];
read(a);        // better: let the compiler figure out the number of elements

span是c++20的新特性,并且传进来的可以是数组或者是类似std的vector之类的,而且厉害的是数量在编译阶段就可以确定了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <vector>
#include <span>
using namespace std;
void func(span<int> input) {
  cout << "size is " << input.size() << endl;
  for (auto item : input) {
    cout << item << " ";
  }
  cout << endl;
}
int main() {
  int arr[] = {1, 2, 3, 4, 5};
  func(arr);
  vector<int> vec{1, 2, 3, 4, 5, 6};
  func(vec);
  return 0;
}

输出:

1
2
3
4
size is 5
1 2 3 4 5
size is 6
1 2 3 4 5 6

2.6 P.6: What cannot be checked at compile time should be checkable at run time

在程序中留有难以检测的错误就是在请求崩溃和错误。我们写代码不可能把所有的错误都检测得到,但是应该要尽力去实现错误检查机制,当不可能在编译时检测出所有异常时,应当在运行时检测剩余的错误。

如博客给出的几个不好的例子:

例子1:

1
2
3
4
5
6
7
8
// separately compiled, possibly dynamically loaded
extern void f(int* p);

void g(int n)
{
    // bad: the number of elements is not passed to f()
    f(new int[n]);
}

上面这个例子f的入参为指针,传数组会退化成指针,这里在f函数中就没有办法指导n的值,也就无法得知数组的大小,容易导致越界。

例子2:

1
2
3
4
5
6
7
8
Example, bad We can of course pass the number of elements along with the pointer:
// separately compiled, possibly dynamically loaded
extern void f2(int* p, int n);

void g2(int n)
{
    f2(new int[n], m);  // bad: a wrong number of elements can be passed to f()
}

当然我们也可以传入数组的大小进去,但是也会存在后续调用的时候,index越界的情况。

例子3:

1
2
3
4
5
6
7
8
9
10
Example, bad The standard library resource management pointers fail to pass the size when they point to an object:
// separately compiled, possibly dynamically loaded
// NB: this assumes the calling code is ABI-compatible, using a
// compatible C++ compiler and the same stdlib implementation
extern void f3(unique_ptr<int[]>, int n);

void g3(int n)
{
    f3(make_unique<int[]>(n), m);    // bad: pass ownership and size separately
}

假如函数设计成标准库和容器大小分开传入的形式,就会存在传入的大小和实际容器大小不符合的可能。

所以我们还是需要将对象和数组大小作为一个完整的整体传入,一些好的例子包括:

1
2
3
4
5
6
7
8
9
extern void f4(vector<int>&); // separately compiled, possibly dynamically loaded extern void f4(span<int>);   // separately compiled, possibly dynamically loaded
                             // NB: this assumes the calling code is ABI-compatible, using a
                             // compatible C++ compiler and the same stdlib implementation

void g3(int n) {
    vector<int> v(n);
    f4(v); // pass a reference, retain ownership
    f4(span<int>{v}); // pass a view, retain ownership
}

2.7 P.7: Catch run-time errors early

本节希望在更早的时候捕捉到运行时异常,还是以越界为例子。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void increment1(int* p, int n)    // bad: error-prone
{
    for (int i = 0; i < n; ++i) ++p[i];
}

void use1(int m)
{
    const int n = 10;
    int a[n] = {};
    // ...
    increment1(a, m);   // maybe typo, maybe m <= n is supposed
                        // but assume that m == 20
    // ...
}

如果m=20,在程序执行时就会异常,如果能够在之前就检查一下是否会超过范围,那么就不会在运行到取下标时才暴露问题了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void increment2(span<int> p)
{
    for (int& x : p) ++x;
}

void use2(int m)
{
    const int n = 10;
    int a[n] = {};
    // ...
    //这里应该要有一个assert(m <= n);比较合理。
    increment2({a, m});    // maybe typo, maybe m <= n is supposed
    // ...
}

当然使用span完全可以不用传入大小了,如:

1
2
3
4
5
6
7
8
void use3(int m)
{
    const int n = 10;
    int a[n] = {};
    // ...
    increment2(a);   // the number of elements of a need not be repeated
    // ...
}

2.8 P.8: Don’t leak any resources

这一章节是劝告不要泄漏资源,即使是很慢的泄漏方式,也会在长期的老化中暴露出来。

不好的例子:

1
2
3
4
5
6
7
8
void f(char* name)
{
    FILE* input = fopen(name, "r");
    // ...
    if (something) return;   // bad: if something == true, a file handle is leaked
    // ...
    fclose(input);
}

中途退出时容易忘记关闭句柄(C代码很容易出现这样的错误)

好的例子:

1
2
3
4
5
6
7
void f(char* name)
{
    ifstream input {name};
    // ...
    if (something) return;   // OK: no leak
    // ...
}

通过隐式的析构函数,在生命周期结束时执行,会是更安全的选择。当然也可以看看gsl库的owner是怎么实现的。

2.9 P.9: Don’t waste time or space

本小节是不要浪费时间和空间,如下面这个例子,在waste中,开辟了一个动态内存进行赋值,结束的时候销毁,完全可以使用静态变量实现。

坏的例子:

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
struct X {
    char ch;
    int i;
    string s;
    char ch2;

    X& operator=(const X& a);
    X(const X&);
};

X waste(const char* p)
{
    if (!p) throw Nullptr_error{};
    int n = strlen(p);
    auto buf = new char[n];
    if (!buf) throw Allocation_error{};
    for (int i = 0; i < n; ++i) buf[i] = p[i];
    // ... manipulate buffer ...
    X x;
    x.ch = 'a';
    x.s = string(n);    // give x.s space for *p
    for (gsl::index i = 0; i < x.s.size(); ++i) x.s[i] = buf[i];  // copy buf into x.s
    delete[] buf;
    return x;
}

void driver()
{
    X x = waste("Typical argument");
    // ...
}

坏的例子:

以下是一个更常见的例子,每一次调用lower时,循环都会调strlen方法,完全可以在进入for前先计算出长度值。

1
2
3
4
void lower(zstring s)
{
    for (int i = 0; i < strlen(s); ++i) s[i] = tolower(s[i]);
}

2.10 P.10: Prefer immutable data to mutable data

本章节很短,目的是优先选择不变的数据,常量。如常量不会被莫名其妙的改动,常量能够在编译阶段优化。

2.11 P.11: Encapsulate messy constructs, rather than spreading through the code

本章的观点是,混乱的结构容易产生异常,并且难以维护和开发,一个好的接口应该是容易去使用的,如下面不好的例子,就会容易疏忽了对内存的消耗。

1
2
3
4
5
6
7
8
9
10
11
12
13
int sz = 100;
int* p = (int*) malloc(sizeof(int) * sz);
int count = 0;
// ...
for (;;) {
    // ... read an int into x, exit loop if end of file is reached ...
    // ... check that x is valid ...
    if (count == sz)
        p = (int*) realloc(p, sizeof(int) * sz * 2);
    p[count++] = x;
    // ...
}

用C++的话,使用vector代替会更简洁清晰。

1
2
3
4
5
6
7
vector<int> v;
v.reserve(100);
// ...
for (int x; cin >> x; ) {
    // ... check that x is valid ...
    v.push_back(x);
}

简单来说,就是善用工具库,不用自己造轮子,不仅容易出错,而且难写。

2.12 P.12: Use supporting tools as appropriate

本章观点是将一些重复性工作使用工具来完成,将精力放在其他上面。如我现在写C++的时候,都会在保存的时候运行下Clang-Format插件,保证格式。工具集后续有用上的继续补充。

2.13 P.13: Use support libraries as appropriate

本章推荐使用良好设计的库,能够节省时间和提高效率。推荐ISO C++和GSL的库(没用过)

3. Interfaces

3.1 Make interfaces explicit

本章的观点是,使得接口能够显示化,原因是一些判断或者假设,如果不是在接口里面定义或者描述的,就很容易被遗漏,比如一个不好的例子:

1
2
3
4
int round(double d)
{
    return (round_up) ? ceil(d) : d;    // don't: "invisible" dependency
}

round_up是一个全局的变量,就会容易产生困惑,可能会因这个变量导致不同的计算结果。