【C++】详解继承机制

目录

前言


一、继承的概念

继承机制 是面向对象程序设计使代码可以复用 的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加成员函数和成员变量,这样产生新的类,称派生类(子类)。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。继承是类设计层次的复用。


二、继承的定义

2.1、定义格式

class 派生类:继承方式 基类
{};


2.2、继承基类成员访问方式的变化

关于派生类的继承方式 主要有三种:public、private、protected。这与类成员的访问方式是一样的。

根据继承方式的不同,基类中不同成员的访问方式会具有相应的变化,进而继承到派生类。我们可以通过以下的表格清晰地了解:

根据表格我们可以总结出:基类的private成员在派生类中无论以何种方式继承都是不可见的(这些私有成员还是被继承到了派生类对象中,只是语法上被限制了),而基类的其他成员在派生类的访问方式遵循着这样一个规律:min{ 成员在基类的访问限定符, 继承方式},(public > protected > private)。

看到这里,其实应该会疑惑:这个protected访问限定符究竟起着个啥作用,好像与private并没有什么差别**??**

这里要补充一点:如果基类成员不想在类外被直接访问,但是需要在派生类中能够访问,我们就可以定义为protected。 这就是它与private的显著差别,可以看出protected访问限定符就是特意为继承而设计的。

我们知道在定义类的时候有两种关键字可以使用:在使用class时,默认的继承方式是private,而在使用struct时,默认的继承方式为public

在实际应用中,我们一般都是使用public继承 ,几乎很少使用protectedprivate继承。由上表可知,protectedprivate继承下来的成员都只能在派生类的类里面使用,实际中的拓展维护性不强

接下来看一段简单的代码加深以上内容的理解:

cpp 复制代码
#include<iostream>
using namespace std;

class Person
{
public:
	void identity()
	{
		cout << "identity_name:" << _name << endl;
	}
protected:
	string _name = "张三"; // 姓名 
	string _address; // 地址 
	string _tel; // 电话 
	int _age = 18; // 年龄 
};
class Student : public Person
{
public:
	void study()
	{
		cout << "study" << endl;
	}

protected:
	int _stuid; // 学号 
};
class Teacher : public Person
{
public:
	void teach()
	{
		cout << "teach" << endl;
	}
protected:
	string _title; // 职称 
};

int main()
{
	Student s;
	Teacher t;
	s.identity();
	s.study();
	t.identity();
	t.teach();
}

2.3、类模板的继承

当基类是类模板,如果我们要在派生类中使用基类的相关成员,此时我们需要指定一下类域。 否则会进行编译报错------"error C3861:找不到标识符"。

如下:

在实例化stack<int>的时候,也实例化了vector<int>

由于模板是按需实例化 ,因此vector<int>内的成员函数并没有实例化,因此找不到对应的标识符。只有当我们指定类域并调用它,编译器才会自动实现对应的成员函数供我们调用。


2.4、基类与派生类的转换🌟

一般情况下,我们将一个类型的对象赋值给另一个类型 的对象的指针引用 时,存在类型转换 ,期间会产生一个具有常性的临时对象(类型转化、传值返回都会产生临时对象) ,所以需要添加const进行修饰。

但是,在public继承中(前提),就是一个特殊处理的意外,派生类的对象可以直接赋值给基类对象的指针或者引用,而不需要使用const修饰(此处没有产生临时对象),并且这里指针或引用绑定的是派生类对象中的基类部分。

如下图:

派生类对象赋值给基类对象是通过拷贝构造 或者赋值运算符重载 完成的,这个过程就像把派生类自己定义的部分成员切掉一样,所以也被叫做切割 或者切片 。(这里要与上述的内容稍作区分)

**注意:**不能将基类的对象赋值给派生类。


三、继承的作用域

在继承机制下,基类和派生类都有独立的作用域

如果派生类和基类中有同名成员,派生类将会屏蔽基类成员,这种行为叫做隐藏

注意:**继承关系才有隐藏的概念 。被隐藏的同名基类成员并非无法访问,但是我们可以使用域访问限定符::** 指定作用域访问。

对于成员函数,只要函数名相同就构成隐藏,与参数、返回值类型、函数体无关。

在实际的使用中,我们要尽可能得避免在继承体系中定义同名的成员。

为了充分理解这个知识点,我们可以通过下面两道笔试题加深影响:

1️⃣A和B类中的两个func构成什么关系()。
A. 重载 B. 隐藏 C. 没关系

2️⃣下面程序的编译运行结果是什么()。
A. 编译报错(即出现了语法错误)
B. 运行报错(出现了野指针、对空指针解引用等操作)
C. 正常运行

cpp 复制代码
class A
{
public:
	void fun()
	{
		cout << "func()" << endl;
	}
};
class B : public A
{
public :
	void fun(int i)
	{
		cout << "func(int i)" << i << endl;
	}
};

int main()
{
	B b;
	b.fun(10);
	b.fun();
	return 0;
}

答案分别为B与A。


四、派生类的默认成员函数

类的默认成员函数总共有六个,分别为构造函数、析构函数、拷贝构造、赋值重载、取地址重载(分为普通对象与const对象两种),这里我们主要讲解在派生类中,编译器如何自动生成前四种比较常用的默认成员函数。

4.1、四个常见的派生类默认成员函数🌟

派生类的默认成员函数均遵循着这样一个处理原则:将派生类的成员分成两部分:基类成员和派生类成员,两部分分开处理,把基类成员视为一个整体,调用基类的默认成员函数;剩余的派生类成员就与普通类的规则一样。

根据接下来讲解,更好地理解这句话:

1️⃣构造函数

派生类的构造函数必须调用基类的构造函数初始化基类的成员

注意:如果基类没有默认的构造函数 ,则必须在派生类构造函数的初始化列表 阶段显式调用**!!!**

(**补充:**引用成员变量、const成员变量、没有默认构造的类类型变量,都必须在初始化列表阶段显式初始化。因为这三种变量都必须在定义的时候初始化。)


2️⃣拷贝构造

派生类的拷贝构造必须调用基类的拷贝构造完成基类成员的拷贝初始化

**注意:**一般情况下,编译器自动生成就足够用。若涉及深拷贝,就需要自己实现。


3️⃣赋值重载

赋值重载与拷贝构造非常类似:派生类的赋值重载函数必须调用基类的赋值重载函数完成基类成员的复制

注意:由于派生类与基类的赋值重载重名,构成重写 ,因此,在实现派生类的赋值重载时,需要显式调用基类的赋值重载


4️⃣析构函数

析构函数相较于前三个会有较大的不同,首先派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。

派生类对象初始化先调用基类构造,再调派生类构造;派生类对象析构清理先调用派生类析构,再调用基类析构。类似于压栈、弹栈的过程

为什么需要这样处理呢🤔??有时候派生类的成员在析构时可能会访问到基类成员,在这样的场景下,就必须这样处理。

此外,在多态的场景下,析构函数构成重写,而重写的条件之一就是函数名相同(相关知识会在《多态》章节中解释)。那么编译器就会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派生类析构函数与基类析构函数构成隐藏关系


4.2、面试题------如何实现一个不能被继承的类?

这是一道面试题,在学习派生类默认成员函数的相关知识后我们能够解决,直接输出结论:

  1. 构造函数私有化 :派生类的构造必须调用基类的构造,这是毋庸置疑的,但是,基类的构造函数私有化后,就意味着派生类看不见基类构造,就不能调用了,那么派生类就无法实例化出对象了。这本质是利用两种语法之间的冲突来实现的。一般在C++98中使用较多,不过该方法不够直观,因此C++11开始提供了一个新的关键字来支持。
cpp 复制代码
class Base
{
public:
	void func5() { cout << "Base::func5" << endl; }
protected:
	int a = 1;
private:
	Base()
	{}
};

class Derive : public Base
{
public:
	void func4() { cout << "Derive::func4" << endl; }
protected:
	int b = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}
  1. final关键字 :这个就是C++11推出来的关键字,用于修饰类,被修饰的类称为 "最终类",往后,这个类就无法被继承。
cpp 复制代码
// C++11的⽅法
class Base final
{
public:
	void func5() { cout << "Base::func5" << endl; }
protected:
	int a = 1;
};

class Derive : public Base
{
public:
	void func4() { cout << "Derive::func4" << endl; }
protected:
	int b = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

五、继承与友元

直接输出结论:友元关系不能继承,也就是说基类友元不能访问派生类私有和保护成员 。这很好理解:你可以继承你爸的房子、车子等,但是你无法继承他的朋友(忘年交除外😓)。

cpp 复制代码
class Student;	// 前置声明
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
protected:
	string _name; // 姓名
};
class Student : public Person
{
protected:
	int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
	cout << p._name << endl;
	cout << s._stuNum << endl;
} 
int main()
{
	Person p;
	Student s;
	// 编译报错:error C2248: "Student::_stuNum": 无法访问protected成员
	Display(p, s);
	return 0;
}

执行上述代码必然会编译报错,只要将Display也变成Student的友元即可解决。


六、继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个派生类,都只有一个static成员实例。简单来说就是,该继承体系中公用一个static成员。

cpp 复制代码
class Person
{
public:
	string _name;
	static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:
	int _stuNum;
};
int main()
{
	Person p;
	Student s;
	// 这里的运行结果可以看出,两个非静态成员_name的地址是不一样的
	// 说明派生类继承下来了,基类和派生类各有一份_name
	cout << &p._name << endl;
	cout << &s._name << endl;
	// 这里的运行结果可以看到静态成员_count的地址是一样的
	// 说明派⽣类和基类共⽤同一份静态成员
	cout << &p._count << endl;
	cout << &s._count << endl;
	// 公有的情况下,⽗派⽣类指定类域都可以访问静态成员
	cout << Person::_count << endl;
	cout << Student::_count << endl;
	return 0;
}

运行结果如下:


七、多继承与菱形继承问题🌟

7.1、继承模式

继承模式主要分为两种:单继承多继承

  1. 单继承:一个派生类只有一个直接基类时,称这个继承关系为单继承
  2. 多继承:一个派生类有两个或以上直接基类时,称这个继承关系为多继承

多继承对象在内存中的模型是:先继承的基类在前面,后面继承的基类在后面,派生类成员在放到最后面。如下图:

多继承可能会导致菱形继承 。菱形继承是多继承的一种特殊情况。菱形继承的问题,从下面的对象成员模型构造就可以看出菱形继承有数据冗余和二义性的问题,在Assistant的对象中Person成员会有两份。支持多继承就一定会存在菱形继承问题,像Java就直接不支持多继承,从而规避掉菱形继承的问题,所以实践中我们也是不建议设计出菱形继承 这样的模型的。

如果在项目中出现了菱形继承,我们应该如何解决数据冗余与二义性问题呢??

看下面这段代码:

cpp 复制代码
class Person
{
public:
	string _name; // 姓名
};
class Student : public Person
{
protected:
	int _num; //学号
};
class Teacher : public Person
{
protected:
	int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};
int main()
{
	// 编译报错:error C2385: 对"_name"的访问不明确
	Assistant a;
	a._name = "peter";
	// 需要显示指定访问哪个基类的成员可以解决二义性问题,但是数据冗余问题无法解决
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";
	return 0;
}

如果不指定类域,单纯地a._name,编译器无法知道该变量是基类Student的还是基类Teacher的,因此,需要显示指定访问哪个基类的成员可以解决二义性问题,但是数据冗余问题无法解决!! 那么我们究竟应该如何解决该问题呢??虚继承virtual


7.2、虚继承

多继承就是C++语法复杂的一个体现。有了多继承,就必然存在菱形继承,为了解决菱形继承带来的各种问题,于是便诞生了虚继承关键字:virtual,格式:class 派生类 : virtual public 基类

虚继承的底层实现就很复杂,性能也会有一些损失,所以最好不要设计出菱形继承。多继承可以认为是C++的缺陷之一,后来的一些编程用语言都没有多继承,如Java。

cpp 复制代码
class Person
{
public:
	string _name; // 姓名
};

class Student : virtual public Person
{
protected:
	int _num; //学号
};

class Teacher : virtual public Person
{
protected:
	int _id; // 职工编号
};

class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};
int main()
{
	// 使用虚继承,可以解决数据冗余和二义性
	Assistant a;
	a._name = "peter";
	return 0;
}

此外,在使用虚继承时,我们要考虑virtual关键字摆放的位置。通常,我们会将可能造成数据冗余与二义性的基类派生出来的派生类设计为虚继承。

在项目实践中,我们可以设计出多继承,但是不建议设计出菱形继承,因为菱形虚拟继承以后,无论是程序员使用还是代码底层都会变得复杂很多。当然有多继承语法支持,就一定存在会设计出菱形继承,像Java是不支持多继承的,就避开了菱形继承。

来看看下面这段代码,思考一下a对象中_name是"张三", "李四", "王五"中的哪一个🤔??

cpp 复制代码
class Person
{
public:
	Person(const char* name)
		: _name(name)
	{}
	string _name; // 姓名
};
class Student : virtual public Person
{
public:
	Student(const char* name, int num)
		: Person(name)
		, _num(num)
	{}
protected:
	int _num; //学号
};
class Teacher : virtual public Person
{
public:
	Teacher(const char* name, int id)
		: Person(name)
		, _id(id)
	{}
protected:
	int _id; // 职工编号
};

class Assistant : public Student, public Teacher
{
	public :
	Assistant(const char* name1, const char* name2, const char* name3)
		: Person(name3)
		, Student(name1, 1)
		, Teacher(name2, 2)
	{}
protected:
	string _majorCourse; // 主修课程
};
int main()
{
	Assistant a("张三", "李四", "王五");
	return 0;
}

运行代码,得到结果如下:

由于菱形继承会为我们带来诸多的麻烦,因此我们一直强调不要设计出菱形继承,但是在现实中是否存在菱形继承呢??答案是肯定的,C++中的IO库就是一个典型的菱形继承设计。


7.3、面试题------多继承中指针的偏移问题

cpp 复制代码
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main()
{
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	return 0;
}

根据上述代码,下面哪一个选项是正确的()??
A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3

有了前文的知识铺垫,答案很显然为C,见下图:


八、继承与组合

public继承 是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。组合 是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。前文类模板继承中,举的stack继承vector模板就是一种典型的is-a关系,而我们一般正常实现stack,是通过在类中定义一个vector对象以此来实现的,这就是一种典型的has-a关系。

继承 允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse) 。术语 "白箱" 是相对可视性而言:在继承方式中,基类的内部细节对派生类可见。继承在一定程度上破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的 依赖关系很强,耦合度高

对象组合 是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse) ,因为对象的内部细节是不可见的。对象只以 "黑箱" 的形式出现。 组合类之间 没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。

优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不要太绝对,如果类之间的关系适合使用继承那就是用继承,此外,如果要实现多态,也必须使用继承。类之间的关系既适合用继承也适合用组合,就用组合。**


完🌍🌏🌎

相关推荐
文祐1 小时前
C语言用双向链表实现单调递减(递增)队列
c语言·开发语言·链表
雪的季节2 小时前
OCR: windows 环境基于c++的 paddle ocr 编译【CPU版本】编译成dll ,在QT、VS中调用
开发语言
拳里剑气2 小时前
C++算法:队列与BFS
c++·算法·bfs·宽度优先·队列
aaPIXa6222 小时前
C++模板元编程:在编译期完成计算,实现零运行时开销
java·开发语言·c++
夜郎king2 小时前
RuoYi-Vue3 企业级后台快速落地实战指南
java·开发语言
郝学胜-神的一滴2 小时前
CMake 038:OBJECT目标复用编译+Linux动态库版本自动化管理
linux·c++·程序人生·软件工程·软件构建·cmake·工程配置
江华森3 小时前
exp01_Python_数据类型
开发语言·python
Sylvia-girl3 小时前
第八站:vector及其常用操作【上】
c++
希冀1233 小时前
【JavaScript】Javascript—JS进阶—Day03
开发语言·javascript·原型模式