返回课程

  本文的中篇已经介绍了的意思,就是要间接获得,并且举例说明电视机的频道就是让人间接获得电视台频率的,因此其从这个意义上说是虚的,因为它可能操作失败--某个频道还未调好而导致一片雪花。并且说明了间接的好处,就是只用编好一段代码(按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();

  上面的容器pA记录了一系列的Animal的实例的引用(关于引用,可参考《C++从零开始(八)》),其语义就是这是3个动物,至于是什么不用管也不知道(就好象这台电视机有10个频道,至于每个是什么台则不知道),然后要求这3个动物每个都叫一次(调用

  Animal::Gnar),结果依次发出猫叫、狗叫和猫叫声。这就是之前说的增加灵活性,也被称作多态性,指同样的Animal::Gnar调用,却表现出不同的形态。上面的for循环不用再写了,它就是“一种手段”,而欲改变它的表现效果,就再使用“另一段代码”,也就是再派生不同的派生类,并把派生类的实例的引用放到数组pA中即可。

  因此一个类的成员函数被声明为虚函数,表示这个类所映射的那种资源的相应功能应该是一个使用方法,而不是一个实现方式。如上面的“叫”,表示要动物“叫”不用给出参数,也没有返回值,直接调用即可。因此再考虑之前的收音机和数字式收音机,其中有个功能为调台,则相应的函数应该声明为虚函数,以表示要调台,就给出频率增量或减量,而数字式的调台和普通的调台的实现方式很明显的不同,但不管。意思就是说使用收音机的人不关心调台是如何实现的,只关心怎样调台。因此,虚函数表示函数的定义不重要,重要的是函数的声明,虚函数只有在派生类中实现有意义,父类给出虚函数的定义显得多余。因此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::ABC变成普通类型,而非偏移类型,就消除了它们对A的实例的依赖,进而实现上面说的静态。


  • 扫一扫 扫二维码继续学习