跳至主要內容

继承

LincZero大约 14 分钟

继承

继承 - 类继承

通用

简概

  • 派生类功能
    • 存储了基类的数据成员(派生类继承了基类的实现)
    • 可以使用基类的方法(派生类继承了基类的接口)
  • 派生类应该增添的东西
    • 需要自己的构造函数(构造函数不能继承,C++新增了一种能继承构造函数机制,但默认仍然不继承构造函数)
    • 可根据需要添加额外的数据成员和成员函数
  • 派生类构造函数要点
    • 首先创建基类对象
    • 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数
    • 派生类构造函数应初始化派生类新增的数据成员

基础方式

公有继承(public

  • 简概
    • 基类称为公有基类,派生类称为公有派生
  • 访问限制
    • 与客户代码的权限差不多,但不用通过对象来调用,而是当做派生类方法直接使用
    • 当重新定义了同名方法覆盖了基类的版本,仍然可以使用作用域解析符访问基类版本的方法
    • 而且多了一个可以访问保护成员的权限
  • 使用
    • 声明:如class A : public APerent {}
    • 访问:使用作用域解析符访问基类的基类方法
  • 使用场景
    • 最常用的方式,建立is-a关系

保护继承(protected

  • 访问限制

    • 基类的公有成员和保护成员都将成为派生类的保护成员
    • 即:基类方法将不会成为派生对象公有接口的一部分,但可以在派生类的成员函数中使用它们
    • 与私有访问不同的是,该类的派生类也可以使用这些方法
  • 使用

    • 声明:如class A : protected APerent {}
    • 访问:使用作用域解析符访问基类的基类方法
  • 使用场景

    • 包含、私有继承,均可实现has-a关系
  • 与私有继承比较

    • 私有继承更安全,在某些情况,保护继承更方便

私有继承(peivate

  • 访问限制
    • 基类的公有成员和保护成员都将成为派生类的私有成员
    • 即:基类方法将不会成为派生对象公有接口的一部分,但可以在派生类的成员函数中使用它们
  • 使用
    • 声明:如class A : peivate APerent {},或class A : APerent {}(默认为私有继承)
    • 访问:使用作用域解析符访问基类的基类方法
  • 使用场景
    • 包含、私有继承,均可实现has-a关系

半公有半私有继承(using声明方法)

在使用保护派生和私有派生时,如果要将一些基类方法在派生类外面可用,有两种方法

  • 方法一
    • 定义一个使用该基类方法的派生类方法
  • 方法二
    • 将函数调用包装在另一个函数调用中,即使用一个using声明
      • 例如:using std::valarray<double>::min;
  • 方法三(老式方法,现已被摒弃,即将停止使用)
    • 在私有派生类中重新声明基类方法,即将方法名放在派生类的公有部分
      • 例如:std::valarray<dpouble>::operator[];

【总结】各种继承方式

派生类对基类的访问权限(表竖着看)

特征公有继承保护继承私有继承
基类的公有成员变成派生类的公有成员派生类的保护成员派生类的私有成员
基类的保护成员变成派生类的保护成员派生类的保护成员派生类的私有成员
基类的私有成员变成无(只能通过基类接口访问)无(只能通过基类接口访问)无(只能通过基类接口访问)
能否隐式向上转换是(但只能在派生类中)

补充:使用基类接口时可以不使用作用域解析运算符,但一般使用。否则当派生类重新定义了该方法时会优先使用派生类方法

多重继承

详见另一篇笔记 多重继承会带来额外的其他复杂性,需要一些额外的补丁,内容较多

继承 - 方法继承

区分:重载、覆盖、隐藏(override、Override、hide)

参考:

重载 (override)

  1. 相同的范围,即处在相同的空间中;
  2. 函数名相同
  3. 参数不同,即参数个数不同,或相同位置的参数类型不同;
  4. const成员函数可以和非const成员函数形成重载;
  5. virtual关键字、返回类型对是否够成重载无任何影响。

覆盖 (Override) 或者应该叫 “重写”

  1. 不同的范围(分别位于派生类与基类);
  2. 函数名相同
  3. 参数相同
  4. 基类函数必须有virtual关键字。

隐藏 (hide)

  1. 如果派生类与基类中的函数名相同,但是参数不同。此时,不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。
  2. 如果派生类与基类中的函数名相同,并且参数相同。但是基类函数没有virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。

小结

  • 相同范围:重载

  • 不同范围:覆盖或隐藏(即:继承 x 多态)

    函数名参数基类有virtual关键字结果
    相同不同有或无隐藏
    相同相同隐藏
    相同相同覆盖

子类构造器和子类解析器

C++

A::A():PerentA(){}
// 一般使用成员初始化列表语法(效率高)来调用基类构造函数
// 如果省略基类的构造函数,则程序自动使用默认的基类构造函数

原理

  • 构造函数原理
    • 先使用基类的构造函数,然后再调用派生类构造函数
      • 基类构造函数负责初始化基类的数据成员,派生类构造函数主要用于初始化新增的数据成员
      • 派生类构造函数总是调用一个基类构造函数,如不显示声明则使用基类默认构造函数
  • 析构函数原理
    • 与构造的顺序相反
    • 先使用派生类的构造函数,再自动调用基类析构函数

指定方法版本

如果希望调用超类而不是子类的方法

  • Java:使用特定的关键字super解决这个问题

    super.getSalary()
    
  • C++:使用::

    父类名::getSalary()
    

函数重写(重载

希望同一个方法在派生类和基类的行为不同,有两种方法

  • 派生类重新定义基类方法
  • 虚方法

派生类重新定义基类方法

  • 使用
    • 重新定义函数
  • 调用
    • 举例:aPerson.fn();a.fn();使用的是两个不同的版本
  • 原理
    • 程序能能使用对象类型来确定使用哪个版本

虚方法/虚函数(virtual method

使用
  • 使用
    • 在基类的原型前面加上关键字virtual
    • 例如:virtual ~Brass(){}
  • 调用
    • aPerson_ref.fn();a_ref.fn();
    • 若只是在派生类中重新定义了方法,则都使用的是基类方法
    • 若基类的该方法声明为虚方法,则先在派生类中寻找该方法,优先使用派生类版本
效率和成本
  • 编译与运行角度
    • 虚函数使用动态联编,因此效率较低
  • 程序上的角度
    • 每个对象都将增大,增大量为存储地址的空间(内存损耗,增大了一个地址的大小)
    • 对于每个类,编译器都创建一个虚函数地址表(内存损耗,随着虚函数的增多数组变大,损耗增大)
    • 每个函数调用,都执行一项额外操作——到表中查找地址(时间损耗,虚函数越多,平均查表时间越长)
底层实现原理
  • 给每个对象(无论他是否基类)添加一个隐藏成员,该隐藏成员中保存了一个指向函数地址数组的指针
    • 这种数组称为虚函数表virtual function table,vtbl)
    • 虚函数表的内容为类中所有虚函数的地址
  • 再看基类和派生类的情况,例如:
    • 基类对象包含了一个指针,该指针指向基类中所有虚函数的地址表(虚函数表)
    • 派生类对象包含了一个指针,该指针指向的虚函数地址表。若派生类没有提供虚函数的定义,则该虚函数表的内容与基类的虚函数表一样
    • 如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址。如果没有重新定义虚函数,则保留原始地址版本
  • 个人原理思考
    • 静态联编与动态联编在调用函数时的区别
      • 静态联编在调用函数时是直接知道该函数的地址的,汇编代码不需要知道类中有没有这个方法
      • 而动态联编是不知道函数的地址
    • 各种语言的方案
      • 动态语言是要到类中找,寻找该类有没有定义这个方法
      • 但**C++**使用虚函数表是不让在类中找有没有定义这个方法,和找这个方法的地址
      • 让你到虚函数表中找。即减少了你寻找这个函数的范围(查表时不用去寻找所有函数,而只需要查找虚函数中的函数)
    • 对比C++方式和动态语言方式
      • 从查表角度再来看具体的成本损耗,C++比起纯动态语言:
      • (1) 减少了虚函数地址表的内存大小
      • (2) 减少了查表的时间
    • 再来看:为什么不把类中的所有方法声明或默认为虚方法?
      • 这是一种方便于编程员,但不利于效率的方法
      • 使用的是动态联编,需要采用一些方法来跟踪基类指针或引用指向的对象类型。开销大、效率低
      • 所以C++并没有这么设计(C++的理念是只有在需要的情况下使用动态联编,其他情况一律使用静态联编)
      • 但这真的很不方便,基类和派生类都需要增加virtual关键字,即可能需要修改源码?!
注意项
  • 构造函数不能是虚函数
    • 因为派生类不会继承基类的构造函数,除了派生类的构造函数先调用基类构造函数外,其余情况不会使用基类的构造函数方法
    • 抬杠:如果基类定义了个与派生类同名的函数,那怎么搞?
  • 析构函数应当是虚函数
    • 这是为了确保释放派生对象时,按正确的顺序释放派生对象
  • 友元不能是虚函数
    • 因为友元不是类成员,只有成员才能是虚函数
    • 但如果由于这个原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决
  • 没有重新定义
    • 如果派生类没有重新定义函数,将使用基类版本
    • 如果派生类位于派生链中,则使用最新的虚函数版本,例外的情况是基类版本是隐藏的
  • 重新定义将隐藏方法
    • 重新定义本质并非是函数重载,而是隐藏起基类版本
    • 即:如果重新定义的版本和原来的版本不同(如接受的参数不同),不会生成函数的两个重载版本,而且可能会出现编译器警告
  • 重新定义的原则(大坑
    • 如果重新定义继承的方法,应确保与原来的原型完全相同 但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针(这种例外是新出现的) 这种特性被称为返回类型协变covariance of return type
    • 如果基类声明被重载了,则应在派生类中重新定义所有的基类版本

虚方法使用场景

  • 选用建议
    • 虚方法更严谨,在使用引用对象时不会,所有可能派生类重新定义的方法都应该声明为虚方法
    • 但正如虚方法会损耗效率,应当谨慎使用

Q:C++只有虚函数才能被重写吗?

Q:引申问题:C++只有虚函数才能被重写吗?

A:如果基类没有virtual关键字,派生类定义相同函数名后,基类的函数将被隐藏

例程

#include <iostream>
class A{
public:
    void func(){std::cout<<"A"<<std::end;}
    void func(int k){}
};

class B:public A{
public:
    using A::func;	// 载入A方法,若注释
    void func(int i){}
};

int main(){
    B b;
    b.func();		// 若注释前面的using,则编译出错这一句,告诉不接受0个参数。
}

继承 - 其他

方法的访问权限

private

protected

  • 访问限制
    • 能使派生类访问,而客户代码无法通过对象访问
  • 使用场景
    • 可以放实现接口方法的辅助方法
  • 与私有成员比较
    • 私有成员更安全,在某些情况,保护成员更方便

public

【功能扩展】继承

【功能扩展】继承 x 转换

  • 派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting
  • 基类引用或指针转换为派生类引用或指针被称为向下强制转换(downcasting
    • 通常向下强制转换不被允许,原因是is-a关系通常是不可逆的

【功能拓展】继承 x 引用

基类指针/引用

引用兼容性属性

  • 直接行为
    • 基类指针可以在不显示类型转换的情况下指向派生类对象,反之不行
    • 基类引用可以在不显示类型转换的情况下指向派生类对象,反之不行
  • 设计原因
    • 派生类(非公有好像不行?)可以使用基类的任何公有成员(保护成员也行)
  • 其他行为
    • 可以用派生类对象对象作参初始化基类对象
      • 例如:APerent aPerent(a)
      • 原理:基类对象有默认复制构造函数APerent(cosnt APerent&);
    • 可以让派生类对象赋值给基类对象
      • 例如:aPerent = a;
      • 原理:基类对象有默认赋值运算符重载APerent & operator = (const TalbeTennisPlayer &) cosnt;

虚函数

【功能拓展】继承 x 类成员 x new

之前说过,在类成员中使用new会引发一些问题:

  • 构造情况,需要显示调用所有的构造函数(包括默认的)都使用new(new方式相同)
  • 销毁情况,需要显示调用默认的析构函数 [和默认赋值运算符] 都使用delete(delte方式与new相同)
  • 赋值情况,需要修改复制构造函数 [和默认赋值运算符] 里的行为为深复制

而在涉及到继承时,该情况更加复杂(基类和派生类都有可以有动态new的类成员)

  • 假如基类使用new,派生类不使用new

    • 总结:不需要特别的操作
    • 默认构造函数:不需要额外操作,调用前会自动调用基类构造函数
    • 析构函数:不需要额外操作,调用后会自动调用基类析构函数
    • 复制构造函数
      • 默认情况下使用浅赋值,但更准确的来说是成员赋值,成员赋值将根据数据类型采用响应的复制方式
      • 但显示定义复制构造函数中的操作会覆盖这一行为(如在内部使用strcmp进行字符串深复制)
      • 而复制类成员或继承的类的组件时,使用该类的复制构造函数完成的,即会自动调用基类复制构造函数处理基类继承过来的组件
    • 赋值运算符
      • 情况同上,会自动调用基类的赋值运算符来对基类组件进行赋值
  • 假如基类使用new,派生类也使用new

    • 总结:必须为派生类定义显式析构函数、复制构造函数和赋值运算符,来处理派生类的动态内存变量(基类的不用处理)

      处理方法同一般类中创建new类成员的处理方法

【功能拓展 补丁】继承 x 友元

友元不是成员函数不使用域解析运算符)、不继承,也不能使用虚函数。那么派生类如何使用基类的友元?

  • 一般通过重新定义
  • 重新定义的友元能访问派生类的成员,但不能直接访问基类的友元
  • 需要使用强制类型转换为基类对象,再使用基类的友元函数来访问基类的成员