首页 > 代码库 > 为什么基类指针和引用可以指向派生类对象,但是反过来不行?

为什么基类指针和引用可以指向派生类对象,但是反过来不行?

为什么基类指针和引用可以指向派生类对象,但是反过来不行?

基类指针和引用


  1. BaseClass *pbase = NULL;
  2. DerivedClass dclass;
  3. pbase = & dclass;

基类指针和引用可以指向派生类对象,但是无法使用不存在于基类只存在于派生类的元素。(所以我们需要虚函数和纯虚函数)

原因是这样的:

  1. 在内存中,一个基类类型的指针是覆盖N个单位长度的内存空间。 
    当其指向派生类的时候,由于派生类元素在内存中堆放是:前N个是基类的元素,N之后的是派生类的元素。
  2. 于是基类的指针就可以访问到基类也有的元素了,但是此时无法访问到派生类(就是N之后)的元素.

类型一致并不是死板地说类型一定要完全一样,类型是一种约束,帮助你验证程序的正确性。比如说你女朋友说:“我要吃水果!”,这时候你送上去一个“苹果”,也应该是满足条件的,而送上去一个馒头可能就孤独一生了,这就是类型系统的作用.

代码中pb的静态类型是Base*,这个是不可改变的,在其定义时就已经决定了。 
但pb的动态类型是DerivedClass*,这个可以在运行时改变(这样才实现了多态

C++在面向对象编程中,存在着静态绑定动态绑定的定义,本节即是主要讲述这两点区分。 
是在一个类的继承体系中分析的,因此下面所说的对象一般就是指一个类的实例。 
首先我们需要明确几个名词定义:

  • 静态类型:对象在声明时采用的类型,在编译期既已确定;
  • 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
  • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
  • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;必须搞清楚的一点是:动态绑定只有当我们指针或引用调用虚函数的时候才会发生。

从上面的定义也可以看出,非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。 
先看代码和运行结果:

  1. 1 class A
  2. 2 {
  3. 3 public:
  4. 4 /*virtual*/ void func(){ std::cout << "A::func()\n"; }
  5. 5 };
  6. 6 class B : public A
  7. 7 {
  8. 8 public:
  9. 9 void func(){ std::cout << "B::func()\n"; }
  10. 10 };
  11. 11 class C : public A
  12. 12 {
  13. 13 public:
  14. 14 void func(){ std::cout << "C::func()\n"; }
  15. 15 };

下面逐步分析测试代码及结果,

  1. 1 C* pc = new C(); //pc的静态类型是它声明的类型C*,动态类型也是C*;
  2. 2 B* pb = new B(); //pb的静态类型和动态类型也都是B*;
  3. 3 A* pa = pc; //pa的静态类型是它声明的类型A*,动态类型是pa所指向的对象pc的类型C*;
  4. 4 pa = pb; //pa的动态类型可以更改,现在它的动态类型是B*,但其静态类型仍是声明时候的A*;
  5. 5 C *pnull = NULL; //pnull的静态类型是它声明的类型C*,没有动态类型,因为它指向了NULL;

如果明白上面代码的意思,请继续,

  1. 1 pa->func(); //A::func() pa的静态类型永远都是A*,不管其指向的是哪个子类,都是直接调用A::func();
  2. 2 pc->func(); //C::func() pc的动、静态类型都是C*,因此调用C::func();
  3. 3 pnull->func(); //C::func() 不用奇怪为什么空指针也可以调用函数,因为这在编译期就确定了,和指针空不空没关系;

如果注释掉类C中的func函数定义,其他不变,即

  1. 1 class C : public A
  2. 2 {
  3. 3 };
  4. 4
  5. 5 pa->func(); //A::func() 理由同上;
  6. 6 pc->func(); //A::func() pc在类C中找不到func的定义,因此到其**基类**中寻找;
  7. 7 pnull->func(); //A::func() 原因也解释过了;

如果为A中的void func()函数添加virtual特性,其他不变,即

  1. 1 class A
  2. 2 {
  3. 3 public:
  4. 4 virtual void func(){ std::cout << "A::func()\n"; }
  5. 5 };
  6. 6
  7. 7 pa->func(); //B::func() 因为有了virtual虚函数特性,pa的动态类型指向B*,因此先在B中查找,找到后直接调用;
  8. 8 pc->func(); //C::func() pc的动、静态类型都是C*,因此也是先在C中查找;
  9. 9 pnull->func(); //空指针异常,因为是func是virtual函数,因此对func的调用只能等到运行期才能确定,然后才发现pnull是空指针;

引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。当我们使用基类的指针或者引用调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,因为它可能是一个基类的对象也可能死派生类的一个对象。如果该函数时虚函数,则知道运行时才能知道到底执行哪一个版本,判断的依据是引用或者指针所绑定的对象的真实类型。

另一方面,对非虚函数的调用和通过对象进行的函数(虚函数或非虚函数)调用 
在编译期绑定。对象的类型是不变的,我们无论如何不能令对象的静态类型和动态类型不同(指针和引用可以不同)。因此,通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。

分析:

  1. 如果基类A中的func不是virtual函数,那么不论pa、pb、pc指向哪个子类对象,对func的调用都是在定义pa、pb、pc时的静态类型决定,早已在编译期确定了。

同样的空指针也能够直接调用no-virtual函数而不报错(这也说明一定要做空指针检查啊!),因此静态绑定不能实现多态

  1. 如果func是虚函数,那所有的调用都要等到运行时根据其指向对象的类型才能确定,比起静态绑定自然是要有性能损失的,但是却能实现多态特性;

本文代码里都是针对指针的情况来分析的,但是对于引用的情况同样适用。

至此总结一下静态绑定和动态绑定的区别:

  1. 静态绑定发生在编译期,动态绑定发生在运行期;

  2. 对象的动态类型可以更改,但是静态类型无法更改;

  3. 要想实现动态,必须使用动态绑定

  4. 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;

建议:

绝对不要重新定义继承而来的非虚(non-virtual)函数(《Effective C++ 第三版》条款36),因为这样导致函数调用由对象声明时的静态类型确定了,而和对象本身脱离了关系,没有多态,也这将给程序留下不可预知的隐患和莫名其妙的BUG

另外,在动态绑定也即在virtual函数中,要注意默认参数的使用。当缺省参数和virtual函数一起使用的时候一定要谨慎,不然出了问题怕是很难排查。 
看下面的代码:

  1. 1 class E
  2. 2 {
  3. 3 public:
  4. 4 virtual void func(int i = 0)
  5. 5 {
  6. 6 std::cout << "E::func()\t"<< i <<"\n";
  7. 7 }
  8. 8 };
  9. 9 class F : public E
  10. 10 {
  11. 11 public:
  12. 12 virtual void func(int i = 1)
  13. 13 {
  14. 14 std::cout << "F::func()\t" << i <<"\n";
  15. 15 }
  16. 16 };
  17. 17
  18. 18 void test2()
  19. 19 {
  20. 20 F* pf = new F();
  21. 21 E* pe = pf;
  22. 22 pf->func(); //F::func() 1 正常,就该如此;
  23. 23 pe->func(); //F::func() 0 哇哦,这是什么情况,调用了子类的函数,却使用了基类中参数的默认值!
  24. 24 }

为什么会有这种情况,请看《Effective C++ 第三版》 条款37。 
这里只给出建议: 
绝对不要重新定义一个继承而来的virtual函数的缺省参数值,因为缺省参数值都是静态绑定(为了执行效率),而virtual函数却是动态绑定。

override关键字(C++ 11)

基类中的虚函数在派生类中隐含的也是一个虚函数。当派生类覆盖了虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。如果,函数名字相同但是形参列表不同,这是合法的,但是新定义的函数与基类中的函数时相互独立的。并没有发生覆盖,通常情况下,我们把这当做一种错误,因为我们希望它发生覆盖,但是不小心形参列表弄错了。调试并发现这样的错误很困难,因此新标准中引入了override关键字。在没有发生覆盖虚函数的情况下(比如:参数列表不同、基类中的这个函数不是虚函数或者是基类中没有该函数)不能通过编译。

相应的,我们还可以使用final将函数声明为不允许覆盖(只有虚函数才存在覆盖)的。

finaloverride出现在形参列表和尾置返回类型之后。

虚函数与默认实参

虚函数也可以拥有默认实参,如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。也就是说,如果我们通过基类的指针或者引用调用参数,则使用基类中定义的默认实参,即使实际运行的是派生类总的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不同。

回避虚函数机制

如果希望对虚函数的调用不要进行动态绑定,而是希望执行它的特定版本,使用作用域运算符可以实现这一目标。

baseP->Base::func();

通常情况下,只有成员函数和友元函数使用作用域运算符回避虚函数。如果一个派生类的虚函数需要调用它的基类版本,如果没有使用作用域运算符,则在运行时该调用将被解析成派生类版本自身的调用,从而导致无限循环。

为什么基类指针和引用可以指向派生类对象,但是反过来不行?