动态内存和智能指针

Dynamic memory and smart pointers

智能指针作为C++11最重要的特性之一,相关的内容本来是辑录在C++11的语法糖中,但是这部分太重要而我最近又比较闲(逃),就单独列出来详细地总结一下咯。

在C语言中动态的分配/撤销一块内存是通过mallocfree来实现的:

1
void* malloc( size_t size );

我们可以分配一块连续内存供我们使用:

1
2
3
4
5
6
7
8
9
10
int *p1 = malloc(4*sizeof(int));
for(int n=0; n<4; ++n){ // populate the array
p1[n] = n*n;
printf("p1[%d] == %d\n", n, p1[n]);
}
// 输出结果
p1[0] == 0
p1[1] == 1
p1[2] == 4
p1[3] == 9

而在C++中动态内存管理是通过另一对运算符来完成的:new在动态内存中为对象分配空间并返回一个指向该对象的指针,我们可以选择对对象初始化;delete接受一个动态对象的指针,销毁该对象并释放与之关联的内存。

以下代码:

1
2
3
4
5
6
7
8
9
// 使用new创建一个string对象并将其赋值给一个string指针
string *testNew=new string("HelloWorld");
cout<<testNew<<"\t"<<*testNew<<endl;
// delete之前创建的对象(testNew指针所指向的对象)
delete(testNew);
cout<<testNew<<"\t"<<*testNew<<endl;
testNew=nullptr;
// 置空动态对象
cout<<testNew<<"\t"<<*testNew<<endl;

可以看到输出的结果为delete前后testNew所指向位置存储的数据(windows上):

使用delete释放后所指向的内存地址(悬垂指针:指向一块曾经保存数据对象但现在已经无效的内存的指针)所存储的值是未定义的。

在Linux上编译和运行会直接显示**段错误(Segmentation fault (core dumped) )**。

所以当我们使用delete释放了动态对象时,最好立即将其置为nullptr(空指针)

正如你所看到的,当使用new/delete来手动管理内存时,一定要记得new/delete成对的出现,防止在某处使用new创建一个动态对象而没有用delete释放它,于是就造成了内存泄露。

使用new/delete相关的问题还可以看看我这两篇博文:

  1. 删除void*指针引发的内存泄露
  2. STL释放指针元素时造成的内存泄露

动态内存的使用非常容易出现问题,因为确保内存在正确的时间释放是一个不容易的事情。有时会忘了释放内存,造成内存泄露;有时会在仍有指针引用该内存的情况下就释放了它,这种情况下就会产生引用非法内存的指针(悬垂指针)。

所幸,为了更安全地使用动态内存,C++11带来了新特性——**智能指针(smart pointer)**。

C++11提供了两种智能指针类型(shared_ptrunique_ptr)来管理动态对象。智能指针行为类似于常规指针(裸指针),最重要的区别就是智能指针可以负责自动释放所指向的对象

C++11提供的两种智能指针的区别在于管理底层指针的方式:

shared_ptr允许多个指针指向同一个对象。
unique_ptr则独占所指向的对象。

标准库还定义了一个伴随类——wake_ptr,它是一种弱引用,指向shared_ptr所管理的对象。

注意:这三种类型都定义在memory中。

shared_ptr类

类似于之前接触到的vector/string/list/deque,智能指针也是模板。因此,当我们创建一个智能指针时,必须提供额外的定义——指针可以指向的类型。与vector一样,我们在尖括号内给出类型,之后是所定义的这种智能指针的名字。

1
2
3
4
// shared_ptr可以指向string
shared_ptr<string> strp;
// shared_ptr可以指向string的vector
shared_ptr<vector<string>> vecStrp;

默认初始化的智能指针中保存着一个空指针(nullptr)

智能指针的使用方式与普通指针类似。解引用一个智能指针返回它指向的对象。如果在一个条件判断中使用智能指针,效果就是检测它是否为空。

1
2
3
4
5
6
7
8
shared_ptr<string> strp=make_shared<string>();
// 如果strp不为空,检查它是否指向一个空string
if(strp&&strp->empty()){
// 如果strp指向一个空string,解引用strp并将一个新值赋值给strp指向的string对象
*strp="helloworld";
}
cout<<*strp<<endl;
//执行结果为helloworld

shared_ptr和unique_ptr都支持的操作。

操作 含义
shared_ptr<T> sp;

unique_ptr<T> up;
空智能指针,可以指向类型为T的对象
p 将p用作一个条件判断,若p指向一个对象则为true
*p 解引用p,获得它指向的对象
p->mem 等价于(*p).mem
p.get() 返回p中保存的指针,要小心使用,若智能指针释放了其对象,返回指针所指向的对象也就消失了(成为悬垂指针)
swap(p,q)

p.swap(q)
交换p和q中的指针

shared_ptr独有的操作

操作 含义
make_shared<T> (args) 返回一个shared_ptr,指向一个动态分配的类型为T的对象。使用args初始化此对象。
shared_ptr<T> p(q) p是shared_ptr q的拷贝;此操作会递增q中的计数器。q中的指针必须能转换为T*(详情参考详细分析下C++中的类型转换:指针的转换)
make_shared<T>(args) 返回一个shared_ptr,指向一个动态分配的类型为T的对象。使用args初始化此对象。
shared_ptr<T> p(q) p是shared_ptr q的拷贝;此操作会递增q中的计数器。q中的指针必须能转换为T*(详情参考详细分析下C++中的类型转换:指针的转换)
p=q p和q都是shared_ptr,所爆粗你的指针必须能相互转换。此操作会递减p的引用计数,递增q的引用计数;若p的引用计数变为0,则将其管理的原内存释放。
p.unique() 若p.use_count()为1,返回true;否则返回false
p.use_count() 返回与p共享对象的智能指针数量,可能比较慢,主要用于调试

我们也可以自己实现一个简单的类似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
26
27
28
29
30
31
32
33
34
35
36
template<typename T>
class smartPoint{
public:
smartPoint():ObjectPoint(nullptr),use_count(new size_t(1)){}
smartPoint(T *i):ObjectPoint(i),use_count(new size_t(1)){}
smartPoint(const smartPoint &i):ObjectPoint(i.ObjectPoint),use_count(i.use_count){++*use_count;}
~smartPoint(){decr_use();}

smartPoint& operator=(const T &rhs){
++*rhs.use_count;
decr_use();
ObjectPoint=rhs.ObjectPoint;
use_count=rhs.use_count;
return this;
}
const T* operator->() const{
if(ObjectPoint)
return ObjectPoint;
}
const T& operator*() const{
if(ObjectPoint)
return *ObjectPoint;
}
size_t count(){
return *use_count;
}
private:
T *ObjectPoint;
size_t *use_count;
void decr_use(){
if(--*use_count==0){
delete ObjectPoint;
delete use_count;
}
}
};

上面只是实现了简单的引用计数,只实现了一个smartPointer::count()成员函数来获取指向对象被引用的个数。

写一个简单的测试代码:

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
26
27
28
29
30
31
32
33
struct test{
test(){cout<<"construction"<<endl;}
~test(){cout<<"destruction"<<endl;}
};
smartPoint<test> funa(void){
smartPoint<test> strp=new test();
// 使用strp
// 当我们返回了strp,引用计数进行了递增操作
return strp;
}// strp离开了作用域但它指向的内存不会被释放掉
void funb(void){
smartPoint<test> strp(new test());
// 使用strp
// strp离开了作用域,因为离开作用域时引用计数会递减,所以会自动释放掉strp
}

int main(void){
auto x=funa();
cout<<"--------"<<endl;
funb();
cout<<"--------"<<endl;
}
// 输出结果
/*
construction (1)
------------
construction (2)
destruction (3)
------------
destruction (4)
*/
// 1.在funa中创建的对象;4.funa中创建的对象在离开主函数的作用域时(X)被销毁
// 2.在funb中创建的对象;3.funb中创建的对象在离开funb作用域时被销毁

make_shared函数

最安全的分配和使用动态内存方法是调用一个名为make_shared的标准库函数。

此函数在动态内存中分配一个对象并初始化它,返回一个指向此对象的shared_ptr。与智能指针一样,make_shared也是定义在memory头文件中。

使用make_shared时,必须指定想要创建的对象的类型。定义方式与模板相同,在函数名之后跟一个尖括号,在其中给出类型。

1
2
3
4
5
6
7
8
// 指向一个值初始化的int,即值为0
shared_ptr<int> ivalp1=make_shared<int>();
// 指向一个值为42的int的shared_ptr
shared_ptr<int> ivalp2=make_shared<int>(42);
// 指向一个值为"HelloWorld"的string
shared_ptr<string> strvalp=make_shared<string>("HelloWorld");
// 指向一个值为"9999999999"的string
shared_ptr<string> strvalp=make_shared<string>(10,'9');

类似于顺序容器emplace成员(直接调用元素类型的构造函数在容器中构造元素,详细参照C++11的语法糖:emplace操作),make_shared用其参数来构造给定类型的对象。

例如:

  1. 调用make_shared<string>时传递的参数必须与string的某个构造函数相匹配。
  2. 调用make_shared<int>时传递的参数必须能用来初始化一个int,以此类推。
  3. 如果我们不传递任何参数,对象就会进行值初始化

我们也可以使用auto来定义一个对象保存make_shared的结果(也就是智能指针对象),这种方式相较于前面的方法比较简单:

1
2
// strp指向一个动态分配的空的vector<string>
auto strp=make_shared<vector<string>>();

shared_ptr的拷贝和赋值

当进行拷贝或赋值操作时,每个shared_ptr都会记录有多少个其他shared_ptr指向相同的对象。

前面表格中提到了我们可以调用shared_ptr的use_count成员函数来获取共享对象的智能指针数量。

1
2
3
4
5
6
7
// p指向的对象只有一个引用者
auto p=make_shared<int>(42);
cout<<p.use_count()<<" ";
// p和q指向相同对象,此对象有两个引用者
auto q(p);
cout<<p.use_count()<<endl;
// 输出结果为1 2

可以认为每个shared_ptr都有一个关联的计数器,通常称其为**引用计数(reference count)**。无论何时我们拷贝了一个shared_ptr,计数器都会递增。

例如:

  1. 当用一个shared_ptr初始化另一个shared_ptr

  2. 将shared_ptr作为参数传递给一个函数

  3. 作为函数的返回值

它所关联的计数器就会递增。

当我们

  1. shared_ptr赋予一个新值
  2. shared_ptr被销毁(e.g.局部的shared_ptr离开其作用域)

计数器就会递减。

一旦一个shared_ptr计数器变为0,它就会自动释放自己所管理的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct test{
test(){cout<<"construction"<<endl;}
~test(){cout<<"destruction"<<endl;}
};

// 以下代码
auto r=make_shared<test>();
r=make_shared<test>();
// 执行结果
construction
construction
destruction
destruction
/*
其顺序为:
1.创建一个可以指向test类型的对象(值初始化)并将其shared_ptr赋值给r,假定此时r指向A
2.新创建一个可以指向test类型的对象(值初始化)并将其shared_ptr重新赋值给r,假定此时r指向B
3.在将r赋值为指向B之后,调用A(test)的析构函数释放A
4.程序结束时,调用B(test)的析构函数释放B
*/

注意:到底是用一个计数器还是其他的数据结构来记录有多少指针共享对象完全有标准库的具体实现来决定。关键是智能指针能记录多少个shared_ptr指向相同的对象,并能在恰当的时候释放他们。

shared_ptr自动销毁所管理的对象

同所有定义的局部变量一样,当对象离开其作用域时,局部对象会被销毁。

1
2
3
4
void func(void){
int ival;
}// 离开func作用域时,在其中定义的局部变量就会被销毁

智能指针也一样,不过智能指针在离开其作用域或者被赋予一个新值时,会将对象内部的引用计数器递减,当计数器为0时智能指针所指向的对象才会被销毁。
可以通过以下这份代码来看智能指针引用计数递减和销毁的次序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
smartPoint<test> funa(void){
smartPoint<test> val=new test();
return val;
}
void funb(void){
cout<<"run funa before."<<endl;
smartPoint<test> temp=funa();
cout<<"run funa after."<<endl;
}

int main(void){
cout<<"run funb before"<<endl;
funb();
cout<<"run funb after."<<endl;
return 0;
}

// 执行结果
run funb before
run funa before.
construction
run funa after.
destruction
run funb after.

可以看到,我们在funb中调用funa,在funa中创建了一个智能指针并让其指向一个test对象实例,并从funa中返回该智能指针至funb函数的调用处,返回时在funa中创建的智能指针的引用计数先递增(返回实际上是拷贝构造了一个新的对象,是值传递。有兴趣的可以试试在funa中返回但在调用处不接收(使用)该返回值的情况,看funa中创建的的动态对象何时被销毁)再递减(离开funa作用域),所以该智能指针所指向的对象不会在离开funa作用域时被销毁。但是其后在离开funb作用域时智能指针连同其所指向的动态内存对象一并被销毁了(因为离开funb作用域时引用计数递减至0,所以调用了该智能指针指向对象的析构函数(delete操作)释放了该对象)。

当指向对象的最后一个shared_ptr被销毁时,shared_ptr类就会自动销毁此对象。它是通过另一个特殊的成员函数——**析构函数(destructor)**完成销毁工作的。类似于构造函数,每个类都有一个析构函数。就像构造函数控制初始化一样,析构函数控制此类型的对象销毁时做什么动作。

shared_ptr的析构函数会递减它的所指向的对象的引用计数。如果引用计数变为0,shared_ptr会的析构函数就会销毁对象并释放它所占用的内存。

当动态对象不再被使用时,shared_ptr类会自动释放动态对象,所以会使得动态内存的使用变得非常容易,因为我们不是时时刻刻谨记什么时候创建(new)了一个动态对象而没有释放(delete)它。

shared_ptr的析构函数会递减它的所指向的对象的引用计数。如果引用计数变为0,shared_ptr会的析构函数就会销毁对象并释放它所占用的内存。

如果你将shared_ptr存放于一个容器中,而后不再需要全部元素,而只使用其中的一部分,要记得用erase删除不需要的那些元素。

使用了动态内存生成期资源的类

程序使用动态内存出于以下几种原因之一:

  1. 程序不知道需要多少对象
  2. 程序不知道所需对象的准确类型
  3. 程序需要在多个对象间共享数据

容器类是使用第一个原因而使用动态内存的典型例子。

下面的例子中我们将定义一个类,它使用动态内存是为了让多个对象能共享相同的底层数据。

我们经常所用到的容器(vector/string/map/set/deque/list)分配的资源与对象对象生存期一致。

例如每个vector对象都拥有其自己的元素,当我们拷贝一个vector时,原vector和新拷贝的vector中的元素是相互分离的。

1
2
3
4
5
6
vector<string> v1;
{// 新的作用域
vector<string> v2={"a","an","the"};
v1=v2;
}// v2被销毁,其中的元素也被销毁
// v1有三个元素,是原来v2中元素的拷贝

由一个vector分配的元素也只有当这个vector存在时才存在。当一个vector被销毁时,这个vector中的元素也被销毁。

但某些类分配资源具有与原对象相独立的生存期。一般而言,如果两个对象共享底层的数据,当某个对象被销毁时,我们不能单方面地销毁底层数据:

1
2
3
4
5
Blob<string> b1;
{
Blob<string> b2={"a","an","the"};
b1=b2;
}// b2被销毁
全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:动态内存和智能指针
文章作者:查利鹏
发布时间:2016/08/25 08:18
本文字数:4.3k 字
原始链接:https://imzlp.com/posts/4280/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!