“智能”指针?
指针本似乎只应是一个特殊的整型变量,其何以有“智能”一说?其源于C/C++中堆内存(Heap memory)管理的有关问题。
C语言在<stdlib.h>
中提供了类似于malloc
、free
等函数来进行堆内存的简单分配及回收;C++中则引入了operator new
、operator delete[]
等来配合面向对象的语义;但仅依赖它们进行内存管理的缺点亦非常明显,包括但不限于:
- 容易“忘记”或“绕过”内存回收的部分,进而导致内存泄漏问题
想必各位刚接触C语言时就被填鸭式地记忆过;malloc
完一定要free
- 内存“所有权”(ownership)不清晰。比如往某个函数中传入指针时无法明确其内存的“所有权”(指是否能重新分配、释放等)是否转移,函数嵌套调用时容易导致内存管理混乱(如
double-free ,即双重释放问题,亦是UB);
- 作为整型变量的指针非常“脆弱”,极易在函数调用链中被不经意地修改。
比如下述C代码片段:
1 | void foo(int*); |
稍多加学习与内存管理等有关的知识就会发现,上述代码的槽点不断。
如void bar()
的形参声明为const int*
,只使用顶层const
约束了不能通过p
来修改指向的值,但架不住p
本身容易被乱改,对于稍长一些的函数来说非常致命(极易忘记前述代码中p
已经被改动过)。同时即使加上底层const
,此般“固若金汤”也无法防止本函数间有人试图提前将其指向的内存free
掉——内存管理权(所有权)混乱,可谓“引
SegmentFault 入室”。
void fun()
中难得地记得了在函数结束时free
申请的内存,但往上看数行就可发觉隐患之一:若status
为false
,则会触发
early
return,进而ptr
指向的内存将(在程序允许结束或者大概率崩溃之前)无法被释放。C++中此类问题更甚——除了显式的return
,exceptions
乱飞也极易在不经意间直接“跳出”函数,且异常相比此类显式的exit
或abort
一类的[[norerturn]]
不算return
更难以被发现和追踪。
但不可否认的是,C/C++的“手动”内存管理也是其极大的特色和优势之一——对底层内存拥有极高自由度的读写权,同时没有后台 garbage collection 的束缚,使之能广泛地应用于底层软硬件的程序开发,同时拥有相比其它解释型/混合型语言“一骑绝尘”的效率。
如何在保留“手动内存管理”特色的同时,应用语言自身机制帮助程序设计者管理内存,自然成为了C++发展的一大课题。
C++98:前世的auto_ptr
C++98提出了一个因为太憨在C++11被废弃(deprecated)并在C++17被移除(removed)的智能指针的雏形:auto_ptr
。
基本设计与成效
回顾到我们的基本需求,即希望在申请的堆内存离开其作用域时自动释放,显然将之封装在构造/析构函数中是其不二之选。
同时还应当提供一些易于访问原始指针的接口,其可通过类成员函数和操作符重载实现。
由此,可以写出一个简单的类模板如下:
1 | template<class Ty> |
其中只需要
1 | template<class Ty> |
就可以实现自动“释放内存”的功能了。operator->
等重载亦能实现“无感”的底层指针访问,如:
1 | auto_ptr<std::string> sp; |
利用operator->
重载的“二次解析”特性,访问类成员的方式无异于sp
是原始指针的情况。
get()
和release()
成员函数都会返回原始指针,区别在于get()
用于向后兼容一些C样式的API以及少数需要利用原始指针的情况(并未“交出”指针的所有权,只是临时供使用),release()
则会“交出”指针的所有权(调用后该对象管理的指针置空,进而该指针指向的堆内存不会被自动释放,需要手动清理)。
reset()
函数则用于重置其管理的指针(新指针亦必须指向有效的、已申请的堆内存),看实现即知其用途:
1 | template<class Ty> |
此处应用了C++的
operator delete
“逃课”机制:free
/delete
一个空指针不会发生任何事情(即该操作是“安全”的)。
Hint:为什么花如此多的篇幅讲述一个已经 deprecated 类的成员函数?因为现代的智能指针“三剑客”成员函数的语义与之相同,只是更改了部分其它函数的实现(或增减了一些其它函数)来完善其功能。
缺点
看似上述auto_ptr
在功能全面的同时实现看似“天衣无缝”,但其依然未能规避开原始指针的诸多缺点。
首先,指针的“所有权”在函数传参等场景时依然不够明确或许是C++98/03没有移动语义的锅:
1 | void fun(auto_ptr<int>); |
在对fun(p)
调用后,fun
函数中auto_ptr
这一形参会将p
管理的指针释放掉,导致现在foo
中的p
管理了一个无效的内存,进而SegmentFault只是时间问题。
C++11:智能指针“三剑客”
auto_ptr
迎合时代发展的浪潮,在C++11中蜕变为了unique_ptr
、shared_ptr
和weak_ptr
三剑客,通过和C++11引入的大量其它语法特性的巧妙配合,使其在内存管理辅助中如鱼得水。
同时
auto_ptr
其也是现代C++中最先被 deprecated 和 removed 的一批库特性之一。
unique_ptr
字如其名,unique_ptr
表示一个“独占”的指针。
其通过和C++11中的= delete;
声明结合,禁止了其复制构造和复制赋值(形如
unique_ptr<Ty>::unique_ptr(const unique_ptr&) = delete;
),保留了移动构造和移动赋值的接口,由此解决了“所有权”问题之争——当传递unique_ptr
时,必须选择是否显式地放弃所有权:
1
2
3
4
5void f1(unique_ptr<int>);
unique_ptr<int> ip;
f1(ip); //Error: invokes function `unique_ptr<int>::unique_ptr(const unique_ptr&)` that is declared as deleted
f1(std::move(ip)); //OK, transferrs ownership to f1
移动构造的语义实现参考:
1 | template<class Ty> |
unique_ptr
的其它成员函数实现和auto_ptr
大同小异,不再赘述。
shared_ptr
但unique_ptr
只涵盖了“只需要”独占指针的情况;在并发编程等任务中,很多时候需要不同函数“共享”一块内存空间,同时又希望实现“自动”的内存管理。
由此引出了“共享指针”shared_ptr
和“弱指针”weak_ptr
。
shared_ptr
的部分接口实现仍与auto_ptr
类似。主要区别之一在其还会内置一个“计数器”且允许复制构造,每次复制构造会令该指针的计数器+1,析构(或因被其它指针赋值而放弃对当前指针管理)则令其计数器-1;
当一个指针对应的的计数器归零时,shared_ptr
将该指针指向的堆内存释放。
在较复杂的并发编程中,因同一
shared_ptr
的各成员函数没有被可以被不同线程同时调用,为了避免数据竞争(data race),应使用synchroized
修饰std::atomic<std::shared_ptr>
。
1 | void fun(std::shared_ptr<int>); |
shared_ptr
的部分重要成员函数如下:
成员函数 | 作用 |
---|---|
size_t use_count() const |
返回管理指针的计数,未绑定则为0 |
operator bool() const |
等价于return static_cast<bool>(use_count()); |
(C++20)bool unique() const |
等价于
return use_count() == 1; |
weak_ptr
“弱指针”weak_ptr
在并发/异步编程中亦能大显身手,其本质上一种不具备底层内存所有权(ownership)的shared_ptr
。
weak_ptr
可以被默认构造(空构造),亦可初始化时即设定“观测”一个shared_ptr
(后续亦可以reset()
方法重置观测对象):
1 | shared_ptr<int> sp; |
这个weak_ptr
将成为一个 Observer
(观测者),其“观测”着这个shared_ptr
的底层指针,但不增加其Counter的计数。
同时由于weak_ptr
不具备内存所有权,因而其不能直接访问shared_ptr
管理的底层指针;访问内存的方法参考以下“独有”的成员函数:
成员函数 | 作用 |
---|---|
size_t use_count() const |
返回观测的shared_ptr 对应指针的计数,未绑定则为0 |
bool expired() const |
若该weak_ptr “观测”的shared_ptr 依然有效(即其管理的指针计数不为0),返回true ,否则为false ;weak_ptr 未初始化,即未绑定至shared_ptr 时同样返回false 。等价于return static_cast<bool>(use_count()); |
shared_ptr<Ty> lock() |
等价于return expired() ? shared_ptr<Ty>(ptr) : shared_ptr<Ty>() ,ptr 为观测的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
void fun(std::weak_ptr<int> wp)
{
if(auto ptr = wp.lock())
{
std::cout << "Get number:" << *ptr << std::endl;
}
else
{
std::cout << "Pointer expired";
}
}
int main()
{
std::shared_ptr<int> sp;
fun(sp);
sp.reset(new int(5));
fun(sp);
delete sp.release();
fun(sp);
}
神秘的第二模板参数
若仔细观察,可以发现这些智能指针作为类模板,实际上有两个模板参数,声明形如:
1 | template<class Ty,class Dx = std::default_delete<Ty>> |
多数情况下只会用到第一个模板参数(指定指针的类型)。
第二个参数意为指定删除器(Deleter),若不指定则默认为::operator delete
(或operator delete[]
,下同)来释放内存,指定之能适配部分手动管理“内存块”的场景。
类似的还有std::vector
等容器的模板声明:
1 | template<class Ty,class Ax = std::allocator<Ty>> |
此处的std::allocator
若不指定,则默认为::operator new
的方式分配内存;指定之可改用自定义的内存分配器。
外传:RAII
RAII,全称 Resource Acquisition Is
Initialization,是一种的程序设计“哲学”,适用于多种编程语言(越“低级”适用性越强)。
智能指针就是RAII的生动实践实例之一,同样地还有fstream
、lock_guard
、vector
等。
有关RAII(还有SFINAE、Rule-of-3/5/0等C++
Idioms)的更详解可移步可能还在咕的C++——Idioms