C++ 是一种扭转程序员思维模式的语言。
一个人思维模式的扭转,不可能轻而易举一蹴而成。
第二章项目目录:
mfc11: 继承
mfc22: 虚函数表,对象在内存中的存储。
mfc23:对象类型的强制转换、vtable、虚函数
mfc24: constructor
mfc25: 执行时期类别信息RTTI
mfc26: template
迩来「对象导向」即面向对象,一词席卷了整个软件界。对象导向程序设计(Object Oriented Programming)其实是一种观念,用什么语言实现它都可以。但,当然,对象导向程序语言(Object Oriented Programming Language )是专门为对象导向观念而发展出来的,以之完成对象导向的封装、继承、多态等特性自是最为便利。
C++ 是最重要的对象导向语言,因为它站在C语言的肩膀上,而C 语言拥有绝对优势的使用者。C++ 并非纯然的对象导向程序语言,不过有时候混血并不是坏事,纯种也不见得就多好。(所谓纯对象导向语言,是指不管什么东西,都应该存在于对象之中。JAVA和Small Talk都是纯对象导向语言)
如果你是C++ 的初学者,本章不适合你(事实上整本书都不适合你),你的当务之急是去买一本C++专书。一位专精Basic和Assembly语言的朋友问我,有没有可能不会C++而学会MFC?答案是当然没有可能。
如果你对C++一知半解,语法大约都懂了,语意大约都不懂,本章是我能够给你的最好礼物。我将从类别与对象的关系开始,逐步解释封装、继承、多态、虚拟函数、动态绑定。不只解释其操作方式,更要点出其意义与应用,也就是,为什么需要这些性质。
C++语言范围何其广大,这一章的主题挑选完全是以MFC Programming 所需技术为前提。下一章,我们就把这里学到的C++技术和OO观念应用到application framework的仿真上,那是一个DOS程序,不牵扯Windows。
让我们把世界看成是一个由对象(object)所组成的大环境。对象是什么?白一点说,「东西」是也!任何实际的物体你都可以说它是对象。为了描述对象,我们应该先把对象的属性描述出来。好,给「对象的属性」一个比较学术的名词,就是「类别」(class )。对象的属性有两大成员,一是资料,一是行为。在对象导向的术语中,前者常被称为property(Java语言则称之为field ),后者常被称为method。另有一双比较像程序设计领域的术语,名为member variable(或data member )和member function。为求统一,本书使用第二组术语,也就是member variable(成员变量)和member function(成员函数)。一般而言,成员变量通常由成员函数处理之。
如果我以CSquare代表「四方形」这种类别,四方形有color ,四方形可以display。好,color就是一种成员变量,display就是一种成员函数:
CSquare square; // 声明square 是一个四方形。
square.color = RED; // 设定成员变量。RED 代表一个颜色值。
square.display(); // 调用成员函数。
class CSquare // 常常我们以C作为类别名称的开头
{
private:
int m_color; // 通常我们以m _ 作为成员变量的名称开头
public:
void display() { ... }
void setcolor(int color) { m_color = color; }
};
把资料声明为private,不允许外界随意存取,只能透过特定的接口来操作,这就是对象导向的封装(encapsulation)特性。
其它语言欲完成封装性质,并不太难。以C为例,在结构(struct)之中放置资料,以及处理资料的函数的指针(function pointer ),就可得到某种程度的封装精神。
C++神秘而特有的性质其实在于继承。
矩形是形,椭圆形是形,三角形也是形。苍蝇是昆虫,蜜蜂是昆虫,蚂蚁也是昆虫。是的,人类习惯把相同的性质抽取出来,成立一个基础类别(base class ),再从中衍化出衍生类别(de ri ve d clas s )。所以,关于形状,我们就有了这样的类别阶层:
注意:衍生类别与基础类别的关系是“Is Kind Of” 的关系。也就是说,Circle「是一种」Ellipse,Ellipse「是一种」Shape;Square「是一种」Rectangle,Rectangle「是一种」Shape。
class CShape // 形状
{
private:
int m_color;
public:
void setcolor(int color) { m_color = color; }
};
class CRect : public CShape // 矩形是一种形状
{ // 它会继承 m_color 和setcolor()
public:
void display() { ... }
};
class CEllipse : public CShape // 椭圆形是一种形状
{ // 它会继承 m_color 和setcolor()
public:
void display() { ... }
};
class CTriangle : public CShape // 三角形是一种形状
{ // 它会继承 m_color 和setcolor()
public:
void display() { ... }
};
class CSquare : public CRect // 四方形是一种矩形
public:
void display() { ... }
};
class CCircle : public CEllipse // 圆形是一种椭圆形
{
public:
void display() { ... }
};
CSquare square;
CRect rect1, rect2;
CCircle circle;
square.setcolor(1); // 令 square.m_color = 1;
square.display(); // 调用CSquare::display
rect1.setcolor(2); // 于是rect1.m_color = 2
rect1.display(); // 调用CRect::display
rect2.setcolor(3); // 于是rect2.m_color = 3
rect2.display(); // 调用CRect::display
circle.setcolor(4); // 于是circle.m_color = 4
circle.display(); // 调用CCircle::display
1 . 所有类别都由CShape衍生下来,所以它们都自然而然继承了CShape的成员,包括变量和函数。也就是说,所有的形状类别都「暗自」具备了m _color变量和setcolor函数。我所谓暗自(implicit),意思是无法从各衍生类别的声明中直接看出来。
2 . 两个矩形对象rect1和rect2各有自己的m _color ,但关于setcolor函数却是共享相同的CRect::setcolor(其实更应该说是CShape::setcolor )。我用这张图表示其间的关系:
让我替你问一个问题:同一个函数如何处理不同的资料?为什么rect1 . setcolor和rect2 . setcolor明明都是调用CRect::s etcolor (其实也就是CShape::setcolor ),却能够有条不紊地分别处理rect1.m _color和rect2. m _color?答案在于所谓的this指针。下一节我就会提到它。
3 . 既然所有类别都有display动作,把它提升到老祖宗CShape去,然后再继承之,好吗?不好,因为display函数应该因不同的形状而动作不同。
4 . 如果display不能提升到基础类别去,我们就不能够以一个for循环或while循环干净漂亮地完成下列动作(此种动作模式在对象导向程序方法中重要无比):
CShape shapes[5];
... // 令5 个shapes 各为矩形、四方形、椭圆形、圆形、三角形
for (int i=0; i<5; i++)
{
shapes[i].display;
}
CShape shape; // 世界上没有「形状」这种东西,
shape.setcolor(); // 所以这个动作就有点奇怪。
如果语法允许你产生一个不应该有的抽象对象,或如果语法不支持「把所有形状(不管什么形状)都display出来」的一般化动作,这就是个失败的语言。C++ 是成功的,自然有它的整治方式。
记住,「对象导向」观念是描绘现实世界用的。所以,你可以以真实生活中的经验去思考程序设计的逻辑。
刚刚我才说过,两个矩形对象rect1和rect2各有自己的m _color成员变量,但rect1.setcolor和rect2.setcolor 却都通往唯一的CRect::setcoor成员函数。那么CRect::setcolor如何处理不同对象中的m _color?答案是:成员函数有一个隐藏参数,名为this指针。当你调用:
rect1.setcolor(2); // rect1 是CRect 对象
rect2.setcolor(3); // rect2 是CRect 对象
编译器实际上为你做出来的码是:
CRect::setcolor(2, (CRect*)&rect1 );
CRect::setcolor(3, (CRect*)&rect2 );
多出来的参数,就是所谓的this指针。至于类别之中,成员函数的定义:
class CShape { ... public: void setcolor(int color) { m_color = color; } };
class CShape { ... public: void setcolor(int color , (CShape*)this ) { this-> m_color = color; } };
我曾经说过,前一个例子没有办法完成这样的动作:
CShape shapes[5];
... // 令5 个shapes 各为矩形、四方形、椭圆形、圆形、三角形
for (int i=0; i<5; i++)
{
shapes[i].display;
}
为了支持这种能力,C++提供了所谓的虚拟函数(virtual function)。
虚拟+函数?!
听起来很恐怖的样子。如果你了解汽车的离合器踩下去代表汽车空档,空档表示失去引擎本身的牵制力,你就会了解「高速行驶间煞车绝不能踩离合器」的道理并矢志遵行。好,如果你真的了解为什么需要虚拟函数以及什么情况下需要它,你就能够掌握它的灵魂与内涵,真正了解它的设计原理,并且发现认为它非常人性。并且,真正知道怎么用它。
让我用另一个例子来展开我的说明。这个范例灵感得自Visual C++ 手册之一:
Introdoction to C++ 。假设你的类别种类如下:
代码工程见mfc21
#include <string.h> //--------------------------------------- class CEmployee //职员 { private: char m_name[30]; public: CEmployee(); CEmployee(const char* nm) { strcpy(m_name, nm); } }; //--------------------------------------- class CWage : public CEmployee //时薪职员 { private : float m_wage; float m_hours; public : CWage(const char* nm) : CEmployee(nm) { m_wage = 250.0; m_hours = 40.0; } void setWage(float wg) { m_wage = wg; } void setHours(float hrs) { m_hours = hrs; } float computePay(); }; //--------------------------------------- class CSales : public CWage //销售员是一种时薪职员 { private : float m_comm; float m_sale; public : CSales(const char* nm) : CWage(nm) { m_comm = 500; m_sale = 0.0; } void setCommission(float comm) { m_comm = comm; } void setSales(float sale) { m_sale = sale; } float computePay(); }; //--------------------------------------- class CManager : public CEmployee //经理也是一种职员 { private : float m_salary; public : CManager(const char* nm) : CEmployee(nm) { m_salary = 15000.0; } void setSalary(float salary) { m_salary = salary; } float computePay(); }; //--------------------------------------- void main() { CManager aManager("陈美静"); CSales aSales("侯俊杰"); CWage aWager("曾铭源"); }
虚拟函数的故事要从薪水的计算说起。根据不同职员的计薪方式,我设计computePay函数如下:
float CManager::computePay() { return m_salary; // 经理以「固定周薪」计薪。 } float CWage::computePay() { return (m_wage * m_hours); // 时薪职员以「钟点费* 每周工时」计薪。 } float CSales::computePay() { // 销售员以「钟点费* 每周工时」再加上「佣金* 销售额」计薪。 return (m_wage * m_hours + m_comm * m_sale); // 语法错误。 }
float CSales::computePay()
{
return computePay() + m_comm * m_sale;
}
这也不好,我们应该指明函数中所调用的computePay 究归谁属-- 编译器没有厉害到能
够自行判断而保证不出错。正确写法应该是:
float CSales::computePay()
{
return CWage::computePay() + m_comm * m_sale;
}
这就合乎逻辑了:销售员是一般职员的一种,他的薪水应该是以时薪职员的计薪方式作为底薪,再加上额外的销售佣金。我们看看实际情况,如果有一个销售员:
CSales aSales(" 侯俊杰");
那么侯俊杰的底薪应该是:
aSales.CWage::computePay(); // 这是销售员的底薪。注意语法。
而侯俊杰的全薪应该是:
aSales.computePay(); // 这是销售员的全薪
结论是:要调用父类别的函数,你必须使用scope resolution operator(::)明白指出。接下来我要触及对象类型的转换,这关系到指针的运用,更直接关系到为什么需要虚拟函数。了解它,对于application framework如MFC者的运用十分十分要。
假设我们有两个对象:
CWage aWager;
CSales aSales(" 侯俊杰");
销售员是时薪职员之一,因此这样做是合理的:
aWager = aSales; // 合理,销售员必定是时薪职员。
这样就不合理:
aSales = aWager; // 错误,时薪职员未必是销售员。
如果你一定要转换,必须使用指针,并且明显地做型别转换(cast)动作:
CWage* pWager;
CSales* pSales;
CSales aSales(" 侯俊杰");
pWager = &aSales; // 把一个「基础类别指针」指向衍生类别之对象,合理且自然。
pSales = (CSales *)pWager; // 强迫转型。语法上可以,但不符合现实生活。
真实世界中某些时候我们会以「一种动物」来总称猫啊、狗啊、兔子猴子等等。为了某种便利(这个便利稍后即可看到),我们也会想以「一个通用的指针」表示所有可能的职员类型。无论如何,销售员、时薪职员、经理,都是职员,所以下面动作合情合理:
CEmployee* pEmployee;
CWage aWager(" 曾铭源");
CSales aSales(" 侯俊杰");
CManager aManager(" 陈美静");
pEmpolyee = &aWager; // 合理,因为时薪职员必是职员
pEmpolyee = &aSales; // 合理,因为销售员必是职员
pEmpolyee = &aManager; // 合理,因为经理必是职员
也就是说,你可以把一个「职员指针」指向任何一种职员。
这带来的好处是程序设计的巨大弹性,譬如说你设计一个串行(linked list),各个元素都是职员(哪一种职员都可以),你的add函数可能因此希望有一个「职员指针」作为参数:
add(CEmployee* pEmp);// pEmp可以指向任何一种职员
继承关系:
我们渐渐接触问题的核心。上述C++性质使真实生活经验的确在计算机语言中仿真了出来,但是万里无云的日子里却出现了一个晴天霹雳:如果你以一个「基础类别之指针」指向一个「衍生类别之对象」,那么经由此指针,你就只能够调用基础类别(而不是衍生类别)所定义的函数。因此:
CSales aSales(" 侯俊杰");
CSales* pSales;
CWage* pWager;
pSales = &aSales;
pWager = &aSales; // 以「基础类别之指针」指向「衍生类别之对象」
pWager->setSales(800.0); // 错误,调用衍生类别之函数(编译器会检测出来),
// 因为CWage 并没有定义setSales 函数。
pSales->setSales(800.0); // 正确,调用CSales::setSales 函数。
延续此例,我们看另一种情况:
pWager->computePay(); // 调用CWage::computePay()
pSales->computePay(); // 调用CSales::computePay()
虽然pSales和pWager实际上都指向CSales对象,但是两者调用的computePay却不相同。到底调用到哪个函数,必须视指针的原始类型而定,与指针实际所指之对象无关。
总结:定义了一个基类指针,一个衍生类指针都指向衍生类。那么基类指针无法调用衍生类中的非基类成员。不管指向哪里,调用函数均为原始类型的member function(本节讨论关心的是基类指针)
我们得到了三个结论:
1 . 如果你以一个「基础类别之指针」指向「衍生类别之对象」,那么经由该指针你只能够调用基础类别所定义的函数。
2 . 如果你以一个「衍生类别之指针」指向一个「基础类别之对象」,你必须先做明显的转型动作(explicit cast)。这种作法很危险,不符合真实生活经验,在程序设计上也会带给程序员困惑。
3 . 如果基础类别和衍生类别都定义了「相同名称之成员函数」,那么透过对象指针调用成员函数时,到底调用到哪一个函数,必须视该指针的原始型别而定,而不是视指针实际所指之对象的型别而定。这与第1 点其实意义相通。
得到这些结论后,看看什么事情会困扰我们。前面我曾提到一个由职员组成的串行,如果我想写一个print Names函数走访串行中的每一个元素并印出职员的名字,我们可以在CEmployee(最基础类别)中多加一个getName函数,然后再设计一个while循环如下:
int count = 0; CEmployee* pEmp; ... while (pEmp = anlter.getNext()) { count++; cout << count << ' ' << pEmp->getName() << endl; }
你可以把anIter.getNext想象是一个可以走访串行的函数,它传回CEmPloyee*,也因此每一次获得的指针才可以调用定义于CEmployee中的getName。
由于每个继承类中都没有定义GetName(),这样使用基类指针指向成员对象调用的是继承类的GetName(),而此函数使用的是this->m_name[30]。
但是,由于函数的调用是依赖指针的原始类型而不管它实际上指向何方(何种对象),因此如果上述while循环中调用的是pEmp-> computePay ,那么while循环所执行的将总是相同的运算,也就是CEmployee::computePay ,这就糟了(销售员领到经理的薪水还不糟吗)。更糟的是,我们根本没有定义CEmployee::computePay ,因为CEmploy ee 只是个抽象概念(一个抽象类别)。指针必须落实到具象类型上如CWage或CManager或CSales ,才有薪资计算公式。
我想你可以体会,上述的while循环其实就是把动作「一般化」。「一般化」之所以重要,在于它可以把现在的、未来的情况统统纳入考量。将来即使有另一种名曰「顾问」的职员,上述计薪循环应该仍然能够正常运作。当然啦,「顾问」computePay 必须设计好。
「一般化」是如此重要,解决上述问题因此也就迫切起来。我们需要的是什么呢?是能够「依旧以CEmpolyee指针代表每一种职员」,而又能够在「实际指向不同种类之职员」时,「调用到不同版本(不同类别中)之computePay 」这种能力。
这种性质就是多态(polymorphism),靠虚拟函数来完成。再次看看那张计薪循环图:
再次看看那张计薪循环图:
■ 当pEmp指向经理,我希望pEmp-> computePay是经理的薪水计算式,也就是 CManager::computePay。
■ 当pEmp指向销售员,我希望pEmp->computePay是销售员的薪水计算式,也就是CSal es ::computePay 。
■ 当pEmp指向时薪职员,我希望pEmp ->computePay是时薪职员的薪水计算式,也就是CWage::computePay 。
虚拟函数正是为了对「如果你以一个基础类别之指针指向一个衍生类别之对象,那么透过该指针你就只能够调用基础类别所定义之成员函数」这条规则反其道而行的设计。
不必设计复杂的串行函数如add或getNext才能验证这件事,我们看看下面这个简单例子。如果我把职员一例中所有四个类别的computePay函数前面都加上virtual保留字,使它们成为虚拟函数.
...
你看,我们以相同的指令却唤起了不同的函数,这种性质称为Polymorphism,意思是" theability to assume many forms"(多态)。编译器无法在编译时期判断pEmp -> computePay到底是调用哪一个函数,必须在执行时期才能评估之,这称为后期绑定late binding 或动态绑定dynamic binding。至于C 函数或C++的non-virtual函数,在编译时期就转换为一个固定地址的调用了,这称为前期绑定early binding 或静态绑定static binding。
Polymorphism的目的,就是要让处理「基础类别之对象」的程序代码,能够完全透通地继续适当处理「衍生类别之对象」。
可以说,虚拟函数是了解多态(Polymorphism)以及动态绑定的关键。同时,它也是了解如何使用MFC的关键。
让我再次提示你,当你设计一套类别,你并不知道使用者会衍生什么新的子类别出来。如果动物世界中出现了新品种名曰雅虎,类别使用者势必在CAnimal之下衍生一个CYahoo。饶是如此,身为基础类别设计者的你,可以利用虚拟函数的特性,将所有动物必定会有的行为(例如哮叫roar),规划为虚拟函数,并且规划一些一般化动作(例如「让每一种动物发出一声哮叫」)。那么,虽然,你在设计基础类别以及这个一般化动作时,无法掌握使用者自行衍生的子类别,但只要他改写了roar 这个虚拟函数,你的一般化对象操作动作自然就可以调用到该函数。
再次回到前述的S ha pe 例子。我们说CShape是抽象的,所以它根本不该有displ ay这个动作。但为了在各具象衍生类别中绘图,我们又不得不在基础类别CShape加上display虚拟函数。你可以定义它什么也不做(空函数):
class CShape { public: virtual void display() { } };
或只是给个消息:
class CShape { public: virtual void display() { cout << "Shape \n"; } };
这两种作法都不高明,因为这个函数根本就不应该被调用(CShape 是抽象的),我们根本就不应该定义它。不定义但又必须保留一块空间(spaceholder )给它,于是C++ 提供了所谓的纯虚拟函数:
class CShape { public: virtual void display() = 0; // 注意"= 0" };
纯虚拟函数不需定义其实际动作,它的存在只是为了在衍生类别中被重新定义,只是为了提供一个多态接口。只要是拥有纯虚拟函数的类别,就是一种抽象类别,它是不能够被具象化(instantiate)的,也就是说,你不能根据它产生一个对象(你怎能说一种形状为'Shape' 的物体呢)。如果硬要强渡关山,会换来这样的编译消息:
error : illegal attempt to instantiate abstract class.
关于抽象类别,我还有一点补充。CCircle 继承了CShape 之后,如果没有改写CShape中的纯虚拟函数,那么CCircle本身也就成为一个拥有纯虚拟函数的类别,于是它也是一个抽象类别。
是对虚拟函数做结论的时候了:
■ 如果你期望衍生类别重新定义一个成员函数,那么你应该在基础类别中把此函数设为virtual。
■ 以单一指令唤起不同函数,这种性质称为Polymorphism,意思是" the ability to assume many forms ",也就是多态。
■ 虚拟函数是C++ 语言的Polymorphism性质以及动态绑定的关键。
■ 既然抽象类别中的虚拟函数不打算被调用,我们就不应该定义它,应该把它设为纯虚拟函数(在函数声明之后加上" =0" 即可)。
■ 我们可以说,拥有纯虚拟函数者为抽象类别(abstract Class ),以别于所谓的具象类别(concrete class ) 。
■ 抽象类别不能产生出对象实体,但是我们可以拥有指向抽象类别之指针,以便于操作抽象类别的各个衍生类别。
■ 虚拟函数衍生下去仍为虚拟函数,而且可以省略virtual关键词。
你一定很想知道虚拟函数是怎么做出来的,对不对?
如果能够了解C++ 编译器对于虚拟函数的实现方式,我们就能够知道为什么虚拟函数可以做到动态绑定。
为了达到动态绑定(后期绑定)的目的,C++ 编译器透过某个表格,在执行时期「间接」调用实际上欲绑定的函数(注意「间接」这个字眼)。这样的表格称为虚拟函数表(常被称为vtable)。每一个「内含虚拟函数的类别」,编译器都会为它做出一个虚拟函数表,表中的每一笔元素都指向一个虚拟函数的地址。此外,编译器当然也会为类别加上一项成员变量,是一个指向该虚拟函数表的指针(常被称为vptr)。举个例:
class Class1 {
public :
data1;
data2;
memfunc();
virtual vfunc1();
virtual vfunc2();
virtual vfunc3();
};
每一个由此类别衍生出来的对象,都有这么一个vptr。当我们透过这个对象调用虚拟函数,事实上是透过vptr找到虚拟函数表,再找出虚拟函数的真正地址。
奥妙在于这个虚拟函数表以及这种间接调用方式。虚拟函数表的内容是依据类别中的虚拟函数声明次序,一一填入函数指针。衍生类别会继承基础类别的虚拟函数表(以及所有其它可以继承的成员),当我们在衍生类别中改写虚拟函数时,虚拟函数表就受了影响:表中元素所指的函数地址将不再是基础类别的函数地址,而是衍生类别的函数地址。看看这个例子:
class Class2 : public Class1 { public : data3; memfunc(); virtual vfunc2(); };
于是,一个「指向Class1所生对象」的指针,所调用的vfunc2就是Class1::vfunc2 ,而一个「指向Class2所生对象」的指针,所调用的vfunc2就是Class2::vfunc2 。
动态绑定机制,在执行时期,根据虚拟函数表,做出了正确的选择。
我们解开了第二道神秘。
口说无凭,何不看点实际。下面是一个测试程序:
见工程mfc22
执行结果与分析如下:
我要在这里说明虚拟函数另一个极重要的行为模式。假设有三个类别,阶层关系如下:
代码见项目:mfc23
#include <iostream> using namespace std; class CObject { public: virtual void Serialize() { cout << "CObject::Serialize() \n\n"; } }; class CDocument : public CObject { public: int m_data1; void func() { cout << "CDocument::func()" << endl; Serialize(); } virtual void Serialize() { cout << "CDocument::Serialize() \n\n"; } }; class CMyDoc : public CDocument { public: int m_data2; virtual void Serialize() { cout << "CMyDoc::Serialize() \n\n"; } }; //--------------------------------------------------------------- void main() { CMyDoc mydoc; CMyDoc* pmydoc = new CMyDoc; cout << "#1 testing" << endl; mydoc.func(); cout << "#2 testing" << endl; ((CDocument*)(&mydoc))->func(); cout << "#3 testing" << endl; pmydoc->func(); cout << "#4 testing" << endl; ((CDocument)mydoc).func(); }
由于CMyDoc 自己没有func 函数,而它继承了CDocument 的所有成员,所以main 之中的四个调用动作毫无问题都是调用CDocument::func。但,CDocument::func 中所调用的Serialize 是哪一个类别的成员函数呢?如果它是一般(non-virtual)函数,毫无问题应该是CDocument::Serialize。但因为这是个虚拟函数,情况便有不同。以下是执行结果:
前三个测试都符合我们对虚拟函数的期望:既然衍生类别已经改写了虚拟函数Serialize,那么理当调用衍生类别之Serialize 函数。这种行为模式非常频繁地出现在applicationframework 身上。后续当我追踪MFC 源代码时,遇此情况会再次提醒你。第四项测试结果则有点出乎意料之外。你知道,衍生对象通常都比基础对象大(我是指内存空间),因为衍生对象不但继承其基础类别的成员,又有自己的成员。那么所谓的upcasting(向上强制转型): (CDocument)mydoc,将会造成对象的内容被切割(object slicing):
当我们调用:
((CDocument)mydoc).func();
mydoc 已经是一个被切割得剩下半条命的对象,而func内部调用虚拟函数Serialize;后者将使用的「mydoc的虚拟函数指针」虽然存在,它的值是什么呢?你是不是隐隐觉得有什么大灾难要发生?
幸运的是,由于((CDocument)mydoc).func() 是个传值而非传址动作,编译器以所谓的拷贝构造式(copy constructor)把CDocument 对象内容复制了一份,使得mydoc 的vtable 内容与CDocument 对象的vtable相同。本例虽没有明显做出一个拷贝构造式,编译器会自动为你合成一个。
说这么多,总结就是,经过所谓的data slicing,本例的mydoc 真正变成了一个完完全全的Cdocument类的对象了。
CMyDoc mydoc;
CMyDoc* pmydoc = new CMyDoc;
#1 :mydoc.func();
使用CMyDoc的Serialize()毫无疑问
#2 :((CDocument*)(&mydoc))->func();
构建了一个指向mydoc的CDocment类指针,相当于一个基类指针。指向的是CMyDoc的vtable!!
Serialize()为虚函数,基类指针可以调用衍生类中的此函数把func()看成先执行cout << "CDocument::func()" << endl;
再执行Serialize();此类中定义了Serialize则自然使用此类的Serialize。
#3 :pmydoc->func();
使用CMyDoc的Serialize()毫无疑问
#4 :((CDocument)mydoc).func();
mydoc被切割后变成了一个完完全全的CDocment对象了,就连vtable也是CDocment得了 T.T
我想你已经很清楚了,如果你依据一个类别产生出三个对象,每一个对象将各有一份成员变量。有时候这并不是你要的。假设你有一个类别,专门用来处理存款帐户,它至少应该要有存户的姓名、地址、存款额、利率等成员变量:
class SavingAccount { private: char m_name[40]; // 存户姓名 char m_addr[60]; // 存户地址 double m_total; // 存款額 double m_rate; // 利率 ... };
m_rate应该独立在各对象之外,成为类别独一无二的资料。怎么做?在m_rate 前面加上static 修饰词即可:
class SavingAccount { private: char m_name[40]; // 存户姓名 char m_addr[60]; // 存户地址 double m_total; // 存款额 static double m_rate; // 利率 ... };
不要把static 成员变量的初始化动作安排在类别的构造式中,因为构造式可能一再被调用,而变量的初值却只应该设定一次。也不要把初始化动作安排在头文件中,因为它可能会被包含许多地方,因此也就可能被执行许多次。你应该在实作档中且类别以外的任何位置设定其初值。例如在main 之中,或全域函数中,或任何函数之外:
double SavingAccount::m_rate = 0.0075; // 设立static 成员变量的初值 void main() { ... }
error LNK2001: unresolved external symbol "private: static double SavingAccount::m_rate"(?m_rate@SavingAccount@@2HA)
// 第一种存取方式 void main() { SavingAccount::m_rate = 0.0075; // 欲此行成立,须把m_rate 改为public }
// 第二种存取方式 void main() { SavingAccount myAccount; myAccount.m_rate = 0.0075; // 欲此行成立,须把m_rate改为public }
只要access level允许,任何函数(包括全域函数或成员函数,static或non-static)都可以存取static成员变量。但如果你希望在产生任何object之前就存取其class的private static成员变量,则必须设计一个static成员函数(例如以下的setRate):
class SavingAccount { private: char m_name[40]; // 存户姓名 char m_addr[60]; // 存户地址 double m_total; // 存款额 static double m_rate; // 利率 ... public: static void setRate(double newRate) { m_rate = newRate; } ... }; double SavingAccount::m_rate = 0.0075; // 设置 static 成员变量的初值 void main() { SavingAccount::setRate(0.0074); // 直接调用类別的 static 成员函数 SavingAccount myAccount; myAccount.setRate(0.0074); // 通过对象调用 stati c 成员函数 }
static成员函数「没有this参数」的这种性质,正是我们的MFC应用程序在准备callback函数时所需要的。第6章的Hello World 例中我就会举这样一个实例。
C++的new运算子和C的malloc函数都是为了配置内存,但前者比之后者的优点是,new不但配置对象所需的内存空间时,同时会引发构造式的执行。
所谓构造式(constructor),就是对象诞生后第一个执行(并且是自动执行)的函数,它的函数名称必定要与类别名称相同。
相对于构造式,自然就有个析构式(destructor),也就是在对象行将毁灭但未毁灭之前一刻,最后执行(并且是自动执行)的函数,它的函数名称必定要与类别名称相同,再在最前面加一个~ 符号。
一个有着阶层架构的类别群组,当衍生类别的对象诞生之时,构造式的执行是由最基础类别(most based)至最尾端衍生类别(most derived);当对象要毁灭之前,析构式的执行则是反其道而行。第3章的frame1程序对此有所示范。
我以实例展示不同种类之对象的构造式执行时机。程序代码中的编号请对照执行结果。
#include <iostream> #include <string.h> using namespace std; class CDemo { public: CDemo(const char* str); ~CDemo(); private: char name[20]; }; CDemo::CDemo(const char* str) { strncpy(name, str, 20); cout << "Constructor called for " << name << '\n'; } CDemo::~CDemo() { cout << "Destructor called for " << name << '\n'; } void func() { CDemo LocalObjectInFunc("LocalObjectInFunc"); // in stack static CDemo StaticObject("StaticObject"); // local static CDemo* pHeapObjectInFunc = new CDemo("HeapObjectInFunc"); // in heap cout << "Inside func" << endl; } CDemo GlobalObject("GlobalObject"); // global static void main() { CDemo LocalObjectInMain("LocalObjectInMain"); // in stack CDemo* pHeapObjectInMain = new CDemo("HeapObjectInMain"); // in heap cout << "In main, before calling func\n"; func(); cout << "In main, after calling func\n"; }
既然谈到了static 对象,就让我把所有可能的对象生存方式及其构造式调用时机做个整理。所有作法你都已经在前一节的小程序中看过。
在C++ 中,有四种方法可以产生一个对象。第一种方法是在堆栈(stack)之中产生它:
void MyFunc() { CFoo foo; // 在堆栈(stack)中产生foo 对象 ... }
第二种方法是在堆积(heap)之中产生它:
void MyFunc() { ... CFoo* pFoo = new CFoo(); // 在堆(heap)中产生对象 }
第三种方法是产生一个全域对象(同时也必然是个静态对象):
CFoo foo; // 在任何函数范围之外做此动作
第四种方法是产生一个区域静态对象:
void MyFunc() { static CFoo foo; // 在函数范围(scope)之内的一个静态对象 ... }
前两种情况,C++在配置内存-- 来自堆栈(stack)或堆积(heap )-- 之后立刻产生一个隐藏的(你的原代码中看不出来的)构造式调用。
第三种情况,由于对象实现于任何「函数活动范围(function scope )」之外,显然没有地方来安置这样一个构造式调用动作。
是的,第三种情况(静态全域对象)的构造式调用动作必须靠startup 码帮忙。startup 码是什么?是更早于程序进入点(main 或WinMain)执行起来的码,由C++ 编译器提供,被联结到你的程序中。startup 码可能做些像函数库初始化、进程信息设立、I/O stream 产生等等动作,以及对static 对象的初始化动作(也就是调用其构造式)。
当编译器编译你的程序,发现一个静态对象,它会把这个对象加到一个串行之中。更精确地说则是,编译器不只是加上此静态对象,它还加上一个指针,指向对象之构造式及其参数(如果有的话)。把控制权交给程序进入点(main 或WinMain)之前,startup 码会快速在该串行上移动,调用所有登记有案的构造式并使用登记有案的参数,于是就初始化了你的静态对象。
第四种情况(区域静态对象)相当类似C语言中的静态区域变量,只会有一个实体(instance)产生,而且在固定的内存上(既不是stack也不是heap )。它的构造式在控制权第一次移转到其声明处(也就是在MyFunc 第一次被调用)时被调用。
我们有可能在程序执行过程中知道某个对象是属于哪一种类别吗?这种在C++ 中称为执行时期型别信息(Runtime Type Information ,RTTI)的能力,晚近较先进的编译器如Visual C++ 4.0 和Borland C++ 5.0 才开始广泛支持。以下是一个实例:
#include <typeinfo.h> #include <iostream> #include <string.h> using namespace std; class graphicImage { protected: char name[80]; public: graphicImage() { strcpy(name,"graphicImage"); } virtual void display() { cout << "Display a generic image." << endl; } char* getName() { return name; } }; //---------------------------------------------------------------- class GIFimage : public graphicImage { public: GIFimage() { strcpy(name,"GIFimage"); } void display() { cout << "Display a GIF file." << endl; } }; class PICTimage : public graphicImage { public: PICTimage() { strcpy(name,"PICTimage"); } void display() { cout << "Display a PICT file." << endl; } }; //---------------------------------------------------------------- void processFile(graphicImage *type) { if (typeid(GIFimage) == typeid(*type)) { ((GIFimage *)type)->display(); } else if (typeid(PICTimage) == typeid(*type)) { ((PICTimage *)type)->display(); } else cout << "Unknown type! " << (typeid(*type)).name() << endl; } void main() { graphicImage *gImage = new GIFimage(); graphicImage *pImage = new PICTimage(); processFile(gImage); processFile(pImage); }
Display a GIF file.
Display a PICT file.
这个程序与RTTI 相关的地方有三个:
1. 编译时需选用/GR选项(/GR 的意思是enable C++ RTTI)
2. 包含typeinfo.h
3. 新的typeid运算子。这是一个重载(overloading)运算式,多载的意思就是拥有一个以上的型式,你可以想象那是一种静态的多态(Polymorphism)。typeid的参数可以是类别名称(如本例#58左),也可以是对象指针(如本例#58右)。它传回一个type _info &。type _info是一个类别,定义于typeinfo.h中:
class type_info { public: virtual ~type_info(); int operator==(const type_info& rhs) const; int operator!=(const type_info& rhs) const; int before(const type_info& rhs) const; const char* name() const; const char* raw_name() const; private: . .. };
MFC的RTTI能力牵扯到一组非常神秘的宏( DECLARE_DYNAMIC 、IMPLEMENT_D YNAMIC)和一个非常神秘的类别(CRuntimeCl as s )。MFC程序员都知道怎么用它,却没几个人懂得其运作原理。大道不过三两行,说穿不值一文钱,下一章我就仿真出一个RTTI的DOS版本给你看。
面向对象术语中有一个名为persistence,意思是永续存留。放在RAM中的东西,生命受到电力的左右,不可能永续存留;唯一的办法是把它写到文件去。MFC的一个术语Serialize,就是做有关文件读写的永续存留动作,并且实做作出一个虚拟函数,就叫作Serialize。
看起来永续存留与本节的主题「动态生成」似乎没有什么干连。有!你把你的资料储存到文件,这些资料很可能(通常是)对象中的成员变量,我把它读出来后,势必要依据文件上的记载,重新new出那些个对象来。问题在于,即使我的程序有那些类别定义(就算我的程序和你的程序有一样的内容好了),我能够这么做吗:
char className[30] = getClassName(); // 从文件(或使用者输入)获得一个类别名称 CObject* obj = new classname; // 这一行行不通
M F C 支持动态生成, 靠的是一组非常神秘的宏( D ECLARE_DYNCREATE、IMPLEMENT_DYNCREATE)和一个非常神秘的类别(CRuntimeClass )。第3章中我将把它抽丝剥茧,以一个DOS程序仿真出来。
Except ion(异常情况)是一个颇为新鲜的C++ 语言特征,可以帮助你管理执行时期的错误,特别是那些发生在深度巢状(nested )函数调用之中的错误。Watcom C++ 是最早支持ANSI C++ 异常情况的编译器,Borland C++ 4. 0随后跟进,然后是Microsoft VisualC++ 和Symantec C++。现在,这已成为C++ 编译器必需支持的项目。
C++ 的exception 基本上是与C 的setjmp和longjmp函数对等的东西,但它增加了一些功能,以处理C++ 程序的特别需求。从深度巢状的例程调用中直接以一条快捷方式撤回到异常情况处理例程(exceptionhandler ),这种「错误管理方式」远比结构化程序中经过层层的例程传回一系列的错误状态来的好。事实上exceptionhandling是MFC和OWL两个application frameworks的防弹中心。
C++ 导入了三个新的exception 保留字:
1 . try。之后跟随一段以{ } 圈出来的程序代码,exception 可能在其中发生。
2 . catch 。之后跟随一段以{ } 圈出来的程序代码,那是exception 处理例程之所在。catch应该紧跟在try之后。
3 . throw。这是一个指令,用来产生(抛出)一个exception。
下面是个实例 :
try { // try block. } catch (char *p) { printf("Caught a char* exception, value %s\n",p); } catch (double d) { printf("Caught a numeric exception, value %g\n",d); } catch (...) { // catch anything printf("Caught an unknown exception\n"); }
MFC 早就支持exception,不过早期它用的是非标准语法。Visual C++ 4.0 编译器本身支持完整的C++ exceptions,MFC 也因此有了两个exception 版本:你可以使用语言本身提供的性能,也可以沿用MFC 古老的方法(以宏形式出现)。人们曾经因为MFC 的方案不同于ANSI标准而非难它,但是不要忘记它已经运作了多少年。
MFC 的exceptions 机制是以宏和exception types 为基础。这些宏类似C++ 的exception 保留字,动作也满像。MFC 以下列宏仿真C++ exception handling:
TRY CATCH(type,object) AND_CATCH(type,object) END_CATCH CATCH_ALL(object) AND_CATCH_ALL(object) END_CATCH_ALL END_TRY THROW() THROW_LAST()
TRY { // try block. } CATCH (CMemoryException, e) { printf("Caught a memory exception.\n"); } AND_CATCH_ALL (e) { printf("Caught an exception.\n"); } END_CATCH_ALL
以下是MFC 4.x 的exceptions 宏定义:
这并不是一本C++ 书籍,我也并不打算介绍太多距离「运用MFC」主题太远的C++ 论题。Template 虽然很重要,但它与「运用MFC」有什么关系?有!第8章当我们开始设计Scribble 程序时,需要用到MFC的collection classes ,而这一组类别自从MFC 3.0以来就有了template 版本(因为Visual C++ 编译器从2.0 版开始支持C++ template)。运用之前,我们总该了解一下新的语法、精神、以及应用。
好,到底什么是template ?重要性如何?Kaare Christian 在1994/01/25的PC-Magazine上有一篇文章,说得很好:
无性生殖并不只是存在于遗传工程上,对程序员而言它也是一个由来已久的动作。过去,我们只不过是以一个简单而基本的
工具,也就是一个文字编辑器,重制我们的程序代码。今天,C++ 提供给我们一个更好的繁殖方法:template。
复制一段既有程序代码的一个最平常的理由就是为了改变数据类型。举个例子,假设你写了一个绘图函数,使用整数x, y 坐
标;突然之间你需要相同的程序代码,但坐标值改采long 。你当然可以使用一个文字编辑器把这段码拷贝一份,然后把其中
的数据类型改变过来。有了C++ ,你甚至可以使用多载(overloaded )函数,那么你就可以仍旧使用相同的函数名称。函数
的多载的确使我们有比较清爽的程序代码,但它们意味着你还是必须在你的程序的许多地方维护完全相同的算法。C 语言对
此问题的解答是:使用宏。虽然你因此对于相同的算法只需写一次程序代码,但宏有它自己的缺点。第一,它只适用于简单
的功能。第二个缺点比较严重:宏不提供资料型别检验,因此牺牲了C++ 的一个主要效益。第三个缺点是:宏并非函数,程
序中任何调用宏的地方都会被编译器前置处理器原原本本地插入宏所定义的那一段码,而非只是一个函数调用,因此你每使
用一次宏,你的执行文件就会膨胀一点。Templates 提供比较好的解决方案,它把「一般性的算法」和其「对资料型别的实
作部份」区分开来。你可以先写算法的程序代码,稍后在使用时再填入实际资料型别。新的C++ 语法使「资料型别」也以参
数的姿态出现。有了template,你可以拥有宏「只写一次」的优点,以及多载函数「类型检验」的优点。
C++ 的template 有两种,一种针对function,另一种针对class 。
假设我们需要一个计算数值幂次方的函数,名曰power 。我们只接受正幂次方数,如果是负幂次方,就让结果为0。对于整数,我们的函数应该是这样:
int power(int base, int exponent) { int result = base; if (exponent == 0) return (int)1; if (exponent < 0) return (int)0; while (--exponent) result *= base; return result; }
对于长整数,函数应该是这样:
long power(long base, int exponent) { long result = base; if (exponent == 0) return (long)1; if (exponent < 0) return (long)0; while (--exponent) result *= base; return result; }
template <class T> T power(T base, int exponent);
写成两行或许比较清楚:
template <class T>
T power(T base, int exponent);
这样的函数声明是以一个特殊的template 前缀开始,后面紧跟着一个参数列(本例只一个参数)。容易让人迷惑的是其中的"class" 字眼,它其实并不一定表示C++ 的class ,它也可以是一个普通的数据类型。<class T> 只不过是表示:T 是一种类型,而此一类型将在调用此函数时才给予。
下面就是power 函数的template 版本:
template <class T> T power(T base, int exponent) { T result = base; if (exponent == 0) return (T)1; if (exponent < 0) return (T)0; while (--exponent) result *= base; return result; }
下面是template 函数的调用方法:
#include <iostream.h> void main() { int i = power(5, 4); long l = power(1000L, 3); long double d = power((long double)1e5, 2); cout << "i= " << i << endl; cout << "l= " << l << endl; cout << "d= " << d << endl; }
在第一次调用中,T 变成int,在第二次调用中,T 变成long 。而在第三次调用中,T 又成为了一个long double 。但如果调用时候把数据类型混乱掉了,像这样:
int i = power(1000L, 4); //
基值是个long,传回值却是个int 。错误示范!编译时就会出错。
template 函数的资料型别参数T究竟可以适应多少种类型?我要说,几乎「任何资料型态」都可以,但函数中对该类型数值的任何运算动作,都必须支持--否则编译器就不知道该怎么办了。以power函数为例,它对于result和base 两个数值的运算动作有:
1. T result = base;
2. return (T)1;
3. return (T)0;
4. result *= base;
5. return result;
C++ 所有内建数据类型如int 或long 都支持上述运算动作。但如果你为某个C++ 类别产生一个power 函数,那么这个C++ 类别必须包含适当的成员函数以支持上述动作。
如果你打算在template 函数中以C++ 类别代替class T ,你必须清楚知道哪些运算动作曾被使用于此一函数中,然后在你的C++ 类别中把它们全部实作出来。否则,出现的错误耐人寻味。
我们也可以建立template classes ,使它们能够神奇地操作任何类型的资料。下面这个例子是让CThree 类别储存三个成员变量,成员函数Min传回其中的最小值,成员函数Max则传回其中的最大值。我们把它设计为template class ,以便这个类别能适用于各式各样的数据类型:
// //#include <iostream> //using namespace std; // // //template <class T> //T power(T base, int exponent) //{ // T result = base; // if (exponent == 0) return (T)1; // if (exponent < 0) return (T)0; // while (--exponent) result *= base; // return result; //} // //void main() //{ // int i = power(5, 4); // long l = power(1000L, 3); // long double d = power((long double)1e5, 2); // // cout << "i= " << i << endl; // cout << "l= " << l << endl; // cout << "d= " << d << endl; // //} #include <iostream> using namespace std; template <class T> class CThree { public : CThree(T t1, T t2, T t3); T Min(); T Max(); private: T a, b, c; }; template <class T> T CThree<T>::Min() { T minab = a < b ? a : b; return minab < c ? minab : c; } template <class T> T CThree<T>::Max() { T maxab = a < b ? b : a; return maxab < c ? c : maxab; } template <class T> CThree<T>::CThree(T t1, T t2, T t3) :a(t1), b(t2), c(t3) { return; } void main() { CThree<int> obj1(2, 5, 4); cout << obj1.Min() << endl; cout << obj1.Max() << endl; CThree<float> obj2(8.52, -6.75, 4.54); cout << obj2.Min() << endl; cout << obj2.Max() << endl; CThree<long> obj3(646600L, 437847L, 364873L); cout << obj3.Min() << endl; cout << obj3.Max() << endl; }
执行结果如下:
2 5
-6.75
8.52
364873
646600
稍早我曾说过,只有当template函数对于资料型别T支持所有必要的运算动作时,T才得被视为有效。此一限制对于template classes 亦属实。为了针对某些类别产生一个CThree,该类别必须提供copy构造式以及operator< ,因为它们是Min和Max 成员函数中对T的运算动作。
但是如果你用的是别人template classes ,你又如何知道什么样的运算动作是必须的呢?唔,该template classes 的说明文件中应该有所说明。如果没有,只有源代码才能揭露秘密。C++ 内建资料型别如int 和float 等不需要在意这份要求,因为所有内建的资料类型都支持所有的标准运算动作。
对程序员而言C++ templates 可说是十分容易设计与使用,但对于编译器和联结器而言却是一大挑战。编译器遇到一个template时,不能够立刻为它产生机器码,它必须等待,直到template被指定某种类型。从程序员的观点来看,这意味着template function或template class的完整定义将出现在template被使用的每一个角落,否则,编译器就没有足够的信息可以帮助产生目的码。当多个源文件使用同一个template时,事情更趋复杂。
随着编译器的不同,掌握这种复杂度的技术也不同。有一个常用的技术,Borland 称之为Smart,应该算是最容易的:每一个使用Template的程序代码的目的档中都存在有template码,联结器负责复制和删除。
假设我们有一个程序,包含两个源文件A.CPP 和B.CPP ,以及一个THREE.H(其内定义了一个template 类别,名为CThree)。A.CPP 和B.CPP都包含THREE.H。如果A.CPP以int 和double 使用这个template 类别,编译器将在A.OBJ 中产生int 和double 两种版本的template 类别可执行码。如果B.CPP 以int 和float 使用这个template 类别,编译器将在B.OBJ 中产生int 和float 两种版本的template 类别可执行码。即使虽然A.OBJ 中已经有一个int 版了,编译器没有办法知道。
然后,在联结过程中,所有重复的部份将被删除。请看图2-1。