1. 介绍
我们先从一段平常的代码说起
int main() {
auto * p = new int [10];
delete [] p;
return 0;
}
申请一段空间,并释放,没有任何问题。
再看下面的代码
int main() {
auto * p = new int [10];
delete [] p;
delete [] p;
return 0;
}
这样就会出问题,这是一个runtime error,成为双释放(double free)。
free(): double free detected in tcache 2
这时有读者可能会说,谁会写出这么操蛋的代码,delete两次难道不难发现?
不急,我们继续往下看。
2.拷贝构造函数
拷贝构造函数一般来说容易被忽略(本人没有大型C++项目经历,一些主观感受都是我意淫的,比如我个人容易忽略拷贝构造函数等习惯),但实际上拷贝构造函数经常在无形中会被调用。我们知道,如果一个类中不声明拷贝构造函数,则会有一个默认的拷贝构造函数。
观察一下代码
class A {
private:
static int id_;
int id;
public:
A() {
id = id_++;
cout << id << " construct!" << endl;
}
~A() {
cout << id << " deconstruct!" << endl;
}
};
int A::id_ = 0;
int main() {
A a;
A b = a;
A c(a);
}
此处的A b = a 和 A c(a)其实都显式调用了拷贝构造函数,因此a b c三个对象id都是1。为了证明拷贝构造函数被调用,我们可以写一个自定义拷贝构造:
A:: A(const A & a) {
this->id = a.id;
cout << id << "copy construct!" << endl;
}
默认拷贝构造对每个成员变量执行等号赋值操作到新的构造对象中,因此运行结果为
0 construct!
0copy construct!
0copy construct!
0 deconstruct!
0 deconstruct!
0 deconstruct!
由于abc三个对象都在栈上,可以自动回收,因此可以看到三个析构运行。
3.由于默认拷贝构造函数引发双释放
上文中我们谈到默认的拷贝构造对 要构造的对象中的成员变量执行等号赋值,其语句类似于我们上文中自定义的拷贝构造函数。这种情况下是可能导致双释放问题的!请看下面的类定义。
class A {
private:
static int id_;
int id;
int * buffer;
public:
A() {
id = id_++;
cout << id << " construct!" << endl;
buffer = new int[10];
}
~A() {
delete [] buffer;
cout << id << " deconstruct!" << endl;
}
};
我们为A加入了一个指针类型的成员变量,用来动态申请空间;同时删掉了自定义拷贝构造函数,让系统执行默认拷贝构造。这时如果执行2中的main函数,必然报错。
int main() {
A a;
A b = a;
A c(a);
}
0 construct!
0 deconstruct!
free(): double free detected in tcache 2
想必聪明的读者已经猜到了原因,这是因为调用了默认拷贝构造,我们有 c.buffer == b.buffer == a.buffer,这是典型的浅拷贝,也就是说abc三个对象实际上共享了buffer指向的内存空间,这时依次析构就会发生问题,因为buffer实际上只能释放一次。
4.一个更隐蔽的案例
我们修改上面的main函数,引入vector
int main() {
vector<A> v;
v.emplace_back(A());
return 0;
}
这是一种隐蔽的情况,因为在压入的时候,A()会率先生成一个A的对象,我们姑且叫做a(实际上这个对象是匿名的,但我们在本文中给他一个名字),但实际上压入v的并不是a,而是a的一个拷贝a’(a’也是匿名的),同时由于a是匿名的,完成拷贝构造a’对象的任务后,a会立刻被回收,也就是调用a的析构函数,这时a的buffer被释放。当然a’的buffer也同时被释放了,但很多开发者到此为止还没有意识到这件事。事实上a’的buffer很可能仍然可用,但不能保证内存安全。
上述过程可以通过运行结果看出
0 construct!
0 deconstruct!
free(): double free detected in tcache 2
解决方案:
我们修改析构函数,不要释放buffer就好啦~
A::~A() {
// delete [] buffer;
cout << id << " deconstruct!" << endl;
}
哈哈哈哈开个玩笑,其实我也没想到好的解决方案。如果我们 重写拷贝构造函数,将buffer的拷贝改成深拷贝,对于vector的使用,显然多此一举,有损效率。
一种方案是通过指针调用。
int main() {
vector<A*> v;
v.emplace_back(new A());
return 0;
}
但这种方法,v中的指针不能自动释放。
5.真正的解决方案
实际上为了解决这个问题,新版的vector已经加入了新方法emplace_back,这个方法与其模板类的构造方法共享参数列表。举个例子
#include <iostream>
#include <vector>
using namespace std;
class A {
private:
static int id_;
int id;
int * buffer;
public:
A(int id) {
this->id = id;
cout << this->id << " construct!" << endl;
buffer = new int[10];
}
A(const A & a){
this->id = a.id;
this->buffer = a.buffer;
cout << this->id << "copy construct!" << endl;
}
int get_id(){
return this->id;
}
~A() {
delete [] buffer;
cout << id << " deconstruct!" << endl;
}
};
int A::id_ = 0;
int main() {
vector<A> v;
v.reserve(10);
v.emplace_back(10);
v.emplace_back(15);
v.emplace_back(20);
cout << v[0].get_id() << ' ' << v[0].get_id() << endl;
return 0;
}
上述代码中的emplace_back相当于调用了A的带参构造函数(参数为id),这样避免了拷贝构造的发生。但同时我们要提防vector。由于vector是动态size,我们必须先reserve一定大小的空间,否则当vector 占用空间达到其capacity,就会发生拷贝构造,导致前述问题。 如何解决,欢迎讨论。