目录
前言
1. 基本用法和构造方法
2. 引用计数机制
3. weak_ptr 解决循环引用
二、unique_ptr
1. 基本用法和构造方法
2. 独占性
3. 所有权转移
1)unique_ptr :: release()
2)移动语义 和 move()
unique_ptr:
自定义删除器
默认删除器
总结
前言
智能指针的主要目的是自动管理内存分配和释放,以减少程序员错误和减轻程序员的负担。它是通过一个特殊的类,封装了一个指针,并添加一些额外的语义来实现的。使用智能指针的好处在于,它可以帮助程序员避免常见的内存错误,如内存泄漏和使用已被释放的内存。智能指针还可以使代码更加清晰和易于理解,因为它们使得内存分配和所有权转移变得显式和清晰。现我们将从概念和实际场景对共享指针和独占指针进行理解和使用,掌握如何高效的使用智能指针来便捷整个项目的编码操作。
一、shared_ptr
1. 基本用法和构造方法
对其而言,有两种常用的构造方法,具体使用方法和普通指针类似,下面给出示例:
构造方法:
// 1.创建一个 shared_ptr,指向 int 类型的对象
std::shared_ptr<int> sharedInt = std::make_shared<int>(42);
// 2.创建一个 shared_ptr,指向一个动态分配的对象
std::shared_ptr<double> sharedDouble(new double(3.14));
访问指针(使用):
// 1.视作普通指针进行访问
*sharedInt = 10;
std::cout << "Value of sharedInt: " << *sharedInt << std::endl;
// 2.调用get()方法取到普通指针再访问
*sharedInt.get() = 20;
std::cout << "Value of sharedInt: " << *sharedInt.get() << std::endl;
通过上面的代码示例,我们了解到 shared_ptr 的常用构造方法有两种:
(1)使用 make_shared():是推荐的创建 shared_ptr 的方法,它在单次分配中同时创建对象和控制块,效率更高。
(2)直接使用 “new” 出的对象初始化:可以直接使用 new 关键字创建动态分配的对象并将其传递给 shared_ptr 构造函数。但这样做可能导致性能损失,因为需要额外的内存用于控制块。
对指针的访问方法明明可以直接对智能指针变量进行访问,那 get() 方法存在的必要性是什么?
当我们需要将指针传给接受普通指针的函数或功能模块时,智能指针是不能被函数接收后自动隐式类型转换为所需类型的,所以 get() 方法很好地解决了这个问题,下面给出示例代码:
print_int(sharedInt); // 编译报错
print_int(sharedInt.get());
2. 引用计数机制
shared_ptr 特性:
- 提供共享所有权的智能指针。
- 使用引用计数来追踪资源的所有者数量。
- 当最后一个
shared_ptr
指向资源销毁时,资源被释放。
至于共享所有权为了便于对比理解,本文后面再阐述,这里先来理解引用计数的概念:
引用计数:
share_ptr 使用一个控制块(control block)来管理引用计数和其他信息。控制块是在内存中动态分配的,包含引用计数和指向实际对象的指针。当创建新的 shared_ptr 时,会为对象分配一个新的控制块,并将引用计数初始化为 1。当共享指针被复制或赋值时,引用计数递增。当共享指针被销毁(超出作用域)时,引用计数递减。当引用计数变为零时,说明没有任何指针指向该对象,因此对象和控制块的内存都会被释放。
shared_ptr :: use_count()
shared_ptr :: reset()
为了便于理解,下面利用 shared_ptr 内置的 use_count() 方法,使用代码和运行结果帮助感受引用计数变化的过程:
// 首次创建一个 shared_ptr,引用计数初始为 1
std::shared_ptr<int> sp1 = std::make_shared<int>(42);
// 打印引用计数
cout << sp1.use_count() << endl;
// 创建另一个 shared_ptr,引用计数增加为 2
std::shared_ptr<int> sp2 = sp1;
cout << sp2.use_count() << endl;
// 创建另一个 shared_ptr,引用计数增加为 3
std::shared_ptr<int> sp3 = sp2;
cout << sp3.use_count() << endl;
// 销毁一个 shared_ptr 对象,引用计数减 1,变为 2
sp2.reset();
cout << sp3.use_count() << endl;
运行结果:
要注意的是,使用引用来引用智能指针变量并不会引起变量引用计数的变化:
// 为了避免代码冗杂,下面代码直接续在前面代码后,不再重复给出
auto& ref_sp = sp1;
cout << ref_sp.use_count() << endl;
运行结果:
ref_sp 仅仅是智能指针 sp1 的一个引用,它们共享相同的引用计数,因此不会增加引用计数。这只是一个别名,没有创建新的智能指针对象。
3. weak_ptr 解决循环引用
weak_ptr 主要用于解决 shared_ptr 的循环引用问题。循环引用可能导致对象无法正常释放,因为 shared_ptr 的引用计数永远不会变为零。通过使用 weak_ptr,可以打破循环引用,允许对象在不再被引用时正常释放。
具体什么是循环引用,下面给出简单示例:
class ObjectB; // 提前声明
class ObjectA {
public:
std::shared_ptr<ObjectB> objectB; // 注意这里是 shared_ptr
ObjectA() {
std::cout << "ObjectA constructed" << std::endl;
}
~ObjectA() {
std::cout << "ObjectA destructed" << std::endl;
}
};
class ObjectB {
public:
std::weak_ptr<ObjectA> objectA; // 注意这里是 weak_ptr
ObjectB() {
std::cout << "ObjectB constructed" << std::endl;
}
~ObjectB() {
std::cout << "ObjectB destructed" << std::endl;
}
};
考虑两个对象相互引用的情况,其中 ObjectA 持有 shared_ptr<ObjectB>,而 ObjectB 持有 shared_ptr<ObjectA>。这样的循环引用会导致对象永远无法释放。在此案例中,只需要将两者其中之一改为 weak_ptr 即可解决他俩相互推脱都不释放的循环引用造成的问题。
测试用例:
// 创建 shared_ptr 和 weak_ptr
std::shared_ptr<ObjectA> sharedA = std::make_shared<ObjectA>();
std::shared_ptr<ObjectB> sharedB = std::make_shared<ObjectB>();
// 建立关联
sharedA->objectB = sharedB;
sharedB->objectA = sharedA;
运行结果:
当我们正常创建并初始化上面两类对象时,通过观察控制台窗口打印的构造和析构函数内容,发现 weak_ptr 成功使得两对象被系统释放并调用各自的析构函数。
weak_ptr :: lock()
由于 lock() 函数具有返回其保留的对象的能力,所以我们可以利用其特性,在以 weak_ptr 为类成员的类中自建一个功能,用于判断该 weak_ptr 包含的对象即 shared_ptr 是否存在,有没有被释放掉,不妨我们将其命名为 is_exist() ,将其作为类成员函数,方便我们利用类对象调用从而了解以 weak_ptr 为类型的成员变量包含的对象是否被释放。
class ObjectB {
public:
std::weak_ptr<ObjectA> objectA;
// 构造函数和析构函数
bool is_exist() // 提供访问方法
{
// 使用 lock() 获取 shared_ptr
if (auto sharedPtr = objectA.lock()) {
// 对象存在,可以安全地使用 sharedPtr
std::cout << "Object exist!" << std::endl;
return true;
}
else {
// 对象已经被销毁
std::cout << "Object has been released" << std::endl;
return false;
}
}
};
调用测试 is_exist() 功能:
void test3()
{
// 创建 shared_ptr 和 weak_ptr
std::shared_ptr<ObjectA> sharedA = std::make_shared<ObjectA>();
std::shared_ptr<ObjectB> sharedB = std::make_shared<ObjectB>();
// 建立关联
sharedA->objectB = sharedB;
sharedB->objectA = sharedA;
sharedB->is_exist(); // 测试B类中的 weak_ptr
// 1.reset作用于sharedB
sharedB.reset(); // 销毁sharedB 使其恢复刚构造完成的状态
// 在使用 sharedB 之前检查是否为空
if (sharedB) {
sharedB->is_exist();
}
else {
std::cout << "sharedB is null." << std::endl;
}
}
运行结果:
接着我们尝试销毁ObjectB类型对象内部的 weak_ptr 指针包含的对象:
void test3()
{
// 创建 shared_ptr 和 weak_ptr
std::shared_ptr<ObjectA> sharedA = std::make_shared<ObjectA>();
std::shared_ptr<ObjectB> sharedB = std::make_shared<ObjectB>();
// 建立关联
sharedA->objectB = sharedB;
sharedB->objectA = sharedA;
sharedB->is_exist(); // reset()销毁前作对比用
// 2.reset作用于ObjectB类型对象内部的weak_ptr指针包含的对象
sharedB->objectA.reset();
sharedB->is_exist();
}
运行结果:
我们发现实现的 is_exist() 功能成功反映了 weak_ptr 对象是否被销毁的状态,以便我们进一步安全操作,由于 reset() 后指针变为悬空指针,通过检测避免出现访问悬空指针的情况。
二、unique_ptr
1. 基本用法和构造方法
std::unique_ptr 是 C++11 引入的智能指针,用于管理动态分配的对象,它独占(unique ownership)所指向的对象。与 shared_ptr 不同,std::unique_ptr 不使用引用计数,因此每个 std::unique_ptr 拥有对对象的唯一所有权。这意味着当 std::unique_ptr 被销毁或通过 std::move 转移所有权时,它所管理的对象会被销毁。
构造方法:
比如需要创建一个已有类的智能指针,下面给出示例类的声明:
class MyClass {
public:
MyClass() {
std::cout << "MyClass constructed" << std::endl;
}
~MyClass() {
std::cout << "MyClass destructed" << std::endl;
}
void DoSomething() {
std::cout << "Doing something..." << std::endl;
}
};
具体构造语句:
// 创建 unique_ptr
std::unique_ptr<MyClass> uniquePtr1 = std::make_unique<MyClass>();
std::unique_ptr<MyClass> uniquePtr2(new MyClass);
使用方法:
// 使用 unique_ptr
uniquePtr1->DoSomething();
uniquePtr1.get()->DoSomething();
uniquePtr2->DoSomething();
uniquePtr2.get()->DoSomething();
运行结果:
对于 unique_ptr 而言,与 shared_ptr 一致的是存在 get() 方法可以取到普通指针。不同的是没有引用计数,独占所拥有的对象,那独占性又是如何体现呢?
2. 独占性
std::unique_ptr 是一种独占所有权的智能指针,它确保一个对象只能由一个 std::unique_ptr 拥有。这意味着任何时候,只有一个 std::unique_ptr 指向一个特定的动态分配对象。这是通过禁止复制构造函数和赋值运算符来实现的,因为这些操作会导致多个 std::unique_ptr 指向同一个对象,破坏了独占性。
注意:这里的独占性是仅仅允许一对一的情况存在,不可多对一和一对多!
不妨我们下面采用各种方式来验证其独占性的规则:
// 创建 unique_ptr
std::unique_ptr<MyClass> uniquePtr1 = std::make_unique<MyClass>();
// 1. 尝试复制构造
// std::unique_ptr<MyClass> uniquePtr2 = uniquePtr1; // 编译错误
// 2. 尝试赋值运算符
// std::unique_ptr<MyClass> uniquePtr3;
// uniquePtr3 = uniquePtr1; // 编译错误
具体编译报错形式(注意编译器检查标红):
另外,我们还有通过其他方法验证独占性,由于部分概念还未阐述,具体操作会在后面给出。
3. 所有权转移
出于编程过程中实际需要,我们定义的 unique_ptr 不得不在其他地方被延续使用下去,但是出于一对一的独占性要求,unique_ptr 是不可复制的(包括上面验证的复制构造函数、赋值运算符)。此时应运而生的就是 “所有权转移” ,既然不能复制那就将自身拥有的对象转移给别的对象进行管理。
下面给出案例,便于理解 “所有权转移” 的使用方法和存在意义:
1)unique_ptr :: release()
现在假定我们需要使用该指针作为函数参数实现功能,而接受的参数类型为C语音风格的普通指针,这时候就有两种选择:
(1)利用unique_ptr :: get()
(2)利用unique_ptr :: release()
通过上图我们了解到 release() 的本质是转移其拥有指针的所有权,以返回值传递权限,通过接受该函数返回值实现所有权接收,要注意的是 release() 后,unique_ptr 会自动置空。这里需要区分一个概念, release() 仅仅是将 unique_ptr 对象的指针置为 nullptr
,并不会影响已经转移的资源(或对象)
这意味着上面两方法的本质区别就在于 release() 该行以后的代码都应避免访问和使用 release() 后的 unique_ptr 指针,因为 release() 后的指针此时变为悬空指针,再次访问会报错或产生未定义行为。
为了便于反映问题,我们对 MyClass 类稍作修改,使其具有一个成员变量 n ,类声明如下:
class MyClass {
public:
MyClass() {
std::cout << "MyClass constructed" << std::endl;
}
~MyClass() {
std::cout << "MyClass destructed" << std::endl;
}
void DoSomething() {
std::cout << "Doing something..." << std::endl;
}
int n{}; // 自主初始化空
};
我们将以普通指针作函数参数的功能命名为:use_uniquePtr_1(MyClass* mc),实际实现如下:
void use_uniquePtr_1(MyClass* mc)
{
cout << "use_uniquePtr_1(MyClass* mc)" << endl;
mc->DoSomething();
}
来看如下代码示例调用上方函数:
// ************* 普通指针 <--> unique_ptr.release() **************
std::unique_ptr<MyClass> up = std::make_unique<MyClass>();
use_uniquePtr_1(up.get());
if (!up) { cout << "(1) up is nullptr" << endl; }
use_uniquePtr_1(up.release());
if (!up) { cout << "(2) up is nullptr" << endl; }
运行结果:
我们看到运行结果最后并没有打印执行析构函数,正是因为 release() 将 up 的所有权转交给use_uniquePtr_1(up.release()); 函数,然而在该函数内并未在结束时对 up 存储的对象进行释放处理,导致此函数后面执行部分也不能释放这块内存,也就自然造成了内存泄漏,为程序后续更大的灾难性错误奠定了基础。
如果我再给代码后面加一句访问 up 的操作,会报错吗?
std::unique_ptr<MyClass> up = std::make_unique<MyClass>();
use_uniquePtr_1(up.get());
if (!up) { cout << "(1) up is nullptr" << endl; }
use_uniquePtr_1(up.release());
if (!up) { cout << "(2) up is nullptr" << endl; }
up->DoSomething(); // ### 注意此行 ###
if (!up) { cout << "(3) up is nullptr" << endl; }
运行结果:
我们看到程序依旧没有报错,成功结束整个调试过程,但是我们明明在 release() 后 up 变为了空指针,为什么在调用类内成员函数 up->DoSomething(); 时没有报错呢?结束了,但是不代表没有进行非法操作,将同样的代码在部分编译器和平台就会运行报错!为什么这里程序正常结束没有报错访问空指针?
在 C++ 中,调用空指针的成员函数并不一定会导致运行时错误。在这种情况下,DoSomething() 函数可能是一个非虚函数,而非虚函数在调用时并不会引发空指针解引用导致的崩溃。当然,也有前提就是不通过this指针访问成员变量,比如我下面给出论证:
std::unique_ptr<MyClass> up = std::make_unique<MyClass>();
use_uniquePtr_1(up.get());
if (!up) { cout << "(1) up is nullptr" << endl; }
cout << up->n << endl; // 注意此行
use_uniquePtr_1(up.release());
if (!up) { cout << "(2) up is nullptr" << endl; }
cout << up->n << endl; // 注意此行
up->DoSomething();
if (!up) { cout << "(3) up is nullptr" << endl; }
运行上面代码:
嘿嘿,报错了吧,不急不急,访问this指针才报错是吧,如果将 release() 后的cout代码行换为访问类内静态成员的值,还会报错吗?
代码如下:
std::unique_ptr<MyClass> up = std::make_unique<MyClass>();
use_uniquePtr_1(up.get());
if (!up) { cout << "(1) up is nullptr" << endl; }
//cout << up->n << endl;
cout << up->s_n << endl; // 注意此行
use_uniquePtr_1(up.release());
if (!up) { cout << "(2) up is nullptr" << endl; }
//cout << up->n << endl;
cout << up->s_n << endl; // 注意此行
运行结果:
具体该部分访问空指针的问题可以参考本人另外一篇文章:悬空指针 ---- 未定义行为
2)移动语义和 move()
我们了解利用move()实现功能的本质还是实行对象所有权的转移,那么我们不妨以此方法来解决和上面 unique_ptr :: release() 情境下的问题,但是我们注意到 move() 返回值类型与参数类型一致,所以仅需改变函数功能要求,将参数从C语言普通指针转换为智能指针类型,即可实现 move() 测试将外部智能指针通过参数传递方式实现所有权转移。
为了便于理解 move() 的功能,下面给出简单示例:
void uniquePointer_move()
{
std::unique_ptr<MyClass> up1 = std::make_unique<MyClass>();
std::unique_ptr<MyClass> up2 = move(up1); // 通过move()使得up2接管了up1拥有的对象
// move()后只会释放 up2 管理的对象,而不会释放 up1 的对象
// ### up1 在构造时调用构造函数, up2 在析构时调用析构函数 ###
}
运行结果:
可以看到正如代码注释部分预测的情况一致,并且没有发生内存泄漏的情况。
为了与上面 release() 情形对应,将函数功能名称定义为use_uniquePtr_2(unique_ptr<MyClass>& mc),这里需要对函数参数为智能指针引用类型并非值类型的原因作出解释:unique_ptr 是独占所有权的智能指针,它的移动构造函数将转移所有权的同时使原指针为空。因此,在 use_uniquePtr_2 调用后,实参将不再拥有对象的所有权,实参的值将变成 nullptr。这就导致在 use_uniquePtr_2 函数内部,mc(形参) 是一个空的 nullptr,而在尝试使用 mc->DoSomething(); 时,会导致空指针解引用,从而编译器报错。
具体测试实现如下:
void uniquePointer_move()
{
std::unique_ptr<MyClass> up1 = std::make_unique<MyClass>();
std::unique_ptr<MyClass> up2 = move(up1);
if (up1) // 试图对 move() 转移后丢失对象所有权的智能指针访问
{
cout << "up1 is not nullptr" << endl;
use_uniquePtr_2(up1);
}
use_uniquePtr_2(up2);
}
运行结果:
通过结果发现 use_uniquePtr_2(up1); 并未被调用,说明经 move() 函数转移对象所有权后,智能指针up1被置空,还原为初始默认状态,为空指针。如果后面代码在未对 up1 判空的情况下使用,就会造成悬空指针的风险,发生程序未定义行为或程序崩溃。
三、 对比 shared_ptr 和 unique_ptr
unique_ptr:
独占所有权:
unique_ptr
独占其所指向的对象的所有权。一个特定的unique_ptr
是唯一能够拥有和管理其指向对象的智能指针。轻量级:
由于独占所有权,unique_ptr
通常比shared_ptr
更轻量级,因为它不需要维护引用计数。移动语义:
支持移动语义,可以通过移动转移所有权,避免复制开销。适用场景:
当你有一个明确的所有权关系,且对象不需要被多个智能指针共享时,使用unique_ptr
。
shared_ptr:
共享所有权:
shared_ptr
允许多个智能指针共享对同一对象的所有权,通过引用计数来追踪对象的引用次数。相对重量级:
由于需要维护引用计数,shared_ptr
相对于unique_ptr
来说可能更重量级。循环引用问题:
当存在循环引用时,shared_ptr
需要谨慎使用,因为它可能导致内存泄漏。适用场景:
当需要多个智能指针共享同一个对象,并且对象的生命周期不容易预测时,使用shared_ptr
。
自定义删除器
众所周知C++中类内的析构函数可以自定义实现释放资源时要进行的额外特定操作,虽然智能指针也是类,但是我们无法修改其析构函数以帮助我们实现释放资源时进行的相关操作,于是我们可以通过构建自定义删除器来指定创建的智能指针对象在析构时要额外执行的指令,通俗讲自定义删除器本质上也是一种仿函数。现在利用仿函数自定义 free() 对C语音风格函数返回的指针处理,并将该规则传给智能指针,删除器简单实现如下:
class FreeDeleter // 自定义删除器
{
public:
void operator()(void* p)
{
free(p);
}
};
假定返回一个堆区指针,其解引用所得值为100,函数定义如下:
int* some_c_function()
{
int* p = (int*)malloc(sizeof(int));
*p = 100;
return p;
}
在该函数外利用智能指针接受返回值,实现自动管理返回指针指向的内存空间,请注意如下两种写法的异同:
void do_work_1() // unique_ptr 接收
{
auto p = unique_ptr<int, FreeDeleter>(some_c_function());
printf("%d\n", *p.get());
}
void do_work_2() // shared_ptr 接收
{
auto p = shared_ptr<int>(some_c_function(), FreeDeleter());
printf("%d\n", *p.get());
}
注意:在 shared_ptr 的构造函数中提供的删除器是用于释放资源的,而在 unique_ptr 的模板参数中提供的删除器是用于释放资源的,因此使用时需要小心确保删除器的正确性。
那 shared_ptr 和 unique_ptr 两者对象绑定自定义删除器的方式可以互换吗?
这是运行前的编译器报错显示,所以答案显然是不可以的。
默认删除器
其实如果想要利用智能指针来接管已有指针的指向的内存或包含的对象,更容易想到的是利用默认删除器来实现:
void do_work_3()
{
auto p = unique_ptr<int>(some_c_function());
printf("%d\n", *p.get());
}
评价:
-
简洁性:更为简洁,省略了提供自定义删除器的步骤,适用于一般情况。
-
默认删除器:使用默认的删除器,这是 unique_ptr 的默认行为,会调用
delete
进行内存释放。(这就意味着无论处理的是 malloc 还是 new 产生的堆区指针,都用 delete 释放)
总体来说,选择取决于你对内存释放的需求和是否有额外的清理操作。在一般情况下,使用默认删除器的 do_work_3
更为简洁,而提供自定义删除器的 do_work_1/2
更适用于需要特定清理操作的情况。
总结
易错点和注意事项:
std::shared_ptr
需要小心循环引用。- 避免裸指针操作,尽可能使用智能指针的接口。
- 避免混合使用
new
/delete
和std::make_shared
/std::make_unique
。- 不要将
std::shared_ptr
转为std::unique_ptr
,除非确保没有其他shared_ptr
指向同一资源- 注意对智能指针部分操作可能带来的悬空指针问题
在选择智能指针时,根据对象的所有权需求和额外的清理操作来决定使用 shared_ptr 还是 unique_ptr。shared_ptr 适用于多个指针共享对象的情况,而 unique_ptr 更适合独占所有权的场景。通过灵活运用它们的特性,可以提高代码的安全性和效率。希望本文对正处于学习智能指针阶段的探索者能有一定的指导作用,为日常实际项目的运用中提供有用的建议。