Double Free与不起眼的拷贝构造函数

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,就会发生拷贝构造,导致前述问题。 如何解决,欢迎讨论。

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值