您现在的位置是:首页 >技术教程 >C++【继承】网站首页技术教程

C++【继承】

北 海 2024-06-17 10:19:56
简介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 比较特殊,只有在 继承 中才能体现它的价值,否则与 私有 作用一样

图解

访问权限:三种
继承权限:三种

根据排列组合,可以列出以下多种搭配方案(子类的可访问情况):

访问权限 / 继承权限publicprotectedprivate
父类的 public 成员可见,变为 public可见,变为 protected可见,变为 private
父类的 protected 成员可见,变为 public可见,变为 protected可见,变为 private
父类的 private 成员不可见不可见不可见

总结:无论是哪种继承方式,父类中的私有成员始终不可被访问;当子类成员可访问父类成员时,最终权限将会变为 访问权限与继承权限 中的较小者

两种不同的权限相遇时,若可见,则在子类中变为较小者

如何证明?

继承

关于默认继承权限

  • 假设不注明继承权限,class 默认为 privatestruct 默认为 public,最好是注明继承权限

如何强行访问父类中的私有成员?

  • 在父类中设计相应的函数,获取私有成员的值进行间接访问即可

其实 C++ 中搞这么多种情况(9种)完全没必要,实际使用中,最常见到的组合为 public : publicprotected : 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;
}

结果

此时可用通过 域作用限定符 :: 显式调用父类中的函数

图解

总的来说,子类中的默认成员函数调用规则可以概况为以下几点:

  1. 子类的构造函数必须调用父类的构造函数,初始化属于父类的那一部分内容;如果没有默认构造函数,则需要显式调用
  2. 子类的拷贝构造、赋值重载函数必须要显式调用父类的,否则会造成重复析构问题
  3. 父类的析构函数在子类对象销毁后,会自动调用,然后销毁父类的那一部分

注意:

  • 子类对象初始化前,必须先初始化父类那一部分
  • *子类对象销毁后,必须销毁父类那一部分
  • 不能显式的调用父类的析构函数(因为这不符合栈区的规则),父子类析构函数为同名函数 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_namePostgraduate 也继承了 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 -> dequelist
  • reverse_iterator -> iterator

在通过后者实现前者时,可以通过 组合,也可以通过 继承


?总结

以上就是本次关于 C++【继承】的全部内容了,在本篇文章中,我们重点介绍了继承的相关知识,如什么是继承、如何继承、继承该注意些什么,最后还学习了多继承模式中容易引发的菱形继承问题,探究了其原因及解决方法,关于继承是如何辅助实现多态的,可以期待下篇文章:C++【多态】


星辰大海

相关文章推荐

STL 之 泛型思想

C++【模板进阶】

C++【模板初阶】

STL 之 适配器

C++ STL学习之【优先级队列】

C++ STL学习之【反向迭代器】

C++ STL学习之【容器适配器】

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。