C++ Tips

C++

New features

C++11 引入了 {} 初始化表达式

C++11 引入了 range-based for-loop

C++14 的 lambda 允许用 auto 自动推断传入参数类型

C++17 CTAD / compile-time argument deduction / 编译期参数推断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;

int main(int argc, char *argv[]) {
vector v = {4,3,2,1}; // 编译期参数推断

int sum;
for_each(v.begin(),v.end(),[&](auto vi){
sum += vi;
});

cout << sum << endl;

return 0;
}

C++20 引入区间(ranges),g++ 编译时指定 -std=gnu20

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <vector>
#include <iostream>
#include <numeric>
#include <ranges>
#include <cmath>

int main() {
std::vector v = {4, 3, 2, 1, 0, -1, -2};

for (auto &&vi: v
| std::views::filter([] (auto &&x) { return x >= 0; })
| std::views::transform([] (auto &&x) { return sqrtf(x); })
) {
std::cout << vi << std::endl;
}

return 0;
}

C++20 引入模块(module)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import <vector>;
import <iostream>;
import <numeric>;
import <ranges>;
import <cmath>;

int main() {
std::vector v = {4, 3, 2, 1, 0, -1, -2};

for (auto &&vi: v
| std::views::filter([] (auto &&x) { return x >= 0; })
| std::views::transform([] (auto &&x) { return sqrtf(x); })
) {
std::cout << vi << std::endl;
}

return 0;
}

C++20 允许函数参数为自动推断(auto)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import <vector>;
import <iostream>;
import <numeric>;
import <ranges>;
import <cmath>;

void myfunc(auto &&v) {
for (auto &&vi: v
| std::views::filter([] (auto &&x) { return x >= 0; })
| std::views::transform([] (auto &&x) { return sqrtf(x); })
) {
std::cout << vi << std::endl;
}
}

int main() {
std::vector v = {4, 3, 2, 1, 0, -1, -2};
myfunc(v);
return 0;
}

C++23 引入协程(coroutine)和生成器(generator),注意需要切换到最新的编译器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import <vector>;
import <iostream>;
import <numeric>;
import <ranges>;
import <cmath>;
import <generator>;

std::generator<int> myfunc(auto &&v) {
for (auto &&vi: v
| std::views::filter([] (auto &&x) { return x >= 0; })
| std::views::transform([] (auto &&x) { return sqrtf(x); })
) {
co_yield vi;
}
}

int main() {
std::vector v = {4, 3, 2, 1, 0, -1, -2};
for (auto &&vi: myfunc(v)) {
std::cout << vi << std::endl;
}
return 0;
}

C++20 标准库加入 format 支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import <vector>;
import <iostream>;
import <numeric>;
import <ranges>;
import <cmath>;
import <generator>;
import <format>;

std::generator<int> myfunc(auto &&v) {
for (auto &&vi: v
| std::views::filter([] (auto &&x) { return x >= 0; })
| std::views::transform([] (auto &&x) { return sqrtf(x); })
) {
co_yield vi;
}
}

int main() {
std::vector v = {4, 3, 2, 1, 0, -1, -2};
for (auto &&vi: myfunc(v)) {
std::format_to(std::cout, "number is {}\n", vi);
}
return 0;
}

C++思想:封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdlib.h>
#include <stdio.h>

int main() {
//size_t nv = 4;
//int *v = (int *)malloc(nv * sizeof(int));
std::vector<int> v(4);

v[0] = 4;
v[1] = 3;
v[2] = 2;
v[3] = 1;

int sum = 0;
for (size_t i = 0; i < nv; i++) {
sum += v[i];
}

printf("%d\n", sum);

free(v);
return 0;
}

比如要表达一个数组,需要:起始地址指针v,数组大小nv。

因此 C++ 的 vector 将他俩打包起来,避免程序员犯错。

封装:不变性

当需要修改一个成员时,其他也成员需要被修改,否则出错

这种情况出现时,就意味着你需要把成员变量的读写封装为成员函数

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
#include <stdlib.h>
#include <stdio.h>

int main() {
//size_t nv = 2;
//int *v = (int *)malloc(nv * sizeof(int));
std::vector<int> v(2);

v[0] = 4;
v[1] = 3;

//nv = 4;
//v = (int *)realloc(v, nv * sizeof(int));
v.resize(4);

v[2] = 2;
v[3] = 1;

int sum = 0;
for (size_t i = 0; i < nv; i++) {
sum += v[i];
}

printf("%d\n", sum);

free(v);
return 0;
}

仅当出现“修改一个成员时,其他也成员要被修改,否则出错”的现象时,才需要getter/setter 封装。

各个成员之间相互正交,比如数学矢量类 Vec3,就没必要去搞封装,只会让程序员变得痛苦,同时还有一定性能损失。特别是当 getter/setter 函数分离了声明和定义,实现在另一个文件时。

C++思想:RAII(Resource Acquisition Is Initialization)

资源获取视为初始化,反之,资源释放视为销毁

与 Java,Python 等垃圾回收语言不同,C++ 的解构函数是显式的,离开作用域自动销毁,毫不含糊(有好处也有坏处,对高性能计算而言利大于弊)

异常安全(exception-safe)

C++ 标准保证当异常发生时,会调用已创建对象的解构函数。

因此 C++ 中没有(也不需要) finally 语句。

如果对时序有要求或对性能有要求就不能依靠 GC。比如 mutex 忘记 unlock 造成死锁等等……

自定义构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <string>

struct Pig {
std::string m_name;
int m_weight;

Pig()
{
m_name = "佩奇";
m_weight = 80;
}
};

int main() {
Pig pig;

std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;

return 0;
}

为什么需要初始化表达式?

  1. 假如类成员为 const 和引用

  2. 假如类成员没有无参构造函数

  3. 避免重复初始化,更高效

构造函数:单个参数(避免陷阱)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <string>

struct Pig {
std::string m_name;
int m_weight;

Pig(int weight)
: m_name("一只重达" + std::to_string(weight) + "kg的猪")
, m_weight(weight)
{}
};

int main() {
Pig pig = 80; // 编译通过
//Pig pig(80);

std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <string>

struct Pig {
std::string m_name;
int m_weight;

explicit Pig(int weight)
: m_name("一只重达" + std::to_string(weight) + "kg的猪")
, m_weight(weight)
{}
};

int main() {
// Pig pig = 80; // 编译错误
Pig pig(80); // 编译通过

std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;

return 0;
}

避免 80 被隐式转换为 pig 类,使用 explicit 禁止隐式类型转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <string>

struct Pig {
std::string m_name;
int m_weight;

explicit Pig(int weight)
: m_name("一只重达" + std::to_string(weight) + "kg的猪")
, m_weight(weight)
{}
};

void show(Pig pig) {
std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
// show(80); // 编译错误
show(Pig(80)); // 编译通过

return 0;
}

比如 std::vector 的构造函数 vector(size_t n) 也是 explicit 的

使用 {} 和 () 调用构造函数,有什么区别?

  1. int(3.14f) 不会出错,但是 int{3.14f} 会出错,因为 {} 是非强制转换。

  2. Pig(“佩奇”, 3.14f) 不会出错,但是 Pig{“佩奇”, 3.14f} 会出错,原因同上,更安全。

  3. 可读性:Pig(1, 2) 则 Pig 有可能是个函数,Pig{1, 2} 看起来更明确。

在 C++ 中:

  • 使用 static_cast<int>(3.14f) 而不是 int(3.14f)
  • 使用 reinterpret_cast<void >(0xb8000) 而不是 (void )0xb8000

更加明确用的哪一种类型转换(cast),从而避免一些像是 static_cast(ptr) 的错误。

编译器默认生成的构造函数:无参数

默认生成的构造函数,这些类型不会被初始化为 0

1.int, float, double 等基础类型

2.void , Object 等指针类型

3.完全由这些类型组成的类

这些类型被称为 POD(plain-old-data).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <string>

struct Pig {
std::string m_name;
int m_weight;
};

void show(Pig pig) {
std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
Pig pig;

show(pig);
return 0;
}
1
2
name: 
weight: -265808448

可以手动指定初始化 weight 为0。

通过 {} 语法指定的初始化值,会在编译器自动生成的构造函数里执行。

1
2
3
4
struct Pig {
std::string m_name;
int m_weight{0};
};
1
2
name: 
weight: 0

通过 {} 语法指定的初始化值,不仅会在编译器自动生成的构造函数里执行,也会在用户自定义构造函数里执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <string>

struct Pig {
std::string m_name;
int m_weight{0};

Pig(std::string name) : m_name(name)
{}
};

void show(Pig pig) {
std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
Pig pig("佩奇");

show(pig);
return 0;
}
1
2
name: 佩奇
weight: 0

类成员的 {} 中还可以有多个参数,甚至能用 =,当然 explicit 限制的构造函数除外。

除了不能用 () 之外,和函数局部变量的定义方式基本等价。

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

struct Demo {
explicit Demo(std::string a, std::string b) {
std::cout << "Demo(" << a << ',' << b << ')' << std::endl;
}
};

struct Pig {
std::string m_name{"佩奇"};
int m_weight = 80;
Demo m_demo{"Hello", "world"}; // 编译通过
// Demo m_demo = {"Hello", "world"}; // 编译出错
};

void show(Pig pig) {
std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
Pig pig;

show(pig);
return 0;
}

另外:

1
2
int x{};
void *p{};

1
2
int x{0};
void *p{nullptr};

等价,都会零初始化

std::cout << int{}; 会打印出 0

当一个类(和他的基类)没有定义任何构造函数,这时编译器会自动生成一个参数个数和成员一样的构造函数

他会将 {} 内的内容,会按顺序赋值给对象的每一个成员。目的是为了方便程序员不必手写冗长的构造函数一个个赋值给成员。

且初始化列表的构造函数只支持通过 {} 或 = {} 来构造,不支持通过 () 构造 (为了向下兼容 C++98)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string>

struct Pig {
std::string m_name;
int m_weight;
};

void show(Pig pig) {
std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
Pig pig1 = {"佩奇", 80}; // 编译通过
Pig pig2{"佩奇", 80}; // 编译通过
// Pig pig3("佩奇", 80); // 编译错误!

show(pig1);
return 0;
}

所以可以使用默认构造函数类,解决函数多返回值(妙用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct {
bool hit;
Vec3 pos;
Vec3 normal;
float depth;
} intersect(Ray r) {
...
return {true, r.origin, r.direction, 233.0f};
}

int main() {
Ray r;
auto hit = intersect(r);
if (hit.hit) {
r.origin = hit.pos;
r.direction = hit.normal;
...
}
}

和 std::tuple 相比,最大的好处是每个属性都有名字,不容易搞错。

函数的参数,如果是很复杂的类型,你不想把类型名重复写一遍,也可以利用 {} 初始化列表来简化

1
2
3
4
5
6
7
8
9
10
11
12
void func(std::tuple<int, float, std::string> arg, std::vector<int> arr) {
...
}

int main() {
func({1, 3.14f, "佩奇"}, {1, 4, 2, 8, 5, 7});
// 等价于:
func(std::tuple<int, float, std::string>(1, 3.14f, "佩奇"),
std::vector<int>({1, 4, 2, 8, 5, 7}));
// (C++17起)等价于:
func(std::tuple(1, 3.14f, "佩奇"), std::vector({1, 4, 2, 8, 5, 7}));
}

一旦我们定义了自己的构造函数,编译器就不会再生成默认的无参构造函数

有自定义构造函数时仍想用默认构造函数:= default

1
2
3
4
5
6
7
8
9
10
struct Pig {
std::string m_name;
int m_weight{0};

Pig() = default;

Pig(std::string name, int weight)
: m_name(name), m_weight(weight)
{}
};

拷贝构造函数

编译器默认会生成拷贝构造函数:Pig(Pig const &)

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

struct Pig {
std::string m_name;
int m_weight{0};
};

void show(Pig pig) {
std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
Pig pig{"佩奇", 80};

show(pig);

Pig pig2 = pig; // 调用 Pig(Pig const &)
// Pig pig2(pig); // 与上一种方式等价

show(pig);

return 0;
}

拷贝赋值函数

编译器默认还会生成这样一个重载’=’这个运算符的函数:

Pig &operator=(Pig const &other);

1
2
3
4
Pig pig = pig2; // 拷贝构造

Pig pig; // 无参构造
pig = pig2; // 拷贝赋值

追求性能时推荐用拷贝构造,因为可以避免一次无参构造,拷贝赋值是出于需要临时修改对象的灵活性需要。

如何避免不经意的隐式拷贝

将拷贝构造函数声明为 explicit 的,这样隐式的拷贝就会出错。

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

struct Pig {
std::string m_name;
int m_weight{0};

Pig(std::string name, int weight)
: m_name(name), m_weight(weight)
{}

explicit Pig(Pig const &other) = default;
};

void show(Pig pig) {
std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
Pig pig{"佩奇", 80};

// show(pig); // 编译错误
show(Pig{pig});
return 0;
}

自动生成的特殊函数

1
2
3
4
5
6
7
8
9
10
struct C {
C(); // 默认构造函数

C(C const &c); // 拷贝构造函数
C(C &&c); // 移动构造函数(C++11 引入)
C &operator=(C const &c); // 拷贝赋值函数
C &operator=(C &&c); // 移动赋值函数(C++11 引入)

~C(); // 解构函数
};
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
#include <iostream>
#include <string>

struct Pig {
std::string m_name;
int m_weight{0};

Pig(std::string name, int weight)
: m_name(name), m_weight(weight)
{}

Pig()
{}

Pig(Pig const &other)
: m_name(other.m_name)
, m_weight(other.m_weight)
{}

Pig &operator=(Pig const &other) {
m_name = other.m_name;
m_weight = other.m_weight;
return *this;
}

Pig(Pig &&other)
: m_name(std::move(other.m_name))
, m_weight(std::move(other.m_weight))
{}

Pig &operator=(Pig &&other) {
m_name = std::move(other.m_name);
m_weight = std::move(other.m_weight);
return *this;
}

~Pig() {}
};

void show(Pig pig) {
std::cout << "name: " << pig.m_name << std::endl;
std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
Pig pig;

show(pig);
return 0;
}

如果其中一个成员(比如m_name)不支持拷贝构造函数,那么 Pig 类的拷贝构造函数将不会被编译器自动生成。

设计规则 经验

  1. 如果一个类定义了析构函数,那么您必须同时定义或删除 拷贝构造函数和拷贝赋值函数,否则出错。
  2. 如果一个类定义或删除拷贝构造函数,那么您必须同时定义或删除 拷贝赋值函数,否则出错,删除可导致低效。
  3. 如果一个类定义了移动构造函数,那么您必须同时定义或删除 移动赋值函数,否则出错,删除可导致低效。
  4. 如果一个类定义了拷贝构造函数或拷贝赋值函数,那么您必须最好同时定义 移动构造函数或 移动赋值函数,否则低效。

例如:

在 = 时,默认是会拷贝的。

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
#include <cstdlib>
#include <iostream>
#include <cstring>

struct Vector {
size_t m_size;
int *m_data;

Vector(size_t n) {
m_size = n;
m_data = (int *)malloc(n * sizeof(int));
}

~Vector() {
free(m_data);
}

size_t size() {
return m_size;
}

void resize(size_t size) {
m_size = size;
m_data = (int *)realloc(m_data, m_size);
}

int &operator[](size_t index) {
return m_data[index];
}
};

int main() {
Vector v1(32);

Vector v2 = v1;
// Vector v2(v1); // 与上一种等价

return 0; // 自动释放 v1, v2
}
1
free(): double free detected in tcache 2

当前 Vector 的实现造成一个很大的问题:其 m_data 指针是按地址值浅拷贝的,而不深拷贝其指向的数组!

在退出 main 函数作用域的时候,v1.m_data 会被释放两次!更危险的则是 v1 被解构而 v2 仍在被使用的情况。

这就是为什么“如果一个类定义了解构函数,那么您必须同时定义或删除拷贝构造函数和拷贝赋值函数,否则出错。”

最简单的办法是,直接禁止用户拷贝这个类的对象,在 C++11 中可以用 = delete 表示这个函数被删除,让编译器不要自动生成一个默认的(会导致指针浅拷贝的)拷贝构造函数了。

这样就可以在编译期提前发现错误

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
struct Vector {
size_t m_size;
int *m_data;

Vector(size_t n) {
m_size = n;
m_data = (int *)malloc(n * sizeof(int));
}

~Vector() {
free(m_data);
}

Vector(Vector const &other) = delete;

size_t size() {
return m_size;
}

void resize(size_t size) {
m_size = size;
m_data = (int *)realloc(m_data, m_size);
}

int &operator[](size_t index) {
return m_data[index];
}
};

如果需要允许用户拷贝你的 Vector 类对象,保证任何单个操作前后,对象都是处于正确的状态,从而避免程序读到错误数据(如空悬指针)的情况。

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
struct Vector {
size_t m_size;
int *m_data;

Vector(size_t n) {
m_size = n;
m_data = (int *)malloc(n * sizeof(int));
}

~Vector() {
free(m_data);
}

Vector(Vector const &other) {
m_size = other.m_size;
m_data = (int *)malloc(m_size * sizeof(int));
memcpy(m_data, other.m_data, m_size * sizeof(int));
}

size_t size() {
return m_size;
}

void resize(size_t size) {
m_size = size;
m_data = (int *)realloc(m_data, m_size);
}

int &operator[](size_t index) {
return m_data[index];
}
};

区分拷贝构造和拷贝赋值

区分两种拷贝可以提高性能

int x = 1; // 拷贝构造函数 x = 2; // 拷贝赋值函数

拷贝赋值函数 ≈ 解构函数 + 拷贝构造函数

拷贝构造:直接未初始化的内存上构造 2 拷贝赋值:先销毁现有的 1,再重新构造 2

1
2
3
4
5
6
7
8
9
10
11
12
13
...
Vector(Vector const &other) {
m_size = other.m_size;
m_data = (int *)malloc(m_size * sizeof(int));
memcpy(m_data, other.m_data, m_size * sizeof(int));
}

Vector &operator=(Vector const &other) {
this->~Vector(); // 先销毁现有的
new (this) Vector(other); // 再重新构造(placement new)
return *this; // 支持连等号:v1 = v2 = v3
}
...

更高效的写法

1
2
3
4
5
6
Vector &operator=(Vector const &other) {
m_size = other.m_size;
m_data = (int *)realloc(m_data, m_size * sizeof(int));
memcpy(m_data, other.m_data, m_size * sizeof(int));
return *this;
}

内存的销毁重新分配可以通过realloc,从而就地利用当前现有的m_data,避免重新分配。

拷贝和移动

时间复杂度:移动是 O(1),拷贝是 O(n)。

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
#include <iostream>
#include <vector>

void test_copy() {
std::vector<int> v1(10);
std::vector<int> v2(200);

v1 = v2; // 拷贝赋值 O(n)

std::cout << "after copy:" << std::endl;
std::cout << "v1 length " << v1.size() << std::endl; // 200
std::cout << "v2 length " << v2.size() << std::endl; // 200
}

void test_move() {
std::vector<int> v1(10);
std::vector<int> v2(200);

v1 = std::move(v2); // 移动赋值 O(1)

std::cout << "after move:" << std::endl;
std::cout << "v1 length " << v1.size() << std::endl; // 200
std::cout << "v2 length " << v2.size() << std::endl; // 0
}

void test_swap() {
std::vector<int> v1(10);
std::vector<int> v2(200);

std::swap(v1, v2); // 交换两者 O(1)

std::cout << "after swap:" << std::endl;
std::cout << "v1 length " << v1.size() << std::endl; // 200
std::cout << "v2 length " << v2.size() << std::endl; // 10
}

int main() {
test_copy();
test_move();
test_swap();
return 0;
}

swap 可能是这样实现的:

1
2
3
4
5
6
template <class T>
void swap(T& t1, T& t2) {
T tmp = std::move(t2);
t2 = std::move(t1);
t1 = std::move(tmp);
}

swap 在高性能计算中可以用来实现双缓存(ping-pong buffer)。

哪些情况会触发“移动”

这些情况下编译器会调用移动:

1
2
3
return v2;          // v2 作返回值
v1 = std::vector<int>(200); // 就地构造的 v2
v1 = std::move(v2); // 显式地移动

这些情况下编译器会调用拷贝:

1
2
return std::as_const(v2)     // 显式地拷贝
v1 = v2 // 默认拷贝

注意,下面两个语句没有任何作用:

这两个函数只是负责转换类型,实际产生移动/拷贝效果的是在类的构造/赋值函数里。

1
2
std::move(v2)   // 不会清空 v2,需要清空可以用 v2 = {} 或 v2.clear()
std::as_const(v2) // 不会拷贝 v2,需要拷贝可以用 { auto _ = v2; }

移动构造函数

默认移动构造和移动赋值,编译器会自动这样做:

移动构造≈拷贝构造+他解构+他默认构造 移动赋值≈拷贝赋值+他解构+他默认构造

虽然低效,但至少可以保证不出错。

若自定义了移动构造,则: 移动赋值≈解构(当前自身的资源)+ 移动构造

1
2
3
4
5
6
7
8
9
10
11
12
Vector(Vector &&other) {
m_size = other.m_size;
other.m_size = 0;
m_data = other.m_data;
other.m_data = nullptr;
}

Vector &operator=(Vector &&other) {
this->~Vector();
new (this) Vector(std::move(other));
return *this;
}

如果有移动赋值函数,可以删除拷贝赋值函数。那么,当用户调用:

1
v2 = v1;

时,因为拷贝赋值被删除,编译器会尝试:

1
v2 = List(v1)

从而先调用拷贝构造函数(拷贝v1),然后因为 List(v1) 相当于就地构造的对象,从而变成了移动语义,从而进一步调用移动赋值函数。

智能指针

unique_ptr

C++11 引入了 unique_ptr 容器,他的解构函数中会调用 delete p

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
#include <cstdio>
#include <cstdlib>

struct C {
C() {
printf("分配内存!\n");
}

~C() {
printf("释放内存!\n");
}
};

int main() {
C *p = new C;

if (rand() != 0) {
printf("出了点小状况……\n");
// delete p; // 程序员粗心忘记释放指针
return 1;
}

delete p;
return 0;
}

unique_ptr 则把下面连个操作封装成一个操作

1
2
delete p;
p = nullptr;

只需要:

1
p = nullptr;      // 等价于:p.reset()

= nullptr 时,就释放了 unique_ptr 。

1
2
3
4
5
6
7
8
9
10
int main() {
std::unique_ptr<C> p = std::make_unique<C>();

if (1 + 1 == 2) {
printf("出了点小状况……\n");
return 1; // 自动释放 p
}

return 0; // 自动释放 p
}

禁止拷贝

1
2
3
4
5
int main() {
std::unique_ptr<C> p = std::make_unique<C>();
func(p); // 出错
return 0;
}

因为 unique_ptr 删除了拷贝构造函数。如果要拷贝,有以下方法:

第一种是获取原始指针,你的 func() 实际上并不需要“夺走”资源的占有权(ownership)。

func() 只是调用了 p 的某个成员函数而已,并没有接过掌管对象生命周期的大权

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
#include <cstdio>
#include <memory>

struct C {
C() {
printf("分配内存!\n");
}

~C() {
printf("释放内存!\n");
}

void do_something() {
printf("成员函数!\n");
}
};

void func(C *p) {
p->do_something();
}

int main() {
std::unique_ptr<C> p = std::make_unique<C>();
func(p.get());
return 0;
}

第二种是移动,你的 func() 需要“夺走”资源的占有权

func 把指针放到一个全局的列表里,p 的生命周期将会变得和 objlist 一样长。因此需要接过掌管对象生命周期的大权。

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
#include <cstdio>
#include <memory>
#include <vector>

struct C {
C() {
printf("分配内存!\n");
}

~C() {
printf("释放内存!\n");
}

void do_something() {
printf("我的数字是 %d!\n", m_number);
}
};

std::vector<std::unique_ptr<C>> objlist;

void func(std::unique_ptr<C> p) {
objlist.push_back(std::move(p)); // 进一步移动到 objlist
}

int main() {
std::unique_ptr<C> p = std::make_unique<C>();
printf("移交前:%p\n", p.get()); // 不为 null
func(std::move(p)); // 通过移动构造函数,转移指针控制权
printf("移交后:%p\n", p.get()); // null,因为移动会清除原对象
return 0;
}
1
2
3
4
分配内存!
移交前:0x55ec91b162b0
移交后:(nil)
释放内存!

如果,移交控制权后仍希望访问到 p 指向的对象。最简单的办法是,在移交控制权给 func 前,提前通过 p.get() 获取原始指针。

1
2
3
4
5
6
7
8
int main() {
std::unique_ptr<C> p = std::make_unique<C>();
func(std::move(p));

p->do_something(); // 出错,p 已经为空了!

return 0;
}
1
2
3
4
5
6
7
8
9
10
int main() {
std::unique_ptr<C> p = std::make_unique<C>();

C *raw_p = p.get();
func(std::move(p));

raw_p->do_something(); // 正常执行,raw_p 保留了转移前的指针

return 0;
}

不过你得保证 raw_p 的存在时间不超过 p 的生命周期,否则会出现危险的空悬指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
std::unique_ptr<C> p = std::make_unique<C>();

C *raw_p = p.get();
func(std::move(p));

raw_p->do_something(); // 正常执行,raw_p 保留了转移前的指针

objlist.clear(); // 刚刚 p 移交给 func 的生命周期结束了!

raw_p->do_something(); // 错误!raw_p 指向的对象已经被释放!

return 0;
}
1
2
3
4
分配内存!
我的数字是 42!
释放内存!
我的数字是 -803182608!

shared_ptr

牺牲效率换来自由度的 shared_ptr 则允许拷贝,他解决重复释放的方式是通过引用计数

当一个 shared_ptr 初始化时,将计数器设为1。 当一个 shared_ptr 被拷贝时,计数器加1。 当一个 shared_ptr 被解构时,计数器减1。减到0时,则自动销毁他指向的对象。

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
#include <cstdio>
#include <memory>
#include <vector>

struct C {
int m_number;

C() {
printf("分配内存!\n");
m_number = 42;
}

~C() {
printf("释放内存!\n");
m_number = -2333333;
}

void do_something() {
printf("我的数字是 %d!\n", m_number);
}
};

std::vector<std::shared_ptr<C>> objlist;

void func(std::shared_ptr<C> p) {
objlist.push_back(std::move(p)); // 这里用移动可以更高效,但不必须
}

int main() {
std::shared_ptr<C> p = std::make_shared<C>(); // 引用计数初始化为1

func(p); // shared_ptr 允许拷贝!和当前指针共享所有权,引用计数加1
func(p); // 多次也没问题~ 多个 shared_ptr 会共享所有权,引用计数加1

p->do_something(); // 正常执行,p 指向的地址本来就没有改变

objlist.clear(); // 刚刚 p 移交给 func 的生命周期结束了!引用计数减2

p->do_something(); // 正常执行,因为引用计数还剩1,不会被释放

return 0; // 到这里最后一个引用 p 也被释放,p 指向的对象才终于释放
}

只要还有存在哪怕一个指针指向该对象,就不会被解构。

可以使用 p.use_count() 来获取当前指针的引用计数。

注意 p.func() 是 shared_ptr 类型本身的成员函数,而 p->func() 是 p 指向对象(也就是 C)的成员函数,不要混淆。

weak_ptr

弱引用的拷贝与解构不影响其引用计数器。

可以通过 lock() 随时产生一个新的 shared_ptr 作为强引用。但不 lock 的时候不影响计数。

如果失效(计数器归零)则 expired() 会返回 false,且 lock() 也会返回 nullptr。

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
int main() {
std::shared_ptr<C> p = std::make_shared<C>(); // 引用计数初始化为1

printf("use count = %ld\n", p.use_count()); // 1

std::weak_ptr<C> weak_p = p; // 创建一个不影响计数器的弱引用

printf("use count = %ld\n", p.use_count()); // 1

func(std::move(p)); // 控制权转移,p 变为 null,引用计数加不变

if (weak_p.expired())
printf("错误:弱引用已失效!");
else
weak_p.lock()->do_something(); // 正常执行,p 的生命周期仍被 objlist 延续着

objlist.clear(); // 刚刚 p 移交给 func 的生命周期结束了!引用计数减1,变成0了

if (weak_p.expired()) // 因为 shared_ptr 指向的对象已释放,弱引用会失效
printf("错误:弱引用已失效!");
else
weak_p.lock()->do_something(); // 不会执行

return 0; // 到这里最后一个弱引用 weak_p 也被释放,他指向的“管理块”被释放
}
1
2
3
4
5
6
分配内存!
use count = 1
use count = 1
我的数字是 42!
释放内存!
错误:弱引用已失效!

智能指针作为类的成员变量

判断要用哪一种智能指针:

  1. unique_ptr:当该指针所指对象仅仅属于我时。比如:父窗口中指向子窗口的指针。
  2. 原始指针:当该对象不属于我,但他释放前我必然被释放时。有一定风险。对象使用指针。比如:子窗口中指向父窗口的指针。
  3. shared_ptr:当该对象由多个对象共享时,或虽然该对象仅仅属于我,但有使用 weak_ptr 的需要,对象使用shared_ptr。
  4. weak_ptr:当该对象不属于我,且他释放后我仍可能不被释放时,对象使用weak_ptr。比如:指向窗口中上一次被点击的元素。
  5. 初学者可以多用 shared_ptr 和 weak_ptr 的组合,更安全。

shared_ptr 需要维护一个 atomic 的引用计数器,效率低,需要额外的一块管理内存,访问实际对象需要二级指针,而且 deleter 使用了类型擦除技术。

全部用 shared_ptr,可能出现循环引用之类的问题,导致内存泄露,依然需要使用不影响计数的原始指针或者 weak_ptr 来避免。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <memory>

struct C {
std::shared_ptr<C> m_child;
std::shared_ptr<C> m_parent;
};

int main() {
auto parent = std::make_shared<C>();
auto child = std::make_shared<C>();

// 建立相互引用:
parent->m_child = child;
child->m_parent = parent;

parent = nullptr; // parent 不会被释放!因为 child 还指向他!
child = nullptr; // child 也不会被释放!因为 parent 还指向他!

return 0;
}

如何解决?只需要把其中逻辑上“不具有所属权”的那一个改成 weak_ptr 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <memory>

struct C {
std::shared_ptr<C> m_child;
std::weak_ptr<C> m_parent;
};

int main() {
auto parent = std::make_shared<C>();
auto child = std::make_shared<C>();

// 建立相互引用:
parent->m_child = child;
child->m_parent = parent;

parent = nullptr; // parent 会被释放。因为 child 指向他的是 **弱引用**
child = nullptr; // child 会被释放。因为指向 child 的 parent 已经释放了

return 0;
}

改成 weak_ptr, 没有 parent 的所有权。

也可以把 m_parent 变成原始指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <memory>

struct C {
std::shared_ptr<C> m_child;
C *m_parent;
};

int main() {
auto parent = std::make_shared<C>();
auto child = std::make_shared<C>();

// 建立相互引用:
parent->m_child = child;
child->m_parent = parent.get();

parent = nullptr; // parent 会被释放。因为 child 指向他的是原始指针
child = nullptr; // child 会被释放。因为指向 child 的 parent 已经释放了

return 0;
}

假定他释放前我必然被释放,完全可以把 m_child 变成一个标志这“完全所有权”的 unique_ptr。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <memory>

struct C {
std::unique_ptr<C> m_child;
C *m_parent;
};

int main() {
auto parent = std::make_unique<C>();
auto child = std::make_unique<C>();

// 建立相互引用:
child->m_parent = parent.get();
parent->m_child = std::move(child); // 移交 child 的所属权给 parent

parent = nullptr; // parent 会被释放。因为 child 指向他的是原始指针
// 此时 child 也已经被释放了,因为 child 完全隶属于 parent

return 0;
}

安全类型

以下类型是安全的:

1
2
3
4
int id;                            // 基础类型
std::vector<int> arr; // STL 容器
std::shared_ptr<Object> child; // 智能指针
Object *parent; // 原始指针,如果是从智能指针里 .get() 出来的

以下对象是不安全的:

1
2
3
char *ptr;        // 原始指针,如果是通过 malloc/free 或 new/delete 分配的
GLint tex; // 是基础类型 int,但是对应着某种资源
std::vector<Object *> objs; // STL 容器,但存了不安全的对象

有不安全类型成员的对象的类的设计需要考虑之前的设计原则问题。

如果你的类所有成员,都是安全的类型,那么五大构造/析构函数都无需声明(或声明为 = default),你的类自动就是安全的。

最好的判断方式是:如果你不需要自定义的解构函数,那么这个类就不需要担心。

因为如果用到了自定义解构函数,往往意味着你的类成员中,包含有不安全的类型。

这样的类型一般无外乎两种情况:

  1. 你的类管理着资源

  2. 你的类是数据结构

管理着资源的类

这个类管理着某种资源,资源往往不能被“复制”。比如一个 OpenGL 的着色器,或是一个 Qt 的窗口。

一般删除拷贝函数,然后统一用智能指针管理

1
2
3
4
5
6
7
8
9
10
11
struct Shader {
GLuint sha;
GLuint target{GL_ARRAY_BUFFER};

Shader(GLuint type) { CHECK_GL(sha = g1CreateShader(type)); }
~Shader() { CHECK_GL(g1DeIeteShader(sha)); }

Shader(Shader const &) = delete;
Shader &operator=(Shader const &) = delete;

};

数据结构类

如果可以,自己定义拷贝和移动函数。

函数参数类型优化

如果是基础类型(比如 int,float)则按值传递: float squareRoot(float val);

如果是原始指针(比如 int ,Object )则按值传递: void doSomethingWith(Object *ptr);

如果是数据容器类型(比如 vector,string)则按常引用传递: int sumArray(std::vector const &arr);

如果数据容器不大(比如 tuple<int, int>),则其实可以按值传递: glm::vec3 calculateGravityAt(glm::vec3 pos);

如果是智能指针(比如 shared_ptr),且需要生命周期控制权,则按值传递: void addObject(std::shared_ptr obj);

如果是智能指针,但不需要生命周期,则通过 .get() 获取原始指针后,按值传递: void modifyObject(Object *obj);

其他语言对比

Java 和 Python 的业务需求大多是在和资源打交道,从而基本都是刚刚说的要删除拷贝函数的那一类。

Java 和 Python 干脆简化:一切非基础类型的对象都是浅拷贝,引用计数由垃圾回收机制自动管理。

系统级编程、算法数据结构、高性能计算为主要业务的 C++,才发展出了这些思想,并将拷贝/移动/指针/可变性/多线程等概念作为语言基本元素存在。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!