0%

C++指针与引用

指针

  • 可以为空(万恶之首)
  • 可以做指针的偏移操作
  • 可以用做修改一个变量
  • 可以用来用来快速传递一个对象,没有复制的成本(只有指针复制的成本)
    见下面代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int f1(int* i) {
*i = 2;
}

int f2(int i){
i = 3;
}

int main(){
int num = 0;
f1(&num);
std::cout << num << std::endl; ///< 此处num为2
f2(num);
std::cout << num << std::endl; ///< 此处num仍为2
}

引用

  • 引用同指针一样可以直接修改对象本身,减少传递对象的复制成本
  • 引用不可为空,即创建必须初始化
  • 引用不能同指针一样,进行四则运算
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void f1(int& num) {
num = 100;
}

int main() {
int i = 0;
int &r = i;
r = 1;
std::cout << i << std::endl; ///< i这里为1
std::cout << r << std::endl; ///< r这里为1
f1(r);
std::cout << i << std::endl; ///< i这里为100
std::cout << r << std::endl; ///< r这里为100
}

详情可见What are the differences between a pointer variable and a reference variable in 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
51
52
53
54
55
56
57
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class observer;
class subscriber : public std::enable_shared_from_this<subscriber> {
public:
subscriber(const std::string& user_name) : user_name_(user_name) {
}

void callback(const std::string& str) {
std::cout << user_name_ << ": " << str << std::endl;
}
private:
std::string user_name_;
};

class observer {
public:
void boardcast(const std::string& message) {
for (auto it = vec.begin(); it != vec.end();) {
auto sp = it->lock();
if (sp) {
sp->callback(message);
it++;
} else {
it = vec.erase(it);
}
}
}

void regist(std::weak_ptr<subscriber> wp) {
if (wp.lock()) {
vec.push_back(wp);
}
}
private:
std::vector<std::weak_ptr<subscriber>> vec;
};

int main() {
auto ob = std::make_shared<observer>();
auto sp1 = std::make_shared<subscriber> ("subscriber1");
ob->regist(sp1->weak_from_this());
auto sp2 = std::make_shared<subscriber> ("subscriber2");
ob->regist(sp2->weak_from_this());
{
auto sp3 = std::make_shared<subscriber> ("subscriber3");
ob->regist(sp3->weak_from_this());
auto sp4 = std::make_shared<subscriber> ("subscriber4");
ob->regist(sp4->weak_from_this());
ob->boardcast("start boardcast!");
}
ob->boardcast("boardcast again!");
return 0;
}

《Effective Modern C++》读书笔记(2)

  1. 裸指针在声明中并没有指出,裸指针指涉到的是单个对象还是一个数组。

  2. 裸指针在声明中也没有提示在使用完指涉到的对象以后,是否需要析构它。换言之,你从声明中看不出来指针是否拥有其指涉的对象。

  3. 即使知道需要析构指针所指涉的对象,也不可能知道如何析构才是适当的。是应该使用delete运算符,还是有其他用途。

  4. 即使知道了使用delete运算符,还是会发生到底应该用的那个对象形式(delete)还是数组形式(delete[])。

  5. 即启用够确信,指针拥有其指涉对象,并且也确知应该如何析构,要保证析构在所有代码路径上都仅执行一次(包括那些异常导致的路径)仍然困难重重。只要少在一条路径上执行,就会导致资源泄露。而如果析构在一条路径上执行了多次,则会导致未定义行为。

  6. 没有什么正规的方式能检测出指针是否空悬,也就是说,它指涉的内存是否已经不再持有指针本应该指涉的对象。如果一个对象已经被析构了,而某些指针仍然指涉到它,就会产生空悬指针。

在创建对象时注意区分(){}

1
2
3
Widget w1;  ///< 调用默认构造函数
Widget w2 = w1; ///< 调用复制构造函数
w1 = w2; ///< 赋值运算符

大括号可以用来为非静态成员指定默认初始化值,却不能使用小括号。

1
2
3
4
5
6
class Widget {
private:
int x{0}; ///< 可行
int y = 0; ///< 可行
int z(0); ///< 不可行
};

不可复制的对象可以采用大括号和小括号来进行初始化,却不能使用=:

1
2
3
std::atomic<int> ai1{0}; ///< 可行
std::atomic<int> ai2(0); ///< 可行
std::atomic<int> ai3 = 0; ///< 不可行

大括号适用所有场合。
大括号初始化有一项新特性,就是它禁止内建型别之间进行隐式窄化型别转换。而采用小括号和=的初始化则不会进行窄化型别转换检查,因为如果那样的化就会破坏太多的遗留代码了。

大括号初始化的另一项值得一提的特征是,它对于C++最令人苦恼之解析语法免疫。C++规定:任何能够解析为声明的都要解析为声明。本来想要以默认方式构造一个对象,结果却一不小心声明了一个函数。这个错误的根本原因构造函数调用语法。
当你想要以传参的方式调用构造函数时:

1
Widget w1(10);  ///< 调用Widget的构造函数,传入形参10

但你如果试图用相同的语法构造一个没有形参的Widget的话,结果却变成了声明了一个函数而非对象:

1
Widget w2();  ///< 最令人苦恼之解析语法现身

由于函数声明不能使用大括号来指定形参列表,所以使用大括号来完成对象的默认构造上面这个问题:

1
Widget w3{};  ///< 调用没有形参的Widget构造函数

大括号初始化的缺陷在于伴随它有时会出现的意外行为。这种行为源于大括号初始化物、std::initializer_list以及构造函数重载决议之间的纠结。

如果一个或多个构造函数声明了任何一个具备std::initializer_list型别的形参那么采用了大括号初始化语法的调用语句会强烈地优先选用带有std::initializer_list型别形参的重载版本。

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
};

Widget w1(10, true); ///< 调用第一个构造函数
Widget w2{10, true}; ///< 使用最后一个构造函数, 10, true 被强制转化为long double

如果你的确想要调用一个带有std::initializer_list型别形参的构造函数,并传入一个空的std::initializer_list的话,你可以通过把空大括号对作为构造函数实参的方式实现这个目的,即把一对空大括号放入一对小括号或大括号的方式来清楚地表明你传递地是什么:

1
2
Widget w4({});  ///< 带有std::initializer_list型别形参地构造函数
Widget w5{{}}; ///< 同上
1
2
std::vector<int> v1(10, 20);  ///< 创建了一个拥有十个元素,每个元素值都为20的vector
std::vector<int> v1(10, 20); ///< 创建了一个拥有两个元素,值分别为10、20 的vector

std::make_uniquestd::make_shared在函数内部使用的小括号,作为其接口的一部分。

  • 大括号初始化可以应用的语境最为宽泛,可以阻止隐式窄化型别转换,还对最令人苦恼之解析语法免疫
  • 在构造函数重载决议期间,只要有任何可能,大括号初始化物就会与带有std::initializer_list型别的形参相匹配,即使其他重载版本有着貌似更加匹配的形参表。
  • 使用小括号还是大括号,会造成结果大相径庭的一个例子是:使用两个实参来创建一个std::vector<数值型别>对象。
  • 在模板内容进行对象创建时,到底应该使用小括号还是大括号会成为一个棘手问题。

理解特种成员函数的生成机制

两种复制操作是彼此独立的:声明了其中一个,并不会阻止编译器生成另外一个。如果你生成了一个复制构造函数,同时未声明复制赋值运算符,并撰写了要求复制赋值的代码,则编译器会为你生成复制赋值运算符。反过来一样。

两种移动操作并不彼此独立:声明了其中一个就会阻止编译器生成另外一个。假设你声明了一个移动构造函数,你实际上表明了移动操作的实现方式将会与编译器生成的默认按成员移动的移动构造函数多少有些不同。而若是按成员进行的移动构造操作有不合用之处的话,那么按成员进行的移动赋值运算符极有可能也会有不合用之处。综上声明一个移动构造函数会阻止编译器去生成移动赋值运算符,而声明一个移动赋值运算符也会阻止编译器去生成移动构造函数。

一旦显式声明了赋值操作,这个类也就不再会生成移动操作了。依据在于,声明复制操作的行为表明了对象的常规复制途径(按成员复制)对于该类并不适用。从而判定既然按成员复制不适用于赋值操作,则按成员移动极有可能也不适用于移动操作。
一旦声明了移动操作,编译器就会删除复制操作。

三大律:如果你声明了复制构造函数、复制复制运算符,或析构函数中的任何一个,你就得同时声明所有这三个。
如果有改写复制操作的需求,往往意味着该类需要执行某种资源管理,而这就意味着:1. 在一种复制操作中进行的任何资源管理,也极有可能在另一种复制操作中也需要进行。 2. 该类的析构函数也会参与到该资源的管理之中。

大三律的一个推论是,如果存在用户声明的析构函数,则平凡的按成员赋值也不适用于该类。如果声明了析构函数,则复制操作就不该被自动生成,因为他们呢的行为不可能正确。所以在C++11中:只要用户声明了析构函数,就不会生成移动操作。

移动操作的生成条件(如果需要生成)仅当以下三者同时成立:

  • 该类未声明任何复制操作
  • 该类未声明任何移动操作
  • 该类未声明任何析构操作

总而言之, C++11中, 支配特种成员函数的机制如下:

  • 默认构造函数: 仅当类中不包含用户声明的构造函数时才生成
  • 析构函数:与C++98中基本相同,唯一的区别在于析构函数默认为noexcept.仅当基类的析构函数为虚的,派生类析构函数才是虚的。
  • 复制构造函数: 按成员进行非静态数据成员的复制构造。仅当类中不包含用户声明的复制构造函数时才生成。如果该类声明了移动操作,则复制构造函数将被删除。在已经存在复制赋值运算符或析构函数的条件下,仍然生成复制构造函数已经成为了被废弃的行为。
  • 移动构造函数和移动赋值运算符
    都按成员进行非静态数据成员的移动操作。仅当类中不包含用户声明的复制操作、移动操作和析构函数时才生成。

成员函数模板的存在会阻止编译器生成任何特种成员函数。

  • 移动操作仅当类中未包含用户显式声明的复制操作、移动操作和析构函数时才生成

  • 复制构造函数仅当类中不包含用户显式声明的复制构造函数时才生成,如果该类声明了移动操作则复制构造函数时才生成,复制赋值运算符仅当类中不包含用户显式声明的复制赋值运算符才生成,如果该类声明了移动操作则复制赋值运算符将被删除。在已经存在显式声明的析构函数的条件下,生成复制操作已经成为了被废弃的行为。

  • 成员函数模板在任何情况下都不会抑制特种成员函数的生成。

  • auto 变量必须初始化,基本上对会导致兼容性和效率问题的型别不匹配现象免疫,还可以简化重构流程,通常也比显式指定型别少打一些字

  • 在模板推导过程中,具有引用型别的实参会被当成非引用型别来处理。换言之,其引用性会被忽略。

  • 对万能引用形参进行推导时,左值实参会进行特殊处理。

  • 对按值传递的形参进行推导时,若实参型别中带有constvolatile饰词,则它们还是会被当作不带constvolatile饰词的型别来处理。

  • 在模板型别推导过程中, 数组或函数型别的实参会退化成对应的指针,除非它们被用来初始化引用。

  • 在一般情况下,auto型别推导和模板型推导是一模一样的,但是auto型别推导会假定用大括号括起的初始化表达式代表一个std::initializer_list, 但模板型别推导却不会。

  • 在函数返回值或lambda式的形参中使用auto, 意思是使用模板型别推导而非auto型推导。

  • 绝大多数情况下,decltype会得出变量或表达式的型别而不做任何修改

  • 对于型别为T的左值表达式,除非该表达式仅有一个名字,decltype总是得出型别T&

智能指针是为了更方便的管理内存而设计的,设计思想就是让使用者不再管理内存,而是由智能指针来进行管理。
换句话说以后不用再考虑new出来的对象什么时候需要delete,智能指针能帮你管理内存。
智能指针分为三种: std::shared_ptrstd::weak_ptrstd::unique_ptr

std::unique_ptr

std::unique_ptr指针拥有其管理对象的所有权,该智能指针不能被复制,只能被移动。当std::unique_ptr智能指针被析构,则其管理的对象也会被析构。
举一个简单的例子,高中宿管大爷,早六点开灯,晚十点关灯,其他人没有办法参与开关灯的事情。这里开关灯指的就是内存的分配与释放。

用法示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <memory>
int main()
{
{
int* p = new int(0); ///< 分配一个int的内存,其值为0
std::unique_ptr<int> up = std::unique_ptr<int>(p); ///< 使用指针p来创建一个智能指针对象
/*
/// 由于std::unique_ptr对象不可复制,所以下面三句话编译不过
std::unique_ptr<int> up_copy1(up);
std::unique_ptr<int> up_copy2;
up_copy2 = up;
*/
} ///< 在这个地方智能指针up被析构,up的析构函数对指针p执行delete操作,从而达到智能回收内存的作用

{
auto up = std::make_unique<int>(0); ///< 这句话等价于上面的两句话
}
}

下面看一下std::unique_ptr简单实现

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
template<typename OBJECT>
class unique_ptr {
public:
/// 构造函数
unique_ptr(OBJECT* p) : p_(p) {}
/// 析构函数
~unique_ptr(){
if(p_ != nullptr){
delete p_;
}
}
/// 删除拷贝构造函数, 标识这个类不可拷贝
unique_ptr(const unique_ptr<OBJECT>& other) = delete;
/// 删除拷贝构造赋值符, 标识这个类不可拷贝
unique_ptr<OBJECT>& operator=(const unique_ptr<OBJECT>& other) = delete;
/// 移动构造函数
unique_ptr(unique_ptr&& other) {
p_ = other.p_;
other.p_ = nullptr;
}

/// 移动赋值符
unique_ptr<OBJECT>& unique_ptr(unique_ptr&& other) {
if (&other == this) {
return *this;
}
delete p_;
p_ = other.p_;
other.p_ = nullptr;
}
private:
OBJECT* p_ = nullptr;
};

见示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class object {
public:
object() {
std::cout << "object()" << std::endl;
}
~object() {
std::cout << "~object()" << std::endl;
}
};

int main() {
{
unique_ptr<object> p(new object);
unique_ptr<object> other(std::move(p)); ///< 调用移动构造函数
/// 此时other智能指针拥有对象的管理权,而p失去了该对象的管理权
}
return 0;
}

运行结果:

Snipaste_2020-12-03_15-42-50

std::shared_ptr

std::shared_ptr使用引用计数的方法来决定是否需要释放掉管理对象的内存。
举一个很简单的例子,办公室中每一个人下班出门前都会看一下还有没有人在办公室中,如果有就直接走掉不关灯,如办公室内没有人了就执行关灯操作。
见下面简单实现

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
template <typename OBJECT>
class shared_ptr {
public:
/// 构造函数
shared_ptr(OBJECT* object):p_(object), count_(new int(0)) {
++(*count_);
}

/// 析构函数
~shared_ptr() {
if(--(*count_) == 0) { ///< 如果自己是最后一个管理该对象的人,自己被析构时负责做善后工作即delete对象
delete p_;
delete count_;
}
}

/// 拷贝构造函数
shared_ptr(const shared_ptr& other) {
count_ = other.count_; ///< 把引用计数的指针复制过来
++(*count_); ///< 所有shared_ptr中的count_都自加一下
p_ = other.p_; ///< 复制管理对象的指针
}

/// 拷贝赋值运算符
shared_ptr<OBJECT>& operator=(const shared_ptr& other) {
if (&other == this) {
return &this; ///< 如果复制的对象是自己,则直接返回
}
count_ = other.count_; ///< 把引用计数的指针复制过来
++(*count_); ///< 所有shared_ptr中的count_都自加一下
delete p_;
p_ = other.p_; ///< 复制管理对象的指针
return &this;
}

private:
OBJECT* p_ = nullptr; ///< 指向管理对象的指针
int* count_ = 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
class object {
public:
object() {
std::cout << "object()" << std::endl;
}

~object() {
std::cout << "~object()" << std::endl;
}
};

int main() {
{
shared_ptr<object> sp(new object);

auto sp1(sp);
auto sp2(sp);
auto sp3(sp);
auto sp4(sp);
auto sp5(sp);
auto sp6(sp);
}
return 0;
}

运行结果:

Snipaste_2020-12-03_10-47-05
\resource\C++智能指针简介与错误使用情况

可以看到该对象被构造了一次,又被析构了一次。

上面的实现虽然让内存管理变得简单,但也带来了一些麻烦。
由于智能指针拥有了对象的管理权,万一两个智能指针管理同一对象,那么这两个智能指针在析构时会对同一对象执行两次delete, 从而造成崩溃。见下面代码举例。

1
2
3
4
5
6
7
8
int main() {
{
object* p = new object();
shared_ptr<object> sp1(p);
shared_ptr<object> sp2(p);
}
return 0;
}

上方代码运行会崩溃。因为两个截然不同的智能指针sp1sp2同时管理了同一个object对象, 它们分别析构时会对指针pdelete两次。
所以我们更加推荐使用std::make_shared来代替使用裸指针初始化智能指针。见下面举例。

1
2
3
std::shared_ptr<object> sp = std::make_shared<object>();
/// 代替下面的初始化的方法
/// std::shared_ptr<object> sp = std::shared_ptr<object>(new object);

特殊情况

我们有时会在代码里遇到这样一种情况,见下面代码。

1
2
3
4
5
6
class error_object {
public:
std::shared_ptr<error_object> get_sp() {
return std::shared_ptr<error_object>(this);
}
};

上面代码中get_sp()的函数,目的是想返回一个能够管理自己的智能指针,但是我们可以看到每调用一次get_sp(),我们都会用同一个指针this, 创建一个不同的智能指针
这意味着我们调用两次get_sp()函数后,程序运行时会崩溃。

为了应对这种情况标准库中设计了一个工具函数std::enable_shared_from_this, 具体用法如下。

1
2
3
4
5
6
7
8
9
class correct_object : public std::enable_shared_from_this<correct_object> {
};

int main() {
auto instance = std::make_shared<correct_object>();
std::shared_ptr<correct_object> sp1 = instance.shared_from_this();
std::shared_ptr<correct_object> sp2 = instance.shared_from_this();
return 0;
}

上面代码可以正常运行。
关于std::enable_shared_from_this的实现原理,见C++ enable_shared_from_this原理与简单实现

std::weak_ptr

std::weak_ptr不能被称为一个独立的智能指针,它是std::shared_ptr智能指针的一种扩展。

std::weak_ptr的功能是,观察一个被std::shared_ptr管理的对象, 但不会影响std::shared_ptr的引用计数。

std::weak_ptr可以观察一个对象有没有被释放,或是用来防止std::shared_ptr的循环引用问题。

下面代码用于检查资源是否已经释放。用于解决裸指针的野指针的问题。

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
class object {
public:
object() {
std::cout << "object()" << std::endl;
}
~object() {
std::cout << "~object()" << std::endl;
}
};

int main()
{
std::weak_ptr<object> weak;
{
auto sp = std::make_shared<object>();
weak = sp;
std::shared_ptr<object> sp1 = weak.lock();
if(sp1){
sp1->doSomeThing();
}
}
if (weak.expired()) {
std::cout << "资源没有释放" << std::endl;
} else {
std::cout << "资源已经释放" << std::endl;
}
}

以下代码出现了引用回环,会导致智能指针对象被销毁了,但资源没有被销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class list_node {
public:
list_node() {
std::cout << "list_node()" << std::endl;
}

~list_node() {
std::cout << "~list_node()" << std::endl;
}
std::shared_ptr<list_node> next;
};

int main()
{
{
auto sp0 = std::make_shared<list_node>();
auto sp1 = std::make_shared<list_node>();
auto sp2 = std::make_shared<list_node>();
sp0->next = sp1;
sp1->next = sp2;
sp2->next = sp0;
}
return 0;
}

运行结果:

Snipaste_2020-12-03_14-50-26

我们可以看到该对象的析构函数一个也没有执行,说明内存没有释放。

我们现在换成std::weak_ptr来保存指向下一个节点的智能指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class list_node {
public:
list_node() {
std::cout << "list_node()" << std::endl;
}

~list_node() {
std::cout << "~list_node()" << std::endl;
}
std::weak_ptr<list_node> next;
};

int main()
{
{
auto sp0 = std::make_shared<list_node>();
auto sp1 = std::make_shared<list_node>();
auto sp2 = std::make_shared<list_node>();
sp0->next = sp1;
sp1->next = sp2;
sp2->next = sp0;
}
return 0;
}

运行结果:
Snipaste_2020-12-03_15-00-25

现在指针形成的环路被std::weak_ptr完美解决了。内存能够正确释放了。

实际使用用例

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

/// 订阅者
class subscriber {
public:
/// 订阅者被观察器调用的函数
void read(const std::string& str) { std::cout << str << std::endl; }
};

/// 广播者
class boardcaster {
public:
/// 广播者注册订阅者
void regist(std::shared_ptr<subscriber> sp) { vec.push_back(sp); }

/// 广播事件
void boardcast(const std::string& str) {
for (auto it = vec.begin(); it != vec.end();) {
auto sp = it->lock();
if (sp) {
/// 让订阅者接受消息
sp->read(str);
it++;
} else {
std::cout << "subscriber is delete!" << std::endl;
it = vec.erase(it); ///< 擦除这个订阅者的指针
}
}
}

private:
std::vector<std::weak_ptr<subscriber>> vec; ///< 用于保存订阅者的智能指针
};

int main() {
boardcaster obj;
auto reader0 = std::make_shared<subscriber>();
obj.regist(reader0);
{
auto reader1 = std::make_shared<subscriber>();
obj.regist(reader1);
auto reader2 = std::make_shared<subscriber>();
obj.regist(reader2);

obj.boardcast("start boardcast!");
}

obj.boardcast("reader1 and reader2 is deleted, boardcast angin!");
return 0;
}

智能指针(现代 C++)

C/C++内存泄漏及检测

知乎 C++ 怎么检测内存泄露,怎么定位内存泄露?

1
2
3
4
5
6
7
8
9
10
11
#ifdef _WIN32
#include <crtdbg.h>
#ifdef _DEBUG

#define new new(_NORMAL_BLOCK,__FILE__,__LINE__)
#endif
#endif

#ifdef _WIN32
_CrtSetDbgFlag(_CrtSetDbgFlag(_CRTDBG_REPORT_FLAG)|_CRTDBG_LEAK_CHECK_DF);
#endif

使用 CRT 库查找内存泄漏

C++ 类内默认成员函数

当我们在创建一个类时, 如果你没有主动定义六个默认函数的话,编译器将为你自动创建。
如下面两个类完全等价

1
2
3
4
5
6
7
8
9
10
11
class object {};

class object {
public:
object() = default;
~object() = default;
object(const object& other) = default;
object(object&& other) = default;
object& operator=(const object& other) = default;
object& operator=(object&& other) = default;
};

下面代码示例默认函数的调用场景

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
class object {
public:
object() = default; ///< 构造函数 #1
~object() = default; ///< 析构函数 #2
object(const object& other) = default; ///< 拷贝构造函数 #3
object(object&& other) = default; ///< 移动构造函数 #4
object& operator=(const object& other) = default; ///< 拷贝赋值符 #5
object& operator=(object&& other) = default; ///< 移动赋值符 #6
};

int main() {
/// 构造函数#1示例
object x; ///< 调用默认构造函数#1

/// 析构函数#2示例
{
object x2;
} ///< 超出作用域临时变量x2,调用析构函数#2

object* p3 = new object();
delete p3; ///< 调用析构函数#2

/// 拷贝构造函数#3示例
object base;
object copy1(base); ///< 调用拷贝构造函数 #3
object copy2 = base; ///< 调用拷贝构造赋值符#3

/// 移动构造函数#4示例
object base_move;
object copy_move(std::move(base_move)); ///< 由于入参为右值, 调用移动构造函数#4

/// 拷贝构造赋值符#5示例
object base_operator;
object copy_operator;
copy_operator = base_operator; ///< 调用拷贝构造赋值符#5

/// 移动构造赋值符#6示例
object move_base;
object move_operator = std::move(move_base); ///< 调用移动构造赋值符#6
return 0;
}

特殊情况

当一个类的析构函数被定为private或是delete时,该类只能构造而无法析构。

1
2
3
4
5
6
7
8
9
10
11
12
class object1 {
private:
~object() = default;
};


int main() {
object1* p = new object1; ///< 编译通过
/// delete p; ///< 编译错误
/// object1 obj; ///< 编译错误
return 0;
}

下面这个类object2等同于上面object1

1
2
3
4
class object2 {
public:
~object() = delete;
};

各个默认合成函数的生成关系

构造析构与拷贝构造与拷贝赋值运算符的默认生成关系

原则:
需要析构函数的类也需要拷贝和赋值操作。
需要拷贝和赋值操作函数的类,不一定需要析构函数。
需要拷贝操作的类也需要赋值操作,反之亦然。
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

原因:

一个成员有删除的或不可访问的析构函数会导致合成的默认拷贝和拷贝构造函数被定义为删除的,为了防止创建出无法销毁的对象。

1
2
3
4
5
6
7
8
9
10
class a
{
private:
~a() {}
};

int main()
{
a obj0; ///< 没有默认构造函数, 编译错误
}

对于具有引用成员或无法默认构造的const成员的类,编译器不会为其合成默认构造函数。

1
2
3
4
5
6
7
8
9
10
class a
{
const int i; ///< 未赋值的 const 变量
int& j; ///< 未指向的引用变量
};

int main()
{
/// a obj0; ///< 没有默认构造函数, 编译错误
}

如果一个类有const成员,则它不能使用合成的拷贝复制运算符,因为const成员被创建后无法再次赋值。

1
2
3
4
5
6
7
8
9
10
11
class a
{
const int i = 0;
};

int main()
{
a obj0;
a obj1;
obj0 = obj1; ///< 编译错误
}

对于具有引用成员的类, 其合成拷贝构造函数也是被删除的。因为无法使引用改变指向。

管理类外资源的类必须定义拷贝控制成员。

赋值运算符有两个要求:

  • 如果将一个对象赋予它自身,赋值运算符必须能正常工作。
  • 大多数赋值运算符组合了析构函数和拷贝构造函数。
1
2
3
4
5
6
7
8
9
10
11
class object{
std::string* p;
public:
object& operator=(const object& other) {
if(&other != this) {
delete p;
p = new std::string(*other.p); ///< 当传入对象就是自己时会出错。
}
return *this;
}
};

如果我们不声明自己的拷贝构造函数或拷贝赋值运算符,编译器总会为我们合成这些操作,而编译器不会为某些类合成移动操作。特别是,当一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符。
如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时编译器才会为合成移动构造函数或移动赋值运算符。

与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。但是,如果我们显式要求编译器生成=default的移动操作,且编译器不能移动所有成员移动所有成员,则编译器会将移动操作定义为删除的函数。

  • 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。

  • 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。

    Snipaste_2020-12-07_14-14-02

  • 类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的。

    Snipaste_2020-12-07_14-16-31

定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地定义为删除的。

1
2
3
4
5
6
7
8
9
10
11
12
13
class object {
public:
object() = default;
object&& operator=(object&& other) {}
object(object&& other) {}
};

int main() {
object obj;
auto obj_copy1 = std::move(obj);
auto obj_copy2 = obj;
return 0;
}

Snipaste_2020-12-07_11-15-47

下面代码中#1拷贝构造函数由于接收的是const的引用,所以也能够匹配右值入参。

1
2
3
4
5
6
7
8
9
10
class object{
public:
object() = default;
object(const object& obj) = default; ///< #1
};

int main(){
object obj1;
object obj2(std::move(obj1));
}

如果一个类有一个拷贝构造函数但未定义移动构造函数时,编译器不会合成移动构造函数。但是函数匹配规则保证该类型的对象会被拷贝,即使我们试图使用std::move来移动他们时也是如此。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class a
{
public:
/// 默认构造函数
a() {}
/// 拷贝构造函数
a(const a& other){}
};

int main()
{
a obj0;
a obj1 = std::move(obj0); ///< 此处会调用拷贝构造函数
}

特殊成员函数之间的依赖关系

编译器隐式声明

默认构造函数 析构函数 拷贝构造函数 拷贝赋值 移动构造函数 移动赋值
全部不声明 预置 预置 预置 预置 预置 预置
任意构造函数 不声明 预置 预置 预置 预置 预置
默认构造函数 用户声明 预置 预置 预置 预置 预置
析构函数 预置 用户声明 预置 预置 不声明 不声明
拷贝构造函数 不声明 预置 用户声明 预置 不声明 不声明
拷贝赋值 预置 预置 预置 用户声明 不声明 不声明
移动构造函数 不声明 预置 弃置 弃置 用户声明 不声明
移动赋值 预置 预置 弃置 弃置 不声明 用户声明

用于集成测试时的模拟机器的硬件情况,如 cpu 占用率、内存占用率、磁盘占用率。

本文使用 MoSCoW (莫斯科) 法则, 对需求优先级进行排序。

需求分析

5. 自动化测试

应可以在跨平台的环境下,自动化测试。

6. 使用提示

6.1 提示应具有良好的可读性

Must have

6.2 重点文本,颜色高亮

Could have

假设约束

功能需求

1. cpu 占用率模拟

1.1 支持单核满载

Should have

1.2 支持多核满载

Must have

1.3 CPU 占用率区间

不限制最低 CPU 占用率,限制最高 CPU 占用率为 95%([x%, 95%]).

1.4 关闭程序后,CPU 占用率恢复到运行前水平

Must have

2. 内存占用率模拟

TODO(lijiancong): 需要确认内存占用率的计算方式。

2.1 支持内存占用率区间

不限制最低内存占用率,限制最高内存占用率为 95%([x%, 95%]).

2.2 关闭程序后,内存占用率恢复到运行前水平

3. 磁盘占用率模拟

3.1 支持磁盘占用率区间

Must have

不限制最低磁盘占用率,限制最高磁盘占用率为 95%([x%, 95%]).

3.2 关闭程序后,磁盘占用率恢复到运行前水平

Should have

3.3 支持运行当前运行磁盘的占用率模拟

Must have

3.4 支持指定磁盘的占用率模拟

Won’t have

非功能需求

4. 跨平台支持

4.1 跨系统支持

4.1.1. Windows11

Must have

4.1.2. Windows10

Must have

4.1.3. Centos7

Should have

4.1.4. Ubuntu

Should have

4.1.5. openEuler

Should have

4.1.6. MacOS

Could have

4.2 多编译器支持

4.2.1. G++12

Must have

4.2.2. Visual Studio 2022

Must have

4.2.3. Visual Studio 2010

Could have

4.2.4. clang14

Could have

4.2.5. G++3.8 及以上

Could have

7. 安全要求

以不损害硬件为原则,运行本软件后恢复硬件环境到运行前水平。

7.1 cpu 高负载

7.1.1 高负载限制 95%
7.1.2 高负载时间限制 20min

附录

MoSCoW (莫斯科) 法则

MoSCoW 法则是一种优先级排序法则,用于项目管理定义范围、确定功能质量、变更管理中常用的工具法则,以便用户、项目主管、项目经理、供应商对纳入项目中的每个需求交付的重要性和紧急性达成共识。

MoSCoW 代表四个优先级别的首字母的缩写,再加上O使之能够形成便于记忆的名称——MoSCoW

  1. Must have: 必须有。 如果不包含,则产品不可行。 Must Have 的功能,通常就是最小可行产品(MVP)的功能。
  2. Should have: 应该有。 这些功能很重要,但不是必需的。 虽然“应该有”的要求与“必须有”一样重要,但它们通常可以用另一种方式来代替,去满足客户要求。
  3. Could have: 可以有。 这些要求是客户期望的,但不是必需的。 可以提高用户体验,或提高客户满意度。 如果时间充足,资源允许,通常会包括这些功能。 但如果交付时间紧张,通常现阶段不会做,会挪到下一阶段或者下一期做。
  4. Won’t have:这次不会有。 最不重要,最低回报的事项,或在当下是不适合的要求。 不会被计划到当前交货计划中。 “不会有”会被要求删除,或重新考虑。

image

SIL2 认证的中对于信号子系统中软件开发质量保障的标准 EN50128 标准的基本要求摘录。

文档要求

  • 5.3.2.7 对于每个文档,应根据唯一的参考编号以及与其他文档的定义和记录关系提供可追溯性。

  • 5.3.2.8 每个术语、首字母缩略词或缩写在每个文档中应具有相同的含义。如果由于历史原因无法做到这一点,则应列出不同的含义并给出参考文献。

  • 5.3.2.9 除与原有软件有关的文档(见7.3.4.7)外,每份文档应按照以下规则编写:

    • 它应包含或实施与其具有继承关系的先前文档的所有适用条件和要求;
    • 它不得与前一份文档相抵触。
  • 5.3.2.10 每个项目或概念应在每份文档中使用相同的名称或描述。

通用要求

  • 6.5.4.14 在验证安全相关系统时,应将要求的可追溯性作为重要考虑因素,并应提供方法,以便在生命周期的所有阶段进行证明。

  • 6.5.4.15 在本欧洲标准的背景下,并在适合指定软件安全完整性级别的程度上,可追溯性应特别解决

  • a) 将需求追溯到设计或满足这些要求的其他对象
    
  • b) 设计对象的可追溯性到实例化它们的实现对象
    
  • c) 设计对象的可追溯性到实例化它们的实现对象
    
  • 可追溯性应成为配置管理的主题

  • 6.5.4.16 在特殊情况下,例如预先存在的软件或原型软件,可以在代码的实施和/或记录之后,但在验证/确认之前创建可追溯性。在这些情况下,应证明验证/确认与所有阶段的可追溯性一样有效。

  • 6.5.4.17 无法充分追溯的要求、设计或实施对象应证明与系统的安全性或完整性无关。

验证

  1. 软件架构、接口和设计规范的内部一致。 软件架构、接口和设计规范在一致性和完整性方面是否充分满足软件要求规范。
  2. 规范与质量保证进程使用了相同的术语。对于规范这个文档使用了唯一的文件编号(文档编号: xxx,版本Vxxx),并具有与其他文档的定义和记录关系(规范中章节1.5. 与其它开发任务/文档的关系),以提供可追溯性。
  3. 每个术语、首字母缩略词或缩写在每个文档中应具有相同的含义。见规范中章节1.6. 术语和缩写词。
  4. 它应包含或实施与其具有继承关系的先前文档的所有适用条件和要求;也不与前一份文档抵触。
  5. 每个项目或概念应在每份文档中使用相同的名称或描述。见规范中章节1.6. 术语和缩写词。
  6. 在规范中,可追溯性得到满足。见规范中章节 6. 可追溯性分析。
  7. 规范中没有已经预先存在的软件,所以不用为其创建可追溯性和验证其可追溯性。

验证报告要求

  • 6.2.4.12 任何软件验证报告均应由验证员负责,根据输入的文档编写。为了清晰和方便起见,可以对这些报告进行分区,并应遵循软件验证计划。6.2.4.13 中的要求是指软件验证报告

  • 6.2.4.13 每份软件验证报告应记录以下内容:

  • a) 已验证项目的身份和配置,以及验证者名称

  • b) 不符合规格要求的物品;

  • c) 对问题适配的组件、数据、结构和算法。

  • d) 检测到错误或缺陷;

  • e) 软件验证计划的实现或偏离(如果出现偏差,验证报告应说明偏差是否严重);

  • f) 假设,如果有的话;

  • g) 核查结果摘要

测试规范要求

  • 6.1.4.4 每个测试规范应记录以下内容
  • a) 测试目标
  • b) 测试用例、测试数据和预期结果;
  • c) 要执行的测试类型;
  • d) 测试环境、工具、配置和进程
  • e) 判断测试完成情况的测试标准;
  • f) 要达到的测试覆盖率的标准和程度
  • g) 参与测试过程的人员的角色和职责
  • h) 测试规范涵盖的要求
  • i) 软件测试设备的选择和使用;

测试报告要求

  • 6.1.4.5 测试报告应按如下方式制作:
  • a) 测试报告应当载明测试人员姓名,说明测试结果以及是否满足测试规范的测试目标和测试标准。故障应记录和总结;
  • b) 测试用例及其结果应记录,最好以机器可读的形式记录,以供后续分析;
  • c) 测试应是可重复的,并在可行的情况下通过自动方式进行;
  • d) 应验证用于自动执行测试的测试脚本;
  • e) 应记录所有涉及项目(使用的硬件、使用的软件、使用的设备、设备校准以及测试规范的版本信息)的身份和配置;
  • f) 应提供对测试覆盖率和测试完成情况的评估,并注明任何偏差

确认报告

  • 6.3.4.8 确认结果应记录在软件确认报告中。

  • 6.3.4.9 确认者应检查验证过程是否完成。

  • 6.3.4.10 软件确认报告应完整说明已验证的软件基线。

  • 6.3.4.11 确认报告应明确指出软件中任何已知的缺陷,以及这些缺陷可能对软件使用产生的影响

NULL与nullptr的区别

NULL为宏定义

1
#define NULL 0

NULL的类型不明显,而一下情况会出现函数重载不明确的情况

1
2
3
4
5
6
void f1(int i){}
void f1(int* p){}

int main(){
f1(NULL); ///< 调用函数不确定,编译器警告或报错
}

nullptr是一个特殊类型(nullptr_t)专门用来指代空指针。见下面代码

1
2
3
4
5
6
void f1(int i){}  ///< #1
void f1(int* p){} ///< #2

int main(){
f1(nullptr); ///< 明确调用#2函数
}

《Effective Modern C++》读书笔记(1)

在运行期, std::movestd::forward都不会做任何操作。

1
2
3
4
5
6
7
8
9
void f(Widget&& param); ///< 右值引用
Widget&& var1 = Widget(); ///< 右值引用
auto&& var2 = var1; ///< 非右值引用

template<typename T>
void f(std::vector<T>&& param) ///< 右值引用

template<typename T>
void f(T&& param) ///< 非右值引用

T&&有两种不同的含义

  1. 右值引用
  2. 表示既可以是右值引用也可以是左值引用

万能引用会在两个地方现身

1
2
template<typename T>
void f(T&& param); ///< param是个万能引用
1
auto&& var2 = var1; ///< var2是个万能引用

而不涉及型别推导&&就是右值引用

1
void f(Widget&& param); ///< 不涉及型别推导

const关键字也可以确定const T&&是右值引用

1
2
template<typename T>
void f(const T&& param);

在一个模板中的T&&也不一定是万能引用, 见下面。

1
2
3
4
5
template<class T, class Allocator = allocator<T>>
class vector {
public:
void push_back(T&& x);
};

因为push_backvector的成员函数, 如果vector实例存在的话就一定有确定的类型,所以并不存在型别推导。

另外,声明auto&&都是万能引用。

针对右值引用实施std::move,针对万能引用实施std::forward

当转发右值引用给其他函数是,应当对其实施向右值的无条件强制型别转换(通过std::move),因为它们一定绑定到右值,而当转发万能引用时,应当对其实施向右值的有条件强制型别转换(通过std::forward), 因为它们不一定绑定到右值。
应当避免针对右值引用实施std::forward。而另一方面,针对万能引用使用std::move的想法更为糟糕,因为那样做的后果是某些左值会遭到意外改动(例如某些临时变量)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Widget {
public:
template<typename T>
void setName(T&& newName) {
name = std::move(newName); ///< 可以编译但糟糕透顶
}
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName(); ///< 工厂函数

Widget w;

auto n = getWidgetName(); ///< n是个局部变量

w.setName(n); ///< 将n移入了w

... ///< n的值变得未知了
1
2
3
4
5
6
7
8
9
10
11
Widget makeWidget() {
Widget w;
... ///< 对w进行操作
return w; ///< 没有任何东西被复制
}

Widget makeWidget() {
Widget w;
...
return std::move(w); ///< 将w移入返回值, 千万不要这么做
}

RVO(return value optimization): 编译器若要在一个按值返回的函数里省略对局部对象的复制(或者移动), 则需要满足两个前提条件: 1. 局部对象型别和函数返回值型别相同. 2. 返回的就是局部对象本身。即使实施RVO的前提条件满足,但编译器选择不执行复制省略的时候,返回对象必须作为右值处理。当RVO的前提条件允许时,要么发生复制省略,要么std::move隐式地被实施于返回的局部对象。

  • 针对右值引用的最后一次使用实施std::move, 针对万能引用的最后一次使用实施std::forward
  • 作为按值返回的函数的右值引用和万能引用,依上一条所述采取相同行动。
  • 若局部对象可能适用于返回值优化,则请勿针对其实施std::movestd::forward
1
2
3
4
template<typename T>
void logAndAdd(T&& name) {
logAndAddImpl(std::forward<T>(name), std::is_integral<T>()); ///< std::is_integral不够正确
}

std::is_integral<>不够正确是因为如果传给万能引用name实参是个左值,那么T就会被推导为左值引用。因为int&不是int.

1
2
3
4
template<typename T>
void logAndAdd(T&& name) {
logAndAddImpl(std::forward<T>(name), std::is_integral<std::remove_reference_t<T>>());
}

完美转发的含义是我们不仅转发对象,还转发其显著特征:型别、左值还是右值,以及是否带有constvolation饰词等等。

大括号初始化物
假设f的声明如下:

1
void f(const std:vector<int>& v);

在此情况下,以大括号初始化物调用f可以通过编译:

1
f({1,2,3})

但如果把同一大括号初始化物的运用,就是一种完美转发失败的情形。编译器采用推导的手法来取得传递给fwd实参的型别结果,而后它会比较推导型别结果和f声明的形参型别。完美转发会在下面两个条件中的任何一个成立时失败:

  • 编译器无法为一个或多个fwd的形参推导出型别结果。编译器无法编译通过。
  • 编译器为一个或多个fwd的形参推导出了”错误的”型别结果。
1
2
3
4
5
6
7
8
9
template<typename... Ts>
void fwd(Ts&&... params){
f(std::forward<Ts>(params)...);
}

class Widget{
public:
static const std::size_t MinVals = 28;
}
1
2
f(Widget::MinVals); ///< 没问题, 当f(28)处理
fwd(Widget::MinVals); ///< 错误,无法链接

无法链接的原因是,完美转发,转发的是入参(Widget::MinVals)的引用,而引用在编译器底层是指针实现的。由于static变量并没有被分配实际的地址,所以产生了链接错误。

完美转发的失败情形还包括:重载的函数名字和模板名字。

1
2
3
4
5
6
void f(int (*pf)(int)); ///< 一个接受函数指针入参的函数f
int processVal(int value);
int processVal(int value, int priority);

/// 然后调用
f(processVal);

上面在调用函数f的时候,其中processVal仅仅只是函数的名字,但编译器知道匹配的是单入参版本的函数。

而使用完美转发时,编译器是无法知道使用的是什么版本。

1
fwd(processVal);  ///< 编译不过

最后一种完美转发失败的情形是位域被用作函数实参的时候。
标准中:非const引用不得绑定到位域。既然没有办法创建指涉到任意比特的指针(C++标准规定,可以指涉的最小实体是单个char),那自然没有办法把引用绑定到任意比特上了。

1
2
3
4
5
6
7
8
9
10
struct IPV4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;
};

f(h.totalLength); ///< 没问题
fwd(h.totalLength); ///< 错误!

把位域传递给完美转发函数的关键,就是利用转发目的函数接收的总是位域值的副本这一事实。可以自己复制一份,并以该副本调用。

1
2
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length);

C++11 中有两种捕获模式: 按引用和按值。按引用的默认捕获模式可能导致空悬引用,按值的默认捕获模式也无法对空悬引用免疫,而且会让你认为你的闭包是独立的(事实上它们可能不是独立的)。

按引用捕获会导致闭包包含指涉到局部变量的引用,或者指涉到定义lambda式的作用域内的形参的引用。一旦由lambda式所创建的闭包越过了该局部变量或形参的生命周期,那么闭包内的引用就会空悬。

1
2
3
4
5
6
7
8
9
10
11
std::vector<std::function<int(int)>> vec;

int f() {
int divisor = 0;
vec.push_back([&](int val){ return val / divisor;});
}

int f2() {
auto lamdba = vec.at(0);
lambda(1); ///< 此处可能会出现空悬引用。
}

你可能会觉得如果把上面的按引用捕获换为按值捕获这样的空悬引用就不会出现。

1
2
3
4
5
6
7
8
9
10
11
  std::vector<std::function<int(int)>> vec;
class object {
public:
int f() {
int divisor = 0;
vec.push_back([=](int val){ return val / divisor;}); ///< 这里是引用捕获
}

private:
int divisor = 0;
}

引用捕获只能针对于在创建lambda式的作用域内可见的非静态局部变量(包括形参);

上面代码等价于这样

1
2
3
4
5
6
7
8
9
10
11
12
13
class object {
public:
int f() {
int divisor = 0;
vec.push_back([=](int val){ return val / divisor;});
/// 等价于下面这句
/// vec.push_back([this](int val){ return val / this->divisor;});
/// 默认捕获列表捕获的是this的副本,而不是divisor的副本
}

private:
int divisor = 0;
}

lambda表达式同样不能捕获static变量, 在lambda表达式中使用static变量只能是按引用。

lambda 表达式与 std::bind 优先选用lambda

1
auto setSoundB = std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);

在创建setSoundB这函数对象的时候,通过std::bind去创建的时候steady_clock::now()已经进行了计算,而不是在调用时刻进行计算,
同样在具有函数重载的情况时,std::bind接收的只是函数名称无法参与函数重载的判断,会导致编译不过。