万字长文详解C++智能指针

前言

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是类类型,而且有所指向对象的引用计数,所以其管理所指向对象是通过这两者结合来实现的:

  1. shared_ptr析构函数会递减它所指向的对象的引用计数 ,同时给一个shared_ptr赋予新值时也会递减。

  2. 当引用计数变为0时,shared_ptr会销毁所指向的对象 ,同时释放内存 。注意这里一般是析构函数来做的 ,但是也不完全是,比如前一段的实例代码中,r所指向的对象42,当r指向其他对象时,这个42就再也没有shared_ptr指向它了,所以这时还是由r会去销毁42以及释放其内存。

所以结合这2点,以及递增和递减引用计数的原理,在平时使用时我们直接使用shared_ptr类型进行值传递,这样就可以完成自动释放内存的功能了。

1.4 使用动态资源的场景

在程序开发中,经常有如下3种情况会使用动态内存:

  1. 程序不知道自己需要使用多少对象。比如容器类,在编译阶段并不知道真正使用时会有多少个元素,所以使用动态内存创建和管理数组是非常方便的。
  2. 程序不知道所需对象的准确类型。这种情况涉及到多态,即父类指针可以指向子类对象,这种情况多用于接口编程。
  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. newdelete

虽然现在C++11后就推荐使用智能指针,但是大量的老项目以及各种情况,让开发者有时不得不使用普通指针,还有就是理解普通指针的各种用法,有利于理解智能指针的实现以及解决一些常见错误。

2.1 内存耗尽

简单的如何使用newdelete就不说了,这里先说一种内存耗尽的情况。当程序出现内存泄漏,有限的堆内存就有可能被耗尽,这时已经没有足够的空间来分配动态对象了,这时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的用法就不说了,现在来说几点需要特别注意的。

  1. delete接收的指针必须是指向动态分配的内存 或者是一个空指针
  2. 释放一块非new分配的内存,其行为是未定义的。
ini 复制代码
    int i = 0;
	//pi1是指向栈内存的指针
	int* pi1 = &i;
	//pi2是空指针
	int* pi2 = nullptr;
	//delete一个指向非动态内存的指针是严重错误!!!
	delete pi1;
	//可以delete一个空指针
	delete pi2;

在上面代码中,pi1是指向非动态内存的指针,编译器是无法判断传递给delete的指针具体指向的是什么,所以可以编译通过,运行会报错,这种潜在的问题要时刻注意。

  1. 相同的指针值释放多次,其行为是未定义的。
go 复制代码
    int* pi3 = new int(20);
	delete pi3;
	//delete一个指针多次,是严重错误!!
	delete pi3;

上面这种情况同样编译器无法识别,只会在运行时才报错,需要时刻注意。这里我们可以想一下,既然delete一个空指针是可以的,但是为什么不能delete一个指针2次呢?这是因为delete后的指针值是一个无效值,但不是nullptr

  1. delete之后的指针被称为空悬指针 (dangling pointer),即指向一块曾经保存数据对象但现在已经无效的内存的指针 ,对空悬指针再次调用delete是严重错误。
  2. 如果有业务需求,需要再次使用被delete后的指针,这时我们可以在delete之后对指针赋予nullptr ,这样在下次再使用时不论是误操作delete还是判空再赋予新值,都可以正常使用。
  3. 对于多个普通指针指向同一块内存的情况,操作delete要十分注意,避免出现一个指针已经释放了内存,其他指针再次使用的情况,导致引用非法内存。解决这种情况最好的办法就是使用shared_ptr

2.3 shared_ptrnew结合使用

前面说了创建shared_ptr对象最佳方案是使用make_shared方法,但是更多的时候我们不得不和普通指针打交道,标准库也提供了shared_ptrnew结合使用的各种场景。

  1. 可以使用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的普通指针也必须是指向动态内存的

  1. 既然智能指针是指针的封装,所以它肯定不能一直和一个指针绑定,所以标准库还定义了一些改变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时,即使有普通指针还在指向它,也会被释放

  1. 不要混用普通指针和智能指针,这一点非常关键,也是在日常开发中非常容易出错的地方。
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后,就不要再使用普通指针了。

  1. 不得不混用的情况还有一种,就是有些库函数要求传入普通指针,但是程序中使用的却是智能指针,这时可以使用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 使用智能指针确保资源安全释放

一个简单确保资源被释放的方法就是使用智能指针 ,对于在方法中使用动态内存的场景,我们要求在方法结束前能释放动态内存,假如不使用智能指针,当在newdelete之间出现异常时,程序就会执行到异常处理分支,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,即wp都指向同一个对象。
w = p p可以是一个shared_ptr或者weak_ptr对象,赋值后,wp共享一个对象。
w.reset() w置空
w.use_count() w共享对象的shared_ptr的数量。
w.expired() expired为过期、失效、不再有效的意思,即说明这个weak_ptr是否失效了,当use_count()为0时返回true,否则返回false
w.lock 如果expiredtrue,即已经失效,返回一个空的shared_ptr,否则返回一个指向wshared_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_ptrunique_ptr的区别就是对资源的所有权不一样,前者可以有多个引用者共享,而后者是独享。所以,当要使用指针时,应该先明确需求,这个指针所指向的对象是否需要共享,即先要明确资源的所有权

假如资源拥有者要把一个对象借给别人用一下,用完就归还,方法大致如下:

arduino 复制代码
void borrow(??? res);

这里如何定义方法参数类型,有3种选择:

  1. 首先是const shared_ptr<T>,这种肯定是先pass的,因为从需求上来说,没有共享的必要,使用shared_ptr只会消耗性能。
  2. 其次使用const unique_ptr<T>&,根据需求,这里必须使用引用,而不能使用const unique_ptr<T>,原因在前面说过。对于使用const unique_ptr<T>&的情况,这种是可行的,也是比较建议的,可能出现的问题就是假如这是一个第三方函数,需要给别人用,别人可能是使用的是普通指针,这种情况就需要转换。
  3. 最后就是直接使用T*,因为从资源所有权的角度来说,这个方法只是使用一下资源而已,它并不需要独占或者共享资源,而且定义为T*也更加方便别人使用。

上述观点,也是个人愚见,没有绝对之分,也是很多人的观点,但是不变的思想是:需求决定技术细节先看资源的所有权,再决定使用什么智能指针。如果只是简单调用,可以不用智能指针

6.2 少使用指针

网上有个段子,就是越是厉害的C++大佬,越少使用C++。其实对于指针也是类似,作为一把双刃剑,用好了性能大大地提高,用不好不仅会导致内存泄漏还有增加代码复杂度。所以,有个观点是少用指针。

  1. 优先考虑引用和值,特别是函数之间,基本上没有必要传指针。
  2. 再考虑使用unique_ptr替代类的成员不能使用引用的情况下使用指针。
  3. 所有资源都应该使用RAII来管理尽量一个类管理资源 。对于一个类管理不了的情况 ,再使用shared_ptrweak_ptr

通过少使用指针以及合理地使用指针,可以减少绝大多数指针的使用场景。

6.3 如果可能,尽可能使用智能指针

这个标题或许和前面说的有出入,但是这是我从实际项目中踩坑得到的观点。不要滥用智能指针 ,如果用,就明确用法,做到全部使用,杜绝newdelete,使用make_shared/unique替代。理由如下:

  • 对于多线程 编程来说,资源共享使用手动释放非常麻烦,建议使用智能指针
  • 异常处理更加方便,程序可靠性更好。
  • 使用智能指针可以让开发者更加明确资源的所有权
  • 减少忘记delete或者不合时宜的delete造成的问题。

总的来说,要理性看待C++的智能指针,在项目中要尽可能少使用指针,如果使用则必须明确资源的所有权,再采取合适的智能指针,进行合理地使用。

总结

智能指针不是C++解决动态内存问题的万能钥匙,明确资源所属权,合理且正确地使用智能指针才是根本目的。

参考:<<C++ Primer>>、<<Effective C++>>。

相关推荐
诸神黄昏EX17 分钟前
Android 分区相关介绍
android
爱摸鱼的孔乙己28 分钟前
【数据结构】链表(leetcode)
c语言·数据结构·c++·链表·csdn
捂月38 分钟前
Spring Boot 深度解析:快速构建高效、现代化的 Web 应用程序
前端·spring boot·后端
烦躁的大鼻嘎1 小时前
模拟算法实例讲解:从理论到实践的编程之旅
数据结构·c++·算法·leetcode
IU宝1 小时前
C/C++内存管理
java·c语言·c++
瓜牛_gn1 小时前
依赖注入注解
java·后端·spring
fhvyxyci1 小时前
【C++之STL】摸清 string 的模拟实现(下)
开发语言·c++·string
大白要努力!1 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
C++忠实粉丝1 小时前
计算机网络socket编程(4)_TCP socket API 详解
网络·数据结构·c++·网络协议·tcp/ip·计算机网络·算法
Estar.Lee1 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip