五条基本规则:
1、如果基类已经插入了vptr, 则派生类将继承和重用该vptr。vptr(一般在对象内存模型的顶部)必须随着对象类型的变化而不断地改变它的指向,以保证其值和当前对象的实际类型是一致的。
2、在遇到通过基类指针或引用调用虚函数的语句时,首先根据指针或引用的静态类型来判断所调函数是否属于该class或者它的某个public 基类,如果
属于再进行调用语句的改写:
1
|
(*(p->_vptr[slotNum]))(p, arg-list);
|
其中p是基类指针,vptr是p指向的对象的隐含指针,而slotNum 就是调用的虚函数指针在vtable 的编号,这个数组元素的索引号在编译时就确定下来,
并且不会随着派生层的增加而改变。如果不属于,则直接调用指针或引用的静态类型对应的函数,如果此函数不存在,则编译出错。
3、C++标准规定对对象取地址将始终为对应类型的首地址,这样的话如果试图取基类类型的地址,将取到的则是基类部分的首地址。我们常用的编译器,如vc++、g++等都是用的尾部追加成员的方式实现的继承(前置基类的实现方式),在最好的情况下可以做到指针不偏移;另一些编译器(比如适用于某些嵌入式设备的编译器)是采用后置基类的实现方式,取基类指针一定是偏移的。
4、delete[] 的实现包含指针的算术运算,并且需要依次调用每个指针指向的元素的析构函数,然后释放整个数组元素的内存。
在类继承机制中,构造函数和析构函数具有一种特别机制叫 “层链式调用通知” 《 C++编程思想 》
如下面的例子:
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 |
#include<iostream>
using namespace std; class IRectangle { public: virtual ~IRectangle() {} virtual void Draw() = 0; }; class Rectangle: public IRectangle { public: virtual ~Rectangle() {} virtual void Draw(int scale) { cout << "Rectangle::Draw(int)" << endl; } virtual void Draw() { cout << "Rectangle::Draw()" << endl; } }; int main(void) { IRectangle *pI = new Rectangle; pI->Draw(); pI->Draw(200); delete pI; return 0; } |
按照上面的规则2,pI->Draw(200); 会编译出错,因为在基类并没有定义Draw(int) 的虚函数,于是查找基类是否定义了Draw(int),还是没有,就出错了,从出错提示也可以看出来:“IRectangle::Draw”: 函数不接受 1 个参数。
此外,上述小例子还隐含另一个知识点,我们把出错的语句屏蔽掉,看输出:
Rectangle::Draw()
~Rectangle()
~IRectangle()
即派生类和基类的析构函数都会被调用,这是因为我们将基类的析构函数声明为虚函数的原因,在pI 指向派生类首地址的前提下,如果~IRectangle()
是虚函数,那么会找到实际的函数~Rectangle() 执行,而~Rectangle() 会进一步调用~IRectangle()(规则5)。如果没有这样做的话,只会输出基类的
析构函数,这种输出情况通过比对规则2也可以理解,pI 现在虽然指向派生类对象首地址,但执行pI->~IRectangle() 时 发现不是虚函数,故直接调用,
假如在派生类析构函数内有释放内存资源的操作,那么将造成内存泄漏。更甚者,问题远远没那么简单,我们知道delete pI ; 会先调用析构函数,再释
放内存(operator delete),上面的例子因为派生类和基类现在的大小都是4个字节即一个vptr,故不存在释放内存崩溃的情况,即pI 现在就指向派生
类对象的首地址。如果pI 偏离了呢?问题就严重了,直接崩溃,看下面的例子分析。
现在来看下面这个问题:
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 37 38 39 40 41 42 43 |
#include <iostream>
using namespace std; class Base { public: ~Base() { cout << "~Base()" << endl; } void fun() { cout << "Base::fun()" << endl; } }; class Derived : public Base { public: ~Derived() { cout << "~Derived()" << endl; } virtual void fun() { cout << "Derived::fun()" << endl; } }; int main() { Derived *dp = new Derived; Base *p = dp; p->fun(); cout << sizeof(Base) << endl; cout << sizeof(Derived) << endl; cout << (void *)dp << endl; cout << (void *)p << endl; delete p; p = NULL; return 0; } |
输出为:
由于基类的fun不是虚函数,故p->fun() 调用的是Base::fun()(规则2),而且delete p 还会崩溃,为什么呢?因为此时基类是空类1个字节,派生类有虚函数故有vptr 4个字节,基类“继承”的1个字节附在vptr下面,现在的p 实际上是指向了附属1字节,即operator delete(void*) 传递的指针值已经不是new 出来时候的指针值,故造成程序崩溃。 将基类析构函数改成虚函数,fun() 最好也改成虚函数,只要有一个虚函数,基类大小就为一个vptr ,此时基类和派生类大小都是4个字节,p也指向派生类的首地址,问题解决,参考规则3。
最后来看一个所谓的“多态数组” 问题
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 |
#include<iostream>
using namespace std; class B { int b; public: virtual ~B() { cout << "B::~B()" << endl; } }; class D: public B { int i; public: virtual ~D() { cout << "D::~D()" << endl; } }; int main(void) { cout << "sizeB:" << sizeof(B) << " sizeD:" << sizeof(D) << endl; B *pb = new D[2]; delete [] pb; return 0; } |
由于sizeB != sizeD,参照规则4,pb[1] 按照B的大小去跨越,指向的根本不是一个真正的B对象,当然也不是一个D对象,因为找到的D[1] 虚函数表位置是错的,故调用析构函数出错。程序在g++ 下是segment fault 的,但在vs 中却可以正确运行,在C++的标准中,这样的用法是undefined 的,只能说每个编译器实现不同,但我们最好不要写出这样的代码,免得庸人自扰。
delete-expression:
::opt delete cast-expression
::opt delete [ ] cast-expression
In the first alternative (delete object), if the static type of the operand is different from its dynamic type, the static type shall be a base class of the
operand’s dynamic type and the static type shall have a virtual destructor or the behavior is undefined.
In the second alternative (delete array) if the dynamic type of the object to be deleted differs from its static type, the behavior is undefined.
第二点也就是上面所提到的问题。关于第一点。也是论坛上经常讨论的,也就是说delete 基类指针(在指针没有偏离的情况下) 会不会造成内存泄漏的问题,上面说到如果此时基类析构函数为虚函数,那么是不会内存泄漏的,如果不是则行为未定义。
如下所示:
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 |
#include<iostream>
using namespace std; class B { int b; public: virtual ~B() { cout << "B::~B()" << endl; } }; class D: public B { int i; public: virtual ~D() { cout << "D::~D()" << endl; } }; int main(void) { cout << "sizeB:" << sizeof(B) << " sizeD:" << sizeof(D) << endl; D *pd = new D; B *pb = pd; cout << (void *)pb << endl; cout << (void *)pd << endl; delete pb; return 0; } |
现在B与D大小不一致,delete pb; 此时pb 没有偏移,在linux g++ 下通过valgrind (valgrind --leak-check=full ./test )检测,并没有内存泄漏,基类和派生类的析构函数也正常被调用。
如果将B 的析构函数virtual 关键字去掉,那么B与D大小不一致,而且此时pb 已经偏移,delete pb; 先调用~B(),然后free 出错,如
*** glibc detected *** ./test: free(): invalid pointer: 0x09d0000c *** ,参照前面讲过的例子。
如果将B和D 的virtual 都去掉,B与D大小不一致,此时pb 没有偏移,delete pb; 只调用~B(),但用varlgrind 检测也没有内存泄漏,实际上如上所说,这种情况是未定义的,但可以肯定的是没有调用~D(),如果在~D() 内有释放内存资源的操作,那么一定是存在内存泄漏的。
参考:
《高质量程序设计指南C++/C语言》
http://coolshell.cn/articles/9543.html
http://blog.csdn.net/unituniverse2/article/details/12302139
http://bbs.csdn.net/topics/370098480
“C++的数组不支持多态”?
先是在微博上看到了个微博和云风的评论,然后我回了“楼主对C的内存管理不了解”。
后来引发了很多人的讨论,大量的人又借机来黑C++,比如:
//@Baidu-ThursdayWang:这不就c++弱爆了的地方吗,需要记忆太多东西
//@编程浪子张发财:这个跟C关系真不大。不过我得验证一下,感觉真的不应该是这样的。如果基类的析构这种情况不能 调用,就太弱了。
//@程序元:现在看来,当初由于毅力不够而没有深入纠缠c++语言特性的各种犄角旮旯的坑爹细枝末节,实是幸事。为现在还沉浸于这些诡异特性并乐此不疲的同志们感到忧伤。
然后,也出现了一些乱七八糟的理解:
//@BA5BO: 数组是基于拷贝的,而多态是基于指针的,派生类赋值给基类数组只是拷贝复制了一个基类新对象,当然不需要派生类析构函数
//@编程浪子张发财:我突然理解是怎么回事了,这种情况下数组中各元素都是等长结构体,类型必须一致,的确没法多态。这跟C#和java不同。后两者对于引用类型存放的是对象指针。
等等,看来我必需要写一篇博客以正视听了。
因为没有看到上下文,我就猜测讨论的可能会是下面这两种情况之一:
1) 一个Base*[]的指针数组中,存放了一堆派生类的指针,这样,你delete [] pBase; 只是把指针数组给删除了,并没有删除指针所指向的对象。这个是最基础的C的问题。你先得for这个指针数组,把数据里的对象都delete掉,然后再删除数组。很明显,这和C++没有什么关系。
2)第二种可能是:Base *pBase = new Derived[n] 这样的情况。这种情况下,delete[] pBase 明显不会调用虚析构函数(当然,这并不一定,我后面会说) ,这就是上面云风回的微博。对此,我觉得如果是这个样子,这个程序员完全没有搞懂C语言中的指针和数组是怎么一回事,也没有搞清楚, 什么是对象,什么是对象的指针和引用,这完全就是C语言没有学好。
后来,在看到了 @GeniusVczh 的原文 《如何设计一门语言(一)——什么是坑(a)》最后时,才知道了说的是第二种情况。也就是下面的这个示例(我加了虚的析构函数这样方便编译):
class
Base
{
public
:
virtual
~B(){ cout <<
"B::~B()"
<<endl; }
};
class
Derived :
public
Base
{
public
:
virtual
~D() { cout <<
"D::D~()"
<<endl; }
};
Base* pBase =
new
Derived[10];
delete
[] pBase;
C语言补课
我先不说这段C++的程序在什么情况下能正确调用派生类的析构函数,我还是先来说说C语言,这样我在后面说这段代码时你就明白了。
对于上面的:
Base* pBase =
new
Derived[10];
这个语言和下面的有什么不同吗?
Derived d[10];
Base* pBase = d;
一个是堆内存动态分配,一个是栈内存静态分配。只是内存的位置和类型不一样,在语法和使用上没有什么不一样的。(如果你把Base 和 Derived想成struct,把new想成malloc() ,你还觉得这和C++有什么关系吗?)
那么,你觉得pBase这个指针是指向对象的,是对象的引用,还是指向一个数组的,是数组的引用?
于是乎,你可以想像一下下面的场景:
int
*pInt;
char
* pChar;
pInt = (
int
*)
malloc
(10*
sizeof
(
int
));
pChar = (
char
*)pInt;
对上面的pInt和pChar指针来说,pInt[3]和pChar[3]所指向的内容是否一样呢?当然不一样,因为int是4个字节,char是1个字节,步长不一样,所以当然不一样。
那么再回到那个把Derived[]数组的指针转成Base类型的指针pBase,那么pBase[3]是否会指向正确的Derrived[3]呢?
我们来看个纯C语言的例程,下面有两个结构体,就像继承一样,我还别有用心地加了一个void *vptr,好像虚函数表一样:
struct
A {
void
*vptr;
int
i;
};
struct
B{
void
*vptr;
int
i;
char
c;
int
j;
}b[2] ={
{(
void
*)0x01, 100,
'a'
, -1},
{(
void
*)0x02, 200,
'A'
, -2}
};
注意:我用的是G++编译的,在64bits平台上编译的,其中的sizeof(void*)的值是8。
我们看一下栈上内存分配:
struct
A *pa1 = (
struct
A*)(b);
用gdb我们可以看到下面的情况:(pa1[1]的成员的值完全乱掉了)
(
gdb
) p b
$7 = {{vptr = 0x1, i = 100, c = 97
'a'
, j = -1}, {vptr = 0x2, i = 200, c = 65
'A'
, j = -2}}
(
gdb
) p pa1[0]
$8 = {vptr = 0x1, i = 100}
(
gdb
) p pa1[1]
$9 = {vptr = 0x7fffffffffff, i = 2}
我们再来看一下堆上的情况:(我们动态了struct B [2],然后转成struct A *,然后对其成员操作)
struct
A *pa = (
struct
A*)
malloc
(2*
sizeof
(
struct
B));
struct
B *pb = (
struct
B*)pa;
pa[0].vptr = (
void
*) 0x01;
pa[1].vptr = (
void
*) 0x02;
pa[0].i = 100;
pa[1].i = 200;
用gdb来查看一下变量,我们可以看到下面的情况:(pa没问题,但是pb[1]的内存乱掉了)
(
gdb
) p pa[0]
$1 = {vptr = 0x1, i = 100}
(
gdb
) p pa[1]
$2 = {vptr = 0x2, i = 200}
(
gdb
) p pb[0]
$3 = {vptr = 0x1, i = 100, c = 0
'\000'
, j = 2}
(
gdb
) p pb[1]
$4 = {vptr = 0xc8, i = 0, c = 0
'\000'
, j = 0}
可见,这完全就是C语言里乱转型造成了内存的混乱,这和C++一点关系都没有。而且,C++的任何一本书都说过,父类对象和子类对象的转型会带来严重的内存问题。
但是,如果在64bits平台下,如果把我们的structB改一下,改成如下(把struct B中的int j给注释掉):
struct
A {
void
*vptr;
int
i;
};
struct
B{
void
*vptr;
int
i;
char
c;
//int j; <---注释掉int j
}b[2] ={
{(
void
*)0x01, 100,
'a'
},
{(
void
*)0x02, 200,
'A'
}
};
你就会发现,上面的内存混乱的问题都没有了,因为struct A和struct B的size是一样的:
(
gdb
) p sizeof(struct A)
$6 = 16
(
gdb
) p sizeof(struct B)
$7 = 16
注:如果不注释int j,那么sizeof(struct B)的值是24。
这就是C语言中的内存对齐,内存对齐的原因就是为了更快的存取内存(详见《深入理解C语言》)
如果内存对齐了,而且struct A中的成员的顺序在struct B中是一样的而且在最前面话,那么就没有问题。
再来看C++的程序
如果你看过我5年前写的《C++虚函数表解析》以及《C++内存对象布局 上篇、下篇》,你就知道C++的标准会把虚函数表的指针放在类实例的最前面,你也就知道为什么我别有用心地在struct A和struct B前加了一个 void *vptr。C++之所以要加在最前面就是为了转型后,不会找不到虚表了。
好了,到这里,我们再来看C++,看下面的代码:
#include
using
namespace
std;
class
B
{
int
b;
public
:
virtual
~B(){ cout <<
"B::~B()"
<<endl; }
};
class
D:
public
B
{
int
i;
public
:
virtual
~D() { cout <<
"D::~D()"
<<endl; }
};
int
main(
void
)
{
cout <<
"sizeB:"
<<
sizeof
(B) <<
" sizeD:"
<<
sizeof
(D) <<endl;
B *pb =
new
D[2];
delete
[] pb;
return
0;
}
上面的代码可以正确执行,包括调用子类的虚函数!因为内存对齐了。在我的64bits的CentOS上——sizeof(B):16 ,sizeof(D):16
但是,如果你在class D中再加一个int成员的问题,这个程序就Segmentation fault了。因为—— sizeof(B):16 ,sizeof(D):24。pb[1]的虚表找到了一个错误的内存上,内存乱掉了。
再注:我在Visual Studio 2010上做了一下测试,对于 struct 来说,其表现和gcc的是一样的,但对于class的代码来说,其可以“正确调用到虚函数”无论父类和子类有没有一样的size。
然而,在C++的标准中,下面这样的用法是undefined! 你可以看看*上的相关问题讨论:《Why is it undefined behavior to delete[] an array of derived objects via a base pointer?》(同样,你也可以看看《More Effective C++》中的条款三)
Base* pBase =
new
Derived[10];
delete
[] pBase;
所以,微软C++编程译器define这个事让我非常不解,对微软的C++编译器再度失望,看似默默地把其编译对了很漂亮,实则误导了好多人把这种undefined的东西当成defined来用,还赞扬做得好,真是令人无语。(就像微博上的这个贴一样,说VC多么牛,还说这是OO的特性。我勒个去!)
现在,你终于知道Base* pBase = new Derived[10];这个问题是C语言的转型的问题,你也应该知道用于数组的指针是怎么回事了吧?这是一个很奇葩的代码!请你不要像那些人一样在微博上和这里的评论里高呼并和我理论到:“微软的C++编译器支持这个事!”。
最后,我越来越发现,很多说C++难用的人,其实是不懂C语言。
关于通过不含虚析构函数的基类类型的指针删除派生类对象的问题
如题。问这问题时先基于一个前提条件:析构函数不含释放其他资源的代码,甚至可以是空函数,甚至甚至都可以干脆的不写。这种情况下是否仍有任何问题。
这个问题的结论是 会导致未定义的行为(但不是内存泄漏那么简单)。具体如何就看编译器的实现了。
我们常用的编译器,如vc、gcc等都是用的尾部追加成员的方式实现的继承(前置基类的实现方式)。这样的话在最好的情况下,可以做到对于同一个对象,整个类 和 其中的基类部分 共享一个内存起始地址(比如单继承且类和其所有基类均无任何虚函数。而这个条件实际上经常无法满足)。也就是说取对象地址,然后转换为void *或者size_t类型再输出,同用基类指针指向这个对象,然后转换指针为void *或者size_t类型再输出,将会发现这两个地址在数值上是相等的。此时如果用delete通过基类指针删除这个对象,可以认为是直接的调用了staitc void operator delete (void *);这个操作符(因为析构函数没做删除其他资源的操作)。所以不会有任何问题。当条件不满足的时候,面临的情况则和下面一种类似。
另一些编译器(比如适用于某些嵌入式设备的编译器)却使用了先行添加本类成员的方式实现继承(或者说是尾部追加基类视图的方式)。这样即使是单继承也存在类指针数值上的改变(C++标准规定对对象取地址将始终为对应类型的首地址,这样的话如果试图取基类类型的地址,将取到的则是基类部分的首地址。而因为基类被追加到对象末端,所以就会通过在数值上增加地址来跳过派生部分)。此时如果对基类指针做delete操作,会导致很严重的后果。因为编译器从基类指针并不知道派生类是什么,所以删除操作仅能试图删除自己和自己拥有的所有基类部分。但是这个delete所使用的staitc void operator delete (void *);所传入的void*指针并不是原先new所生成的地址。这样将会导致堆内存损坏(不光是内存泄漏了)。
而如果基类中已经提供了虚析构函数(哪怕只是个空函数)就不会导致错误了,因为通过基类指针调用delete删除派生类对象的时候,delete将通过虚函数定位机制(我这里不说虚表,因为不同的编译器实现不同,有的可能根本没有虚表这种方式)找到整个对象的首地址而不仅仅是基类部分的首地址。注意即使是这种情况下,前面说的问题仍然存在,即通过一个基类指针仍然不可能知道派生类的存在,从而不可能通过形式上的类型推导直接修正指针到最终派生对象的地址。所以这种推导一定发生在运行时(运行时无法推导类型(注意这里不关RTTI的事),而是通过推导逻辑实现)。说的简单些,当一个类的析构函数为虚函数时,通过这种类型的指针删除任一个此类的派生类对象的时候,逻辑上将等同于直接通过最末端派生类的指针删除这个对象(实现的时候多了一个指针重定位动作。但 运行时 开销极小)
即使在继承树中的各类视图基址不共享的情况下,一般的类型转换(只要你不是将void *指针强制转换成类指针)却并不成问题(但是比前置型多出一定的运行时开销,包括按偏移量移动指针和对NULL地址的特殊处理)。对于p到pBase的隐式指针转换,编译器完全可以偷偷的将地址直接换掉(因为编译器完整的知道源类型和目标类型)。对于将pBase强制转换为p,编译器则通过开发者提供的目标类型获取转换所需的步骤。这种转换和delete删除操作是不一样的,因为仅一句delete pBase中并不包含任何p指向的对象类型的信息。
始终需要强调的仍然是:不要写出依赖于编译器实现的代码。绝不能依赖于未定义的行为
最后再说说题外话,为什么有编译器要设计成基类后置型的?因为一些小系统对指针的位宽比较敏感(比如可以参考8086汇编,里面的跳转,不同位宽的偏移量寻址指令速度差异巨大)。让基类视图后置可以做到本类数据成员更靠前从而地址相对于类型基址的偏移量较小,从而加快访问速度。还有些机器的偏移量寻址寄存器的位宽设计本身就比较小,不支持直接跨越较大的地址范围。
[C++][经典探讨]类继承中,通过基类指针delete释放,是否会造成内存泄漏
[序言]
很久不写C/C++技术贴了,算一下自己用C++也有7~8年了,虽然现在用Delphi比较多,但是对C++还是有一份热情.前段时间在CSDN看到一个帖子, 很多人都没有引用权威文献来针对这个问题进行讨论,如果没有全文文献的引用,那么讨论将会是一个持久战.要结束这种情况,还是以书为准。如果大家都喜欢探讨技术,可以加入我的QQ:643439947一起学习
[建议]
C++是一门非常重要语言且博大精深.没有10年的使用时间和大量C++的书籍阅读,最好不要轻易去探讨C++某些特性,不然真的是那着石头砸自己的脚.就因为这些原因本人也很少在CSDN解答C++的问题,因为C++实在太多细节要注意了,知道得越多,越觉得自己是C++菜鸟.我很害怕的回答是错误的.
[感谢]
曾半仙, 简约而不简单 这些热心网友提出建议性
[适用范围]
本问题所涉及的知识点太多和范围太广,我特定归类为windows桌面系统. 如果突然有人牵扯到嵌入式系统以及嵌入式编译器,那就真的没完没了.下面是一个牛人看了文件给的思路和范围,可想而知太多不可预测的因素了."你考虑一下嵌入开发环境, 虽然语法上支持, 但是库并没有实现new和delete, 这样就引发了不确定因素, 特别是程序员喜欢模版, 喜欢优化, 想使用内存池的情况 "
[原则]
本人是中立人士,不针对任何人,只针对问题.在分析这个问题我又复习了一边C++.这个问题牵涉到 析构函数 虚函数 构造函数 派生 new/delete 5个主要问题.本着学术认真的态度,我翻阅了如下C++书籍
1> C++ Primer Plus
2> C++编程思想 2卷合订本 新版
3> Effective C++
4> Imperfect C++
[引发问题的CSDN链接]
http://topic.csdn.net/u/20110715/15/7ca1e66b-8a04-4c90-80f0-6265ff0269af.html?91968
[还原问题]
class A
{
public:
A(){} ;
~A() {} ; // Ooops must use virtual ~A()
} ;
class B : public A
{
public:
B(){} ;
~B() {} ;
} ;
int main()
{
A *pclass_A = new B ; // 创建一个B对象指针 隐性转换为 A*
// 这里我们需要注意这个转换涉及到一个概念叫: Upcast 中文翻译叫:向上类型转换
delete pclass_A ;
pclass_A= NULL ;
return 0;
}
[分析结论]
就这段代码本身而言我看了4本书也没有很明确的说到这样的写法就会有泄漏.但可以确定这样的写法是一个隐性错误,已违反C++的继承规则和违背继承的实现原理机制.
详细请看:http://www.parashift.com/c++-faq-lite/virtual-functions.html#faq-20.7
"....不把析构函数设为虚函数是一个隐性的错误,因为它常常不会对程序有直接影响。但要注意它不知不觉得引入存储器泄漏(关闭程序是内存为释放)。同样,这样的析构操作还有可能掩盖发生的问题...."(摘自: C++编程思想 2卷合订本 第387页)。这句话虽然很短,但是解答了我们很多疑问.
1> “如果你不使用虚析构函数,不会对程序有直接影响”.这里的“不会对程序有直接影响”,我们可以认为delete一个基类指针(基类是没有析构函数),不会照成内存泄漏(仅针对上面的代码而言,如果在派生类中有分配堆,那么肯定会有内存泄漏).
这里为什么我们可以认为delete一个基类指针(基类是没有析构函数),不会照成内存泄漏呢?这就是C++的new 和 delete 的特有机制和职责了.下面看这句话:
"....当在堆栈里主动创建对象时,对象的大小和它们的声明周期被准确地内置在生成的代码里,这是因为编译器知道确切的类型,数量和范围....."(摘自: C++编程思想 2卷合订本 第318页的)这里非常明确的告诉我们,会知道确切的"类型,数量和范围",注意这里有"范围",因此可以推断通过基类指针进行delete,是不会对“不会对程序有直接影响”(备注:请谅解,我没敢直接说不会有内存泄漏,因为我没有能跟编译器厂商求证,但我认为是"应该"不会造成内存泄漏).
2>"但要注意它不知不觉地引入存储器泄漏"这句话又针对前句话做了补充,特别强调了"不知不觉地"+"引入"+"存储器泄漏".很明显的说明了,如果会发生泄漏,那就是外部人为造成的,比如的B类内部中使用了new操作,比如申请10个字节char *char_A = new char[10],那么根据“C++的继承规则和继承的实现原理机制”如果你不把基类的析构函数声明并定义为virtual,那么B类在释放的时候,没法做尾场清理的.比如前面的 new char[10]不能被释放.
额外讨论: 在类继承机制中,构造函数和析构函数具有一种特别机制叫 “层链式调用通知”,这个机制原理是建立在 “vpointer” “VPTR” “VTABLE”这种东西(摘自: C++编程思想 2卷合订本 第369页)(备注:层链式调用通知是我个人理解并总结的词汇.大家可以通过阅读 C++编程思想 2卷合订本 第385页).
流程是这样:在构造一个有类继承机制的类,比如上面的类B,那么会先调用A类的构造,A构造完成之后在调用B类的构造函数,达到"由里向外"通知调用的效果.那么释放一个有类继承机制的类,那么会调用B类的析构函数, 再调用A类的析构函数,达到"由外向里"通知通知的效果,那么为了达到这个这种“层链式调用通知”的效果,C++标准规定:基类的析构函数必须声明为virtual, 如果你不声明,那么"层链式调用通知"这样的机制是没法构建起来.从而就导致了基类的析构函数被调用了,而派生类的析构函数没有调用这个问题发生.但这里要特别注意:这种特殊情况下派生类的析构函数没有被调用,有2中情况发生:
1>如果你的派生类内部没有分配任何堆,而只是单一的局部变量,那么根据局部变量和类的生命周期理论,他们是会被释放的,“不会对程序有直接影响”(备注:请谅解,我没敢直接说不会有内存泄漏,因为我没有能跟编译器厂商求证,但我因为是"应该"不会造成内存泄漏),比如本文顶部列举的代码片段.
2>如果你的派生类内部有分配堆,那么派生类就没法通过自身的析构函数进行尾场清理了,比如 delete []a ;
[结尾]
写这个文章花费了我1个小时,但在写之前,花费了我2个小时去翻阅4本C++书籍重新去消化这个经典问题.
[查阅资料]
1> C++ Primer Plus 里面的 第13章 类继承
2> C++编程思想 2卷合订本 新版 里面的 第13章 动态对象创建 第14章 继承和组合 第15章 多态性和虚函数