C++、类继承与虚函数

1 继承

1.1 继承方式

  1. public,基类成员在派生类中的访问权限不变。
  2. protected,基类中的 public 和 protected 成员在派生类中都变成 protected 成员。
  3. private,基类中的 public 和 protected 成员在派生类中都变成 private 成员。

无论是那种继承,基类中的 public 和 protected 成员在派生类的定义体中都是可访问的。

1.2 派生类到基类的转换与赋值

派生类对象的内存头部包含了完整的基类内存结构,之后才是派生类新定义成员的内存。因此,派生类对象在一定程度上能够转换为基类对象的指针或引用,或者对基类对象进行初始化和赋值。

在同一个程序中,派生类类型的对象既可能可以,也可能不可以转换或赋值到基类。基类对象只能访问基类的 public 成员,而对于不同的继承方式,派生类对象对这些成员的访问权限会发生改变。只有当派生类对象对于继承自基类的 public 成员的可访问性与基类对象一致时,进行转换或者赋值才是安全的。

  1. 无论是哪种继承方式,派生类定义体中基类的 public 成员都是可访问的,因此在派生类的成员函数内部进行转换或者赋值都是允许的;

  2. 对于 protected 和 private 继承,派生类对象并不能访问基类的 public 成员,此时在派生类定义体外部进行转换或赋值是不允许的。

class A {
    
    };
class B : private A {
    
    
   void tmp(B b) 
   {
    
    
      A x = b;    // ok,使用派生类对象进行基类对象的初始化
      x = b;      // ok,使用派生类对象对基类对象赋值
      A &y = b;   // ok,派生类通过引用转换为基类
      A *z = &b;  // ok,派生类通过指针转换为基类
   }
};
class C : protected B {
    
    
   void tmp(C c)
   {
    
    
      A x1 = c;   // error,C类不能访问A类的public成员,除非B类至少是以protected继承A类
      A &y1 = c;  // error,同上
      B x2 = c;   // ok,这时B类是C类的直接基类,C类可以访问B类的public成员
      B &y2 = c;  // ok,同上
   }
};
int main() {
    
    
   A a1 = B();    // error,这时B类不能访问A类的public成员
   A a2 = C();    // error,这时C类不能访问A类的public成员
   B b1 = C();    // error,这时C类不能访问B类的public成员
}

这里所说的转换与初始化或赋值的意义不一样(个人理解):

  1. 转换相对于指针或引用而言,即内存中并不存在基类对象的实例,但基类指针或引用可以按照基类的访问权限,访问派生类的对象实例中继承自基类的数据成员或函数成员。注意,如果派生类对基类的数据成员或者函数成员进行了重定义以及函数重写或者重载,那么进行转换后,基类指针或者引用也只能访问带有基类域限定符的成员,而不是重定义后的成员,因为重定义成员的内存不在基类的可访问范围内。

    不过对于函数成员有一点特殊,如果基类中某函数被声明为虚函数,那么基类指针或引用调用的是重写后的函数。这是继承实现运行时多态的关键,此时内存中会维持一个虚函数表,基类的指针或引用包含一个指向虚函数表的指针,当基类指针或引用调用虚函数时,实际调用的是派生类对象所对应的虚函数,从而实现运行时多态。具体查看《虚函数》部分内容。

  2. 初始化或赋值即按照基类的访问方式,将派生类对象实例的数据成员复制到正在构造或已存在的基类对象实例对应的成员中。这个一般是通过调用基类的拷贝构造函数以及赋值函数实现的。从这以后,该基类对象与输入的派生类对象再无联系,从而也不能实现运行时多态。

注意,回忆指针与引用的区别,使用指针和使用引用进行派生类转换也是有区别的:

  1. 通过引用 A &a=b; a=c;

    由于引用只能初始化,一经绑定后再也不能更改,当执行 A &a=b; 后,a 的运行时类型就固定为 B 的类型,a 的虚表指针更新为 b 的虚表指针。当执行 a=c; 时,a 的运行时类型并不会更改为 C 的类型,而是通过调用基类的赋值函数,将 c 的基类成员复制到 b 的基类成员。a 的虚表指针不会改变。

  2. 通过指针 A *a=&b; a=&c;

    由于指针是可赋值的,当执行 A *a=&b; 后,a 的运行时类型就变为 B 的类型,a 的虚表指针也会更新为 b 的虚表指针。但执行 a=&c; 后,a 的运行时类型就变为 C 的类型,同时其虚表指针也会更新为 c 的虚表指针。在这个过程中,只有指针变量的赋值,而没有赋值函数的调用。

2 虚函数

因为派生类对象的内存结构包含了基类对象的完整内存结构,所以派生类对象可直接赋值给基类对象,或者将派生类的指针赋值给基类指针,或者通过基类类型进行派生类对象的引用。

class A {
    
    };
class B : public A {
    
    };
B b;
A a = b;
A *a = &b;
A &a = b;

变量 a 的可访问成员范围由基类 A 定义。如果派生类中对基类的函数进行了重定义,基类变量并不能因此调用派生类的实现方法。为了实现变量a的成员函数的动态绑定,C++提供了虚函数的方法。

只有类中的成员函数才有资格成为虚函数,因为虚函数仅适用于有继承关系的类对象。虚函数在基类内的声明前面加上 virtual 关键字,类外定义不能加 virtual。当存在虚函数时,基类对象实例化或者作为指针或引用时,在其内存空间头部维持一个虚表(virtual table)指针。

一个程序的虚表通常是一大块的连续内存,编译器会将程序中存在虚函数的类的虚函数的地址按顺序填入虚表中。例如,假设 B 继承自 A,且 A 中定义了 f1() 和 f2() 两个虚函数;而 C 继承自 B,B 中又定义了一个虚函数 f3(),那么虚表可能为:

|vptrA=A::f1|A::f2| xxx |vptrB=B::f1|B::f2|B::f3| xxx |vptrC=C::f1|C::f2|C::f3|

当定义了一个类的对象实例或者指针或引用时,虚表指针会自动初始化为该类在虚表中存储的第一个函数指针的位置。 例如 A a;a.vptr=A::f1; B b;b.vptr=B::f1; C c;c.vptr=C::f1; 当然 vptr 并不是编程可见的。不同的编译器可能有不同的实现方法。

当派生类对象通过指针或引用转换为基类类型时,基类指针或引用的 vptr 会更新为派生类的 vptr。由于虚函数是按顺序摆放的,当基类调用虚函数比如 f2() 时,其实际调用的函数是 vptr[1],而此时 vptr 已经更新为派生类的 vptr,所以就能调用派生类的版本。例如,A &a = b; 此时 a.vptr=vptrB=B::f1a.f2() 调用的是 a.vptr[1]=vptrB[1]=B::f2。同理,B *b = &c; 此时 b->vptr=vptrC=C::f1b->f3() 调用的是 b->vptr[2]=vptrC[2]=C::f3

注意不同编译器对于虚函数的实现方法不一定是一样的,但总体上是相似的。在 vs 中,右键点击 xxx.cpp 文件的 属性 -> c/c++ -> 命令行,添加 /d1 reportAllClassLayout,编译的时候就会输出所有类的详细内存结构,或者 /d1 reportSingleClassLayoutX 输出特定类 X 的内存结构。在 g++ 中,可通过 g++ -fdump-class-hierarchy xxx.cpp 命令获得类的详细内存结构。 以下是 g++ 输出的内存结构示例:

   Vtable for A
   A::_ZTV1A: 4u entries
   0     (int (*)(...))0
   8     (int (*)(...))(& _ZTI1A)
   16    (int (*)(...))A::f1
   24    (int (*)(...))A::f2

   Class A
      size=16 align=8
      base size=12 base align=8
   A (0x0x7f9333cd3840) 0
      vptr=((& A::_ZTV1A) + 16u)

   Vtable for B
   B::_ZTV1B: 5u entries
   0     (int (*)(...))0
   8     (int (*)(...))(& _ZTI1B)
   16    (int (*)(...))B::f1
   24    (int (*)(...))B::f2
   32    (int (*)(...))B::f3

   Class B
      size=16 align=8
      base size=16 base align=8
   B (0x0x7f9333c979c0) 0
      vptr=((& B::_ZTV1B) + 16u)
   A (0x0x7f9333cd38a0) 0
         primary-for B (0x0x7f9333c979c0)

派生类重定义虚函数时,可以不加 virtual 关键字,其默认为虚函数,参数列表与返回类型都必须与基类一致。但有一种例外,当基类虚函数返回的是基类指针或引用时,派生类可返回派生类的指针或引用。

基类虚函数在派生类中的可见性和普通函数一致,即如果派生类中没有重写基类虚函数,但包含了其他同名函数的重载,则派生类对象无法直接访问虚函数,但可以通过指针或引用转换为基类类型时调用基类的版本。但是该派生类被其他类继承时,派生类的虚表中依然包含了基类的虚函数。因此基类变量同样可以接受派生类的派生类的对象通过指针或引用的转换以及虚函数的调用。

构造函数不能为虚函数。对于定义一个对象实例,在调用构造函数时,对象还不存在,虚表指针也没有被初始化,这时候讨论虚函数是没有意义的。对于指针或者引用,因为构造函数并不是一般意义上的成员函数,你无法通过指针和引用来调用构造函数,它们必须绑定一个已经构造好的对象才有意义,而这时候构造函数是否为虚函数同样是没有意义的。

静态函数也不能为虚函数。因为静态函数是独立于对象实例而存在的,无法进行动态绑定。

析构函数可以为虚函数。因为析构函数是在对象实例化后才调用的,而且可以像普通成员函数那样显式调用。实际上,析构函数最好应该为虚函数。特别是当派生类中存在着内存动态分配时,基类析构函数必须定义为虚函数。因为如果析构函数不是虚函数,那么delete基类指针时,其无法访问到派生类的析构函数,这就造成了内存泄漏问题。但因为虚函数需要维持虚表,会造成内存增加和效率降低的问题,所以编译器默认析构函数为非虚函数。

猜你喜欢

转载自blog.csdn.net/qq_33552519/article/details/124092454
今日推荐