即使把析构函数定义为virtual
依然会无法调用到派生类的析构函数
因为数组的多态会导致未定义的行为
编译器需要建立起遍历数组来调用析构函数的代码
这样他不得不先确定数组的大小
调用如下语句时
//p为指向基类的指针
delete [] p;
编译器把指针p指向的静态类型的大小和析构函数指针一并传给delete运算符
而这二者都与实际不符
所以最终没有调用到派生类的destructor
大家会困惑
为何delete数组时不能像delete单个对象一样
使用虚拟化 动态决议删除的实际对象类型
这里涉及数组的实例化机制
数组的元素数量事实上在初始化时被存储于一个hash map中
hash key就是数组首元素地址
但是 并未保存元素的大小和元素的构造函数和析构函数指针等内容
销毁数组的时候
对数组中的每个元素迭代调用类似如下全局函数
void * vec_delete (
//数组首地址
void *array,
//元素大小
size_t elem_size,
//元素个数
int elem_count,
//析构函数指针
void (*destructor)(void *)
);
这里的array传入p
因为p是个基类指针
当它是一个非第一直接基类时
与一个正确的派生类指针的地址相比
会有一定的偏移
elem_size和destructor都是根据p的静态类型来获取
显然都是不符合实际的
elem_count则是通过hash map取出
由于hash key是数组首地址
这个都不一定是对的
那么就不一定能取得正确的elem_count
甚至当对应的hash槽位为空时根本取不到
以上所言种种变数导致了使用基类指针来做delete[]
是一个未定义行为
只需要对题目的示例程序稍作改变立刻能玩崩:
class Base {
public:
Base() {printf("Base\n");}
~Base() {printf("~Base\n");}
int i = 1;
};
class A : virtual public Base
{
public:
A() { printf("A\n");}
~A(){ printf("~A\n");}
};
class B : virtual public Base
{
public:
B() { printf("B\n");}
~B(){ printf("~B\n");}
};
class C : public A, public B
{
public:
C() { printf("C\n");}
~C(){ printf("~C\n");}
};
int main()
{
C *c = new C[2];
/*
分别将如下各变体拿到注释外执行:
Base *p = c;
A *p = c;
B *p = c;
*/
printf("%p %p\n", p, c);
delete[] p;
}
我们修改了原来的单继承为多继承+虚继承
诸位可以用示例代码中的注释中的变体程序一一尝试
各种崩溃的结果可以玩出花来
原来的A *p如果说还能碰巧因为和C *c的初始地址对齐而勉强执行成功
而新来的非第一直接基类B *p和虚基类Base *p则会带来不一样的初始地址
和酸爽的崩溃
触目惊心之余得来一句逆耳忠言
别用基类指针释放动态数组内存
下面对比一下单个对象的delete
delete p;
这里如果p的析构函数是个非虚函数
那么就直接调用了
如果是个虚析构函数
编译器会直接进入p所指对象的虚表里
通过thunk技术检索得到派生类的析构函数指针
并将this指针完成一定偏移指向派生类对象的初始地址
所以单个变量的析构过程是可以动态化的
进一步思考,假如我们在题目示例代码的程序层面上做些调整;
const static int N = 2;
A* c = new B[N];
for (int i=0; i<N; ++i) {
delete ((B*)c+i);
}
这样也是不行的
C++标准表示,对数组调用delete而不是delete[]运算符会导致未定义行为
以上代码会出现一个运行时错误:
ABAB~B~A
TestCPP(1739,0x10012c3c0) malloc: *** error for object 0x1004067e8: pointer being freed was not allocated
可见已经析构到了第一个对象的基类了
但是显示的错误是重复delete
这就是未定义行为
所以在对数组元素执行虚函数时
还是要用派生类的指针来delete
B* p = new B[N];
delete[] p;
2018-04-11 更
感谢 @欧阳大哥2013 的回复
回复内容对new动态分配的数组的大小存储的位置做了质疑
在其空间拜读了《C++的new和delete详解》
写得很好
其中相关内容如下
// CA *p3 = new CA[20]; 这句代码在编译时其实会转化为如下的代码片段
//64位系统多分配8字节 *p = 20; //这里保存分配的对象的数量。
unsigned long *p = operator new[](20 * sizeof(CA) + sizeof(unsigned long));
CA *p3 = (CA*)(p + 1);
CA *pt = p3;
for (int i = 0; i < *p; i++) { CA::CA(pt); pt += 1; }
据此我发表自己的以下看法:
首先引用以下Lippman(C++标准委员会成员之一,《C++ Primer》等著作的作者)所著的《Inside the C++ object model》一书中 “6.2 Operators new and delete”这一章节中的子内容。
建议大家看英文版,因为中文版的翻译不好,并且错漏百出
这本书讲了Lippman在贝尔实验室写第一代C++编译器的故事
引用的这一节说的正是new[]和delete[]的设计和演变思路
下面引用里面一些关键部分
How is this caching of the element count implemented? One obvious way is to allocate an additional word of memory with each chunk of memory returned by the vec_new() operator, tucking the element count in that word (generally, the value tucked away is called a cookie). However, Jonathan and the Sun implementation chose to keep an associative array of pointer values and the array size. (Sun also tucks away the address of the destructor—see [CLAM93n].)
简单翻译如下:
动态数组里的元素数量怎么存呢?一个显而易见的方式就是多分配一个字(由数据总线宽度决定,根据架构基本上32位芯片一个字为4字节,64位芯片一个字为8字节)的存储空间给vec_new()运算符(其实就是operator new),然后把元素个数存在这个字里(这个元素个数值绰号叫cookie)。但是Sun公司的总设计师Jonathan在实现时选择保存一个关联数组(associative array),这个数组保存了动态数组的首指针和数组大小,甚至还存入了析构函数的地址。
所以 @欧阳大哥2013 的代码中所表现的思想正是Lippman文中所述的“obvious way”
这个思路清晰简洁
但是我曾经做过如下实验
#include <iostream>
using namespace std;
int main()
{
unsigned long *p = new unsigned long[10];
cout << sizeof(unsigned long) << endl;
cout << *(p-1) << endl;
return 0;
}
得出的结果并非数组大小,而是脏数据
其实细想一下,这种方式的编译器实现是危险的
因为数组大小暴露在用户可以掌控的范围
那么用户完全可以对数组大小进行篡改
从而达到数组溢出攻击的目的
那么我们来看看Jonathan的做法
也就是关联数组
以下就是关联数组所用到的关键外部函数
// array_key is the address of the new array which mustn't either be 0 or already entered
// elem_count is the count; it may be 0
typedef void *PV;
extern int __insert_new_array(PV array_key, int elem_count);
这个函数实现了把数组和其元素个数插入缓存的目的
当然这个cache具体实现Lippman没说 也没有公布 __insert_new_array的细节
我根据自己的所看过的系统内部源码实现的经验认为
最有可能的是Hash map
当然 诸君想要用单纯的链表作cache也无可厚非
这块的实现具体细节大家可以把这一节都看完
聊了很多有趣的细节
只是最后一段我做了clarify
循环调用delete销毁动态数组我做实验
是会报错的 正如我在正文中所言属于未定义行为
做了以上论述并非说 @欧阳大哥2013 的论述是错误的
确切地说 编译器一直在发展 没有永恒不变的东西
也许Jonathan的设计也在某个时间点被更新的编译器推翻了
所以 设计思路没有对错之分 一切只是顺着历史潮流而已
所以 大家只是一直在“实验-求证-实验”的循环往复中前进
日后有时间考虑再转为汇编代码看看内部实现的蛛丝马迹
如果大家有其他编译器的实现方式不妨在留言中告诉我
如果我的以上实验有不妥之处 请不吝赐教