C++:类继承

60 阅读10分钟

1、公有继承

继承:is-a 关系。DerivedClass is a BaseClass,即 DerivedClass is a kind of BaseClass,即 “DerivedClass 是一种 BaseClass”。

1.1 基类和派生类 の 构造和析构

最常见的是公有派生(class DerivedClass : public BaseClass {};)。

  • 派生类根据需要增加 Data Members 和 Method Members,但无法删除基类成员。
  • 派生类可以直接访问基类的 protected、public 成员。
  • 派生类的方法可以使用作用域解析运算符(::)调用基类中的 public 方法。
  • 派生类可以间接通过 non-private 方法访问基类的 private 成员。

实例化派生类对象时,首先调用基类构造函数创建基类对象,然后调用派生类构造函数创建派生类对象,即先初始化基类数据成员,再初始化派生类的数据成员。初始化数据成员,C++ 是通过使用成员初始化列表语法来完成的

  • 可以指明要使用的基类构造函数👇
    DerivedClass::DerivedClass(T t, U u, V v):BaseClass(U u, V v), mT(t) {...}
    根据参数 uv 调用基类构造函数,创建基类实例化对象。
    DerivedClass::DerivedClass(T t, BaseClass bc):BaseClass(bc), mT(t) {...}
    根据 bc 调用基类的复制构造函数(若基类没有显式定义则自动创建)。
  • 若未指定基类构造函数,则将使用默认的基类构造函数。因为默认构造函数什么事情也不做,基类的数据成员就需要在派生类构造函数中进行初始化了,或者在其使用之前进行必要的初始化!!!否则,容易因此导致未定义的问题。特别是指针类型的变量,未经初始化就使用,容易产生空指针问题。
  • 若存在多级继承,除虚基类外,类只能将参数传递给相邻的基类。

【总结】派生类构造函数
(1)首先创建基类对象;
(2)派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数;
(3)派生类构造函数应初始化派生类新增的数据成员。

析构派生类对象时,顺序与构造时相反:先调用派生类析构函数,再调用基类析构函数

1.2 继承和动态内存分配-派生类默认生成的函数

1、第一种情况:派生类不使用 new

即不使用动态内存分配。派生类不需要定义显式析构函数、赋值构造函数、赋值运算符。

image.png

2、第二种情况:派生类使用 new 动态分配

则需要定义显式析构函数、复制构造函数、赋值运算符。因为堆内存要手动进行管理,析构时需要 delete,复制和赋值时需要执行深度拷贝。

image.png

image.png

  • hasDMA 复制构造函数只能访问 hasDMA 的数据,因此它必须调用 baseDMA 复制构造函数来处理共享的 baseDMA 数据。注意成员初始化列表将一个 hasDMA 引用给 baseDMA 复制构造函数,完全没问题,因为 baseDMA 复制构造函数本就是接受一个 const baseDMA& ,基类引用指向派生类 hasDMA 完全可以。
  • 派生类的显式赋值运算符必须负责所有继承的 baseDMA 基类对象的赋值,可以通过显式调用基类赋值运算符来完成。

3、派生类如何访问基类的友元

使用 baseDMA 类的友元函数,因为友元不是成员函数,所以不能使用(::)来指出要使用哪个函数。【解决方法】是使用强制类型转换,以便匹配原型时能够选择正确的函数

image.png

2、指针和引用类型的兼容性

通常,C++ 不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型。但是,在类继承场景中,允许在不进行显式类型转换时,指向基类的引用或指针可以引用或指向派生类对象;反之不可。

【向上强制转换】
将派生类引用或指针转换为基类引用或指针。这样不好理解,换句话:声明为基类的引用或指针,引用或指向派生类对象

  • 具有可传递性:可以一级一级向祖先类转换。
  • 可以隐式转换。
DerivedClass dc = DerivedClass();
BaseClass* p_bc = &dc;
BaseClass& r_bc = dc;

【向下强制转换】
将基类指针或引用转换为派生类指针或引用。换句话:声明为派生类的引用或指针,引用或指向基类对象必须进行显式类型转换,否则向下强制转换是不允许的

总结

  • 基类指针(or 引用)  可以在“不显式转换”的情况下指向(or 引用)派生类对象
  • 反之,不可以将基类对象和地址赋值给派生类引用和指针。若可以,派生类引用能够为基对象调用派生类方法,这肯定出问题啊!

3、多态公有继承

如果要在派生类中重新定义基类的方法,通常应将基类方法声明为 virtual。

在实际开发场景中,多态场景,一般是通过基类指针(or 引用)指向派生类对象,实现所谓的“父类调用子类的方法”。注意,“父类调用子类方法”,这是一种不严谨甚至不正确的说法!

3.1 多态的实现-虚函数

2 种必要机制 用于实现多态公有继承:
(1)在派生类中重新定义基类的方法
(2)将基类方法声明为虚方法,关键词 virtual

方法在基类中被声明为 virtual 后,它在基类及其所有的派生类(包括从派生类派生出来的类)中将自动成为虚方法

  • 若基类方法是 non-virtual 的,则将根据引用(or 指针)选择方法;
  • 若基类方法是 virtual 的,则将根据引用(or 指针)实际指向的对象类型选择方法。

多态设计中,基类必须声明虚析构函数。确保释放派生对象时,按正确的顺序调用析构函数。

虚函数的注意事项

  • 构造函数不能是虚函数。
  • 析构函数必须是虚函数,除非类不作为基类。
    建议:给每一个类都定义个虚析构函数,即使它不作为基类,但程序员是人,粗心会导致一些问题。这个建议只是增加了一些开销,远远小于犯错引入的成本。
  • 只有类成员才可能是虚函数。
    友元函数不能是虚函数,友元函数又不是类成员。那想要继承并重新定义基类的一个友元函数要咋办?可以通过让友元函数使用虚成员函数来解决。
  • 注意区别类中的重写、重新定义和重载。
    重写(override),子类在集成父类时,重写父类中的方法。函数原型相同,但具体实现不同。
    重载(overload),涉及类时,一般是在单个类 中讨论这个概念,指多个同名但不同参的函数。
    重新定义(redefine):子类重定义父类中的方法
    1、函数原型相同,则等于重写;
    2、函数名相同,但函数参数不同,等价于重载。但这会有两种隐藏
    (1)重新定义的同名不同参函数,对其父类和子类是不可见的,即使通过多态机制(基类指针指向派生类对象)也是无法实现访问的。
    (2)其父类中的同名函数,对其本身变为不可见了,即无法被继承,除非再次针对同名同参函数进行重写
    因此,若基类函数在派生类中被重载,即这里的重新定义了一个同名不同参函数,若派生类还想要访问基类函数中的同名函数,则必须对其进行重写
    如下👇,派生类 MyClassChild 重载基类方法 void show(int n) 为 void show(),若 派生类 还想要调用基类方法 void show() 则必须额外进行重写

image.png

派生类 MyClassChild 额外对其进行重写👇

image.png

3.2 虚函数的工作原理

注意,不同的编译器实现虚函数的细节上可能有所不同,包括一些性能上的优化。但虚函数的工作原理是相同的,这里通过一种可能的虚函数实现机制来学习虚函数的工作原理。

1、工作原理

给每个类实例对象添加一个隐藏成员,它是一个指针,一般称为虚指针(vptr)。虚指针指向一个函数地址数组,该数组被称为虚函数表(virtual function table,vtbl)。虚函数表中存储了为类实例对象进行声明的虚函数的地址。

image.png

基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。
派生类对象将包含一个指向独立地址表的指针。
(1)如果派生类重新定义了基类的虚函数,该虚函数表将保存派生类新定义的新函数的地址。
(2)如果派生类没有重新定义基类的虚函数,该虚函数表将保存基类函数的地址。
(3)如果派生类新定义了基类没有的虚函数,则该函数的地址也将被添加到虚函数表中。
调用虚函数时,程序将查看存储在对象中的 vtbl 地址,然后转向相应的函数地址表。

举个栗子:

image.png

Physicist adam();
Scientist* psc = &adam; // psc->vptr 获取到 Physicist vtbl 表地址 2096
psc->show_all();        // 获悉表中第2个函数地址6820,并前往执行该函数

使用虚函数时,在内存和执行速度方法有一定的成本:

  • 每个对象都增大,增大量为存储地址的空间;
  • 对于每个类,编译器都会为其创建一个虚函数地址表(e.g.数组实现的一个表);
  • 对于每个函数调用,都需要执行一项额外的操作,即到 vptr 指向的 vtbl 中查找地址。

3.3 纯虚函数(pure virtual function)

C++ 通过使用纯虚函数提供未实现的函数。纯虚函数声明的结尾处为 =0 。
e.g. virtual void show() const = 0;

3.4 抽象基类

类声明中包含至少一个纯虚函数时,其被称为抽象基类

  • 因为可以不提供对纯虚函数的实现,故不能创建抽象基类的对象
  • 抽象基类要求其派生类必须实现抽象基类声明的所有纯虚函数

4、静态联编和动态联编

【函数名联编】将源代码中的函数调用解释为执行特定的函数代码块。
【静态联编】在编译过程中进行联编。
——编译器对非虚方法使用静态联编。
【动态联编】编译必须生成能够在程序运行时选择正确的虚方法的代码。
——编译器对虚方法使用动态联编。

编译器默认使用静态联编。WHY ?

  • 效率层面,动态联编,需要程序在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型。这增加了额外的处理开销。
  • 概念层面,在设计类时,一些成员函数不需要在派生类中重新定义。这指出了仅将预期需要被重新定义的方法声明为 virtual,另一方面,这样做效率更高。
    【总结】
     针对在派生类中重新定义基类的方法,将其设置为 virtual。否则,按普通方法进行处理。