std::vector/指针成员 联用时的大坑

下面这段代码有何问题?

#include <vector>
 
class A {
public:
	A() {
		p = new int;
	}
	~A() {
		delete p;
	};
 
	int * p;
};
 
int main()
{
	std::vector<A> vec;
	for (int i=0; i<5; ++i)
	{
		vec.push_back(A());
	}
	return 0;
}

看起来好像挺正常的。虽然包括了一个指针,但有newdelete一一对应,浓眉大眼的,应该没有问题。

然而,事实是运行起来马上抛异常,诊断发现异常出在delete,显然是试图释放一个不存在的对象。

补充一个小知识:C++标准规定`delete`应该自己检查指针是否为`null_ptr`,所以任何情况下都不用手动检查`null_ptr`问题,不会在这上面出错的。

但是A中唯一的构造函数确定new了一个int出来,p为何会指向一个不存在的对象?

断点半天看不出来问题,于是上log:

class A {
public:
	A() {
		std::cout << "construct A" << std::endl;
		p = new int;
	}
	~A() {
		std::cout << "destroy A" << std::endl;
 
		//delete p;
		//此bug的另一大坑在于,即使打了log,由于程序很快就会出错,log收集到的信息并不多,所以不容易看出来原因。
		//所以诊断代码中需要先屏蔽掉指针操作
	};
 
	int * p;
};
 
int main()
{
	std::vector<A> vec;
	for (int i=0; i<5; ++i)
	{
		std::cout << "i = " << i << std::endl;
		vec.push_back(A());
	}
	std::cout << "cycle end" << std::endl;
	return 0;
}

运行出来的结果很有意思:

construct A
destroy A
i = 1
construct A
destroy A
destroy A
i = 2
construct A
destroy A
destroy A
destroy A
i = 3
construct A
destroy A
destroy A
destroy A
destroy A
i = 4
construct A
destroy A
destroy A
destroy A
destroy A
destroy A
cycle end
destroy A
destroy A
destroy A
destroy A
destroy A

每次循环中都正常新建了一个A。然而,第二次循环中就出现了1次“不应有”的析构,第三次循环中出现了2次,第四次则析构了3个A……

显然,就是这个情况导致了多次析构,而从第三轮循环开始,如果没有屏蔽掉delelte p,程序就会死于尝试析构第二轮循环中已经被析构的那个指针,不信可以输出一下指针地址看看。

继续分析可以知道,这些被析构的对象,显然不是由我们手写的构造函数搞出来的,否则不会没有log。那么只剩下一种途径,就是编译器隐式添加的那个默认复制函数A(const &A)。验证很简单,这里不搞了。

整件事情至此比较明了:每次循环中,整个vector中的所有原有的元素都被默认复制函数浅拷贝了一次,然后原有的对象被析构。再下一次循环中,这些被复制出来的对象又一次被析构,但上次复制出来的指针现在成了野指针,既不合法又不是null_ptr,被delete傻乎乎的拿去用,显然会出错。

为什么会搞出这些反复的复制/析构?也不难猜测:当程序试图增大vector的时候,如果之前的保留空间不足,为了保证vector是连续的,STL会另外开辟一块更大的空间储存所有对象,然后把原先的数组整个干掉。

这个是确实是疏忽了,不过这里(VC 2017)STL的表现跟以前听说的也不完全一致嘛,以前听说是按$2^n$分配的,一个不够分配俩,两个不够了下次直接分配四个……当然,这些都是具体实现的事情,谁也管不着微软。

定位问题之后解决就简单了,有好几种方案:

  1. 手动实现复制构造函数,实现深拷贝(最安全)
  2. 事先算出需要多少空间,然后在第一次push_back()之前reserve()一下(最高效)
  3. 使用shared_ptr代替裸指针(最简单)
  4. 别用指针了,这玩意儿确实麻烦……

如果要防止一切可能的拷贝,最安全的办法是手动声明一个A(const A&)在那里,设置为private并且不实现。如果实现了,从理论上讲,其他类把A搞成friend,还是可以调用的。

然而std::vector是要求自己存放的对象有拷贝构造函数的。所以这个办法只是让你提前注意到这个可能的问题而已,并不是一个解决方案。

  • 最后更改: 2019/05/24 01:01