C++ 进阶基础之一

大纲

智能指针

智能指针的入门案例

unique_ptr 对象的介绍

unique_ptr 是 C++ 11 提供的用于防止内存泄漏的智能指针中的一种实现,独享被管理对象指针所有权的智能指针。unique_ptr 对象包装了一个原始指针,并负责其生命周期。当该对象被销毁时,会在其析构函数中删除关联的原始指针。unique_ptr 实现了 ->* 运算符的重载,因此它可以像普通指针一样使用。

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

using namespace std;

class Task {

public:
Task(int id) {
this->id = id;
cout << "构造函数被调用" << endl;
}

~Task() {
cout << "析构函数被调用" << endl;
}

int getId() {
return this->id;
}

private:
int id;

};

int main() {
unique_ptr<Task> taskPtr(new Task(23));
cout << "id = " << taskPtr->getId() << endl;
return 0;
}

程序运行输出的结果如下:

1
2
3
构造函数被调用
id = 23
析构函数被调用

unique_ptr<Task> 对象 taskPtr 接受原始指针作为参数。当 main 函数退出时,该对象超出作用范围就会自动调用自身的析构函数。在 unique_ptr<Task> 对象 taskPtr 的析构函数中,会删除关联的原始指针,这样就不用专门执行 Task 对象的 delete 操作了。以后不管函数正常退出还是异常退出(由于某些异常),也会始终调用 taskPtr 对象的析构函数。因此,原始指针将始终被删除并防止内存泄漏。

unique_ptr 对象独享所有权

unique_ptr 对象始终是关联的原始指针的唯一所有者,因此开发者无法通过拷贝构造函数或赋值运算符复制 unique_ptr 对象的副本,只能移动它。由于每个 unique_ptr 对象都是原始指针的唯一所有者,因此在其析构函数中,它可以直接删除关联的指针,不需要任何参考计数。

智能指针的基础操作

获取被管理对象的原始指针

unique_ptr 对象上调用 get() 函数,可以获取管理对象的原始指针

1
Task *p1 = taskPtr.get();

检查 unique_ptr 对象是否为空

有两种方法创建一个空的 unique_ptr 对象,因为没有与之关联的原始指针,所以它是空的

1
unique_ptr<int> ptr;
1
unique_ptr<int> ptr = nullptr;

有两种方法可以检查 unique_ptr 对象是否为空或者是否有与之关联的原始指针

1
2
3
if (!ptr) {
cout<<"ptr is empty"<<endl;
}
1
2
3
if (ptr == nullptr){
cout<<"ptr is empty"<<endl;
}

使用原始指针创建 unique_ptr 对象

要创建非空的 unique_ptr 对象,需要在创建对象时在其构造函数中传递原始指针

1
unique_ptr<Task> taskPtr(new Task(22));

或者

1
unique_ptr<Task> taskPtr(new unique_ptr<Task>::element_type(23));

不能通过赋值的方法创建 unique_ptr 对象

1
unique_ptr<Task> taskPtr = new Task();     // 错误写法,编译失败

智能指针的进阶操作

重置 unique_ptr 对象

unique_ptr 对象上调用 reset() 函数可以重置它,即它会 delete 已关联的原始指针,并将 unique_ptr 对象设置为空

1
taskPtr.reset();

unique_ptr 对象不允许复制

由于 unique_ptr 不可复制,只能移动。因此,无法通过拷贝构造函数或赋值运算符创建 unique_ptr 对象的副本

1
2
3
4
5
6
unique_ptr<Task> taskPtr1(new Task(22));
unique_ptr<Task> taskPtr2(new Task(35));

unique_ptr<Task> taskPtr4 = taskPtr1; // 错误写法,编译失败

taskPtr2 = taskPtr1; // 错误写法,编译失败

转移 unique_ptr 对象的所有权

不允许复制 unique_ptr 对象,但可以转移它们。这意味着 unique_ptr 对象可以将自身关联的原始指针的所有权转移给另一个 unique_ptr 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 通过原始指针创建taskPtr1
unique_ptr<Task> taskPtr1(new Task(55));

// 把taskPtr1中关联指针的所有权转移给taskPtr2
unique_ptr<Task> taskPtr2 = move(taskPtr1);

// taskPtr1关联指针的所有权现在转移到了taskPtr2中,此时taskPtr1关联的指针为空
if (taskPtr1 == nullptr) {
cout << "taskPtr1 is empty" << endl;
}

// taskPtr1关联指针的所有权现在转移到了taskPtr2中,此时taskPtr2关联的指针不为空
if (taskPtr2 != nullptr) {
cout << "taskPtr2 is not empty" << endl;
}

cout << taskPtr2->getId() << endl;

程序运行输出的结果如下:

1
2
3
taskPtr1 is  empty
taskPtr2 is not empty
55

释放 unique_ptr 对象关联的原始指针

unique_ptr 对象上调用 release() 函数,将释放其关联的原始指针的所有权,并返回原始指针,同时设置 unique_ptr 对象为空。特别注意,这里是释放其关联的原始指针的所有权,并没有 delete 原始指针,而调用 reset() 函数则会 delete 原始指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unique_ptr<Task> taskPtr1(new Task(55));

if (taskPtr1 != nullptr) {
cout << "taskPtr1 is not empty" << endl;
}

// 释放关联指针的所有权
Task* ptr = taskPtr1.release();

if (taskPtr1 == nullptr) {
cout << "taskPtr1 is empty" << endl;
}

cout << "id = " << ptr->getId() << endl;

程序运行输出的结果如下:

1
2
3
taskPtr1 is not empty
taskPtr1 is empty
id = 55

C++ 14 使用原始指针创建 unique_ptr 对象

C++ 引入了新的语法,可以使用 make_unique 来创建 unique_ptr 对象,省去了 new 关键字的使用

1
unique_ptr<Task> taskPtr = make_unique<Task>(34);

原子操作的使用

原子操作简介

所谓的原子操作,取的就是 “原子是最小的、不可分割的最小个体” 的意义,它表示在多个线程访问同一个全局资源的时候,能够确保在同一时刻只有唯一的线程对这个资源进行访问。这有点类似互斥对象对共享资源的访问的保护,但是原子操作更加接近底层,因而效率更高。在以往的 C++ 标准中并没有对原子操作进行规定,开发人员往往是使用汇编语言,或者是借助第三方的线程库,例如 Intel 的 pthread 来实现。在新标准 C++ 11 中,引入了原子操作的概念,并通过这个新的头文件提供了多种原子操作数据类型,例如 atomic_boolatomic_int 等等。如果在多个线程中对这些类型的共享资源进行操作,编译器将保证这些操作都是原子性的,也就是说,确保任意时刻只有一个线程对这个资源进行访问;这样就可以保证多个线程访问这个共享资源的正确性,从而避免了锁的使用,提高了效率。在新标准 C++ 11 中,atomicintcharbool 等基础数据结构进行了原子性封装,在多线程环境中,对 atomic 对象的访问不会造成资源竞争,利用 atomic 可实现数据结构的无锁设计。

atomic 的简介

在新标准 C++ 11 中,新增了 atomic 关键字,可以使用它定义一个原子类型,详见 C++ 参考手册一C++ 参考手册二

  • 成员函数
成员函数说明
store 原子地以非原子对象替换原子对象的值
load 原子地获得原子对象的值
operator= 存储值于原子对象
is_lock_free 检查原子对象是否免锁
operator T 从原子对象加载值
exchange 原子地替换原子对象的值,并获得它先前持有的值
compare_exchange_weak、compare_exchange_strong 原子地比较原子对象与非原子参数的值,若相等则进行交换,若不相等则进行加载
  • 特化成员函数
特化成员函数说明
fetch_add 原子地将参数加到存储于原子对象的值,并返回先前保有的值
fetch_sub 原子地从存储于原子对象的值减去参数,并获得先前保有的值
fetch_and 原子地进行参数和原子对象的值的逐位与,并获得先前保有的值
fetch_or 原子地进行参数和原子对象的值的逐位或,并获得先前保有的值
fetch_xor 原子地进行参数和原子对象的值的逐位异或,并获得先前保有的值
operator++operator++(int)operator--operator--(int)令原子值增加或者减少一
operator+=operator-=operator&=operator^=加、减,或者与原子值进行逐位与、异或

值得一提的是,所谓特化函数,也就是 atomic 自身提供的,可以进行原子操作的函数。使用这些函数进行的操作,都是原子的。

atomic 的使用案例

加锁不使用 atomic

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 <ctime>
#include <mutex>
#include <vector>
#include <thread>

using namespace std;

mutex mtx;
size_t total = 0;

void threadFun()
{
for (int i = 0; i < 1000000; i++)
{
// 加锁防止多个线程同时访问同一资源
unique_lock<mutex> lock(mtx);
total++;
}
}

int main(void)
{
clock_t start_time = clock();

// 启动多个线程
vector<thread> threads;
for (int i = 0; i < 10; i++) {
threads.push_back(thread(threadFun));
}
for (auto& thad : threads) {
thad.join();
}

// 检测total是否正确 10000*10 = 100000
cout << "total number:" << total << endl;

clock_t end_time = clock();
cout << "耗时:" << end_time - start_time << "ms" << endl;

return 0;
}

程序运行输出的结果如下:

1
2
total number:10000000
耗时:615ms

不加锁使用 atomic

与加锁相比,使用原子操作(atomic)能大大地提高程序的运行效率。

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

using namespace std;

atomic<size_t> total(0);

void threadFun()
{
for (int i = 0; i < 1000000; i++) {
total++;
}
}

int main(void)
{
clock_t start_time = clock();

// 启动多个线程
vector<thread> threads;
for (int i = 0; i < 10; i++) {
threads.push_back(thread(threadFun));
}
for (auto& thad : threads) {
thad.join();
}

// 检测total是否正确 10000*10 = 100000
cout << "total number:" << total << endl;

clock_t end_time = clock();
cout << "耗时:" << end_time - start_time << "ms" << endl;

return 0;
}

程序运行输出的结果如下:

1
2
total number:10000000
耗时:321ms

为什么要定义一个原子类型

举个例子,int64_t 类型,在 32 位机器上为非原子操作。更新时该类型的值时,需要进行两步操作(高 32 位、低 32 位)。如果多线程操作该类型的变量,且在操作时未加锁,可能会出现读脏数据的情况。解决该问题的话,可以使用加锁,或者提供一种定义原子类型的方法。

  • 定义原子类型
1
2
// 定义一个"int64_t"的原子类型
std::atomic<int64_t> value;
  • 自加操作(原子)
1
2
// atomic提供的特化成员函数,已经重载了++运算符
value++
  • 读取变量值(原子)
1
2
// 此处的原子操作,指的是读取value的值这一步,而不是将value的值赋给x
int64_t x = value.load(std::memory_order_relaxed);
  • 更新变量(原子)
1
2
int64_t x = 10;
value.store(x, std::memory_order_relaxed)

atomic 不能与 string 一起使用

特别注意,atomic 关键字不能与 string 类型一起使用,因为 string 不是可简单复制的类型(TriviallyCopyable),详见 C++ 参考文档

The primary std::atomic template may be instantiated with any TriviallyCopyable type T satisfying both CopyConstructible and CopyAssignable.


1
2
3
4
5
6
#include <iostream>

int main() {
std::atomic<std::string> str{ "Hello" };
return 0;
}

上述代码编译后,C++ 编译器会出现编译错误,如下所示:

1
error C2338: atomic<T> requires T to be trivially copyable, copy constructible, move constructible, copy assignable, and move assignable.

关于 C++ 编译器为什么会对 std::atomic<std::string> 给出简单的可复制错误,在 Stack Overflow 上找到了一个类似的问题可供参考。

参考博客