前言
C++程序的内存可以分为3种,分别为静态内存、栈内存 和堆,下表简单概述:
内存类型 | 作用 | 生命周期 |
---|---|---|
静态内存 | 用来保存局部static 对象、类static 数据成员以及定义在任何函数之外的变量。 |
由编译器自动创建和销毁,static 对象在使用之前分配,在程序结束时销毁。 |
栈内存 | 用来保存定义在函数内的非static 对象。 |
由编译器自动创建和销毁,栈对象仅在其定义的程序块运行时才存在。 |
堆内存(自由空间) | 用来存储动态分配的对象。 | 动态对象的生命周期由程序来控制,也就是说,当动态对象不再使用时,我们的代码必须显示地销毁他们。 |
在C++中,动态内存的管理是通过一对运算符来完成的:
new
:在动态内存中为对象分配空间 ,并且返回一个指向该对象的指针 ,我们可以选择对对象进行初始化;delete
:接受一个动态对象的指针 ,销毁该对象 ,并且释放与之关联的内存。
动态内存的使用很容易出问题,因为确保在正确的时间释放内存是极其困难的:
- 有时我们会忘记释放内存 ,在这种情况下会产生内存泄漏;
- 有时在尚有指针引用内存的情况下我们就释放了它 ,这种情况下会产生引用非法内存的指针。
为了更容易同时也更安全地使用动态内存,新的标准提供了两种智能指针类型来管理动态对象,智能指针的行为类似常规指针 ,重要的区别是它负责自动释放所指向的对象 。新标准库提供的这两种智能指针的区别在于管理底层指针的方式:
shared_ptr
:允许多个指针指向同一个对象;unique_ptr
:"独占"所指向的对象。
标准库同时还定义了一个名为weak_ptr
的伴随类,它是一种弱引用,指向shared_ptr
所管理的对象。
明确了几种类型的内存的特点,我们知道其难点就是堆内存的释放,也就是指向堆内存的普通指针不知道何时该释放。这时我们可以转换一下思路,利用栈内存的特点(自动申请和释放)以及类成员变量在类销毁时可以自动销毁这一特性,我们把普通指针给封装一层成类,这样普通指针对象就可以变成类类型的栈内对象,以及类类型的数据成员。
其实这也就是标准库中的做法,使用智能指针就让我们不必再在意动态内存的手动销毁了。
正文
1. shared_ptr
类
智能指针是一个模板类 ,当我们创建智能指针时,必须提供额外信息:指针可以指向的类型,在尖括号内给出类型,之后是所定义的这种智能指针的名字:
c
std::shared_ptr<std::string> p1; //可以指向std::string类型对象
std::shared_ptr<std::vector<int>> p2; //可以指向int类型的vector
默认初始化的智能指针中保存着一个空指针,所以下面写法是严重错误:
c
std::shared_ptr<std::string> pString;
*pString = "hi"; //对nullptr进行解引用,肯定报错
为了使用习惯和减少使用成本,智能指针的使用方式与普通指针类似。解引用一个智能指针可以返回它指向的对象 ,普通指针的->
符号也可以正常使用,这是重载了相关运算符的结果:
c
//初始化一个智能指针,指向"hello"
std::shared_ptr<std::string> pString = std::make_shared<std::string>("hello");
//如果指针不为空,且指向的对象不为空
if (pString != nullptr && !pString->empty()) {
*pString = "hi";
}
由于我们在只用普通指针时,当普通指针为nullptr
时,且把指针作为条件判断是为false
的,所以智能指针也有类似的特点:把智能指针作为条件判断,若智能指针指向一个对象,则为true
:
ini
if (pString) { //pString指向一个对象时,为true
*pString = "C\n";
}
关于这些用法,其实都是C++重载运算符的用法,可以极大地方便开发者。
1.1 make_shared
函数
最安全的分配和使用动态内存的方法是调用一个名为make_shared
的标准库函数,此函数在动态内存中分配一个对象 ,并且初始化它 ,返回指向该对象的shared_ptr
。
这个函数有2个动作,一个是申请一块内存,其次是进行初始化。当使用普通指针以及new
关键字时,我们通过构造函数来进行初始化,那么类似的make_shared
函数的参数,也必须要和初始化的对象的构造函数的参数匹配:
c
//指向一个值为42的int类型的shared_ptr
std::shared_ptr<int> p3 = std::make_shared<int>(42);
//指向一个值为10个9的string类型的shared_ptr
std::shared_ptr<std::string> p4 = std::make_shared<std::string>(10, '9');
//指向一个默认值初始化的int类型的shared_ptr,值为0
std::shared_ptr<int> p5 = std::make_shared<int>();
在上述代码中,比如要创建一个指向string
类型的shared_ptr
,其make_shared
的参数必须符合string
的某一个构造函数 ,而对于不传入任何参数的情况来说,对象会进行值初始化。
1.2 shared_ptr
的拷贝和赋值
既然智能指针的原理是通过把普通指针封装一层成类类型,当作普通对象来使用,所以其拷贝和赋值的操作就非常重要。
我们可以认为每个shared_ptr
对象都有一个关联的计数器 ,被称为引用计数,用来记录有多少个shared_ptr
一起指向所管理的内存对象 ,这句话的关键是多少个shared_ptr
指向所管理的内存对象,而非所有指针,比如下面代码:
c
//i1是一个普通指针
int* i1 = new int(10);
//利用i1初始化智能指针si1
std::shared_ptr<int> si1(i1);
//拷贝si1会增加引用计数
std::shared_ptr<int> si2(si1);
//只会统计共同指向对象的shared_ptr数量
std::cout << si1.use_count();
这里的use_count()
方法就是返回其计数器,这里可以发现虽然有1个普通指针和2个shared_ptr
都指向同一个对象,但是这里计数器返回值是2,这里也就引入一个基本原则:不要混用智能指针和普通指针,因为智能指针会销毁所管理的对象,假如再使用普通指针,会出现非法引用的情况,后面会详细说明。
当拷贝一个shared_ptr
时,对于被拷贝的shared_ptr
所指向的对象来说,其引用计数会增加,通常来说有3种常见的情况:
- 使用一个
shared_ptr
去初始化另一个shared_ptr
,会拷贝参数的shared_ptr
对象。 - 将它作为参数,传递给一个函数时。
- 将它作为返回值,也会发生拷贝。
这3种情况我们需要经常注意,而有哪些情况会减少其关联对象的引用计数呢?通常有两种情况:
- 当
shared_ptr
销毁时,比如离开其作用域,会触发其析构函数,这时所管理对象的引用计数会减一。 - 当给
shared_ptr
赋予一个新值时,其原来所指向的对象的引用计数会减一,
arduino
//r指向的int对象只有一个引用者
auto r = std::make_shared<int>(42);
r = si1; //给r赋值,让其指向另外对象
//递增si1指向的对象的引用计数
//递减r原来指向的对象的引用计数
//r原来指向的对象已经没有引用者,会自动释放
这里要清楚引用计数记得是什么,记的是其指向对象有多少个共享的引用者 ,当被赋予新值时,这个shared_ptr
会指向新的对象,而原来对象的引用者就会减一。
1.3 shared_ptr
自动销毁动态对象
前面说了shared_ptr
是类类型,而且有所指向对象的引用计数,所以其管理所指向对象是通过这两者结合来实现的:
-
shared_ptr
的析构函数会递减它所指向的对象的引用计数 ,同时给一个shared_ptr
赋予新值时也会递减。 -
当引用计数变为0时,
shared_ptr
会销毁所指向的对象 ,同时释放内存 。注意这里一般是析构函数来做的 ,但是也不完全是,比如前一段的实例代码中,r
所指向的对象42
,当r
指向其他对象时,这个42
就再也没有shared_ptr
指向它了,所以这时还是由r
会去销毁42
以及释放其内存。
所以结合这2点,以及递增和递减引用计数的原理,在平时使用时我们直接使用shared_ptr
类型进行值传递,这样就可以完成自动释放内存的功能了。
1.4 使用动态资源的场景
在程序开发中,经常有如下3种情况会使用动态内存:
- 程序不知道自己需要使用多少对象。比如容器类,在编译阶段并不知道真正使用时会有多少个元素,所以使用动态内存创建和管理数组是非常方便的。
- 程序不知道所需对象的准确类型。这种情况涉及到多态,即父类指针可以指向子类对象,这种情况多用于接口编程。
- 程序需要在多个对象之间共享数据。比如在处理流数据的系统中,一个比较大的数据流,在不同函数之间和不同对象之间传递时,肯定不能使用值拷贝,这样太费性能了,最好的方式是使用动态内存,多个对象共享数据。
1.5 使用shared_ptr
共享底层数据
现在我们就来实践一下,我们准备定义一个StrBlob
类,该类存储着string
列表,同时需要在各个函数之间传递和处理其string
列表,这就要求该数据能在不同StrBlod
类之间共享,不能进行值拷贝,所以直接使用std::vector<std::string>
作为数据成员的方案就不可以了。
解决方案就是使用指针,而且是使用智能指针,这里保存数据的容器还是使用标准容器,所以StrBlob.h
的定义如下:
c
class StrBlob {
public:
//使用typedef简化代码
typedef std::vector<std::string>::size_type size_type;
StrBlob();
//用来初始化vector数据
StrBlob(std::initializer_list<std::string> il);
//因为智能指针的重载运算符,所以可以把data当成std::vector<>*普通指针来使用
size_type size() const { return data->size(); }
bool empty() const { return data->empty(); }
//添加和删除元素
void push_back(const std::string& t) { data->push_back(t); }
void pop_back();
//元素访问
std::string& front();
std::string& back();
private:
//非裸指针的数据成员
std::shared_ptr<std::vector<std::string>> data;
//如果data[i]不合法,抛出异常
void check(size_type i, const std::string &msg) const;
};
代码中关键地方都有注释,核心就是不再使用裸指针 ,以及可以像使用裸指针方式一样使用智能指针。接下来就是几个方法的实现,也是非常容易:
c
//使用初始化列表,初始化一个空vector
StrBlob::StrBlob(): data(std::make_shared<std::vector<std::string>>()) { }
//使用初始化列表,由于il是vector中的构造函数之一的参数,所以这里make_shared的参数
//就是il
StrBlob::StrBlob(std::initializer_list<std::string> il) :
data(std::make_shared<std::vector<std::string>>(il)){ }
void StrBlob::pop_back() {
check(0, "pop_back on empty StrBlob!");
return data->pop_back();
}
std::string& StrBlob::front() {
check(0, "front on empty StrBlob!");
return data->front();
}
std::string& StrBlob::back() {
check(0, "back on empty StrBlob!");
return data->back();
}
//检查是否越界
void StrBlob::check(size_type i, const std::string& msg) const {
if (i >= data->size()) {
throw std::out_of_range(msg);
}
}
上述代码实现起来非常简单,我们可以像使用裸指针一样来使用智能指针,最重要的是再也不用在析构函数里释放内存了,其data
数据还可以共享,是一个非常常见的使用场景。
2. new
和delete
虽然现在C++11后就推荐使用智能指针,但是大量的老项目以及各种情况,让开发者有时不得不使用普通指针,还有就是理解普通指针的各种用法,有利于理解智能指针的实现以及解决一些常见错误。
2.1 内存耗尽
简单的如何使用new
和delete
就不说了,这里先说一种内存耗尽的情况。当程序出现内存泄漏,有限的堆内存就有可能被耗尽,这时已经没有足够的空间来分配动态对象了,这时new
表达式就会失败,同时抛出一个类型为bad_alloc
的异常。
但是我们可以改变使用new
的方式来阻止它抛出异常:
php
//如果分配失败,new抛出std::bad_alloc异常
int* px = new int;
//如果分配失败,new返回一个空指针
int* px1 = new (std::nothrow) int;
这种形式的new
被称为定位new
(placement new
),这时我们想一下这有什么用呢?就是可以配合判断new
的指针是否为空 ,来看new
操作是否成功了,如下:
php
//如果分配失败,new返回一个空指针
int* px1 = new (std::nothrow) int;
if (!px1) {
//new成功了
}
而对于普通指针,很多开发者也喜欢这样写,判断一下new
的指针是否为空,但是一旦为空,就会抛出异常,后面的判断语句根本执行不了,是无效判断。 所以在申请一些大的内存数据时,还是建议使用new (std::nothrow)
配合判空,来判断内存是否耗尽。
2.2 delete
注意事项
C++中使用delete
来释放new
申请的内存,通过delete
可以将动态内存归还给系统。delete
会执行2个动作:销毁给定的指针指向的对象以及释放对应的内存。
delete
的用法就不说了,现在来说几点需要特别注意的。
delete
接收的指针必须是指向动态分配的内存 或者是一个空指针。- 释放一块非
new
分配的内存,其行为是未定义的。
ini
int i = 0;
//pi1是指向栈内存的指针
int* pi1 = &i;
//pi2是空指针
int* pi2 = nullptr;
//delete一个指向非动态内存的指针是严重错误!!!
delete pi1;
//可以delete一个空指针
delete pi2;
在上面代码中,pi1
是指向非动态内存的指针,编译器是无法判断传递给delete
的指针具体指向的是什么,所以可以编译通过,运行会报错,这种潜在的问题要时刻注意。
- 将相同的指针值释放多次,其行为是未定义的。
go
int* pi3 = new int(20);
delete pi3;
//delete一个指针多次,是严重错误!!
delete pi3;
上面这种情况同样编译器无法识别,只会在运行时才报错,需要时刻注意。这里我们可以想一下,既然delete
一个空指针是可以的,但是为什么不能delete
一个指针2次呢?这是因为delete
后的指针值是一个无效值,但不是nullptr
。
- 被
delete
之后的指针被称为空悬指针 (dangling pointer
),即指向一块曾经保存数据对象但现在已经无效的内存的指针 ,对空悬指针再次调用delete
是严重错误。 - 如果有业务需求,需要再次使用被
delete
后的指针,这时我们可以在delete
之后对指针赋予nullptr
,这样在下次再使用时不论是误操作delete
还是判空再赋予新值,都可以正常使用。 - 对于多个普通指针指向同一块内存的情况,操作
delete
要十分注意,避免出现一个指针已经释放了内存,其他指针再次使用的情况,导致引用非法内存。解决这种情况最好的办法就是使用shared_ptr
。
2.3 shared_ptr
和new
结合使用
前面说了创建shared_ptr
对象最佳方案是使用make_shared
方法,但是更多的时候我们不得不和普通指针打交道,标准库也提供了shared_ptr
和new
结合使用的各种场景。
- 可以使用
new
返回的指针来初始化智能指针,接收指针参数的智能指针构造函数是explicit
的 ,即是显示的,所以无法将一个内置指针隐式转换为一个智能指针。
c
int* i = new int(1024);
//错误,必须使用直接初始化的方式
std::shared_ptr<int> si = i;
//正确的方式
std::shared_ptr<int> si1(i);
这种直接使用的场景非常容易识别,难的是很多旧函数库中的参数和返回值是指针的情况,这时我们就要特别注意,后面我们会说这种情况。
和前面delete
注意事项一样,由于shared_ptr
默认会调用指针的delete
函数来释放内存,所以传递给shared_ptr
的普通指针也必须是指向动态内存的。
- 既然智能指针是指针的封装,所以它肯定不能一直和一个指针绑定,所以标准库还定义了一些改变
shared_ptr
指向对象的其他方法,都是非常有用的。
值得关注的是reset()
方法,它表示"重置"的意思 ,可以把一个shared_ptr
包含的指针重置为空指针或者其他指针,在重置的过程中,我们就应该清晰的认识到相关联的对象的引用数的变化。
c
//测试类,打印了析构函数
class Test{
public:
Test(int i);
~Test(){
std::cout << "析构 k = " << k << std::endl;
}
int getValue() { return k; }
private:
int k;
};
int main()
{
std::shared_ptr<Test> sp1 = std::make_shared<Test>(100);
//重置为空,sp1原来指向的对象就没有引用者了,会被析构
sp1.reset();
if (!sp1){
std::cout << "sp1 is nullptr!" << std::endl;
}
Test* p2 = new Test(1024);
//sp1指向了新的对象,这时值为1024的Test对象,有一个普通指针指向和智能
//指针指向
sp1.reset(p2);
std::cout << "sp1 = " << sp1->getValue() << std::endl;
//方法执行完,由于1024对象只有一个shared_ptr指向,当sp1销毁时,引用变为0,
//它会去销毁对象。
return 0;
}
//运行结果
析构 k = 100
sp1 is nullptr!
sp1 = 1024
析构 k = 1024
上述代码我们用了一个Test
类来更加清晰地分辨对象析构的时机,在每一步reset()
时,我们都应该注意该shared_ptr
原来所指向的对象以及新指向的对象的引用计数,同时还验证了:对象的引用计数是其shared_ptr
的个数,当一个共享对象的shared_ptr
为0时,即使有普通指针还在指向它,也会被释放。
- 不要混用普通指针和智能指针,这一点非常关键,也是在日常开发中非常容易出错的地方。
c
//业务处理,当参数非常大时,我们想使用指针来避免值拷贝
void process(std::shared_ptr<Test> ptr) {
//进行业务处理
std::cout << "process ptr.Value=" << ptr->getValue() << std::endl;
//...
}//离开作用域时,ptr会被销毁
int main()
{
Test* p = new Test(100);
//因为要传递指针指针类型,所以创建了一个临时变量
process(std::shared_ptr<Test>(p));
//指向的对象已经被delete,这是一个空悬指针,比空指针更可怕
std::cout << "after process p.Value=" << p->getValue() << std::endl;
return 0;
}
//运行结果
process ptr.Value=100
析构 k = 100
after process p.Value=-572662307
可以发现经过process
处理后,内存会被释放,这时p
是指向了一个被delete
了的内存,也就是空悬指针,这种情况非常可怕,因为代码在运行时都不会报错,排查起来也困难。
为了杜绝这种情况,我们应该定一个原则:如果接管了普通指针的所有权,就应该全权交由智能指针来管理,不应该再使用普通指针来访问shared_ptr
所指向的内存。所以正确使用如下:
c
int main()
{
Test* p = new Test(100);
//使用智能指针接管
std::shared_ptr<Test> sp(p);
//会发生拷贝
process(sp);
//全权使用智能指针
std::cout << "after process p.Value=" << sp->getValue() << std::endl;
return 0;
}
//运行结果
process ptr.Value=100
after process p.Value=100
析构 k = 100
总的来说,这里的不要混用的意思不是不能使用普通指针,而是可以使用普通指针来初始化智能指针,但是一旦内存所有权交由shared_ptr
后,就不要再使用普通指针了。
- 不得不混用的情况还有一种,就是有些库函数要求传入普通指针,但是程序中使用的却是智能指针,这时可以使用
get
函数返回一个普通指针,指向智能指针管理的对象。
如同前面说的原则,尽量不要使用get()
获取智能指针中的普通指针,如果非要使用的话,需要理解一个原则:不要使用使用get
初始化另一个智能指针或为智能指针赋值。
我们先来看几种情况,需要把智能指针中的普通指针获取出来,然后传递给函数:
c
void process(std::shared_ptr<Test> ptr) {
//进行业务处理
std::cout << "process ptr.Value=" << ptr->getValue() << std::endl;
}//离开作用域时,ptr会被销毁
void process1(Test* ptr) {
//进行业务处理
ptr->addOne();
std::cout << "process1 ptr.Value=" << ptr->getValue() << std::endl;
}//离开作用域时,ptr不会销毁
void process2(Test* ptr) {
//进行业务处理
ptr->addOne();
std::cout << "process2 ptr.Value=" << ptr->getValue() << std::endl;
//需要对ptr进行销毁
delete ptr;
}
int main()
{
std::shared_ptr<Test> p1(new Test(100));
Test* p2 = p1.get();
//传递普通指针,且方法里不进行销毁
process1(p2);
std::cout << "after process1 p1.Value=" << p1->getValue() << std::endl;
//传递普通指针,且方法里进行销毁
process2(p2);
//在方法process2中对内存对象进行了销毁和释放,此时p1将指向delete了的内存
//即空悬指针
std::cout << "after process2 p1.Value=" << p1->getValue() << std::endl;
return 0;
}
在上述代码中,我们使用new
创建了一个指针,给智能指针p1
赋值,然后获取其管理的指针赋值给p2
,在process1()
方法中,我们对对象进行加一操作,但是在process2()
方法中,我们对传入的指针进行了delete
,这时所指向的内存就被释放了,这时智能指针p1
也是一个空悬指针,获取其值是未定义的。
而对于空悬指针的值,不同编译器有不同处理方案,比如我使用msvc2017
进行编译时,空悬指针的内容会是被delete
之前的值,上述代码运行如下:
ini
process1 ptr.Value=101
after process1 p1.Value=101
process2 ptr.Value=102
~Test k = 102
after process2 p1.Value=102
然后换成了gcc
编译套件,空悬指针的内容是不确定的未知数,运行如下:
ini
process1 ptr.Value=101
after process1 p1.Value=101
process2 ptr.Value=102
~Test k = 102
after process2 p1.Value=15105512
所以空悬指针的bug还是很难发现的,因此在写代码时要时刻注意。
然后我们再理解为什么get()
的指针不能用来初始化其他智能指针就很容易理解了,因为当多个独立的shared_ptr
指向同一个对象时,引用计数是分开计数的 ,当其中一类的shared_ptr
的引用计数为0时,就会释放对象内存,这时其他shared_ptr
就是空悬指针了。
c
void process4(Test* ptr) {
//创建智能指针
std::shared_ptr<Test> p(ptr);
}//离开作用域时,p会释放ptr指向的内存
int main()
{
std::shared_ptr<Test> p1(new Test(100));
Test* p2 = p1.get();
process4(p2);
//p1会变成空悬指针
std::cout << "after process4 p1.Value=" << p1->getValue() << std::endl;
return 0;
}
//msvc2017运行结果
~Test k = 100
after process4 p1.Value=100
~Test k = 100
//gcc运行结果
~Test k = 100
after process4 p1.Value=14777832
尤其是没有规范好的项目,很容易出现这种问题,空悬指针排查还比较麻烦,所以切勿混用。
3. 智能指针和异常
异常处理程序在现代编程项目中非常常见,目的就是为了程序能在发生异常时流程可以继续执行,这就要求在发生异常时资源可以被正确地释放。
3.1 使用智能指针确保资源安全释放
一个简单确保资源被释放的方法就是使用智能指针 ,对于在方法中使用动态内存的场景,我们要求在方法结束前能释放动态内存,假如不使用智能指针,当在new
和delete
之间出现异常时,程序就会执行到异常处理分支,delete
将永远不会执行。
我们还是写个测试程序验证一下:
c
void testException() {
try {
//申请动态内存
int* p = new int[1024 * 1024];
//出现异常
throw std::runtime_error("error");
//释放内存
delete[] p;
}
catch (const std::exception&) {
std::cout << "occur exception!" << std::endl;
}
}
int main()
{
while (true) {
// 让程序休眠2秒
std::this_thread::sleep_for(std::chrono::seconds(2));
testException();
}
return 0;
}
由VS的调试器,发现程序的内存一直在增长:
说明当出现异常时,delete
无法被调用,我们再使用智能指针版本测试一下:
c
void testException() {
try {
//使用智能指针
std::unique_ptr<int[]> up = std::make_unique<int[]>(1024 * 1024);
//出现异常
throw std::runtime_error("error");
//无需释放动态内存
}
catch (const std::exception&) {
std::cout << "occur exception!" << std::endl;
}
}
把申请动态内存的操作改成unique_ptr
智能指针时,当出现异常,由于up
是栈内存对象,它肯定会在方法执行完退出栈,同时释放内存,所以不会导致内存泄漏:
4. unique_ptr
类
大致搞清楚了shared_ptr
的使用,unique_ptr
使用就非常简单了,它和shared_ptr
的区别就是管理所指向对象的方式,shared_ptr
允许多个shared_ptr
指向同一个对象,而unique_ptr
独占对象 ,即只允许一个unique_ptr
指向对象。
这时就会有一个很值得思考的问题,假如内存中有一个对象X
,这时我可以定义多个普通指针和shared_ptr
指向它,在函数和类之间传递来传递去处理业务。但是现在你说只能有一个指针指向对象,这显然是底层逻辑上无法限制的,因为完全可以把X
的地址赋值给多个智能指针对象,比如下面代码:
c
int* p = new int(100);
//多个unique_ptr指向同一个对象
std::unique_ptr<int> up1(p);
std::unique_ptr<int> up2(p);
所以这里其实是一个资源所属权的设计问题 ,假如在需求上,这个对象不需要共享,则使用unique_ptr
,否则使用shared_ptr
。注意,是需求决定使用哪种智能指针,一旦选择了某种智能指针,就需要遵守相关规则。
4.1 基本介绍
关于unique_ptr
的类似shared_ptr
的使用就不细说了,这里简单过一遍:
- 类似
make_shared()
函数,可以使用make_unique()
来创建unique_ptr
对象,需要传入的参数符合模板类型的构造函数之一。 - 可以结合
new
来创建unique_ptr
对象,即接管对象。 - 不存在引用计数了,因为是独占对象,所以在
unique_ptr
对象销毁时,就会释放所指向的内存,默认也是调用delete
函数。
4.2 释放所指向对象
正常来说,一个unique_ptr
对象被销毁时,其所指向的对象也就自动释放了,但是还可以通过其他方式来释放对象,如下:
up = nullptr
,把一个类类型的对象置为nullptr
,这说明unique_ptr
里面重载了=
操作符,在这种情况下,会释放up
指向的对象。
arduino
//memory.h源码
unique_ptr& operator=(nullptr_t) noexcept
{ // assign a null pointer
reset();
return (*this);
}
这里调用了reset()
方法,即重置,在shared_ptr
中有类似的用法,当没有传递参数时,即重置为空,可以理解为智能指针放弃对普通指针的管理权,同时会释放所指向对象。
c
//示例代码
int main()
{
std::unique_ptr<Test> up = std::make_unique<Test>(100);
up = nullptr;
std::cout << "up is nullptr";
return 0;
}
//运行结果
~Test k = 100 //先调用析构,说明不是因为up出作用域导致的析构
up is nullptr
up.reset()
,把一个智能指针重置,可以重置为空指针或者其他对象 ,类似调用shared_ptr
对象的重置方法,会减少原来指向对象的引用计数,增加新指向对象的引用计数,调用unique_ptr
对象的重置方法,也会释放原来指向的对象,重新指向新的对象。up.release()
,放弃指针的控制权 ,返回裸指针,并且将up
置为空。这里和reset()
的最大区别就是,返回的裸指针可以继续用,并不会释放所指向的对象。
4.3 unique_ptr
不支持普通的拷贝和赋值
由于unique_ptr
独占的特点,所以不允许进行普通的拷贝和赋值,这里的实现也非常简单,我们只需要对其拷贝构造函数和拷贝赋值运算符进行限制即可:
arduino
//memory.h源码
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
这里还是说明一点,是先有独占的需求,再选择unique_ptr
,然后再遵循它的规则。
4.4 移动unique_ptr
的对象
既然同一时刻只能有一个unique_ptr
独享对象,所以就必须要有一种情况就是转移控制权 ,把一个对象的控制权由一个unique_ptr
转移给另一个。有2种方式可以实现:
- 使用
release()
,因为release()
的职责就是放弃对指针的控制权,所以我们可以使用release()
返回的裸指针来初始化另一个unique_ptr
,从而完成控制权转移:
c
int main()
{
std::unique_ptr<Test> up = std::make_unique<Test>(100);
std::unique_ptr<Test> up1(up.release());
std::cout << "up1.Value=" << up1->getValue() << std::endl;
return 0;
}
- 使用
move()
,move
语义是C++11中新定义的一种操作,可以将一个对象的资源从一个对象移动到另一个对象中,从而避免不必要的复制和重新资源分配,使用如下:
c
int main()
{
std::unique_ptr<Test> up = std::make_unique<Test>(100);
std::unique_ptr<Test> up1(std::move(up));
std::cout << "up1.Value=" << up1->getValue() << std::endl;
return 0;
}
4.5 unique_ptr
在函数中使用
由于unique_ptr
不能被拷贝,所以把unique_ptr
作为参数类型肯定是报错的:
c
void testUniquePtr(std::unique_ptr<Test> ptr) {
ptr->addOne();
}
int main(){
std::unique_ptr<Test> up = std::make_unique<Test>(100);
//直接编译不过,提示拷贝构造函数是delete的
testUniquePtr(up);
return 0;
}
这里可以把使用的函数的参数改成引用类型,则可以正常使用:
c
void testUniquePtr(std::unique_ptr<Test> &ptr) {
ptr->addOne();
}
假如testUniquePtr
的参数不是引用类型,而且如果外部不再需要使用该指针 ,完全可以把该对象的控制权转移,将其交由调用的函数管理 ,这时可以使用move()
或者release()
:
c
void testUniquePtr(std::unique_ptr<Test> ptr) {
ptr->addOne();
} //作用域结束,将会释放ptr所指向的对象
int main(){
std::unique_ptr<Test> up = std::make_unique<Test>(100);
//将对象的唯一控制权交给了函数
//函数结束后,对象被释放
testUniquePtr(std::unique_ptr<Test>(std::move(up)));
//不能继续使用up了
//std::cout << up->getValue();
return 0;
}
这里时刻要记住一个unique_ptr
所独占的对象,同一时刻只有一个引用者。
当把unique_ptr
作为参数返回时,其实是调用的其move()
方法,同时还可以把返回的unique_ptr
转换为shared_ptr
使用:
c
//使用move操作,而非拷贝构造函数
std::unique_ptr<Test> test(int i) {
return std::make_unique<Test>(i);
}
int main(){
//使用move给up赋值,而非拷贝构造函数
std::unique_ptr<Test> up = test(100);
//可以把一个对象的控制权交由shared_ptr来管理
std::shared_ptr<Test> sp = test(100);
return 0;
}
至于为什么这里可以用=
来进行初始化,因为并非调用拷贝构造函数,而是调用移动构造函数。
4.6 为什么优先选用unique_ptr
从前面分析我们可知,当资源不需要有多个所属权时可以使用unique_ptr
来替代裸指针,这里给出2个基本原因:
- 更安全,避免内存泄漏。最常见的使用场景就是前面的异常部分,利用栈内存的特性可以确保资源被回收。
- 相比于
shared_ptr
,避免更大的开销。因为它没有引用计数和原子操作等,所以和使用裸指针所消耗的资源几乎是一样的。很多开发者为了方便,都直接使用shared_ptr
,这是不可取的。
5. weak_ptr
类
weak_ptr
是一种特殊的智能指针类型 ,熟悉Java开发的同学应该知道,Java的垃圾回收机制在很早期的时候使用的是引用计数法,但是后来迅速被淘汰而采用可达性分析法,被淘汰的原因就是无法处理循环引用。而C++的shared_ptr
其实就是小型的垃圾回收机制,其所使用的引用计数法也存在一样问题,所以就引入了weak_ptr
来解决该问题。
5.1 简单介绍
weak_ptr
是一种不控制所指向对象生存期 的智能指针,它指向由一个shared_ptr
管理的对象 。所以初始化一个weak_ptr
必须需要一个shared_ptr
,并且将一个weak_ptr
绑定到一个shared_ptr
不会改变shared_ptr
的引用计数。
weak_ptr
的最大特点是:一旦最后一个指向对象的shared_ptr
被销毁,该对象就会被销毁,即使有weak_ptr
指向该对象。
这里也就会出现一种情况,即weak_ptr
还指向着对象,但是该对象已经被销毁了,所以通过weak_ptr
访问其所指向的对象,不能直接访问。
如下几个API就是上述描述的实现:
API | 作用 |
---|---|
weak_ptr<T> w |
可以指向类型为T的空的weak_ptr ,和其他智能指针一样,在创建对象时,需要类型模板参数。 |
weak_ptr<T> w(sp) |
使用shared_ptr 对象来初始化w ,即w 和p 都指向同一个对象。 |
w = p |
p 可以是一个shared_ptr 或者weak_ptr 对象,赋值后,w 和p 共享一个对象。 |
w.reset() |
将w 置空 |
w.use_count() |
与w 共享对象的shared_ptr 的数量。 |
w.expired() |
expired 为过期、失效、不再有效的意思,即说明这个weak_ptr 是否失效了,当use_count() 为0时返回true ,否则返回false 。 |
w.lock |
如果expired 为true ,即已经失效,返回一个空的shared_ptr ,否则返回一个指向w 的shared_ptr 。 |
其实理解起来非常容易,下面是简单使用示例:
c
int main(){
std::shared_ptr<Test> sp = std::make_shared<Test>(100);
//w和sp指向一个对象
std::weak_ptr<Test> w(sp);
std::cout << "共有" << w.use_count() << "个shared_ptr指向共享对象" << std::endl;
//使用weak_ptr访问对象
if (std::shared_ptr<Test> sp1 = w.lock()) {
//当shared_ptr不指向空时,可以作为if判断条件,返回true
std::cout << "w.Value=" << sp1->getValue() << std::endl;
}
return 0;
}
在上述代码中,我们直接判断w.loack()
返回的shared_ptr
示例,这是前面我们说过的shared_ptr
的特性,重载了=
操作符。
5.2 解决循环引用
话不多说,weak_ptr
的真正作用是解决shared_ptr
的循环引用问题,测试代码如下:
c
//定义AClass,拥有BClass类型指针
class AClass {
public:
std::shared_ptr<BClass> spb;
~AClass() {
std::cout << "~AClass" << std::endl;
}
};
//定义BClass,拥有AClass类型的指针
class BClass {
public:
std::shared_ptr<AClass> spa;
~BClass() {
std::cout << "~BClass" << std::endl;
}
};
void testCircularRef() {
//局部变量pa和pb
std::shared_ptr<AClass> pa = std::make_shared<AClass>();
std::shared_ptr<BClass> pb = std::make_shared<BClass>();
//pa内部指向pb
pa->spb = pb;
//pb内部执行pa
pb->spa = pa;
}
//1. 当方法结束时,局部变量的销毁是按照其创建顺序的相反顺序销毁,即pb先销毁,再pa销毁。
//2. pb销毁时会调用pb的析构函数,即shared_ptr的析构函数,它会检测到它所指向的对象有2个引用者,
//即pb自己和pa所指向对象的数据成员spb。
//根据shared_ptr的规则,pb销毁时,不会去释放pb所指向的内存。
//3. 这时轮到pa被销毁,同样是调用shared_ptr的析构函数,它会检测到它所指向的对象也有2个引用者,
//即pa自己和刚刚没有被释放掉的pb所指向的对象的数据成员spa。
//同样根据规则,pa自己被销毁,但是其所指向的对象不会被释放
int main(){
//会导致循环引用,2个堆内存对象无法被释放
testCircularRef();
return 0;
}
上述代码中的注释需要仔细琢磨,不难理解为什么循环引用时,会导致内存无法释放。
而解决上述问题的的方法就是使用weak_ptr
,因为互相引用都是使用的强引用,如果把其中一个换成弱引用即可解决 ,比如把AClass
中的数据成员进行修改:
c
//定义AClass,拥有BClass类型的weak_ptr指针
class AClass {
public:
//改动在这里
std::weak_ptr<BClass> wb;
~AClass() {
std::cout << "~AClass" << std::endl;
}
};
//定义BClass,拥有AClass类型的指针
class BClass {
public:
std::shared_ptr<AClass> spa;
~BClass() {
std::cout << "~BClass" << std::endl;
}
};
void testCircularRef() {
std::shared_ptr<AClass> pa = std::make_shared<AClass>();
std::shared_ptr<BClass> pb = std::make_shared<BClass>();
//pa内部的weak_ptr指向pb
pa->wb = pb;
//pb内部的依旧是强引用
pb->spa = pa;
}
//1. 根据局部变量销毁顺序,pb先销毁,pa再销毁。
//2. pb销毁时,调用pb即shared_ptr的析构函数,会发现pb所指向的对象有2个引用者,一个是shared_ptr类型即自己,另一个是weak_ptr类型。
//根据shared_ptr和weak_ptr的特点,这时会释放pb所指向的对象。
//3. pa销毁时,调用pa即shared_ptr的析构函数,会发现pa所指向的对象有1个引用者,即shared_ptr类型的自己,
//本来pb所指向的对象中的spa也指向它,但是pb所指向的对象已经被释放,故不存在。
//根据shared_ptr的特点,会正常释放pa所指向的对象。
int main(){
//2个堆内存对象可以正常被释放
testCircularRef();
return 0;
}
6. 是否应该使用智能指针完全替换裸指针?
很多开发者都有一个这样的看法,既然C++11开始提供了智能指针,那么就应该完全使用智能指针,把项目中的裸指针都改造成智能指针,这样就可以避免内存泄漏了,真的是这样吗?
先说基本观点,即使全部使用智能指针也避免不了内存泄漏,比如使用shared_ptr
,就有可能在不必要的地方比如全局对象中持有了一个对象的shared_ptr
引用,这个对象就无法被回收,这是使用上容易犯的错误。
其次,对于智能指针是否完全可以替换裸指针这个问题,我的观点是:可以,但不全是。
6.1 明确资源的所有权(ownership
)
在文章刚开始就说了,shared_ptr
和unique_ptr
的区别就是对资源的所有权不一样,前者可以有多个引用者共享,而后者是独享。所以,当要使用指针时,应该先明确需求,这个指针所指向的对象是否需要共享,即先要明确资源的所有权。
假如资源拥有者要把一个对象借给别人用一下,用完就归还,方法大致如下:
arduino
void borrow(??? res);
这里如何定义方法参数类型,有3种选择:
- 首先是
const shared_ptr<T>
,这种肯定是先pass
的,因为从需求上来说,没有共享的必要,使用shared_ptr
只会消耗性能。 - 其次使用
const unique_ptr<T>&
,根据需求,这里必须使用引用,而不能使用const unique_ptr<T>
,原因在前面说过。对于使用const unique_ptr<T>&
的情况,这种是可行的,也是比较建议的,可能出现的问题就是假如这是一个第三方函数,需要给别人用,别人可能是使用的是普通指针,这种情况就需要转换。 - 最后就是直接使用
T*
,因为从资源所有权的角度来说,这个方法只是使用一下资源而已,它并不需要独占或者共享资源,而且定义为T*
也更加方便别人使用。
上述观点,也是个人愚见,没有绝对之分,也是很多人的观点,但是不变的思想是:需求决定技术细节 ,先看资源的所有权,再决定使用什么智能指针。如果只是简单调用,可以不用智能指针。
6.2 少使用指针
网上有个段子,就是越是厉害的C++大佬,越少使用C++。其实对于指针也是类似,作为一把双刃剑,用好了性能大大地提高,用不好不仅会导致内存泄漏还有增加代码复杂度。所以,有个观点是少用指针。
- 优先考虑引用和值,特别是函数之间,基本上没有必要传指针。
- 再考虑使用
unique_ptr
,替代类的成员不能使用引用的情况下使用指针。 - 所有资源都应该使用RAII来管理 ,尽量一个类管理资源 。对于一个类管理不了的情况 ,再使用
shared_ptr
和weak_ptr
。
通过少使用指针以及合理地使用指针,可以减少绝大多数指针的使用场景。
6.3 如果可能,尽可能使用智能指针
这个标题或许和前面说的有出入,但是这是我从实际项目中踩坑得到的观点。不要滥用智能指针 ,如果用,就明确用法,做到全部使用,杜绝new
和delete
,使用make_shared/unique
替代。理由如下:
- 对于多线程 编程来说,资源共享使用手动释放非常麻烦,建议使用智能指针。
- 异常处理更加方便,程序可靠性更好。
- 使用智能指针可以让开发者更加明确资源的所有权。
- 减少忘记
delete
或者不合时宜的delete
造成的问题。
总的来说,要理性看待C++的智能指针,在项目中要尽可能少使用指针,如果使用则必须明确资源的所有权,再采取合适的智能指针,进行合理地使用。
总结
智能指针不是C++解决动态内存问题的万能钥匙,明确资源所属权,合理且正确地使用智能指针才是根本目的。
参考:<<C++ Primer>>、<<Effective C++>>。