humid1ch blogs

本篇文章

手机用户建议
PC模式 或 横屏
阅读


C++ 2022 年 7 月 21 日

[C++] C++继承详细分析

C++ 是一种面向对象的编程语言, 而面向对象有三大特性: 封装、继承、多态(三大特性 而不是 只有三个特性). 本篇文章的主要内容是 C++继承
在首次接触, 类与对象的时候就已经介绍过
C++ 是一种面向对象的编程语言, 而面向对象有三大特性: 封装、继承、多态(三大特性 而不是 只有三个特性)
在 类和对象中, 就已经介绍过什么是封装特性, 在本篇文章开始之前 再来说一下 我个人理解的封装特性

面向对象的语言中, 面向对象的封装特性是什么?

在学习C++ 之前, 先学习了 C语言, 在接触了 C++ 的类之后, 可以明显感觉到面向对象和面向过程的某些方面的区别

就拿对某种数据结构的模拟实现而言, 比如 queue 队列:

  1. 使用C语言实现队列, 会发现 队列的结构 与 各种操作队列的功能: 入队列、出队列、取队列的头等, 是分离的。并且, C语言中实现队列 并没有对队列的数据等加以限制, 这就表示 每一个使用者都可以直接操作数据而不是通过数据结构提供的接口等。 但是 C++ 中无论是模拟实现 队列, 还是 库中提供的 queue, 容器的结构 与 数据的操作接口是整体在一个类中的, 并且, 还可以指定 类中的内容用 访问限定符限制类外内容对对象内容的访问。这是C语言无法做到的。通过访问限定符 合适地对 类成员进行限制, 使操作者能够更加 安全、规范的操作、整理、修改数据。进而可以禁止操作者对数据的非法行为等
  2. 不仅仅是 整合 和 访问限制, 封装更好地隐藏了 容器结构的底层。在我看来封装最好的诠释之一就是STL容器中的 迭代器设计, 即之前介绍的 iterator 和 reverse_iterstor 的结构、实现及其部分细节, 都是为了对容器结构的隐藏和保护。倘若没有 iterator 即迭代器的的设计, 那么使用者想要访问、修改数据 大概率只能通过直接操作底层来操作数据, 迭代器 杜绝了这种情况。可以不让操作者与底层直接建立联系, 进而可以保护底层
  3. 而且, 封装可以更加简单快捷的编写代码, 比如 库中实现 stack、queue、priority_queue 使用的一种适配器的模式, 将已经实现过的容器进行封装整理, 在以后实现其他容器时 加以使用, 能更加简单的得到想要的结果。
  4. 在互联网中, 在这个信息时代, 最重要的资源 就是数据。封装 某中程度上提升了 数据的安全, 将底层、细节等对大部分人隐藏起来, 只提供最简单、便捷的接口, 从而可以降低对数据操作的门槛, 但也进行了对底层访问的限制。总的来说, 封装一种可以让数据更安全、让编写者更方便快捷、让使用者更简单易懂的一种手段、方法
而, 本篇文章的主要内容 是面向对象的三大特性之一的 继承 在C++ 中的体现

继承

C++中, 继承是什么?
在 C++ 中继承主要表现形式是: 类的复用
在此之前, 其实已经接触过了 关于复用的操作——关于函数的复用。 在此之后, 复用就不再仅仅局限于函数, 还有类的复用, 不过类的复用一般被称作 继承

继承的概念

继承是一种机制, 是面向对象程序设计 使代码可以复用的最重要的手段
它允许 在保持原有类特性的基础上进行扩展, 增加功能, 从而产生新的类
继承呈现了面向对象程序设计的层次结构

继承一般用于什么?

举一个简单的例子:

比如在学校这种存在多种职务的场所:

学校: 校长、老师、学生等

学校的每种职务, 其实都具有 人 的共同的某些属性——姓名、性别、年龄、电话、住址…… 而不同的职务又有各自的独有的属性: 老师—评分—工号, 学生—学分—学号 等

所以, 在定义 老师、学生类之前, 可以先定义一个成员包括姓名、性别、年龄、电话等的 人 类, 然后再复用这个 人 类 定义老师、学生的类

这样可以有效减少代码量, 方便、快捷

继承的定义

那么 类的继承 是怎么样定义的呢?
就以 学校的老师和学生为例, 首先需要定义一个 人 类:
在介绍类和对象的时候 提到过, 类的访问限定符有三个: public、protected、private, 分别表示公共、保护、私有
且当时提了一句, 暂且将 protected 和 private 修饰的成员看作相同的
而在继承中, 它们就不一样了
类的访问限定符是他们三个, 而类的继承方式 也是他们三个: publicprotectedprivate
类的继承究竟是如何定义的呢?
以 定义学生类继承上面那个类为例:
图中所示即为继承定义的格式, 简单的表示即为:
以上面的继承格式为例: Student 被称为, 子类 或 派生类;public 被称为 继承方式;Person 被称为 父类 或 基类

继承方式与基类成员访问限定符的关系

继承方式有三种, public 公有继承, protected 保护继承, private 私有继承
访问限定符也有三种, public 共有访问, protected 保护访问, private 私有访问
那么这 三者 与 三者之间有什么关系呢?
继承方式👉
类成员限定👇
public 继承protected 继承private 继承
父类的public成员可看作 子类的public成员可看作 子类的protected 成员可看作 子类的private 成员
父类的protected 成员可看作 子类的protected 成员可看作 子类的protected 成员可看作 子类的private 成员
父类的private成 员在子类中不可见在子类中不可见在子类中不可 见
表中表示的就是 继承方式 与 基类成员访问限定符 的关系, 分析一下就是:
  1. 父类的 private成员 在子类中无论以什么方式继承都是不可见的

    不可见 是指父类的private成员 已经被继承到了子类对象中, 但是语法上限制 子类对象不管在类里面还是类外都不能去访问它

    做个父类的 公有成员和私有成员 对比更能展现 父类私有成员的特点:

    公有成员:

    私有成员:

    即, 父类的私有成员, 虽然对子类不可见、不可访问, 但是 是实实在在继承下来了的

    父类有私有成员时子类的大小 与 父类没有私有成员时子类的大小 相同

  2. 父类private成员在子类中是不能被访问, 如果父类成员不想在类外直接被访问, 但需要在派生类中能访问, 就定义为protected

    在 刚学习类和对象时, 说 暂时将 protected 和 private 看为作用一样的访问限定符, 但是 到了继承这里, 他们的作用就不一样了。

    父类的 protected 保护成员, 在子类内 是可访问的, 但是在子类外 是不可访问的 类似 没有继承关系的 private 私有成员

    用一张图可以清晰的表现出来

    父类的保护成员, 被子类继承之后, 在子类内可以访问, 但是在子类外不能访问

  3. 仔细分析 父、子类限定成员关系与继承方式关系 的表格, 可以发现

    除 父类的私有成员, 无论哪种继承方式在子类中都不可见 之外

    继承方式 与 父类成员种类, 在子类中的访问限定 是 两者取小 的方式, 比如: 父类中的 protected成员, 即使是 public继承 给子类, 在子类中依旧看作 protected成员

    而 父类中的 public成员, 若是 protected继承给 子类, 在子类中则看作 protected成员

    父类中的 public成员、protected成员, 若是 private继承给 子类, 在子类中就看作 private成员

  4. 而在实际运用中一般都使用 public继承

    几乎很少使用 protected / private继承, 也不提倡使用 protected/private继承, 因为 protected/private继承下来的成员都只能在派生类的类里面使用, 实际中扩展维护性不强

父类 与 子类对象的赋值

当两个类有继承关系的时候, 子类对象 是可以 赋值给父类对象、父类指针、父类引用 的, 这是 C++ 的语法设计, 即 语法默认支持的, 中间没有任何的 类似类型转换的转换
但是, 这个赋值 并不是将 子类对象的全部 赋值给父类对象, 父类对象也不可能接收子类对象的全部; 而是 将子类对象中 从父类对象继承过来的部分 赋值给父类对象, 这个过程 被形象的称为 切片、切割
举个例子:
可以看到, stu 切割给 per, 使 per 原数据改变, 即说明 子类对象 可切割给 父类对象
而 对于 父类指针 和 父类引用:
stu_pper_perx
stu_pper_perx
可以看到 子类对象的地址 可以 切割给父类指针子类对象 可以直接切割给 父类引用

子类对象 可以切割给 父类对象, 这个父类对象是一个独立的、新的父类对象

而 父类指针 和 父类引用 就没有那么简单了,

这两个, 一个是指针 应该指向父类对象的地址, 另一个是引用 应该是一个父类对象的别名 而 由子类对象赋值, 会发生什么呢?

说明, 其实 子类对象的地址 切割给父类指针子类对象 切割给 父类引用 就表示 此父类指针是直接指向 子类中从父类继承部分的, 此父类引用 是子类对象中 从父类继承部分的别名

也就表示了, 修改 父类指针 和 父类引用, 是直接修改在 子类对象上的:

但是要注意, 此操作 仅限于 子类对象被切割给的 父类指针 和 父类引用上

不过, 虽然 子类对象 可以 赋值给父类对象 , 但是 父类对象 不能 赋值给 子类对象

父类指针 和 父类引用 是通过强制类型转换 可以赋值给 子类指针 和 子类引用的 , 但是 这个操作与 多态 有关, 就暂不赘述

注意: 子类对象 赋值给父类对象、父类指针、父类引用 , 这是 语法默认支持的, 中间没有任何的 类似类型转换的转换

综上所述, 父类 与 子类对象的赋值转换 可以这样用图表示:
子类对象 至 父类
子类对象 至 父类

继承作用域相关

拥有继承关系的类, 子类内的作用域 也有限制:
  1. 子类内, 继承父类的部分 与 子类自己的部分 是 两个独立的作用域

    既然是两个独立的作用域, 就不得不考虑一个问题: 两个独立的作用域 按理来说是可以存在 同名变量或同名函数的, 那么子类内可不可以存在 与 父类内 同名的变量 或 同名的函数(同名成员)呢?

    答案是 可以

  2. 子类和父类中可以存在同名成员, 但是子类成员 将屏蔽 父类对同名成员的直接访问, 这种情况叫隐藏, 即 子类中的成员 将 父类中的同名成员隐藏了起来, 这种情况也叫 重定义

    什么是 屏蔽父类对同名成员的直接访问

    当这样的 子类实例化出来的对象, 直接访问 _age , 会访问哪个 _age 呢?是 继承父类部分中的 还是 自己的?

    答案 很明显:

    直接访问 _age 会访问 stu 自己的 _age 而不是继承父类的 _age

    其实这就是 子类成员 屏蔽父类对同名成员的直接访问, 就是 子类成员将 父类同名成员隐藏了起来

    虽然, 父类同名成员被隐藏了起来, 但是 还是可以通过 指定类域 来实现对父类同名成员的访问的:

  3. 成员函数也可以构成隐藏, 且 仅函数名相同 就会构成隐藏

  4. *在实际中在继承体系里面最好不要定义同名的成员 **

子类的默认成员函数

普通类有 默认成员函数, 具有继承关系的子类也是有默认成员函数的, 它们的作用就不一一介绍了, 看下图可知
但是, 子类中默认成员函数的用法 还是需要介绍一下的
思考一个问题: 子类 实例化的对象, 对象内部明显还存在父类的成员变量, 那么实例化时是怎么调用构造函数的呢?
  1. 子类的构造函数 必须 先调用父类的构造函数初始化父类的那一部分成员

    就像这样:

    Inherit_constructor
    Inherit_constructor

    可以看到 子类实例化对象调用子类构造函数时, 会先去调用父类默认构造函数, 然后再继续执行子类的构造函数

    如果 父类没有默认构造函数, 则必须在子类构造函数的初始化列表阶段显示调用

    否则会出现:

    并且, 不能直接对 父类成员变量进行初始化, 只能传参调用父类的构造函数

    为实现, 实例化 子类对象时, 指定 姓名、性别、年龄, 可以 给子类对象的构造函数添加指定相应类型的形参:

    对象实例化时 构造函数的调用 是这样的, 而 析构函数、拷贝构造函数、赋值重载函数 也类似

  2. 子类的拷贝构造的调用, 是 先调用父类的拷贝构造函数将父类的部分拷贝过来, 然后在调用子类的拷贝构造进行拷贝。 赋值重载函数也是一样的

    子类 显式定义 拷贝构造函数, 是需要在 初始化列表手动传参调用父类的拷贝构造函数的:

    示例:

    Inherit_Copy
    Inherit_Copy

    当然, 当成员变量所属类型提供的有拷贝构造函数, 编译器自动生成的默认拷贝构造函数, 也是可以用的

    显式定义 子类的赋值重载函数, 也是需要在内部 手动调用父类的赋值重载函数的:

    并且, 需要注意的是, 子类内部调用 父类的赋值重载函数时, 需要指明类域, 否则将无限循环调用子类的赋值重载函数, 因为 父类的赋值重载函数被隐藏

    Person::operator=(s); 这个语句, 存在的两个切片操作是哪两个?

    operator=(s) 在调用时, 编译器会将其 转换为 operator=(this, s)

    这样一看就能明白, 子类的this 传给 父类operator= 的形参this子类对象 s 传给 父类operator= 的形参p

    一共发生两个 切片/切割 操作

    Inherit_=
    Inherit_=

    在此父子类中, 默认赋值重载函数也是可以用的

  3. 子类对象 调用析构函数时, 会 先调用子类的析构函数 析构子类部分的, 再 调用父类的析构函数析构父类部分的

    因为先构造的后析构, 这个规则 在 类和对象的默认构造函数 时就已经说过了, 原因是因为 栈区是按照顺序向上使用的, 也需要按照顺序清理释放

    按照之前的逻辑, 子类的构造函数应该这样写:

    为什么会出现: 没有与这些操作数匹配的'~'运算符 没有与参数列表匹配的构造函数Person::Person Person 没有合适地默认构造可用 这样的错误?

    出现这样的错误, 其实是因为 编译器把 ~Person 中的 Person 当作了构造函数, 把 ~ 当作了一个操作符; 并没有把 ~Person 当作一个析构函数, 为什么?

    因为, 继承关系中, 父子类的析构函数构成隐藏

    父子类的析构函数是特殊的, 虽然没有满足函数名相同这个构成隐藏的条件, 但是它们还是构成隐藏

    这是因为 C++ 中多态的需要, 会把 继承关系的父子类的析构函数 统一处理为 ~destructor(), 所以才会构成隐藏

    PS: 多态中的需求, 所以会处理析构函数, 对构造函数没有什么特殊需求, 所以不处理

    构成隐藏, 所以 指出类域应该可以调用:

    知名类域确实可以 调用 Person的析构函数了, 但是 子类的析构过程 却调用了两次~Person, 这又是为什么?

    原因是, 子类对象析构时, 为了保证析构顺序, 调用完子类的析构函数, 编译器会自动去调用父类的析构函数

    所以, 子类析构函数的定义其实不需要显式调用父类的析构函数

    可以看到 子类对象的析构是 先调用 子类的析构函数 后自动调用 父类的析构函数的 调用父类析构函数的操作, 是编译器自动执行的, 不需要手动在子类析构函数内写出来:

    Inherit_destructor
    Inherit_destructor

问题: 怎么定义一个不能被继承的类

上面 介绍了 继承关系中 子类默认成员函数的调用过程
那么 提出问题: 怎么才能定义一个 不能被继承的类?
答案其实很简单, 把这个类的构造函数定义为私有成员, 此类就不能被继承了 因为, 私有成员 对子类不可见, 而 子类实例化对象需要调用父类的构造函数, 当父类的构造函数为私有成员时, 子类无法调用, 也就达成了 类不能被继承的条件

继承与友元

友元与继承的关系很简单, 就是没有什么关系
友元不能被继承:
TEST 作为 Person的友元函数, 可以访问 Person的成员:
Student作为 Person的子类, TEST作为Person的友元 若解开注释:

继承 与 静态成员

一个继承体系中, 可以有静态成员
但是, 整个继承体系只能有 一个静态成员, 无论实例化多少个子类, 静态成员也只是同一个
所以继承体系中的静态成员一般用来统计子类数目
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)

作者: 哈米d1ch 发表日期:2022 年 7 月 21 日