您现在的位置是:首页 >技术教程 >C++【继承】网站首页技术教程
C++【继承】
✨个人主页: 北 海
?所属专栏: C++修行之路
?操作环境: Visual Studio 2019 版本 16.11.17
文章目录
?前言
继承
是面向对象三大特性之一(封装、继承、多态),所有的面向对象(OO
)语言都具备这三个基本特征,封装相关概念已经在《类和对象》系列中介绍过了,今天主要学习的是 继承
,即如何在父类的基础之上,构建出各种功能更加丰富的子类
王阿姨(父类)的两个孩子(子类),在父类的基础之上,衍生出了不同的特性
?️正文
1、继承的概念
什么是继承?继承遗产还是继承花呗?答案都不是,先来看看官方解释:继承(inheritance)机制是面向对象程序设计使代码可以复用的重要的手段,它允许程序员在保持原有基类(父类)特性的基础上进行扩展,增加功能,这样产生新的类,称为派生类(子类)
继承相关概念:
- 被继承对象:父类 / 基类 (
base
) - 继承方:子类 / 派生类 (
derived
)
1.1、本质
继承的本质就是 复用代码
假设你现在需要写一个 学校教务系统,单从角色划分上来说,可以简单分为:教职工和学生 这两大类,但如果继续划分的话,还可以分出:校领导、各级院长、辅导员、后勤人员、大一/大二/大三/大四学生等,假设为每种不同的角色都设计一个 struct
,那么这个工程量也未免太大了
为了复用代码、提高开发效率,可以从各种角色中选出共同点,组成 基类,比如每个 人 都有姓名、年龄、性别、联系方式等基本信息,而 教职工 与 学生 的区别就在于 管理与被管理,因此可以在 基类 的基础上加一些特殊信息如教职工号 表示 教职工,加上 学号 表示学生,其他细分角色设计也是如此
这样就可以通过 继承
的方式,复用 基类 的代码,划分出各种 子类
1.2、作用
子类基础父类后,可以享有父类中的所有 公开 / 保护
属性,也就是说,除了 私有
内容外,父类有的,子类全都有
示例:在 父类-车 的基础上,派生出 越野车 和 跑车 这两个 子类
//父类-车
class Car
{
public:
Car(int speed = int())
:_speed(speed)
{}
int getSpeed()
{
return _speed;
}
private:
int _speed;
};
//子类-越野车
class SUV : public Car
{
public:
SUV()
:Car(100)
{
cout << "我是越野车,我的最高速度只有:" << getSpeed() << "km/h" << endl;
}
};
//子类-跑车
class coupe : public Car
{
public:
coupe()
:Car(200)
{
cout << "我是跑车,我的最高速度可以到:" << getSpeed() << "km/h" << endl;
}
};
int main()
{
SUV s;
coupe c;
return 0;
}
可以看到,两个子类都能具备父类中的 公有 / 保护
属性,并且能做到互不干扰
1.3、实际例子
在实际开发中,继承 会经常用到(不然也不会作为 面向对象三大特性 之一了)
比较经典的例子:C++
中的 IO
流玩的就继承,并且还是菱形继承
2、继承的定义
了解完继承相关概念后,就可以开始学习使用继承了
2.1、格式
继承的格式很简单,格式为 子类 : 继承方式 父类
,比如 class a : public b
就表示 a
继承了 b
,并且还是 公有继承
注:Java
中的继承符号为 extern
,而 C++
中为 :
2.2、权限
继承有权限的概念,分别为:公有继承(public
)、保护继承(protected
)、私有继承(private
)
没错,与 类 中的访问 限定修饰符 一样,不过这些符号在这里表示 继承权限
简单回顾下各种限定符的用途
- 公有
public
:公开的,任何人都可以访问- 保护
protected
:保护的,只有当前类和子类可以访问- 私有
private
:私有的,只允许当前类进行访问权限大小:公有 > 保护 > 私有
保护protected
比较特殊,只有在 继承 中才能体现它的价值,否则与 私有 作用一样
访问权限:三种
继承权限:三种
根据排列组合,可以列出以下多种搭配方案(子类的可访问情况):
访问权限 / 继承权限 | public | protected | private |
---|---|---|---|
父类的 public 成员 | 可见,变为 public | 可见,变为 protected | 可见,变为 private |
父类的 protected 成员 | 可见,变为 public | 可见,变为 protected | 可见,变为 private |
父类的 private 成员 | 不可见 | 不可见 | 不可见 |
总结:无论是哪种继承方式,父类中的私有成员始终不可被访问;当子类成员可访问父类成员时,最终权限将会变为 访问权限与继承权限 中的较小者
两种不同的权限相遇时,若可见,则在子类中变为较小者
如何证明?
关于默认继承权限
- 假设不注明继承权限,
class
默认为private
,struct
默认为public
,最好是注明继承权限
如何强行访问父类中的私有成员?
- 在父类中设计相应的函数,获取私有成员的值进行间接访问即可
其实 C++
中搞这么多种情况(9种)完全没必要,实际使用中,最常见到的组合为 public : public
和 protected : public
2.3、使用
如何优雅的使用好 权限?
对于只想自己类中查看的成员,设为 private
,对于想共享给子类使用的成员,设为 protected
,其他成员都可以设为 public
比如在张三家中,张三家的房子面积允许公开,家庭存款只限家庭成员共享,而个人隐私数据则可以设为私有
class Home
{
public:
int area = 500; //500 平米的大房子
};
class Father : public Home
{
protected:
int money = 50000; //存款五万
private:
int privateMoney = 100; //私房钱,怎能公开?
};
class Zhangsan : public Father
{
public:
Zhangsan()
{
cout << "我是张三" << endl;
cout << "我知道我家房子有 " << area << " 平方米" << endl;
cout << "我也知道我家存款有 " << money << endl;
cout << "但我不知道我爸爸的私房钱有多少" << endl;
}
};
class Xiaoming
{
public:
Xiaoming()
{
cout << "我是小明" << endl;
cout << "我只知道张三家房子有 " << Home().area << " 平方米" << endl;
cout << "其他情况我一概不知" << endl;
}
};
int main()
{
Zhangsan z;
cout << "================" << endl;
Xiaoming x;
return 0;
}
实际使用中,权限 可以很好的保护成员
3、继承的作用域
子类虽然继承自父类,但两者的作用域是不相同的,假设出现同名函数时,默认会将父类的同名函数隐藏调,进而执行子类的同名函数
隐藏 也叫 重定义,与它类似的概念还有:重写(覆盖)、重载
3.1、隐藏
子类中出现父类的 同名 方法或成员
//父类
class Base
{
public:
void func() { cout << "Base val: " << val << endl; }
protected:
int val = 123;
};
//子类
class Derived : public Base
{
public:
int func()
{
cout << "Derived val: " << val << endl;
return 0;
}
private:
int val = 668;
};
int main()
{
Derived d;
d.func();
return 0;
}
此时 父子类中的方法和成员均被隐藏,执行的是 子类方法,输出的是子类成员
只修改子类方法名为
funA
int funA()
{
cout << "Derived val: " << val << endl;
return 0;
}
发现此时 隐藏 消失,并且结果的是 父类方法 + 父类成员
只修改子类成员为
num
int num = 668;
此时 隐藏 也消失,执行结果 子类方法 + 父类成员
综上所述,当子类中的方法出现 隐藏 行为时,优先执行 子类 中的方法;当子类中的成员出现 隐藏 行为时,优先选择当前作用域中的成员(局部优先)
这已经证明了 父子类中的作用域是独立存在的
如何显式的使用父类的方法或成员?
- 利用域作用限定符
::
进行访问范围的限制
注意:
- 只要是命名相同,都构成 隐藏 ,与 返回值、参数 无关
- 隐藏会干扰调用者的意图,因此在继承中,要尽量避免同名函数的出现
4、基类与派生类对象的赋值转换
在继承中,允许将 子类 对象直接赋值给 父类,但不允许 父类 对象赋值给 子类
- 这其实很好理解,儿子以后可以当父亲,父亲还可以当儿子吗?
并且这种 赋值 是非常自然的,编译器直接处理,不需要调用 赋值重载 等函数
//父类
class Base
{
protected:
int val = 123;
};
//子类
class Derived : public Base
{
private:
int num = 668;
};
int main()
{
Base b;
Derived d;
b = d;
d = b; //非法,只允许 子->父
return 0;
}
子类对象 在 赋值 给 父类对象 时,触发 切片 机制,丝滑的完成 赋值
黄瓜切片变成 黄瓜片,黄瓜片可变不回完整的黄瓜了
4.1、切片
将 父类对象 看作一个结构体,子类对象 看作结构体Plus 版
将 子类对象 中多余的部分去除,留下 父类对象 可接收的成员,最后再将 对象 的指向进行改变就完成了 切片
因为整个切片过程是由编译器自己完成的,所以效率很高,并且不会发生 借助临时对象构造再赋值 的情况,具体切片实现原理还后续再进行讲解
注意: 切片只在 子类->父类 时发生,因为父类无法满足子类的需求
5、派生类的中的默认成员函数
派生类(子类)也是 类,同样会生成 六个默认成员函数(用户未定义的情况下)
不同于单一的 类,子类 是在 父类 的基础之上创建的,因此它在进行相关操作时,需要为 父类 进行考虑
5.1、隐式调用
子类在继承父类后,构建子类对象时 会自动调用父类的 默认构造函数,子类对象销毁前,还会自动调用父类的 析构函数
class Person
{
public:
Person() { cout << "Person()" << endl; }
~Person() { cout << "~Person()" << endl; }
};
class Student : public Person
{
public:
Student() { cout << "Student()" << endl; }
~Student() { cout << "~Student()" << endl; }
};
int main()
{
Student s;
return 0;
}
注意: 自动调用是由编译器完成的,前提是父类存在对应的默认成员函数;如果不存在,会报错
5.2、显式调用
因为存在 隐藏 的现象,当父子类中的函数重名时,子类无法再自动调用父类的默认成员函数,此时会引发 浅拷贝 相关问题
class Person
{
public:
Person() { cout << "Person()" << endl; }
void operator=(const Person& P) { cout << "Person::operator=()" << endl; }
~Person() { cout << "~Person()" << endl; }
};
class Student : public Person
{
public:
Student() { cout << "Student()" << endl; }
void operator=(const Student&) { cout << "Student::operator=()" << endl; }
~Student() { cout << "~Student()" << endl; }
};
int main()
{
Student s1;
cout << "================" << endl;
Student s2;
s1 = s2;
return 0;
}
此时可用通过 域作用限定符 ::
显式调用父类中的函数
总的来说,子类中的默认成员函数调用规则可以概况为以下几点:
- 子类的构造函数必须调用父类的构造函数,初始化属于父类的那一部分内容;如果没有默认构造函数,则需要显式调用
- 子类的拷贝构造、赋值重载函数必须要显式调用父类的,否则会造成重复析构问题
- 父类的析构函数在子类对象销毁后,会自动调用,然后销毁父类的那一部分
注意:
- 子类对象初始化前,必须先初始化父类那一部分
- *子类对象销毁后,必须销毁父类那一部分
- 不能显式的调用父类的析构函数(因为这不符合栈区的规则),父子类析构函数为同名函数
destructor
,构成隐藏,如果想要满足我们的析构需求,就需要将其变为虚函数,构成重写
析构函数必须设为 虚函数,这是一个高频面试题,同时也是 多态 中的相关知识
6、继承与友元函数
友元关系不能被继承
场景:友元函数 Print
可以访问父类中的私有成员,但子类继承父类后,友元函数无法访问子类中的私有成员
class Base
{
friend void Print();
private:
static const int a = 10;
};
class Derived : public Base
{
private:
static const int b = 20;
};
void Print()
{
cout << Base::a << endl;
cout << Derived::b << endl;
}
int main()
{
Print();
return 0;
}
如果想让 Print
函数也能访问子类中的私有成员,则需要 将其也声明为子类的友元函数
总之记住:友元关系不能被继承
这个就像是西欧社会中的一句名言:我的附庸的附庸,不是我的附庸
7、继承与静态成员
静态成员是唯一存在的,无论是否被继承
静态变量为于静态区,不同于普通的堆栈区,静态变量的声明周期很长,通常是程序运行结束后才会被销毁,因此 假设父类中存在一个静态变量,那么子类在继承后,可以共享此变量
可以利用这个特性,写一个统计 创建多少个父类子类对象 的小 demo
class Base
{
friend void Print();
public:
Base() { num++; }
static int num; //静态变量
};
int Base::num = 0; //初始化静态变量
class Derived : public Base
{
public:
Derived() { num++; }
};
void Print()
{
cout << Base::num << endl;
}
int main()
{
Derived d1;
Derived d2;
Derived d3;
Print();
return 0;
}
创建了三个子类对象,同时 因为在创建子类对象前,会自动调用父类的默认构造函数,因此最终结果为 6
这也从侧面证明了静态成员是唯一存在的,并且被子类共享
8、菱形继承
单继承:一个子类只能继承一个父类
多继承:一个子类可以继承多个父类(两个及以上)
C++
支持多继承,即支持一个子类继承多个父类,使其基础信息更为丰富,但凡事都有双面性,多继承 在带来巨大便捷性的同时,也带来了个巨大的坑:菱形继承问题
注:其他面向对象的高级语言为了避免出现此问题,直接规定了不允许出现多继承
8.1、概念
首先 C++
允许出现多继承的情况,如下图所示
这样看很正常是吧,但如果出现以下这种 重复继承 的情况,就比较麻烦了
此时 普通人X 会纠结于使用哪一个 不用吃饭 的属性!这对于编译器来说,是一件无法处理的事
8.2、现象
将上述概念转化为代码,观察实际现象
注:多继承时,只需要在 父类 之后,添加 ,
号,继续增加想要继承的父类
class Person
{
public:
string _name; //姓名
};
//本科生
class Undergraduate : public Person
{};
//研究生
class Postgraduate : public Person
{};
//毕业生
class Graduate : public Undergraduate, public Postgraduate
{};
int main()
{
Graduate g1;
g1._name = "zhangsan";
return 0;
}
无法编译!
8.3、原因
Undergraduate
中继承了 Person
的 _name
,Postgraduate
也继承了 Person
的 _name
Graduate
多继承 Undergraduate
、Postgraduate
后,同时拥有了两个 _name
,使用时,无法区分!
通过监视窗口查看信息:
8.4、解决方法
想要解决二义性很简单,通过 ::
限制访问域即可
Graduate g1;
g1.Undergraduate::_name = "zhangsan";
cout << g1.Undergraduate::_name << endl;
但这没有从本质上解决问题!而且还没有解决数据冗余问题
真正的解决方法:虚继承
注:虚继承是专门用来解决 菱形继承 问题的,与多态中的虚函数没有直接关系
虚继承:在菱形继承的腰部继承父类时,加上 virtual
关键字修饰被继承的父类
class Person
{
public:
string _name; //姓名
};
//本科生
class Undergraduate : virtual public Person
{};
//研究生
class Postgraduate : virtual public Person
{};
//毕业生
class Graduate : public Undergraduate, public Postgraduate
{};
int main()
{
Graduate g1;
g1._name = "zhangsan";
cout << g1._name << endl;
return 0;
}
此时可以解决 菱形继承 的 数据冗余 和 二义性 问题
虚继承是如何解决菱形继承问题的?
- 利用 虚基表 将冗余的数据存储起来,此时冗余的数据合并为一份
- 原来存储 冗余数据 的位置,现在用来存储 虚基表指针
此时无论这个 冗余 的数据存储在何处,都能通过 基地址 + 偏移量 的方式进行访问
虚继承相关知识补充
虚继承底层是如何解决菱形继承问题的?
- 对于冗余的数据位,改存指针,该指针指向相对距离
- 对于冗余的成员,合并为一个,放置后面,假设想使用公共的成员(冗余成员),可以通过相对距离(偏移量)进行访问
- 这样就解决了数据冗余和二义性问题
为何在冗余处存指针?
- 指针指向空间有预留一个位置,可以用于多态
- 因此虚继承用的是第二个位置
新建对象进行兼容赋值时,对象指向指针处
- 该指针(偏移量)指向的目标位置不定
- 无论最终位置在何处,最终汇编指令都一样(得益于偏移量的设计模式)
虚函数是否会造成空间浪费?
- 不会,指针大小固定为
4/8
字节指针所指向的空间(虚基表)是否浪费空间?
- 可以忽略不计,所有对象共享
假设存在多个共享成员,需要新增指针(偏移量),因为这些成员都是连续的,找到第一个,即可找到其他
- 即使涉及内存对齐问题,编译器也会根据规则做出调整
为了解决 菱形继承 问题,想出了 虚继承 这种绝妙设计,但在实际使用中,要尽量避免出现 菱形继承 问题
9、补充
继承是面向对象三大特性之一,非常重要,需要对各种特性进行学习
关于多继承时,哪个父类先被初始化的问题
- 谁先被声明,谁就会先被初始化,与继承顺序无关
除了可以通过继承使用父类中的成员外,还可以通过 组合 的方式进行使用
- 公有继承:
is-a
—> 高耦合,可以直接使用父类成员 - 组合:
has-a
—> 低耦合,可以间接使用父类成员
实际项目中,更推荐使用 组合 的方式,这样可以做到 解耦,避免因父类的改动而直接影响到子类
当然,使用哪种方式还要取决于具体场景,具体问题具体分析
//父类
class A {};
//继承
class B : public A
{
//直接继承,直接使用
};
//组合
class C
{
private:
A _aa; //创建 A 对象,使用成员及方法
}
可能有的人问 继承 到底有什么用?答案很简单,为后面的 多态 实现铺路,也就是说,多态的实现离不开继承!
关于之前的 适配器 模式,除了可以使用 组合 的方式进行适配外,还可以通过 继承 的方式进行适配
queue
->deque
、list
reverse_iterator
->iterator
在通过后者实现前者时,可以通过 组合,也可以通过 继承
?总结
以上就是本次关于 C++【继承】的全部内容了,在本篇文章中,我们重点介绍了继承的相关知识,如什么是继承、如何继承、继承该注意些什么,最后还学习了多继承模式中容易引发的菱形继承问题,探究了其原因及解决方法,关于继承是如何辅助实现多态的,可以期待下篇文章:C++【多态】
相关文章推荐
STL 之 泛型思想
C++【模板进阶】
C++【模板初阶】
STL 之 适配器
C++ STL学习之【优先级队列】
C++ STL学习之【反向迭代器】
C++ STL学习之【容器适配器】