前面已经介绍了自定义类型的成员变量和成员函数的概念,并给出它们各自的语义,本文继续说明自定义类型剩下的内容,并说明各自的语义。
权限
成员函数的提供,使得自定义类型的语义从资源提升到了具有功能的资源。什么叫具有功能的资源?比如要把收音机映射为数字,需要映射的操作有调整收音机的频率以接收不同的电台;调整收音机的音量;打开和关闭收音机以防止电力的损耗。为此,收音机应映射为结构,类似下面:
struct Radiogram { double Frequency; /* 频率 */ void TurnFreq( double value ); // 改变频率 float Volume; /* 音量 */ void TurnVolume( float value ); // 改变音量 float Power; /* 电力 */ void TurnOnOff( bool bOn ); // 开关 bool bPowerOn; // 是否开启 }; |
上面的Radiogram::Frequency、Radiogram::Volume和Radiogram::Power由于定义为了结构Radiogram的成员,因此它们的语义分别为某收音机的频率、某收音机的音量和某收音机的电力。而其余的三个成员函数的语义也同样分别为改变某收音机的频率、改变某收音机的音量和打开或关闭某收音机的电源。注意这面的“某”,表示具体是哪个收音机的还不知道,只有通过成员操作符将左边的一个具体的收音机和它们结合时才知道是哪个收音机的,这也是为什么它们被称作偏移类型。这一点在下一篇将详细说明。
注意问题:为什么要将刚才的三个操作映射为结构Radiogram的成员函数?因为收音机具有这样的功能?那么对于选西瓜、切西瓜和吃西瓜,难道要定义一个结构,然后给它定义三个选、切、吃的成员函数??不是很荒谬吗?前者的三个操作是对结构的成员变量而言,而后者是对结构本身而言的。那么改成吃快餐,吃快餐的汉堡包、吃快餐的薯条和喝快餐的可乐。如果这里的两个吃和一个喝的操作变成了快餐的成员函数,表示是快餐的功能?!这其实是编程思想的问题,而这里其实就是所谓的面向对象编程思想,它虽然是很不错的思想,但并不一定是合适的,下篇将详细讨论。
上面我们之所以称收音机的换台是功能,是因为实际中我们自己是无法直接改变收音机的频率,必须通过旋转选台的那个旋钮来改变接收的频率,同样,调音量也是通过调节音量旋钮来实现的,而由于开机而导致的电力下降也不是我们直接导致,而是间接通过收听电台而导致的。因此上面的Radiogram::Power、Radiogram::Frequency等成员变量都具有一个特殊特性——外界,这台收音机以外的东西是无法改变它们的。为此,C++提供了一个语法来实现这种语义。在类型定义符中,给出这样的格式:<权限>:。这里的<权限>为public、protected和private中的一个,分别称作公共的、保护的和私有的,如下:
class Radiogram { protected: double m_Frequency; float m_Volume; float m_Power; private: bool m_bPowerOn; public: void TurnFreq( double ); void TurnVolume( float ); void TurnOnOff( bool ); }; |
可以发现,它和之前的标号的定义格式相同,但并不是语句修饰符,即可以struct ABC{ private: };。这里不用非要在private:后面接语句,因为它不是语句修饰符。从它开始,直到下一个这样的语法,之间所有的声明和定义而产生的成员变量或成员函数都带有了它所代表的语义。比如上面的类Radiogram,其中的Radiogram::m_Frequency、Radiogram::m_Volume和Radiogram::m_Power是保护的成员变量,Radiogram::m_bPowerOn是私有的成员变量,而剩下的三个成员函数都是公共的成员函数。注意上面的语法是可以重复的,如:struct ABC { public: public: long a; private: float b; public: char d; };。
什么意思?很简单,公共的成员外界可以访问,保护的成员外界不能访问,私有的成员外界及子类不能访问。关于子类后面说明。先看公共的。对于上面,如下将报错:
Radiogram a; a.m_Frequency = 23.0; a.m_Power = 1.0f; a.m_bPowerOn = true;
因为上面对a的三次操作都使用了a的保护或私有成员,编译器将报错,因为这两种成员外界是不能访问的。而a.TurnFreq( 10 );就没有任何问题,因为成员函数Radiogram::TurnFreq是公共成员,外界可以访问。那么什么叫外界?对于某个自定义类型,此自定义类型的成员函数的函数体内以外的一切能写代码的地方都称作外界。因此,对于上面的Radiogram,只有它的三个成员函数的函数体内可以访问它的成员变量。即下面的代码将没有问题。
void Radiogram::TurnFreq( double value ) { m_Frequency += value; }
因为m_Frequency被使用的地方是在Radiogram::TurnFreq的函数体内,不属于外界。
为什么要这样?表现最开始说的语义。首先,上面将成员定义成public或private对于最终生成的代码没有任何影响。然后,我之前说的调节接收频率是通过调节收音机里面的共谐电容的容量来实现的,这个电容的容量人必须借助元件才能做到,而将接收频率映射成数字后,由于是数字,则CPU就能修改。如果直接a.m_Frequency += 10;进行修改,就代码上的意义,其就为:执行这个方法的人将收音机的接收频率增加10KHz,这有违我们的客观世界,与前面的语义不合。因此将其作为语法的一种提供,由编译器来进行审查,可以让我们编写出更加符合我们所生活的世界的语义的代码。
应注意可以union ABC { long a; private: short b; };。这里的ABC::a之前没有任何修饰,那它是public还是protected?相信从前面举的那么多例子也已经看出,应该是public,这也是为什么我之前一直使用struct和union来定义自定义类型,否则之前的例子都将报错。而前篇说过结构和类只有一点很小的区别,那就是当成员没有进行修饰时,对于类,那个成员将是private而不是public,即如下将错误。
class ABC { long a; private: short b; }; ABC a; a.a = 13;
ABC::a由于前面的class而被看作private.就从这点,可以看出结构用于映射资源(可被直接使用的资源),而类用于映射具有功能的资源。下篇将详细讨论它们在语义上的差别。
构造和析构
了解了上面所提的东西,很明显就有下面的疑问:
struct ABC { private: long a, b; }; ABC a = { 10, 20 };
上面的初始化赋值变量a还正确吗?当然错误,否则在语法上这就算一个漏洞了(外界可以借此修改不能修改的成员)。但有些时候的确又需要进行初始化以保证一些逻辑关系,为此C++提出了构造和析构的概念,分别对应于初始化和扫尾工作。在了解这个之前,让我们先看下什么叫实例(Instance)。
实例是个抽象概念,表示一个客观存在,其和下篇将介绍的“世界”这个概念联系紧密。比如:“这是桌子”和“这个桌子”,前者的“桌子”是种类,后者的“桌子”是实例。这里有10只羊,则称这里有10个羊的实例,而羊只是一种类型。可以简单地将实例认为是客观世界的物体,人类出于方便而给各种物体分了类,因此给出电视机的说明并没有给出电视机的实例,而拿出一台电视机就是给出了一个电视机的实例。同样,程序的代码写出来了意义不大,只有当它被执行时,我们称那个程序的一个实例正在运行。如果在它还未执行完时又要求操作系统执行了它,则对于多任务操作系统,就可以称那个程序的两个实例正在被执行,如同时点开两个Word文件查看,则有两个Word程序的实例在运行。
在C++中,能被操作的只有数字,一个数字就是一个实例(这在下篇的说明中就可以看出),更一般的,称标识记录数字的内存的地址为一个实例,也就是称变量为一个实例,而对应的类型就是上面说的物体的种类。比如:long a, *pA = &a, &ra = a;,这里就生成了两个实例,一个是long的实例,一个是long*的实例(注意由于ra是long&所以并未生成实例,但ra仍然是一个实例)。同样,对于一个自定义类型,如:Radiogram ab, c[3];,则称生成了四个Radiogram的实例。
对于自定义类型的实例,当其被生成时,将调用相应的构造函数;当其被销毁时,将调用相应的析构函数。谁来调用?编译器负责帮我们编写必要的代码以实现相应构造和析构的调用。构造函数的原型(即函数名对应的类型,如float AB( double, char );的原型是float( double, char ))的格式为:直接将自定义类型的类型名作为函数名,没有返回值类型,参数则随便。对于析构函数,名字为相应类型名的前面加符号“~”,没有返回值类型,必须没有参数。如下:
struct ABC { ABC(); ABC( long, long ); ~ABC(); bool Do( long ); long a, count; float *pF; }; ABC::ABC() { a = 1; count = 0; pF = 0; } ABC::ABC( long tem1, long tem2 ) { a = tem1; count = tem2; pF = new float[ count ]; } ABC::~ABC() { delete[] pF; } bool ABC::Do( long cou ) { float *p = new float[ cou ]; if( !p ) return false; delete[] pF; pF = p; count = cou; return true; } extern ABC g_ABC; void main(){ ABC a, &r = a; a.Do( 10 ); { ABC b( 10, 30 ); } ABC *p = new ABC[10]; delete[] p; } ABC g_a( 10, 34 ), g_p = new ABC[5]; |
上面的结构ABC就定义了两个构造函数(注意是两个重载函数),名字都为ABC::ABC(实际将由编译器转成不同的符号以供连接之用)。也定义了一个析构函数(注意只能定义一个,因为其必须没有参数,也就无法进行重载了),名字为ABC::~ABC.再看main函数,先通过ABC a;定义了一个变量,因为要在栈上分配一块内存,即创建了一个数字(创建装数字的内存也就导致创建了数字,因为内存不能不装数字),进而创建了一个ABC的实例,进而调用ABC的构造函数。由于这里没有给出参数(后面说明),因此调用了ABC::ABC(),进而a.a为1,a.pF和a.count都为0.接着定义了变量r,但由于它是ABC&,所以并没有在栈上分配内存,进而没有创建实例而没有调用ABC::ABC.接着调用a.Do,分配了一块内存并把首地址放在a.pF中。
注意上面变量b的定义,其使用了之前提到的函数式初始化方式。它通过函数调用的格式调用了ABC的构造函数ABC::ABC( long, long )以初始化ABC的实例b.因此b.a为10,b.count为30,b.pF为一内存块的首地址。但要注意这种初始化方式和之前提到的“{}”方式的不同,前者是进行了一次函数调用来初始化,而后者是编译器来初始化(通过生成必要的代码)。由于不调用函数,所以速度要稍快些(关于函数的开销在《C++从零开始(十五)》中说明)。还应注意不能ABC b = { 1, 0, 0 };,因为结构ABC已经定义了两个构造函数,则它只能使用函数式初始化方式初始化了,不能再通过“{}”方式初始化了。
上面的b在一对大括号内,回想前面提过的变量的作用域,因此当程序运行到ABC *p = new ABC[10];时,变量b已经消失了(超出了其作用域),即其所分配的内存语法上已经释放了(实际由于是在栈上,其并没有被释放),进而调用ABC的析构函数,将b在ABC::ABC( long, long )中分配的内存释放掉以实现扫尾功能。
对于通过new在堆上分配的内存,由于是new ABC[10],因此将创建10个ABC的实例,进而为每一个实例调用一次ABC::ABC(),注意这里无法调用ABC::ABC( long, long ),因为new操作符一次性就分配了10个实例所需要的内存空间,C++并没有提供语法(比如使用“{}”)来实现对一次性分配的10个实例进行初始化。接着调用了delete[] p;,这释放刚分配的内存,即销毁了10个实例,因此将调用ABC的析构函数10次以进行10次扫尾工作。
注意上面声明了全局变量g_ABC,由于是声明,并不是定义,没有分配内存,因此未产生实例,故不调用ABC的构造函数,而g_a由于是全局变量,C++保证全局变量的构造函数在开始执行main函数之前就调用,所有全局变量的析构函数在执行完main函数之后才调用(这一点是编译器来实现的,在《C++从零开始(十九)》中将进一步讨论)。因此g_a.ABC( 10, 34 )的调用是在a.ABC()之前,即使它的位置在a的定义语句的后面。而全局变量g_p的初始化的数字是通过new操作符的计算得来,结果将在堆上分配内存,进而生成5个ABC实例而调用了ABC::ABC()5次,由于是在初始化g_p的时候进行分配的,因此这5次调用也在a.ABC()之前。由于g_p仅仅只是记录首地址,而要释放这5个实例就必须调用delete(不一定,也可不调用delete依旧释放new返回的内存,在《C++从零开始(十九)》中说明),但上面并没有调用,因此直到程序结束都将不会调用那5个实例的析构函数,那将怎样?后面说明异常时再讨论所谓的内存泄露问题。
因此构造的意思就是刚分配了一块内存,还未初始化,则这块内存被称作原始数据(Raw Data),前面说过数字都必须映射成算法中的资源,则就存在数字的有效性。比如映射人的年龄,则这个数字就不能是负数,因为没有意义。所以当得到原始数据后,就应该先通过构造函数的调用以保证相应实例具有正确的意义。而析构函数就表示进行扫尾工作,就像上面,在某实例运作的期间(即操作此实例的代码被执行的时期)动态分配了一些内存,则应确保其被正确释放。再或者这个实例和其他实例有关系,因确保解除关系(因为这个实例即将被销毁),如链表的某个结点用类映射,则这个结点被删除时应在其析构函数中解除它与其它结点的关系。
派生和继承
上面我们定义了类Radiogram来映射收音机,如果又需要映射数字式收音机,它和收音机一样,即收音机具有的东西它都具有,不过多了自动搜台、存储台、选台和删除台的功能。这里提出了一个类型体系,即一个实例如果是数字式收音机,那它一定也是收音机,即是收音机的一个实例。比如苹果和梨都是水果,则苹果和梨的实例一定也是水果的实例。这里提出三个类型:水果、苹果和梨。其中称水果是苹果的父类(父类型),苹果是水果的子类(子类型)。同样,水果也是梨的父类,梨是水果的子类。这种类型体系是很有意义的,因为人类就是用这种方式来认知世界的,它非常符合人类的思考习惯,因此C++又提出了一种特殊语法来对这种语义提供支持。
在定义自定义类型时,在类型名的后面接一“:”,然后接public或protected或private,接着再写父类的类型名,最后就是类型定义符“{}”及相关书写,如下:
class DigitalRadiogram : public Radiogram { protected: double m_Stations[10]; public: void SearchStation(); void SaveStation( unsigned long ); void SelectStation( unsigned long ); void EraseStation( unsigned long ); }; |
上面就将Radiogram定义为了DigitalRadiogram的父类,DigitalRadiogram定义成了Radiogram的子类,被称作类Radiogram派生了类DigitalRadiogram,类DigitalRadiogram继承了类Radiogram.
上面生成了5个映射元素,就是上面的4个成员函数和1个成员变量,但实际不止。由于是从Radiogram派生,因此还将生成7个映射,就是类Radiogram的7个成员,但名字变化了,全变成DigitalRadiogram::修饰,而不是原来的Radiogram::修饰,但是类型却不变化。比如其中一个映射元素的名字就为DigitalRadiogram::m_bPowerOn,类型为bool Radiogram::,映射的偏移值没变,依旧为16.同样也有映射元素DigitalRadiogram::TurnFreq,类型为void ( Radiogram:: )( double ),映射的地址依旧没变,为Radiogram::TurnFreq所对应的地址。因此就可以如下:
void DigitalRadiogram::SaveStation( unsigned long index ) { if( index >= 10 ) return; m_Station[ index ] = m_Frequency; m_bPowerOn = true; } DigitalRadiogram a; a.TurnFreq( 10 ); a.SaveStation( 3 ); |
上面虽然没有声明DigitalRadiogram::TurnFreq,但依旧可以调用它,因为它是从Radiogram派生来的。注意由于a.TurnFreq( 10 );没有书写全名,因此实际是a.DigitalRadiogram::TurnFreq( 10 );,因为成员操作符左边的数字类型是DigitalRadiogram.如果DigitalRadiogram不从Radiogram派生,则不会生成上面说的7个映射,结果a.TurnFreq( 10 );将错误。
注意上面的SaveStation中,直接书写了m_Frequency,其等同于this->m_Frequency,由于this是DigitalRadiogram*(因为在DigitalRadiogram::SaveStation的函数体内),所以实际为this->DigitalRadiogram::m_Frequency,也因此,如果不是派生自Radiogram,则上面将报错。并且由类型匹配,很容易知道:void ( Radiogram::*p )( double ) = DigitalRadiogram::TurnFreq;。虽然这里是DigitalRadiogram::TurnFreq,但它的类型是void ( Radiogram:: )( double )。
应注意在SaveStation中使用了m_bPowerOn,这个在Radiogram中被定义成私有成员,也就是说子类也没权访问,而SaveStation是其子类的成员函数,因此上面将报错,权限不够。
上面通过派生而生成的7个映射元素各自的权限是什么?先看上面的派生代码:
class DigitalRadiogram : public Radiogram {…};
这里由于使用public,被称作DigitalRadiogram从Radiogram公共继承,如果改成protected则称作保护继承,如果是private就是私有继承。有什么区别?通过公共继承而生成的映射元素(指从Radiogram派生而生成的7个映射元素),各自的权限属性不变化,即上面的DigitalRadiogram::m_Frequency对类DigitalRadiogram来说依旧是protected,而DigitalRadiogram::m_bPowerOn也依旧是private.保护继承则所有的公共成员均变成保护成员,其它不变。即如果保护继承,DigitalRadiogram::TurnFreq对于DigitalRadiogram来说将为protected.私有继承则将所有的父类成员均变成对于子类来说是private.因此上面如果私有继承,则DigitalRadiogram::TurnFreq对于DigitalRadiogram来说是private的。
上面可以看得很简单,即不管是什么继承,其指定了一个权限,父类中凡是高于这个权限的映射元素,都要将各自的权限降低到这个权限(注意是对子类来说),然后再继承给子类。上面一直强调“对于子类来说”,什么意思?如下:
struct A { long a; protected: long b; private: long c; }; struct B : protected A { void AB(); }; struct C : private B { void ABC(); }; void B::AB() { b = 10; c = 10; } void C::ABC() { a = 10; b = 10; c = 10; AB(); } A a; B b; C c; a.a = 10; b.a = 10; b.AB(); c.AB(); |
上面的B的定义等同于struct B { protected: long a, b; private: long c; public: void AB(); };。
上面的C的定义等同于struct C { private: long a, b, c; void AB(); public: void ABC(); };
因此,B::AB中的b = 10;没有问题,但c = 10;有问题, 因为编译器看出B::c是从父类继承生成的,而它对于父类来说是私有成员,因此子类无权访问,错误。接着看C::ABC,a = 10;和b = 10;都没问题,因为它们对于B来说都是保护成员,但c = 10;将错误,因为C::c对于父类B来说是私有成员,没有权限,失败。接着AB();,因为C::AB对于父类B来说是公共成员,没有问题。
接着是a.a = 10;,没问题;b.a = 10;,错误,因为B::a是B的保护成员;b.AB();,没有问题;c.AB();,错误,因为C::AB是C的私有成员。应注意一点:public、protected和private并不是类型修饰符,只是在语法上提供了一些信息,而继承所得的成员的类型都不会变化,不管它保护继承还是公共继承,权限起作用的地方是需要运用成员的地方,与类型没有关系。什么叫运用成员的地方?如下:
long ( A::*p ) = &A::a; p = &A::b;
void ( B::*pB )() = B::AB; void ( C::*pC )() = C::ABC; pC = C::AB;
上面对变量p的初始化操作没有问题,这里就运用了A::a.但是在p = &A::b;时,由于运用了A::b,则编译器就要检查代码所处的地方,发现对于A来说属于外界,因此报错,权限不够。同样下面对pB的赋值没有问题,但pC = C::AB;就错误。而对于b.a = 10;,这里由于成员操作符而运用了类B的成员B::a,所以在这里进行权限检查,并进而发现权限不够而报错。
好,那为什么要搞得这么复杂?弄什么保护、私有和公共继承?首先回想前面说的为什么要提供继承,因为想从代码上体现类型体系,说明一个实例如果是一个子类的实例,则它也一定是一个父类的实例,即可以按照父类的定义来操作它。虽然这也可以通过之前说的转换指针类型来实现,但前者能直接从代码上表现出类型继承的语义(即子类从父类派生而来),而后者只能说明用不同的类型来看待同一个实例。
那为什么要给继承加上权限?表示这个类不想外界或它的子类以它的父类的姿态来看待它。比如鸡可以被食用,但做成标本的鸡就不能被食用。因此子类“鸡的标本”在继承时就应该保护继承父类“鸡”,以表示不准外界(但准许其派生类)将它看作是鸡。它已经不再是鸡,但它实际是由鸡转变过来的。因此私有和保护继承实际很适合表现动物的进化关系。比如人是猴子进化来的,但人不是猴子。这里人就应该使用私有继承,因为并不希望外界和人的子类——黑种人、黄种人、白种人等——能够把父类“人”看作是猴子。而公共继承就表示外界和子类可以将子类的实例看成父类的实例。如下:
struct A { long a, b; }; struct AB : private A { long c; void ABCD(); }; struct ABB : public AB { void AAA(); }; struct AC : public A { long c; void ABCD(); }; void ABC( A *a ) { a->a = 10; a->b = 20; } void main() { AB b; ABC( &b ); AC c; ABC( &c ); } void AB::ABCD() { AB b; ABC( &b ); } void AC::ABCD() { AB b; ABC( &b ); } void ABB::AAA() { AB b; ABC( &b ); } |
上面的类AC是公共继承,因此其实例c在执行ABC( &c );时将由编译器进行隐式类型转换,这是一个很奇特的特性,本文的下篇将说明。但类AB是私有继承,因此在ABC( &b );时编译器不会进行隐式类型转换,将报错,类型不匹配。对于此只需ABC( ( A* )&b );以显示进行类型转换就没问题了。
注意前面的红字,私有继承表示外界和它的子类都不可以用父类的姿态来看待它,因此在ABB::AAA中,这是AB的子类,因此这里的ABC( &b );将报错。在AC::ABCD中,这里对于AB来说是外界,报错。在AB::ABCD中,这里是自身,即不是子类也不是外界,所以ABC( &b );将没有问题。如果将AB换成保护继承,则在ABB::AAA中的ABC( &b );将不再错误。
关于本文及本文下篇所讨论的语义,在《C++从零开始(十二)》中会专门提出一个概念以给出一种方案来指导如何设计类及各类的关系。由于篇幅限制,本文分成了上下两篇,剩下的内容在本文下篇说明。
多重继承
这里有个有趣的问题,如下:
struct A { long a, b, c; char d; }; struct B : public A { long e, f; };
上面的B::e和B::f映射的偏移是多少?不同的编译器有不同的映射结果,对于派生的实现,C++并没有强行规定。大多数编译器都是让B::e映射的偏移值为16(即A的长度,关于自定义类型的长度可参考《C++从零开始(九)》),B::f映射20.这相当于先把空间留出来排列父类的成员变量,再排列自己的成员变量。但是存在这样的语义——西红柿即是蔬菜又是水果,鲸鱼即是海洋生物又是脯乳动物。即一个实例既是这种类型又是那种类型,对于此,C++提供了多重派生或称多重继承,用“,”间隔各父类,如下:
struct A { long A_a, A_b, c; void ABC(); }; struct B { long c, B_b, B_a; void ABC(); }; struct AB : public A, public B { long ab, c; void ABCD(); }; void A::ABC() { A_a = A_b = 10; c = 20; } void B::ABC() { B_a = B_b = 20; c = 10; } void AB::ABCD() { A_a = B_a = 1; A_b = B_b = 2; c = A::c = B::c = 3; } void main() { AB ab; ab.A_a = 3; ab.B_b = 4; ab.ABC(); } |
上面的结构AB从结构A和结构B派生而来,即我们可以说ab既是A的实例也是B的实例,并且还是AB的实例。那么在派生AB时,将生成几个映射元素?照前篇的说法,除了AB的类型定义符“{}”中定义的AB::ab和AB::c以外(类型均为long AB::),还要生成继承来的映射元素,各映射元素名字的修饰换成AB::,类型不变,映射的值也不变。因此对于两个父类,则生成8个映射元素(每个类都有4个映射元素),比如其中一个的名字为AB::A_b,类型为long A::,映射的值为4;也有一个名字为AB::B_b,类型为long B::,映射的值依旧为4.注意A::ABC和B::ABC的名字一样,因此其中两个映射元素的名字都为AB::ABC,但类型则一个为void( A:: )()一个为void( B:: )(),映射的地址分别为A::ABC和B::ABC.同样,就有三个映射元素的名字都为AB::c,类型则分别为long A::、long B::和long AB::,映射的偏移值依次为8、0和28.照前面说的先排列父类的成员变量再排列子类的成员变量,因此类型为long AB::的AB::c映射的值为两个父类的长度之和再加上AB::ab所带来的偏移。
注意问题,上面继承生成的8个映射元素中有两对同名,但不存在任何问题,因为它们的类型不同,而最后编译器将根据它们各自的类型而修改它们的名字以形成符号,这样连接时将不会发生重定义问题,但带来其他问题。ab.ABC();一定是ab.AB::ABC();的简写,因为ab是AB类型的,但现在由于有两个AB::ABC,因此上面直接书写ab.ABC将报错,因为无法知道是要哪个AB::ABC,这时怎么办?
回想本文上篇提到的公共、保护、私有继承,其中说过,公共就表示外界可以将子类的实例当作父类的实例来看待。即所有需要用到父类实例的地方,如果是子类实例,且它们之间是公共继承的关系,则编译器将会进行隐式类型转换将子类实例转换成父类实例。因此上面的ab.A_a = 3;实际是ab.AB::A_a = 3;,而AB::A_a的类型是long A::,而成员操作符要求两边所属的类型相同,左边类型为AB,且AB为A的子类,因此编译器将自动进行隐式类型转换,将AB的实例变成A的实例,然后再计算成员操作符。
注意前面说AB::A_b和AB::B_b的偏移值都为4,则ab.A_b = 3;岂不是等效于ab.B_b = 3;?即使按照上面的说法,由于AB::A_b和AB::B_b的类型分别是long A::和long B::,也最多只是前者转换成A的实例后者转换成B的实例,AB::A_b和AB::B_b映射的偏移依旧没变啊。因此变的是成员操作符左边的数字。对于结构AB,假设先排列父类A的成员变量再排列父类B的成员变量,则AB::B_b映射的偏移就应该为16(结构A的长度加上B::c引入的偏移),但它实际映射为4,因此就将成员操作符左侧的地址类型的数字加上12(结构A的长度)。而对于AB::A_b,由于结构A的成员变量先被排列,故只偏移0.假设上面ab对应的地址为3000,对于ab.B_b = 4;,AB类型的地址类型的数字3000在“。”的左侧,转成B类型的地址类型的数字3012(因为偏移12),然后再将“。”右侧的偏移类型的数字4加上3012,最后返回类型为long的地址类型的数字3016,再继续计算“=”。同样也可知道ab.A_a = 3;中的成员操作符最后返回long类型的地址类型的数字3000,而ab.A_b将返回3004,ab.ab将返回3024.
同样,这样也将进行隐式类型转换long AB::*p = &AB::B_b;。注意AB::B_b的类型为long B::,则将进行隐式类型转换。如何转换?原来AB::B_b映射的偏移为4,则现在将变成12+4=16,这样才能正确执行ab.*p = 10;。
这时再回过来想刚才提的问题,AB::ABC无法区别,怎么办?注意还有映射元素A::ABC和B::ABC(两个AB::ABC就是由于它们两个而导致的),因此可以书写ab.A::ABC();来表示调用的是映射到A::ABC的函数。这里的A::ABC的类型是void( A:: )(),而ab是AB,因此将隐式类型转换,则上面没有任何语法问题(虽然说A::ABC不是结构AB的成员,但它是AB的父类的成员,C++允许这种情况,也就是说A::ABC的名字也作为类型匹配的一部分而被使用。如假设结构C也从A派生,则有C::a,但就不能书写ab.C::a,因为从C::a的名字可以知道它并不属于结构AB)。同样ab.B::ABC();将调用B::ABC.注意上面结构A、B和AB都有一个成员变量名字为c且类型为long,那么ab.c = 10;是否会如前面ab.ABC();一样报错?不会,因为有三个AB::c,其中有一个类型和ab的类型匹配,其映射的偏移为28,因此ab.c将会返回3028.而如果期望运用其它两个AB::c的映射,则如上通过书写ab.A::c和ab.B::c来偏移ab的地址以实现。
注意由于上面的说法,也就可以这样:void( AB::*pABC )() = B::ABC; ( ab.*pABC )();。这里的B::ABC的类型为void( B:: )(),和pABC不匹配,但正好B是AB的父类,因此将进行隐式类型转换。如何转换?因为B::ABC映射的是地址,而隐式类型转换要保证在调用B::ABC之前,先将this的类型变成B*,因此要将其加12以从AB*转变成B*.由于需要加这个12,但B::ABC又不是映射的偏移值,因此pABC实际将映射两个数字,一个是B::ABC对应的地址,一个是偏移值12,结果pABC这个指针的长度就不再如之前所说的为4个字节,而变成了8个字节(多出来的4个字节用于记录偏移值)。
还应注意前面在AB::ABCD中直接书写的A_b、c、A::c等,它们实际都应该在前面加上this->,即A_b = B_b = 2;实际为this->A_b = this->B_b = 2;,则同样如上,this被偏移了两次以获得正确的地址。注意上面提到的隐式类型转换之所以会进行,是因为继承时的权限满足要求,否则将失败。即如果上面AB保护继承A而私有继承B,则只有在AB的成员函数中可以如上进行转换,在AB的子类的成员函数中将只能使用A的成员而不能使用B的成员,因为权限受到限制。如下将失败。
struct AB : protected A, private B {…};
struct C : public AB { void ABCD(); };
void C::ABCD() { A_b = 10; B_b = 2; c = A::c = B::c = 24; }
这里在C::ABCD中的B_b = 2;和B::c = 24;将报错,因为这里是AB的子类,而AB私有继承自B,其子类无权将它看作B.但只是不会进行隐式类型转换罢了,依旧可以通过显示类型转换来实现。而main函数中的ab.A_a = 3; ab.B_b = 4; ab.A::ABC();都将报错,因为这是在外界发起的调用,没有权限,不会自动进行隐式类型转换。
注意这里C::ABCD和AB::ABCD同名,按照上面所说,子类的成员变量都可以和父类的成员变量同名(上面AB::c和A::c及B::c同名),成员函数就更没有问题。只用和前面一样,按照上面所说进行类型匹配检验即可。应注意由于是函数,则可以参数变化而函数名依旧相同,这就成了重载函数。
虚继承
前面已经说了,当生成了AB的实例,它的长度实际应该为A的长度加B的长度再加上AB自己定义的成员所占有的长度。即AB的实例之所以又是A的实例又是B的实例,是因为一个AB的实例,它既记录了一个A的实例又记录了一个B的实例。则有这么一种情况——蔬菜和水果都是植物,海洋生物和脯乳动物都是动物。即继承的两个父类又都从同一个类派生而来。假设如下:
struct A { long a; }; struct B : public A { long b; }; struct C : public A { long c; }; struct D : public A, public C { long d; }; void main() { D d; d.a = 10; } |
上面的B的实例就包含了一个A的实例,而C的实例也包含了一个A的实例。那么D的实例就包含了一个B的实例和一个C的实例,则D就包含了两个A的实例。即D定义时,将两个父类的映射元素继承,生成两个映射元素,名字都为D::a,类型都为long A::,映射的偏移值也正好都为0.结果main函数中的d.a = 10;将报错,无法确认使用哪个a.这不是很奇怪吗?两个映射元素的名字、类型和映射的数字都一样!编译器为什么就不知道将它们定成一个,因为它们实际在D的实例中表示的偏移是不同的,一个是0一个是8.同样,为了消除上面的问题,就书写d.B::a = 1; d.C::a = 2;以表示不同实例中的成员a.可是B::a和C::a的类型不都是为long A::吗?但上面说过,成员变量或成员函数它们自身的名字也将在类型匹配中起作用,因此对于d.B::a,因为左侧的类型是D,则看右侧,其名字表示为B,正好是D的父类,先隐式类型转换,然后再看类型,是A,再次进行隐式类型转换,然后返回数字。假设上面d对应的地址为3000,则d.C::a先将d这个实例转换成C的实例,因此将3000偏移8个字节而返回long类型的地址类型的数字3008.然后再转换成A的实例,偏移0,最后返回3008.
上面说明了一个问题,即希望从A继承来的成员a只有一个实例,而不是像上面那样有两个实例。假设动物都有个饥饿度的成员变量,很明显地鲸鱼应该只需填充一个饥饿度就够了,结果有两个饥饿度就显得很奇怪。对此,C++提出了虚继承的概念。其格式就是在继承父类时在权限语法的前面加上关键字virtual即可,如下:
struct A { long a, aa, aaa; void ABC(); }; struct B : virtual public A { long b; };
这里的B就虚继承自A,B::b映射的偏移为多少?将不再是A的长度12,而是4.而继承生成的3个映射元素还是和原来一样,只是名字修饰变成B::而已,映射依旧不变。那么为什么B::b是4?之前的4个字节用来放什么?上面等同于下面:
struct B { long *p; long b; long a, aa, aaa; void ABC(); };
long BDiff[] = { 0, 8 }; B::B(){ p = BDiff; }
上面的B::p指向一全局数组BDiff.什么意思?B的实例的开头4个字节用来记录一个地址,也就相当于是一个指针变量,它记录的地址所标识的内存中记录着由于虚继承而导致的偏移值。上面的BDiff[1]就表示要将B实例转成A实例,就需要偏移BDiff[1]的值8,而BDiff[0]就表示要将B实例转成B实例需要的偏移值0.为什么还要来个B实例转B实例?后面说明。但为什么是数组?因为一个类可以通过多重派生而虚继承多个类,每个类需要的偏移值都会在BDiff的数组中占一个元素,它被称作虚类表(Virtual Class Table)。
因此当书写B b; b.aaa = 20; long a = sizeof( b );时,a的值为20,因为多了一个4字节来记录上面说的指针。假设b对应的地址为3000.先将B的实例转换成A的实例,本来应该偏移12而返回3012,但编译器发现B是虚继承自A,则通过B::p[1]得到应该的偏移值8,然后返回3008,接着再加上B::aaa映射的8而返回3016.同样,当b.b = 10;时,由于B::b并不是被虚继承而来,直接将3000加上B::b映射的偏移值4得3004.而对于b.ABC();将先通过B::p[1]将b转成A的实例然后调用A::ABC.
为什么要像上面那样弄得那么麻烦?首先让我们来了解什么叫做虚(Virtual)。虚就是假象,并不是真的。比如一台老式电视机有10个频道,即它最多能记住10个电视台的频率。因此可以说1频道是中央1台、5频道是中央5台、7频道是四川台。这里就称频道对我们来说代表着电台频率是虚假的,因为频道并不是电台频率,只是记录了电台频率。当我们按5频道以换到中央5台时,有可能有人已经调过电视使得5频道不再是中央5台,而是另一个电视台或者根本就是一片雪花没有信号。因此虚就表示不保证,其可能正确可能错误,因为它一定是间接得到的,其实就相当于之前说的引用。有什么好处?只用记着按5频道就是中央5台,当以后不想再看中央5台而换成中央2台,则同样的“按5频道”却能得到不同的结果,但是程序却不用再编写了,只用记着“按5频道”就又能实现换到中央2台看。所以虚就是间接得到结果,由于间接,结果将不确定而显得更加灵活,这在后面说明虚函数时就能看出来。但虚的坏处就是多了一道程序(要间接获得),效率更低。
由于上面的虚继承,导致继承的元素都是虚的,即所有对继承而来的映射元素的操作都应该间接获得相应映射元素对应的偏移值或地址,但继承的映射元素对应的偏移值或地址是不变的,为此红字的要求就只有通过隐式类型转换改变this的值来实现。所以上面说的B转A需要的偏移值通过一个指针B::p来间接获得以表现其是虚的。
因此,开始所说的鲸鱼将会有两个饥饿度就可以让海洋生物和脯乳动物都从动物虚继承,因此将间接使用脯乳动物和海洋生物的饥饿度这个成员,然后在派生鲸鱼这个类时,让脯乳动物和海洋生物都指向同一个动物实例(因为都是间接获得动物的实例的,通过虚继承来间接使用动物的成员),这样当鲸鱼填充饥饿度时,不管填充哪个饥饿度,实际都填充同一个。而C++也正好这样做了。如下:
struct A { long a; }; struct B : virtual public A { long b; }; struct C : virtual public A { long c; }; struct D : public B, virtual public C { long d; }; void main() { D d; d.a = 10; } |
当从一个类虚继承时,在排列派生类时(就是决定在派生类的类型定义符“{}”中定义的各成员变量的偏移值),先排列前面提到的虚类表的指针以实现间接获取偏移值,再排列各父类,但如果父类中又有被虚继承的父类,则先将这些部分剔除。然后排列派生类自己的映射元素。最后排列刚刚被剔除的被虚继承的类,此时如果发现某个被虚继承的类已经被排列过,则不用再重复排列一遍那个类,并且也不再为它生成相应的映射元素。
对于上面的B,发现虚继承A,则先排列前面说过的B::p,然后排列A,但发现A需要被虚继承,因此剔除,排列自己定义的映射元素B::b,映射的偏移值为4(由于B::p的占用)。最后排列A而生成继承来的映射元素B::a,所以B的长度为12.
对于上面的D,发现要从C虚继承,因此:
排列D::p,占4个字节。
排列父类B,发现其中的A是被虚继承的,剔除,所以将继承映射元素B::b(还有前面编译器自动生成的B::p),生成D::b,占4个字节(编译器将B::p和D::p合并为一个,后面说明虚函数时就了解了)。
排列父类C,发现C需要被虚继承,剔除。
排列D自己定义的成员D::d,其映射的偏移值就为4+4=8,占4个字节。
排列A和C,先排列A,占4个字节,生成D::a.
排列C,先排列C中的A,结果发现它是虚继承的,并发现已经排列过A,进而不再为C::a生成映射元素。接着排列C::p和C::c,占8个字节,生成D::c.
所以最后结构D的长度为4+4+4+4+8=24个字节,并且只有一个D::a,类型为long A::,偏移值为0.
如果上面很昏,不要紧,上面只是给出一种算法以实现虚继承,不同的编译器厂商会给出不同的实现方法,因此上面推得的结果对某些编译器可能并不正确。不过应记住虚继承的含义——被虚继承的类的所有成员都必须被间接获得,至于如何间接获得,则不同的编译器有不同的处理方式。
由于需要保证间接获得,所以对于long D::*pa = &D::a;,由于是long D::*,编译器发现D的继承体系中存在虚继承,必须要保证其某些成员的间接获得,因此pa中放的将不再是偏移值,否则d.*pa = 10;将导致直接获得偏移值(将pa的内容取出来即可),违反了虚继承的含义。为了要间接访问pa所记录的偏移值,则必须保证代码执行时,当pa里面放的是D::a时会间接,而D::d时则不间接。很明显,这要更多和更复杂的代码,大多数编译器对此的处理就是全部都使用间接获得。因此pa的长度将为8字节,其中一个4字节记录偏移,还有一个4字节记录一个序号。这个序号则用于前面说的虚类表以获得正确的因虚继承而导致的偏移量。因此前面的B::p所指的第一个元素的值表示B实例转换成B实例,是为了在这里实现全部间接获得而提供的。
注意上面的D::p对于不同的D的实例将不同,只不过它们的内容都相同(都是结构D的虚类表的地址)。当D的实例刚刚生成时,那个实例的D::p的值将是一随机数。为了保证D::p被正确初始化,上面的结构D虽然没有生成构造函数,但编译器将自动为D生成一缺省构造函数(没有参数的构造函数)以保证D::p和上面从C继承来的C::p的正确初始化,结果将导致D d = { 23, 4 };错误,因为D已经定义了一个构造函数,即使没有在代码上表现出来。
那么虚继承有什么意义呢?它从功能上说是间接获得虚继承来的实例,从类型上说与普通的继承没有任何区别,即虚继承和前面的public等一样,只是一个语法上的提供,对于数字的类型没有任何影响。在了解它的意义之前先看下虚函数的含义。
虚函数
虚继承了一个函数类型的映射元素,按照虚继承的说法,应该是间接获得此函数的地址,但结果却是间接获得this参数的值。为了间接获得函数的地址,C++又提出了一种语法——虚函数。在类型定义符“{}”中书写函数声明或定义时,在声明或定义语句前加上关键字virtual即可,如下:
struct A { long a; virtual void ABC(), BCD(); }; void A::ABC() { a = 10; } void A::BCD() { a = 5; } |
上面等同于下面:
struct A { void ( A::*pF )(); long a; void ABC(), BCD(); A(); }; void A::ABC() { a = 10; } void A::BCD() { a = 5; } void ( A::*AVF[] )() = { A::ABC, A::BCD }; void A::A() { pF = AVF; } |
struct B : public A { long b; void ABC(); }; struct C : public A { long c; virtual void ABC(); }; struct BB : public B { long bb; void ABC(); }; struct CC : public C { long cc; void ABC(); }; void main() { BB bb; bb.ABC(); CC cc; cc.cc = 10; } |
void B::ABC() { b = 13; } void C::ABC() { c = 13; } void BB::ABC() { bb = 13; b = 10; } void CC::ABC() { cc = 13; c = 10; } |
如上后,对于bb.ABC();,等同于bb.BB::ABC();,虽然有三个BB::ABC的映射元素,但只有一个映射元素的类型为void( BB:: )(),其映射BB::ABC的地址。由于BB::ABC并没有用virtual修饰,因此上面将等同于bb.BB::ABC();而不是( bb.*( pF[0] ) )();,bb将为13.对于cc.ABC();也是同样的,cc将为13.
对于( ( B* )&bb )->ABC();,因为左侧类型为B*,因此将为( ( B* )&bb )->B::ABC();,由于B::ABC并没被定义成虚函数,因此这里等同于( ( B* )&bb )->B::ABC();,b将为13.对于( ( C* )&cc )->ABC();,同样将为( ( C* )&cc )->C::ABC();,但C::ABC被修饰成虚函数,则前面等同于C *pC = &cc; ( pC->*( pC->pF[0] ) )();。这里先将cc转换成C的实例,偏移0.然后根据pC->pF[0]来间接获得函数的地址,为CC::ABC,c将为10.因为cc是CC的实例,在其被构造时将填充cc.pF,那么如下:
void ( CC::*CCVF[] )() = { CC::ABC, CC::BCD }; CC::CC() { cc.pF = &CCVF; }
因此导致pC->ABC();结果调用的竟是CC::ABC而不是C::ABC,这正是由于虚的缘故而间接获得函数地址导致的。同样道理,对于( ( A* )&cc )->ABC();和( ( A* )&bb )->ABC();都将分别调用CC::ABC和BB::ABC.但请注意,( pC->*( pC->pF[0] ) )();中,pC是C*类型的,而pC->pF[0]返回的CC::ABC是void( CC:: )()类型的,而上面那样做将如何进行实例的隐式类型转换?如果不进行将导致操作错误的成员。可以像前面所说,让CCVF的每个成员的长度为8个字节,另外4个字节记录需要进行的偏移。但大多数类其实并不需要偏移(如上面的CC实例转成A实例就偏移0),此法有些浪费资源。VC对此给出的方法如下,假设CC::ABC对应的地址为6000,并假设下面标号P处的地址就为6000,而CC::A_thunk对应的地址为5990.
void CC::A_thunk( void *this ) { this = ( ( char* )this ) + diff; P: // CC::ABC的正常代码 } |
因此pC->pF[0]的值为5990,而并不是CC::ABC对应的6000.上面的diff就是相应的偏移,对于上面的例子,diff应该为0,所以实际中pC->pF[0]的值还是6000(因为偏移为0,没必要是5990)。此法被称作thunk,表示完成简单功能的短小代码。对于多重继承,如下:
struct D : public A { long d; }; struct E : public B, public C, public D { long e; void ABC() { e = 10; } }; |
上面将有三个虚函数表,因为B、C和D都各自带了一个虚函数表(因为从A派生)。结果上面等同于:
struct E { void ( E::*B_pF )(); long B_a, b; void ( E::*C_pF )(); long C_a, c; void ( E::*D_pF )(); long D_a, d; long e; void ABC() { e = 10; } E(); void E_C_thunk_ABC() { this = ( E* )( ( ( char* )this ) 每 12 ); ABC(); } void E_D_thunk_ABC() { this = ( E* )( ( ( char* )this ) 每 24 ); ABC(); } }; void ( E::*E_BVF[] )() = { E::ABC, E::BCD }; void ( E::*E_CVF[] )() = { E::E_C_thunk_ABC, E::BCD }; void ( E::*E_DVF[] )() = { E::E_D_thunk_ABC, E::BCD }; E::E() { B_pF = E_BVF; C_pF = E_CVF; D_pF = E_DVF; } |
结果E e; C *pC = &e; pC->ABC(); D *pD = &e; pD->ABC();,假设e的地址为3000,则pC的值为3012,pD的值为3024.结果pC->pF的值就是E_CVF,pD->pF的值就是E_DVF,如此就解决了偏移问题。同样,对于前面的虚继承,当类里有多个虚类表时,如:
struct A {};
struct B : virtual public A{}; struct C : virtual public A{}; struct D : virtual public A{};
struct E : public B, public C, public D {};
这是E将有三个虚类表,并且每个虚类表都将在E的缺省构造函数中被正确初始化以保证虚继承的含义——间接获得。而上面的虚函数表的初始化之所以那么复杂也都只是为了保证间接获得的正确性。
应注意上面将E_BVF的类型定义为void( E::*[] )()只是由于演示,希望在代码上尽量符合语法而那样写,并不表示虚函数的类型只能是void( E:: )()。实际中的虚函数表只不过是一个数组,每个元素的大小都为4字节以记录一个地址而已。因此也可如下:
struct A { virtual void ABC(); virtual float ABC( double ); };
struct B : public A { void ABC(); float ABC( double ); };
则B b; A *pA = &b; pA->ABC();将调用类型为void( B:: )()的B::ABC,而pA->ABC( 34 );将调用类型为float( B:: )( double )的B::ABC.它们属于重载函数,即使名字相同也都是两个不同的虚函数。还应注意virtual和之前的public等,都只是从语法上提供给编译器一些信息,它们给出的信息都是针对某些特殊情况的,而不是所有在使用数字的地方都适用,因此不能作为数字的类型。所以virtual不是类型修饰符,它修饰一个成员函数只是告诉编译器在运用那个成员函数的地方都应该间接获得其地址。
本文的中篇已经介绍了虚的意思,就是要间接获得,并且举例说明电视机的频道就是让人间接获得电视台频率的,因此其从这个意义上说是虚的,因为它可能操作失败——某个频道还未调好而导致一片雪花。并且说明了间接的好处,就是只用编好一段代码(按5频道),则每次执行它时可能有不同结果(今天5频道被设置成中央5台,明天可以被定成中央2台),进而使得前面编的程序(按5频道)显得很灵活。注意虚之所以能够很灵活是因为它一定通过“一种手段”来间接达到目的,如每个频道记录着一个频率。但这是不够的,一定还有“另一段代码”能改变那种手段的结果(频道记录的频率),如调台。
先看虚继承。它间接从子类的实例中获得父类实例的所在位置,通过虚类表实现(这是“一种手段”),接着就必须能够有“另一段代码”来改变虚类表的值以表现其灵活性。首先可以自己来编写这段代码,但就要求清楚编译器将虚类表放在什么地方,而不同的编译器有不同的实现方法,则这样编写的代码兼容性很差。C++当然给出了“另一段代码”,就是当某个类在同一个类继承体系中被多次虚继承时,就改变虚类表的值以使各子类间接获得的父类实例是同一个。此操作的功能很差,仅仅只是节约内存而已。如:
struct A { long a; }; struct B : virtual public A { long b; }; struct C : virtual public A { long c; }; struct D : public B, public C { long d; }; |
这里的D中有两个虚类表,分别从B和C继承而来,在D的构造函数中,编译器会编写必要的代码以正确初始化D的两个虚类表以使得通过B继承的虚类表和通过C继承的虚类表而获得的A的实例是同一个。
再看虚函数。它的地址被间接获得,通过虚函数表实现(这是“一种手段”),接着就必须还能改变虚函数表的内容。同上,如果自己改写,代码的兼容性很差,而C++也给出了“另一段代码”,和上面一样,通过在派生类的构造函数中填写虚函数表,根据当前派生类的情况来书写虚函数表。它一定将某虚函数表填充为当前派生类下,类型、名字和原来被定义为虚函数的那个函数尽量匹配的函数的地址。如:
struct A { virtual void ABC(), BCD( float ), ABC( float ); }; struct B : public A { virtual void ABC(); }; struct C : public B { void ABC( float ), BCD( float ); virtual float CCC( double ); }; struct D : public C { void ABC(), ABC( float ), BCD( float ); }; |
在A::A中,将两个A::ABC和一个A::BCD的地址填写到A的虚函数表中。
在B::B中,将B::ABC和继承来的B::BCD和B::ABC填充到B的虚函数表中。
在C::C中,将C::ABC、C::BCD和继承来的C::ABC填充到C的虚函数表中,并添加一个元素:C::CCC.在D::D中,将两个D::ABC和一个D::BCD以及继承来的D::CCC填充到D的虚函数表中。
这里的D是依次继承自A、B、C,并没有因为多重继承而产生两个虚函数表,其只有一个虚函数表。虽然D中的成员函数没有用virtual修饰,但它们的地址依旧被填到D的虚函数表中,因为virtual只是表示使用那个成员函数时需要间接获得其地址,与是否填写到虚函数表中没有关系。
电视机为什么要用频道来间接获得电视台的频率?因为电视台的频率人不容易记,并且如果知道一个频率,慢慢地调整共谐电容的电容值以使电路达到那个频率效率很低下。而做10组共谐电路,每组电路的电容值调好后就不再动,通过切换不同的共谐电路来实现快速转换频率。因此间接还可以提高效率。还有,5频道本来是中央5台,后来看腻了把它换成中央2台,则同样的动作(按5频道)将产生不同的结果,“按5频道”这个程序编得很灵活。
由上面,至少可以知道:间接用于简化操作、提高效率和增加灵活性。这里提到的间接的三个用处都基于这么一个想法——用“一种手段”来达到目的,用“另一段代码”来实现上面提的用处。而C++提供的虚继承和虚函数,只要使用虚继承来的成员或虚函数就完成了“一种手段”。而要实现“另一段代码”,从上面的说明中可以看出,需要通过派生的手段来达到。在派生类中定义和父类中声明的虚函数原型相同的函数就可以改变虚函数表,而派生类的继承体系中只有重复出现了被虚继承的类才能改变虚类表,而且也只是都指向同一个被虚继承的类的实例,远没有虚函数表的修改方便和灵活,因此虚继承并不常用,而虚函数则被经常的使用。
虚的使用
由于C++中实现“虚”的方式需要借助派生的手段,而派生是生成类型,因此“虚”一般映射为类型上的间接,而不是上面频道那种通过实例(一组共谐电路)来实现的间接。注意“简化操作”实际就是指用函数映射复杂的操作进而简化代码的编写,利用函数名映射的地址来间接执行相应的代码,对于虚函数就是一种调用形式表现多种执行结果。而“提高效率”是一种算法上的改进,即频道是通过重复十组共谐电路来实现的,正宗的空间换时间,不是类型上的间接可以实现的。因此C++中的“虚”就只能增加代码的灵活性和简化操作(对于上面提出的三个间接的好处)。
比如动物会叫,不同的动物叫的方式不同,发出的声音也不同,这就是在类型上需要通过“一种手段”(叫)来表现不同的效果(猫和狗的叫法不同),而这需要“另一段代码”来实现,也就是通过派生来实现。即从类Animal派生类Cat和类Dog,通过将“叫(Gnar)”声明为Animal中的虚函数,然后在Cat和Dog中各自再实现相应的Gnar成员函数。如上就实现了用Animal::Gnar的调用表现不同的效果,如下:
Cat cat1, cat2; Dog dog; Animal *pA[] = { &cat1, &dog, &cat2 }; for( unsigned long i = 0; i < sizeof( pA ); i++ ) pA[ i ]->Gnar(); |
因此一个类的成员函数被声明为虚函数,表示这个类所映射的那种资源的相应功能应该是一个使用方法,而不是一个实现方式。如上面的“叫”,表示要动物“叫”不用给出参数,也没有返回值,直接调用即可。因此再考虑之前的收音机和数字式收音机,其中有个功能为调台,则相应的函数应该声明为虚函数,以表示要调台,就给出频率增量或减量,而数字式的调台和普通的调台的实现方式很明显的不同,但不管。意思就是说使用收音机的人不关心调台是如何实现的,只关心怎样调台。因此,虚函数表示函数的定义不重要,重要的是函数的声明,虚函数只有在派生类中实现有意义,父类给出虚函数的定义显得多余。因此C++给出了一种特殊语法以允许不给出虚函数的定义,格式很简单,在虚函数的声明语句的后面加上“= 0”即可,被称作纯虚函数。如下:
class Food; class Animal { public: virtual void Gnar() = 0, Eat( Food& ) = 0; }; class Cat : public Animal { public: void Gnar(), Eat( Food& ); }; class Dog : public Animal { void Gnar(), Eat( Food& ); }; void Cat::Gnar(){} void Cat::Eat( Food& ){} void Dog::Gnar(){} void Dog::Eat( Food& ){} void main() { Cat cat; Dog dog; Animal ani; } |
上面在声明Animal::Gnar时在语句后面书写“= 0”以表示它所映射的元素没有定义。这和不书写“= 0”有什么区别?直接只声明Animal::Gnar也可以不给出定义啊。注意上面的Animal ani;将报错,因为在Animal::Animal中需要填充Animal的虚函数表,而它需要Animal::Gnar的地址。如果是普通的声明,则这里将不会报错,因为编译器会认为Animal::Gnar的定义在其他的文件中,后面的连接器会处理。但这里由于使用了“= 0”,以告知编译器它没有定义,因此上面代码编译时就会失败,编译器已经认定没有Animal::Gnar的定义。
但如果在上面加上Animal::Gnar的定义会怎样?Animal ani;依旧报错,因为编译器已经认定没有Animal::Gnar的定义,连函数表都不会查看就否定Animal实例的生成,因此给出Animal::Gnar的定义也没用。但映射元素Animal::Gnar现在的地址栏填写了数字,因此当cat.Animal::Gnar();时没有任何问题。如果不给出Animal::Gnar的定义,则cat.Animal::Gnar();依旧没有问题,但连接时将报错。
注意上面的Dog::Gnar是private的,而Animal::Gnar是public的,结果dog.Gnar();将报错,而dog.Animal::Gnar();却没有错误(由于它是虚函数结果还是调用Dog::Gnar),也就是前面所谓的public等与类型无关,只是一种语法罢了。还有class Food;,不用管它是声明还是定义,只用看它提供了什么信息,只有一个——有个类型名的名字为Food,是类型的自定义类型。而声明Animal::Eat时,编译器也只用知道Food是一个类型名而不是程序员不小心打错字了就行了,因为这里并没有运用Food.上面的Animal被称作纯虚基类。基类就是类继承体系中最上层的那个类;虚基类就是基类带有纯虚成员函数;纯虚基类就是没有成员变量和非纯虚成员函数,只有纯虚成员函数的基类。上面的Animal就定义了一种规则,也称作一种协议或一个接口。即动物能够Gnar,而且也能够Eat,且Eat时必须给出一个Food的实例,表示动物能够吃食物。即Animal这个类型成了一张说明书,说明动物具有的功能,它的实例变得没有意义,而它由于使用纯虚函数也正好不能生成实例。
如果上面的Gner和Eat不是纯虚函数呢?那么它们都必须有定义,进而动物就不再是一个抽象概念,而可以有实例,则就可以有这么一种动物,它是动物,但它又不是任何一种特定的动物(既不是猫也不是狗)。很明显,这样的语义和纯虚基类表现出来的差很远。
那么虚继承呢?被虚继承的类的成员将被间接操作,这就是它的“一种手段”,也就是说操作这个被虚继承的类的成员,可能由于得到的偏移值不同而操作不同的内存。但对虚类表的修改又只限于如果重复出现,则修改成间接操作同一实例,因此从根本上虚继承就是为了解决上篇所说的鲸鱼有两个饥饿度的问题,本身的意义就只是一种算法的实现。这导致在设计海洋生物和脯乳动物时,无法确定是否要虚继承父类动物,而要看派生的类中是否会出现类似鲸鱼那样的情况,如果有,则倒过来再将海洋生物和脯乳动物设计成虚继承自动物,这不是好现象。
static(静态)
在《C++从零开始(五)》中说过,静态就是每次运行都没有变化,而动态就是每次运行都有可能变化。C++给出了static关键字,和上面的public、virtual一样,只是个语法标识而已,不是类型修饰符。它可作用于成员前面以表示这个成员对于每个实例来说都是不变的,如下:
struct A { static long a; long b; static void ABC(); }; long A::a; void A::ABC() { a = 10; b = 0; }; void main() { A a; a.a = 10; a.b = 32; } |
上面的A::a就是结构A的静态成员变量,A::ABC就是A的静态成员函数。有什么变化?上面的映射元素A::a的类型将不再是long A::而是long.同样A::ABC的类型也变成void()而不是void( A:: )()。
首先,成员要对它的类的实例来说都是静态的,即成员变量对于每个实例所标识的内存的地址都相同,成员函数对于每个this参数进行修改的内存的地址都是不变的。上面把A::a和A::ABC变成普通类型,而非偏移类型,就消除了它们对A的实例的依赖,进而实现上面说的静态。
由于上面对实例依赖的消除,即成员函数去掉this参数,成员变量映射的是一确切的内存地址而不再是偏移,所以struct A { static long a; };只是对变量A::a进行了声明,其名字为A::a,类型为long,映射的地址并没有给出,即还未定义,所以必须在全局空间中(即不在任何一个函数体内)再定义一遍,进而有long A::a;。同样A::ABC的类型为void(),被去除了this参数,进而在A::ABC中的b = 10;等同于A::b = 10;,发现A::b是偏移类型,需要this参数,则等同于this->A::b = 10;。结果A::ABC没有this参数,错误。而对于a = 10;,等同于A::a = 10;,而已经有这个变量,故没任何问题。
注意上面的a.a = 10;等同于a.A::a = 10;,而A::a不是偏移类型,那这里不是应该报错吗?对此C++特别允许这种类型不匹配的现象,其中的“a.”等于没有,因为这正是前面我们要表现的静态成员。即A a, b; a.a = 10; b.a = 20;执行后,a.a为20,因为不管哪个实例,对成员A::a的操作都修改的同一个地址所标识的内存。
什么意义?它们和普通的变量的区别就是名字被A::限定,进而能表现出它们的是专用于类A的。比如房子,房子的门的高度和宽度都定好了,有两个房子都是某个公司造的,它们的门的高度和宽度相同,因此门的高度和宽度就应该作为那个公司造的房子的静态成员以记录实际的高度和宽度,但它们并不需要因实例的不同而变化。
除了成员,C++还提供了静态局部变量。局部变量就是在函数体内的变量,被一对“{}”括起来,被限制了作用域的变量。对于函数,每次调用函数,由于函数体内的局部变量都是分配在栈上,按照之前说的,这些变量其实是一些相对值,则每次调用函数,可能由于栈的原因而导致实际对应的地址不同。如下:
void ABC() { long a = 0; a++; } void BCD() { long d = 0; ABC(); } void main() { ABC(); BCD(); } |
上面main中调用ABC而产生的局部变量a所对应的地址和由于调用BCD,而在BCD中调用ABC而产生的a所对应的地址就不一样,原理在《C++从零开始(十五)》中说明。因此静态局部变量就表示那个变量的地址不管是通过什么途径调用它所在的函数,都不变化。如下:
void ABC() { static long a = 0; a++; } void BCD() { long d = 0; d++; ABC(); } void main() { ABC(); BCD(); } |
long g_ABC_a = 0; void ABC() { g_ABC_a++; } void BCD() { long d = 0; d++; ABC(); } void main() { ABC(); BCD(); } |
因此上面ABC中的静态局部变量a的初始化实际在执行main之前就已经做了,而不是想象的在第一次调用ABC时才初始化,进而上面代码执行完后,ABC中的a的值为2,因为ABC的两次调用。
它的意义?表示这个变量只在这个函数中才被使用,而它的生命期又需要超过函数的执行期。它并不能提供什么语义(因为能提供的“在这个函数才被使用”使用局部变量就可以做到),只是当某些算法需要使用全局变量,而此时这个算法又被映射成了一个函数,则使用静态变量具有很好的命名效果——既需要全局变量的生存期又应该有局部变量的语义。
inline(嵌入)
函数调用的效率较低,调用前需要将参数按照调用规则存放起来,然后传递存放参数的内存,还要记录调用时的地址以保证函数执行完后能回到调用处(关于细节在《C++从零开始(十五)》中讨论),但它能降低代码的长度,尤其是函数体比较大而代码中调用它的地方又比较多,可以大幅度减小代码的长度(就好像循环10次,如果不写循环语句,则需要将循环体内的代码复制10遍)。但也可能倒过来,调用次数少而函数体较小,这时之所以还映射成函数是为了语义更明确。此时可能更注重的是执行效率而不是代码长度,为此C++提供了inline关键字。
在函数定义时,在定义语句的前面书写inline即可,表示当调用这个函数时,在调用处不像原来那样书写存放、传递参数的代码,而将此函数的函数体在调用处展开,就好像前面说的将循环体里的代码复制10遍一样。这样将不用做传递参数等工作,代码的执行效率将提高,但最终生成的代码的长度可能由于过多的展开而变长。如下:
void ABCD(); void main() { ABCD(); } inline void ABCD() { long a = 0; a++; }
上面的ABCD就是inline函数。注意ABCD的声明并没有书写inline,因为inline并不是类型修饰符,它只是告诉编译器在生成这个函数时,要多记录一些信息,然后由连接器根据这些信息在连接前视情况展开它。注意是“视情况”,即编译器可能足够智能以至于在连接时发现对相应函数的调用太多而不适合展开进而不展开。对此,不同的编译器给出了不同的处理方式,对于VC,其就提供了一个关键字__forceinline以表示相应函数必须展开,不用去管它被调用的情况。
前面说过,对于在类型定义符中书写的函数定义,编译器将把它们看成inline函数。变成了inline函数后,就不用再由于多个中间文件都给出了函数的定义而不知应该选用哪个定义所产生的地址,因为所有调用这些函数的地方都不再需要函数的地址,函数将直接在那里展开。
const(常量)
前面提到某公司造的房子的门的高度和宽度应该为静态成员变量,但很明显,在房子的实例存在的整个期间,门的高度和宽度都不会变化。C++对此专门提出了一种类型修饰符——const.它所修饰的类型表示那个类型所修饰的地址类型的数字不能被用于写操作,即地址类型的数字如果是const类型将只能被读,不能被修改。如:const long a = 10, b = 20; a++; a = 4;(注意不能cosnt long a;,因为后续代码都不能修改a,而a的值又不能被改变,则a就没有意义了)。这里a++;和a = 4;都将报错,因为a的类型为cosnt long,表示a的地址所对应的内存的值不能被改变,而a++;和a = 4;都欲改变这个值。
由于const long是一个类型,因此也就很正常地有const long*,表示类型为const long的指针,因此按照类型匹配,有:const long *p = &b; p = &a; *p = 10;。这里p = &a;按照类型匹配很正常,而p是常量的long类型的指针,没有任何问题。但是*p = 10;将报错,因为*p将p的数字直接转换成地址类型,也就成了常量的long类型的地址类型,因此对它进行写入操作错误。
注意有:const long* const p = &a; p = &a; *p = 10;,按照从左到右修饰的顺序,上面的p的类型为const long* const,是常量的long类型的指针的常量,表示p的地址所对应的内存的值不能被修改,因此后边的p = &a;将错误,违反const的意义。同样*p = 10;也错误。不过可以:
long a = 3, *const p = &a; p = &a; *p = 10;
上面的p的类型为long* const,为long类型的常量,因此其必须被初始化。后续的p = &a;将报错,因为p是long* const,但*p = 10;却没有任何问题,因为将long*转成long后没有任何问题。所以也有:
const long a = 0; const long* const p = &a; const long* const *pp = &p;
只要按照从左到右的修饰顺序,而所有的const修饰均由于取内容操作符“*”的转换而变成相应类型中指针类型修饰符“*”左边的类型,因此*pp的类型是const long* const,*p的类型是const long.
应注意C++还允许如下使用:
struct A { long a, b; void ABC() const; };
void A::ABC() const { a = 10; b = 10; }
上面的A::ABC的类型为void( A:: )() const,其等同于:
void A_ABC( const A *this ) { this->a = 10; this->b = 10; }
因此上面的a = 10;和b = 10;将报错,因为this的类型是const A*.上面的意思就是函数A::ABC中不能修改成员变量的值,因为各this的参数变成了const A*,但可以修改类的静态成员变量的值,如:
struct A { static long c; long a, b; void ABC() const; } long A::c;
void A::ABC() const { a = b = 10; c = 20; }
等同于:void A_ABC( const A *this ) { this->a = this->b = 10; A::c = 20; }.故依旧可以修改A::c的值。
有什么意义?出于篇幅,有关const的语义还请参考我写的另一篇文章《语义的需要》。
friend(友员)
发信机具有发送电波的功能,收信机具有接收电波的功能,而发信机、收信机和电波这三个类,首先发信机由于将信息传递给电波而必定可以修改电波的一些成员变量,但电波的这些成员应该是protected,否则随便一个石头都能接收或修改电波所携带的信息。同样,收信机要接收电波就需要能访问电波的一些用protected修饰的成员,这样就麻烦了。如果在电波中定义两个公共成员函数,让发信机和收信机可以通过它们来访问被protected的成员,不就行了?这也正是许多人犯的毛病,既然发信机可以通过那个公共成员函数修改电波的成员,那石头就不能用那个成员函数修改电波吗?这等于是原来没有门,后来有个门却不上锁。为了消除这个问题,C++提出了友员的概念。
在定义某个自定义类型时,在类型定义符“{}”中声明一个自定义类型或一个函数,在声明或定义语句的前面加上关键字friend即可,如:
class Receiver; class Sender;
class Wave { private: long b, c; friend class Receiver; friend class Sender; };
上面就声明了Wave的两个友员类,以表示Receiver和Sender具备了Wave的资格,即如下:
class A { private: long a; }; class Wave : public A { … };
void Receiver::ABC() { Wave wav; wav.a = 10; wav.b = 10; wav.A::a = 10; }
上面由于Receiver是Wave的友员类,所以在Receiver::ABC中可以直接访问Wave::a、Wave::b,但wav.A::a = 10;就将报错,因为A::a是A的私有成员,Wave不具备反问它的权限,而Receiver的权限等同于Wave,故权限不够。
同样,也可有友员函数,即给出函数的声明或定义,在语句前加上friend,如下:
class Receiver { public: void ABC(); };
class A { private: long a; friend void Receiver::ABC(); };
这样,就将Receiver::ABC作为了A的友员函数,则在Receiver::ABC中,具有类A具有的所有权限。
应注意按照给出信息的思想,上面还可以如下:
class A { private: long a; friend void Receiver::ABC() { long a = 0; } };
这里就定义了函数Receiver::ABC,由于是在类型定义符中定义的,前面已经说过,Receiver::ABC将被修饰为inline函数。
那么友员函数的意义呢?一个操作需要同时操作两个资源中被保护了的成员,则这个操作应该被映射为友员函数。如盖章需要用到文件和章两个资源,则盖章映射成的函数应该为文件和章的友员函数。
名字空间
前面说明了静态成员变量,它的语义是专用于某个类而又独立于类的实例,它与全局变量的关键不同就是名字多了个限定符(即“::”,表示从属关系),如A::a是A的静态成员变量,则A::a这个名字就可以表现出a从属于A.因此为了表现这种从属关系,就需要将变量定义为静态成员变量。
考虑一种情况,映射采矿。但是在陆地上采矿和在海底采矿很明显地不同,那么应该怎么办?映射两个函数,名字分别为MiningOnLand和MiningOnSeabed.好,然后又需要映射在陆地勘探和在海底勘探,怎么办?映射为ProspectOnLand和ProspectOnSeabed.如果又需要映射在陆地钻井和在海底钻井,在陆地爆破和在海底爆破,怎么办?很明显,这里通过名字来表现语义已经显得牵强了,而使用静态成员函数则显得更加不合理,为此C++提供了名字空间,格式为namespace <名字> { <各声明或定义语句> }.其中的<名字>为定义的名字空间的名字,而<各声明或定义语句>就是多条声明或定义语句。如下:
namespace OnLand { void Mining(); void Prospect(); void ArtesianWell(){} }
namespace OnSeabed { void Mining(); void Prospect(); void ArtesianWell(){} }
void OnLand::Mining() { long a = 0; a++; } void OnLand::Prospect() { long a = 0; a++; }
void OnSeabed::Mining() { long a = 0; a++; } void OnSeabed::Prospect() { long a = 0; a++; }
上面就定义了6个元素,每个的类型都为void()。注意上面OnLand::ArtesianWell和OnSeabed::ArtesianWell的定义直接写在“{}”中,将是inline函数。这样定义的六个变量它们的名字就带有限定符,能够从名字上体现从属关系,语义表现得比原来更好,OnSeabed::Prospect就表示在海底勘探。注意也可以如下:
namespace A { long b = 0; long a = 0; namespace B { long B = 0; float a = 0.0f } }
namespace C { struct ABC { long a, b, c, d; void ABCD() { a = b = c = d = 12; } } ab; }
namespace D { void ABC(); void ABC() { long a = 0; a++; } extern float bd; }
即名字空间里面可以放任何声明或定义语句,也可以用于修饰自定义结构,因此就可以C::ABC a; a.ABCD();。应注意C++还允许给名字空间别名,比如:namespace AB = C; AB::ABC a; a.ABCD();。这里就给名字空间C另起了个名字AB,就好像之前提过的typedef一样。
还应注意自定义类型的定义的效果和名字空间很像,如struct A { long a; };将生成A::a,和名字空间一样为映射元素的名字加上了限定符,但应该了解到结构A并不是名字空间,即namespace ABC = A;将失败。名字空间就好像所有成员都是静态成员的自定义结构。
为了方便名字空间的使用,C++提供了using关键字,其后面接namespace和名字空间的名字,将把相应名字空间中的所有映射元素复制一份,但是去掉了名字前的所有限定符,并且这些元素的有效区域就在using所在的位置,如:
void main() { { using namespace C; ABC a; a.ABCD(); } ABC b; b.ABCD(); }
上面的ABC b;将失败,因为using namespace C;的有效区域只在前面的“{}”内,出了就无效了,因此应该C::ABC b; b.ABCD();。有什么用?方便书写。因为每次调用OnLand::Prospect时都要写OnLand::,显得有点烦琐,如果知道在某个区域内并不会用到OnSeabed的成员,则可以using namespace OnLand;以减小代码的繁杂度。
注意C++还提供了using更好的使用方式,即只希望去掉名字空间中的某一个映射元素的限定符而不用全部去掉,比如只去掉OnLand::Prospect而其它的保持,则可以:using OnLand::Prospect; Prospect(); Mining();。这里的Mining();将失败,而Prospect();将成功,因为using OnLand::Prospect;只去掉了OnLand::Prospect的限定符。
至此基本上已经说明了C++的大部分内容,只是还剩下模板和异常没有说明(还有自定义类型的操作符重载,出于篇幅,在《C++从零开始(十七)》中说明),它们带的语义都很少,很大程度上就和switch语句一样,只是一种算法的包装而已。