当多态遇上数组 ... [C++] (Rewritten)
当多态遇上数组 ... [C++] (Rewritten)
When Polymorphism Meets Arrays ... [C++] (Rewritten)
Rewriten on Thursday, March 31, 2005
Written by Allen Lee
1. 问答时间
第一题:实现多态的效果,我们需要具备哪些条件?
第二题:你认为以下代码是否有问题?
#include <iostream>
class A
{
public:
virtual void Print()
{
std::cout << "A.Print();" << std::endl;
}
};
class B : public A
{
public:
virtual void Print()
{
std::cout << "B.Print();" << std::endl;
}
};
void Print(A arr[], int count)
{
for (int i = 0; i < count; ++i)
{
arr[i].Print();
}
}
int main()
{
const int COUNT = 3;
A a_arr[COUNT];
Print(a_arr, COUNT);
B b_arr[COUNT];
Print(b_arr, COUNT);
return 0;
}
请你先自行思考一下上面两个问题。
2. 隐藏炸弹惊现!
Code #01能够正常编译并运行,而且程序输出也是我们所期望的。但请别过早开心,因为它里面隐藏着一个炸弹,只要条件满足就会引爆。是的,我是说“只要条件满足”,也就是现在条件还不满足。请再回顾Code #01,有没有觉得代码中的继承体系实在有点过分简单?好吧,我也不想卖关子了,现在就由我来触发里面所隐藏的炸弹。
class A
{
public:
A()
{
cout << "A.A();" << endl;
}
virtual ~A()
{
cout << "A.~A();" << endl;
}
virtual void Print()
{
cout << "A.Print();" << endl;
}
};
class B : public A
{
public:
B()
: m_Data(299792458)
{
cout << "B.B();" << endl;
}
virtual ~B()
{
cout << "B.~B();" << endl;
}
virtual void Print()
{
cout << "B.m_Data = " << m_Data << endl;
}
private:
long m_Data;
};
你能够看出Code #02和Code #01的这两个类有什么实质的不同吗?好吧,把Code #02的两个类替换Code #01的两个类,然后编译并运行你的程序,看看你有什么发现。我料到有些读者却是懒惰,所以把运行结果截图贴了一下:
请留意命令行界面输出结果,你认为程序中止那刻究竟发生了什么事呢?
3. 引发爆炸的微妙
从输出结果的截图中,你将不难看出,程序于中止时正尝试打印b_arr[1]的m_Data,但又因为某些原因无法在内存中进行定位,于是就向我发脾气了。如果你对继承机制有一定的了解,你也将能够看出此时a_arr的3个A对象和b_arr的3个B对象已经构造完毕。
然而,为什么程序无法对b_arr[1]定位呢?答案就在以下代码中(位于Code #01中):
void Print(A arr[], int count)
{
for (int i = 0; i < count; ++i)
{
arr[i].Print();
}
}
我们知道,arr是个指针,那么你认为从arr所指的内存到arr+i所指的内存,指针要走多远呢?从Code #03的Print();中我们可以看出,这个距离(表面上)是i*sizeof(A)。
为什么说“表面上”呢?因为(Code #03的)Print();给我(们)的感觉是客户端会向其传递一个包含A的对象实例的数组,但如果存放在该数组里面的是A的派生类的对象实例呢?
当我们把b_arr传递给(Code #03的)Print()时,arr到arr+i的实际距离应该是i*sizeof(B)。对于Code #01,sizeof(A)和sizeof(B)是一样的,但对于Code #02,sizeof(A)就比sizeof(B)小了。当程序执行到Print(b_arr, COUNT);时,arr+1就指向了有问题的位置,你可以想象得到,实际上它指向本应指向的位置的前面。
扩展阅读:
《C++ Primer 中文版(第三版)》的“3.9.2 数组与指针”一节详细讲解了数组与指针之间的关系。[1]
《More Effective C++ 中文版》的《条款3:绝对不要以多态方式处理数组》一节详细剖析了该炸弹的机理。[2]
4. 尝试拆卸炸弹
现在我们知道这个炸弹存在的根源是,无法正确预知传递给(Code #03的)Print();的数组里所存放的对象的实际大小。(尽管我加了个逗号,但这句话读起来还是很考肺活量!)
问题男提出把指针放进数组,好吧,我们现在来修改一下Code #01的Print();和main();:
// See Code #02 for class A and class B.
void Print(A* arr[], int count)
{
for (int i = 0; i < count; ++i)
{
arr[i]->Print();
}
}
int main()
{
const int COUNT = 3;
A* a_arr[COUNT];
B* b_arr[COUNT];
for (int i = 0; i < COUNT; ++i)
{
a_arr[i] = new A;
b_arr[i] = new B;
}
Print(a_arr, COUNT);
Print(reinterpret_cast< A** >(b_arr), COUNT);
return 0;
}
现在,把Code #02和Code #04合并起来,编译并运行后,程序的输出如下图所示:
从输出结果中,我们看到了预期多态的效果,然而,你认为到目前为止,我们的代码(合并Code #02和Code #04)还有没有别的问题?
5. 拆弹改进 #01
“不会吧?还有什么问题?”我相信有人会这样惊讶的。现在你回顾Code #02和Picture #02看看我们还有什么应该做的却又被漏掉了?
“析构函数没有被执行!”有人看出了。没错!别忘记a_arr和b_arr这两个数组里面的对象实例是使用new制造出来的,似乎我们还没有把这些对象实例所占用的资源还给系统,因此,我们的代码造成了内存泄漏!
发现这点很好,接下来我们要写一个清理垃圾并归还资源的函数:
void Destroy(A* arr[], int count)
{
for (int i = 0; i < count; ++i)
{
delete arr[i];
}
}
这样,对象实例就被正常析构并把所占资源归还给系统了:
到目前为止,我们的代码(合并Code #02、Code #05和Code #04)的输出就是Picture #02和Picture #03两幅截图的合并(Picture #02在上面,Picture #03在下面)。
到目前为止一切都已经很好,不过不知道你又没有发现我们的代码(合并Code #02、Code #05和Code #04)存在着一些局限性呢?
6. 拆弹改进 #02
“开什么玩笑?还要怎么改进你才满意?”别这样呀,我并没有开玩笑,我是认真的。请想一下一下这种数组声明有什么局限性?
A* a_arr[COUNT];
“噢,是COUNT的确定时期!”很好,终于有人发现。没错,COUNT必须在编译期被确定下来,那么如果我们必须到运行时期才能确定COUNT呢?想象一下COUNT是某函数的客户端透过参数传递过来的:
{
// I want to create arrays here
// using the value of para count!
}
这样,我们只需用new来动态制造数组就行了:
{
A** a_arr = new A*[count];
B** b_arr = new B*[count];
// Anything else here
}
然而,这样一来,我们就需要修改一下Code #05的Destroy();了:
void Destroy(A* arr[], int count)
{
for (int i = 0; i < count; ++i)
{
delete arr[i];
}
delete[] arr;
}
我相信坚持到这里的你绝对不会不明白为何我要这样修改Destroy();的 ^_^ 。嗯,现在,你再检查一下我们的代码,看看我们是否还漏了些什么。
7. 进一步测试
呵呵,先别晕,再坚持一下,好吗?你有没有发觉一路来,每一个数组里面所存放的对象实例都属于同一类类型的,但你知道现实中的你不一定那么好运的,现在我把main()修改一下:
// See Code #02 for class A and class B.
// See Code #04 for function Print();
// See Code #06 for function Destroy();
int main()
{
const int COUNT = 3;
A** arr = new A*[COUNT];
for (int i = 0; i < COUNT; ++i)
{
if ((i % 2) == 0)
{
arr[i] = new A;
}
else
{
arr[i] = new B;
}
}
Print(arr, COUNT);
Destroy(arr, COUNT);
return 0;
}
好了,一切就绪,编译并运行一下,看看有什么结果。
看来,一切都如我们所期望的发展,很好!
8. 进一步思考
现在,本文开篇的第二题似乎被解决了,是吗?真的吗?我认为现实并非我们想的如此简单,你能想出当处于一个真实的环境中我们还需要注意一些什么吗?现实中,class A和class B将包含更多的细节,更复杂,如果程序没有正常归还资源,那么后果将不堪设想。试想一下,如果程序在没有完整构造和/或析构对象的情况下,突然抛出异常导致自身中止会怎么样?
扩展阅读:
《More Effective C++ 中文版》的《条款9:利用destructors避免泄漏资源》、《条款10:在constructors内阻止资源泄漏》和《条款11:禁止异常流出destructors之外》详细的讲解了我们将要考虑的这方面的异常处理问题。[2]
那么,本文开篇的第一题呢?呵呵,我并没有打算在这里正式作答,给出这道题主要是让你(读者)检查一下自己是否具备阅读本文的必要条件(如果你对本题毫无头绪,那么阅读本文将是一项罪过!)。当然,对于Code #02,我认为有两点需要注意的:
a) 如果你要建立继承体系,你应该分清C++的私有继承、保护继承和公有继承之间的区别,哪一种是用来实现多态效果的?什么情况下哪一种更适用?什么情况下我们应该(或不应该)使用继承?
扩展阅读:
《Exceptional C++ 中文版》的《第24条:继承的使用和滥用》一节详细的讲解了使用继承应该注意的事宜。[3]
b) 继承体系中的类的析构函数应该被声明为virtual,否则,(Code #06的)Destroy();将仅仅调用基类(于本文的例子是class A)的析构函数。这样,如果派生类的析构函数进行了一些重要资源的清理和回收,那么将无可避免地被忽略,从而造成资源泄漏。
9. 写在后面的话
自从我Post了本文的第一个版本后,收到了很多关于其错漏的反馈,我也为那篇不够水平的烂文深感抱歉。为了补偿过失,我决定重写本文。这次我尽了最大的努力去收集和试验相关的材料,并写成本文。当然,如果你在阅读的过程中发现(任何)问题,包括不解与错漏,请务必指出,我会尽力改进文章的质量的。
See also:
- Allen Lee,《今天你多态了吗?》
- [1] Stanley B Lippman, Josee Lajoie 著;潘爱民 张丽 译;《C++ Primer 中文版(第三版)》;中国电力出版社,2002
- [2] Scott Meyers 著;侯 捷 译;《More Effective C++中文版》;中国电力出版社,2003
- [3] Herb Sutter 著;卓小涛 译;《Exceptional C++ 中文版》;中国电力出版社,2003