「萝卜荟萃」浅谈 C++11 新特性之智能指针、右值引用

本文最后更新于:2022-10-29 23:41:08 UTC+08:00

邦邦让我分享一下华为实习,我觉得好像没啥可以讲的,该讲的前面的同学都讲了。于是想到了参加过一次分享会,员工们分享了这两个东西,于是决定给大家科普一下。

智能指针

我们来看一下下面的代码:

1
2
3
4
5
6
7
8
9
void func(std::string &str) {
std::string *p = new std::string(str);
// ...
if (something_wrong())
throw exception();
str = *p;
delete p;
return;
}

something_wrong()true 时会抛出异常,这时 delete p; 将不会被执行,导致内存泄漏。

在实际的 C++ 开发中,我们经常会遇到诸如程序运行中突然崩溃、程序运行所用内存越来越多最终不得不重启等问题,这些问题往往都是内存资源管理不当造成的。比如:

  • 有些内存资源已经被释放,但指向它的指针并没有改变指向(成为了野指针),并且后续还在使用;
  • 有些内存资源已经被释放,后期又试图再释放一次(重复释放同一块内存会导致程序运行崩溃);
  • 没有及时释放不再使用的内存资源,造成内存泄漏,程序占用的内存资源越来越多。

针对以上问题,C++ 提供了智能指针来解决垃圾回收的问题。

使用智能指针需要包含头文件:

1
#include <memory>

auto_ptr

C++98/03 标准中支持使用 auto_ptr 智能指针来实现堆内存自动回收。我们可以把上面的例子改一改:

1
2
3
4
5
6
7
8
9
#include <memory>
void func(std::string &str) {
std::auto_ptr<std::string> p = new std::string(str);
// ...
if (something_wrong())
throw exception();
str = *p;
return;
}

当程序退出时,p 被销毁,auto_ptr 会自动把原来 p 所指的堆内存释放掉,不会导致内存泄漏。

但是 auto_ptr 存在一些问题。

1
2
3
std::auto_ptr<std::string> ps(new std::string("abc"));
std::auto_ptr<std::string> vocation;
vocaticn = ps;

上面的程序会导致程序退出时释放同一块内存两次,一次是 ps 被销毁时,一次是 vocation 被销毁时,此时仍然没有解决重复 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
// Not Supported C++11
#include <iostream>
#include <string>
#include <memory>
using namespace std;

int main() {
std::auto_ptr<string> films[5] = {
auto_ptr<string>(new string("Fowl Balls")),
auto_ptr<string>(new string("Duck Walks")),
auto_ptr<string>(new string("Chicken Runs")),
auto_ptr<string>(new string("Turkey Errors")),
auto_ptr<string>(new string("Goose Eggs"))
};
auto_ptr<string> pwin;
pwin = films[2]; // films[2] loses ownership.

cout << "The nominees for best avian baseballl film are\n";
for (int i = 0; i < 5; ++i)
cout << *films[i] << endl;
cout << "The winner is " << *pwin << endl;
cin.get();

return 0;
}

编译运行发现程序崩溃了。上面的程序把 films[2] 赋值给了 pwin ,此时 film[2] 的所有权已经转移给了 pwinfilms[2] 此时已经是一个空指针,访问空指针会出错。

C++11 新标准在废弃 auto_ptr 的同时,增添了 unique_ptrshared_ptr 以及 weak_ptr 这 3 个智能指针来实现堆内存的自动回收。

unique_ptr

unique_ptr 由 C++11 引入,旨在替代不安全的 auto_ptrunique_ptrauto_ptr 类似,持有对对象的独有权——两个 unique_ptr 不能指向一个对象,即 unique_ptr 不共享它所管理的对象。它无法复制到其他 unique_ptr,无法通过值传递到函数,也无法用于需要副本的任何标准模板库 (STL)算法。只能移动 unique_ptr,即对资源管理权限可以实现转移。这意味着,内存资源所有权可以转移到另一个 unique_ptr,并且原始 unique_ptr 不再拥有此资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 构造空指针
unique_ptr<int> u_i;
// 明确指向
u_i.reset(new int(3)); // 释放 u_i 内部对象(若有)并接受新对象所有权
unique_ptr<int> u_i2(new int(4));
std::unique_ptr<int> u_i3 = std::make_unique<int>(10); // C++14 引入 make_unique

// 转移所有权
int *p_i = u_i2.release(); // 释放所有权给普通指针

unique_ptr<string> u_s(new string("abc"));
unique_ptr<string> u_s2 = u_s; // 错误,不可赋值,只能移动
unique_ptr<string> u_s2 = std::move(u_s); // 通过移动构造函数转移所有权,u_s 变为空指针

u_s2 = nullptr; // 显式销毁所指对象,同时智能指针变为空指针。与 u_s2.reset() 等价

至于为什么 C++11 没有提供 make_unique ,C++ 标准委员会主席 Herb Sutter 在他的博客中提到原因是因为『被他们忘记了』。

shared_ptr

C++ 智能指针 shared_ptr 底层是采用引用计数的方式实现的。简单的理解,智能指针在申请堆内存空间的同时,会为其配备一个整形值(初始值为 1),每当有新对象使用此堆内存时,该整形值 + 1;反之,每当使用此堆内存的对象被释放时,该整形值减 1。当堆空间对应的整形值为 0 时,即表明不再有对象使用它,该堆空间就会被释放掉。

shared_ptr 允许多个指针指向同一个对象,解决了 auto_ptr 在对象所有权上的局限性(auto_ptr 独占对象所有权)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 构造空指针
std::shared_ptr<int> p1; //不传入任何实参
std::shared_ptr<int> p2(nullptr); //传入空指针 nullptr
// 明确指向
std::shared_ptr<int> p3(new int(10));
std::shared_ptr<int> p3 = std::make_shared<int>(10);
// 拷贝构造,引用计数 + 1
std::shared_ptr<int> p4(p3);
std::shared_ptr<int> p4 = p3;
// 移动构造
std::shared_ptr<int> p5(std::move(p4));
std::shared_ptr<int> p5 = std::move(p4);

// 获取原始指针,不增加引用计数
int *p6 = p5.get();
// 减少一个引用计数
p3.reset();
// 获取一个对象的引用计数
std::cout << p3.use_count() << ' ' << p5.use_count() << std::endl; // 0 1

另外,不能自动将普通指针转换为智能指针对象,必须显式调用构造函数。一个普通指针也不能同时为多个 shared_ptr 赋值。

1
2
3
4
5
6
7
8
9
10
std::shared_ptr<double> pd; 
double *p_reg = new double;
pd = p_reg; // 错误(隐式转换)
pd = std::shared_ptr<double>(p_reg);
std::shared_ptr<double> pshared = p_reg; // 错误(隐式转换)
std::shared_ptr<double> pshared(p_reg);

int* ptr = new int;
std::shared_ptr<int> p1(ptr);
std::shared_ptr<int> p2(ptr); // 错误

但是,shared_ptr 仍然存在资源无法释放的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct A;
struct B;

struct A {
std::shared_ptr<B> pointer;
~A() {
std::cout << "A 被销毁" << std::endl;
}
};
struct B {
std::shared_ptr<A> pointer;
~B() {
std::cout << "B 被销毁" << std::endl;
}
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->pointer = b;
b->pointer = a;
}

运行结果是 A, B 都不会被销毁,这是因为 a, b 内部的 pointer 同时又引用了 a, b,这使得 a, b 的引用计数均变为了 2,而离开作用域时,a, b 智能指针被析构,却只能造成这块区域的引用计数减一,这样就导致了 a, b 对象指向的内存区域引用计数不为零,而外部已经没有办法找到这块区域了,也就造成了内存泄露。

weak_ptr

weak_ptr 是为了配合 shared_ptr 而引入的一种智能指针,将一个 weak_ptr 绑定到一个 shared_ptr 不会改变 shared_ptr 的引用计数。weak_ptr 是一种弱引用(相比较而言 shared_ptr 就是一种强引用)。不论是否有 weak_ptr 指向,一旦最后一个指向对象的 shared_ptr 被销毁,对象就会被释放。从这个角度看, weak_ptr 更像是 shared_ptr 的一个助手而不是智能指针。

刚才的例子中,把 struct B 中的 pointer 换成 weak_ptr ,可以解决内存泄露的问题。

如上图,a, b 陆续被销毁后,已经没有强引用对象指向 A ,则 A 被释放,此时 B 也没有被强引用对象指向,所以 A, B 均可以被顺利释放。

1
2
3
4
5
6
7
8
std::shared_ptr<int> sp(new int(5));
std::weak_ptr<int> wp(sp); // 只能通过 shared_ptr 构造

std::shared_ptr<A> pa = wp.lock(); // 如果 wp 指向的对象存在,会返回一个指向该对象的 shared_ptr ,否则返回空 shared_ptr

sp.reset();
pa.reset();
wp.expired(); // 判断 wp 所指向的对象是否已被销毁

weak_ptr 并没有重载 operator->operator * 操作符,因此不可直接通过 weak_ptr 使用对象,典型的用法是调用其 lock() 函数来获得 shared_ptr ,进而访问原始对象。

右值引用

在 C++ 或者 C 语言中,一个表达式(可以是字面量、变量、对象、函数的返回值等)根据其使用场景不同,分为左值表达式和右值表达式。确切的说 C++ 中左值和右值的概念是从 C 语言继承过来的。

值得一提的是,左值的英文简写为 "lvalue",右值的英文简写为 "rvalue"。很多人认为它们分别是 "left value"、"right value" 的缩写,其实不然。lvalue 是 "loactor value" 的缩写,可意为存储在内存中、有明确存储地址(可寻址)的数据,而 rvalue 译为 "read value",指的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)。

简单理解,

  • 左值是可以位于赋值运算符 = 左侧的表达式(当然,左值也可以位于 = 的右侧),而
  • 右值是不可以位于赋值运算符 = 左侧的表达式。
1
2
3
4
5
6
7
8
9
10
11
12
int foo(42);
int bar(43);

// foo, bar 都是左值
foo = bar;
bar = foo;
foo = foo * bar;

// foo * bar 是右值
int baz;
baz = foo * bar; // OK: 右值在赋值运算符右侧
foo * bar = 42; // Err: 右值在赋值运算符左侧

在 C++ 中,有两种对对象的引用:左值引用和右值引用。

左值引用是常见的引用,所以一般在提到「对象的引用」的时候,指得就是左值引用。如果我们将一个对象的内存空间绑定到另一个变量上,那么这个变量就是左值引用。在建立引用的时候,我们是「将内存空间绑定」,因此我们使用的是一个对象在内存中的位置,这是一个左值。因此,我们不能将一个右值绑定到左值引用上。另一方面,由于常量左值引用保证了我们不能通过引用改变对应内存空间的值,因此我们可以将右值绑定在常量引用上。

1
2
3
4
int foo(42);
int& bar = foo; // OK: foo 在此是左值,将它的内存空间与 bar 绑定在一起
int& baz = 42; // Err: 42 是右值,不能将它绑定在左值引用上
const int& qux = 42; // OK: 42 是右值,但是编译器可以为它开辟一块内存空间,绑定在 qux 上

右值引用也是引用,和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化。

1
2
3
4
5
6
7
int foo(42);
int& bar = foo; // OK: 将 foo 绑定在左值引用上
int&& baz = foo; // Err: foo 可以是左值,所以不能将它绑定在右值引用上
int&& qux = 42; // OK: 将右值 42 绑定在右值引用上
int&& quux = foo * 1; // OK: foo * 1 的结果是一个右值,将它绑定在右值引用上
int& garply = foo++; // Err: 后置自增运算符返回的是右值,不能将它绑定在左值引用上
int&& waldo = foo--; // OK: 后置自减运算符返回的是右值,将它绑定在右值引用上

和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:

1
2
int && a = 10;
a = 100;

由于右值引用只能绑定在右值上,而右值要么是字面常量,要么是临时对象,所以:

  • 右值引用的对象,是临时的,即将被销毁;并且
  • 右值引用的对象,不会在其它地方使用。

这两个特性意味着:接受和使用右值引用的代码,可以自由地接管所引用的对象的资源,而无需担心对其他代码逻辑造成数据破坏。

延伸阅读:https://liam.page/2016/12/11/rvalue-reference-in-Cpp/

参考

部分文字、图片及代码完全引用自以下链接,侵删。

http://c.biancheng.net/view/7898.html

https://www.cnblogs.com/lanxuezaipiao/p/4132096.html

https://blog.csdn.net/K346K346/article/details/81478223

https://changkun.de/modern-cpp/zh-cn/05-pointers

https://blog.csdn.net/Xiejingfa/article/details/50772571

http://c.biancheng.net/view/7829.html

https://liam.page/2016/12/11/rvalue-reference-in-Cpp/


「萝卜荟萃」浅谈 C++11 新特性之智能指针、右值引用
https://ligen.life/2022/modern-cpp-smart-pointers-and-rvalue-reference/
作者
ligen131
发布于
2022年10月27日
更新于
2022年10月29日
许可协议