【C++:继承】面向对象编程精要:C++继承机制深度解析与最佳实践
🔥艾莉丝努力练剑:个人主页
❄专栏传送门:《C语言》、《数据结构与算法》、C/C++干货分享&学习过程记录、Linux操作系统编程详解、笔试/面试常见算法:从基础到进阶
⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平
🎬艾莉丝的简介:
🎬艾莉丝的C++专栏简介:
目录
C++的两个参考文档
1 ~> 继承基础与概念解析
1.1 继承的核心概念
1.1.1 继承的本质与定义
1.1.2 【拓展视野】企业组织架构与类设计的类比
1.1.3 类体系设计方法论——第一步:设计定义出有哪些类
1.1.4 继承机制的优势与便利性
1.2 继承语法与访问控制
1.2.1 继承定义的标准格式
1.2.2 继承方式与访问权限详解
1.3 基类成员访问方式的九种组合变化
1.4 继承与类模板的结合应用
2 ~> 类型转换
2.1 基类与派生类类型转换原理
2.1.1 转换机制核心概念
2.2 类型转换的特殊处理规则
2.2.1 特殊处理
2.2.2 类型转换实践应用
3 ~> 继承中的作用域规则
3.1 深度剖析名称“隐藏”规则
3.2 同名成员的处理策略
3.3 继承作用域选择题精解
3.4 作用域规则综合总结
4 ~> 派生类的默认成员函数专题
4.1 派生类默认成员函数解析(前四类)
4.2 成员函数生成的核心机制
4.3 构造函数和析构函数专题
4.3.1 构造与析构实践演练
4.3.2 【关键问题】多态环境下的析构函数陷阱(不重写就会内存泄漏)——析构两次
4.3.3 原因
4.3.4 解决方案:构造与析构的顺序控制——【构造先父后子,析构先子后父】
4.3.5 方案验证测试
4.3.6 构造析构规则总结
完整代码示例与实践演示
Test.cpp:
结尾
C++的两个参考文档
老朋友(非官方文档):cplusplus
官方文档(同步更新):cppreference
1 ~> 继承基础与概念解析
1.1 继承的核心概念
1.1.1 继承的本质与定义
继承(inheritance)本质是类设计层次的复用。
继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复用,继承是类设计层次的复用。
假如我们现在要设计一套图书管理系统/学生管理系统,甚至于说学校的门禁系统(这里插一句题外话,),我们需要做的第一步就是设计定义出有哪些类(如学生、老师、保安、食堂阿姨)。
有些信息/方法是独有的,有些则是公共的(大家都有)。同理,有些成员是公共的,每个都写出来就实在是太恶心了,为了方便,我们就要用到接下来我们要介绍的继承——复用——直接用别人的,只不过以前复用的是函数,现在的继承变成了类设计层面的复用。
1.1.2 【拓展视野】企业组织架构与类设计的类比
这里既然说到设计了,艾莉丝就插个题外话:虽然我们大多是奔着研发岗 / 测试开发岗 / 测试岗去的,但还是要懂一些产品设计的知识,当然产品设计是由公司里面的产品经理把控的,有些小一点的公司可能人手不够,很多人身兼数职,比如产品经理既是产品经理又是项目经理,甚至于在小公司老板自己可能就是产品经理,这里提到的项目经理已经是管理层了,偏领导,既然都介绍到公司的岗位划分了,我们就再简单了解一下吧——
1.1.3 类体系设计方法论——第一步:设计定义出有哪些类
公共的特性,抽取出来,放到一个公共类(父类/基类)里面——
1.1.4 继承机制的优势与便利性
下面我们看到没有继承之前我们设计了两个类Student和Teacher,Student和Teacher都有姓名/地址电话/年龄等成员变量,都有identity身份认证的成员函数,设计到两个类里面就是冗余的。当然他们也有一些不同的成员变量和函数,比如老师独有成员变量是职称,学生的独有成员变量是学号;学生的独有成员函数是学习,老师的独有成员函数是授课——
class Student
{
public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证 void identity(){// ...}// 学习 void study(){// ...}
protected:string _name = "peter"; // 姓名 string _address; // 地址 string _tel; // 电话 int _age = 18; // 年龄 int _stuid; // 学号
};
class Teacher
{
public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证 void identity(){// ...}// 授课 void teaching(){//...}
protected:string _name = "艾莉丝"; // 姓名
那我们就把公共的成员都放到Person类中,Student和teacher都继承Person,就可以复用这些成员,就不需要重复定义了,这样省去了很多麻烦——
代码演示如下——
class Person
{
public:// 进入校园/图书馆/实验室刷二维码等身份认证void identify(){cout << "void identify()" << _name << endl;}void func(){cout << _age << endl;}protected:string _name = "艾莉丝"; // 姓名string _address; // 地址string _tel; // 电话private:int _age = 18; // 年龄
};class Student : public Person
{
public:// 学习void study(){//// . . .//// 基类私有成员(类比“爸爸的私房钱”),派生类中不可见,语法上限制不能直接使用//cout << _age << endl;// 但是可以间接使用func();}protected:int _stuid; // 学号
};class Teacher : public Person
{
public:// 授课void teaching(){// . . .}protected:string _title; // 职称
};int main()
{Student s;Teacher t;s.identify();s.study();return 0;
}
运行一下——
1.2 继承语法与访问控制
1.2.1 继承定义的标准格式
下面我们看到Person是基类,也称作父类;Student是派生类,也称作子类。(这主要是因为因为翻译的原因,所以既叫基类 / 派生类,也叫父类 / 子类)
1.2.2 继承方式与访问权限详解
正是因为有三种继承方式和三种访问,所以才有了继承基类成员访问方式的九种变化。
1.3 基类成员访问方式的九种组合变化
1、基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
2、基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
3、实际上面的表格我们进行一下总结会发现,基类的私有成员在派生类都是不可见。基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符,继承方式),即取Min(权限更小的),权限是public(公有) > protected(保护) > private(私有)。
4、使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
5、在实际运用中一般使用都是public继承,几乎很少使用protetced / private继承,也不提倡使用protetced / private继承,因为protetced / private继承下来的成员都只能在派生类的类里面使用,实
际中扩展维护性不强。
1.4 继承与类模板的结合应用
namespace jqj
{template<class T>class stack : public vector<T>{public:void push(const T& x){// 但是模版是按需实例化的,调用了哪个成员函数,就实例化哪个// push_back等成员函数未实例化,所以找不到vector<T>::push_back(x);}void pop(){vector<T>::pop_back();}const T& top(){return vector<T>::back();}bool empty(){return vector<T>::empty();}};
}int main()
{jqj::stack<int> st;st.push(1);st.push(2);st.push(3);while (!st.empty()){cout << st.top() << endl;st.pop();}//// 但是模版是按需实例化的,调用了哪个成员函数,就实例化哪个//// 构造/析构/push_back会实例化,其他成员函数就不会实例化//vector<int> v;//v.push_back(1);return 0;
}
模版是按需实例化的,调用了哪个成员函数,就实例化哪个。
像这里,构造/析构/push_back会实例化,其他成员函数就不会实例化。
2 ~> 类型转换
2.1 基类与派生类类型转换原理
2.1.1 转换机制核心概念
1、通常情况下我们把一个类型的对象赋值给另一个类型的指针或者引用时,存在类型转换,中间会产生临时对象,所以需要加const,如:
int a=l;
const double&d = a;
public继承中,就是一个特殊处理的例外,派生类对象可以赋值给基类的指针/基类的引用,而不需要加const,这里的指针和引用绑定是派生类对象中的基类部分,如下图所示。也就意味着一个基类的指针或者引用,可能指向基类对象,也可能指向派生类对象。
2、派生类对象赋值给基类对象是通过基类的拷贝构造函数或者赋值重载函数完成的(这两个函数的细节后面小节会细讲),这个过程就像派生类自己定义部分成员切掉了一样,所以也被叫做切割或者切片,如下图中所示。
3、基类对象不能赋值给派生类对象。
4、基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-TimeType
Information)的dynamic_cast来进行识别后进行安全转换(ps:这个艾莉丝会在后面的类型转换章节再单独介绍,这里先提一下,我在Linux的开篇——Linux指令那里就提到过“没吃过猪肉还没见过猪跑吗?”,我们先见见猪跑)。
2.2 类型转换的特殊处理规则
2.2.1 特殊处理
2.2.2 类型转换实践应用
class Person
{
protected:string _name; // 姓名string _sex; // 性别int _age; // 年龄
};class Student : public Person
{
public:int _Nu; // 学号
};int main()
{// 类型转换int i = 1;double d = i;const double& rd = i;string s1 = "11111";const string& rs = "11111"; // 常量,临时对象Student s;Person p = s;// 特殊处理// 1.派⽣类对象可以赋值给基类的指针/引用Person& rp = s;Person* ptr = &s;//// 不支持强制类型转换//s = (Student)p; // 强制类型转换// 生类对象可以赋值给基类的对象是通过调用后面会讲解的基类的拷⻉构造完成的 Person p = s;// 2.基类对象不能赋值给派生类对象,这里会编译报错return 0;
}
运行一下——
3 ~> 继承中的作用域规则
3.1 深度剖析名称“隐藏”规则
1、在继承体系中基类和派生类都有独立的作用域;
2、派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏(在派生类成员函数中,可以使用基类::基类成员进行显示访问);
3、需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏;
4、注意在实际中在继承体系里面最好不要定义同名的成员。
3.2 同名成员的处理策略
Student的_num和Person的_num构成隐藏关系,这样代码虽然能跑,但是非常容易混淆——
3.3 继承作用域选择题精解
1、A和B类中的两个func构成什么关系()
A. 重载 B. 隐藏 C. 没关系
2、下面程序的编译运行结果是什么()
A. 编译报错 B. 运行报错 C. 正常运行
图中代码——
// 选择题
class A
{
public:void fun(){cout << "func()" << endl;}
};class B : public A
{
public:void fun(int i){cout << "void func(int i)" << i << endl;}
};int main()
{//// 同名函数,只看函数名,不看参数,参数是否相同不重要//Student s;//s.Print();B b;b.fun(10);//b.fun(); // 报错b.A::fun(); // 解决方式// 实践中不建议定义同名函数,完全是坑自个儿return 0;
}
运行一下——
3.4 作用域规则综合总结
1、同名函数,只看函数名,不看参数,参数是否相同不重要;
2、b.fun(); // 报错;
3、实践中不建议定义同名函数,完全是坑自个儿。
4 ~> 派生类的默认成员函数专题
4.1 派生类默认成员函数解析(前四类)
6个默认成员函数,我们之前在类和对象就已经见识过了,默认的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
4.2 成员函数生成的核心机制
1、派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2、派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3、派生类的operator=必须要调用基类的operator=完成基类的复制。需要注意的是派生类的
operator=隐藏了基类的operator=,所以显示调用基类的operator=,需要指定基类作用域
4、派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5、派生类对象初始化先调用基类构造再调派生类构造。
6、派生类对象析构清理先调用派生类析构再调基类的析构。
7、因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们多态章节会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destructor),所以基类析构函数不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。
4.3 构造函数和析构函数专题
4.3.1 构造与析构实践演练
class Person
{
public:Person(const char* name):_name(name){cout << "Person()" << endl;}Person(const Person& p):_name(p._name){cout << "Person(const Person& p)" << endl;}Person& operator=(const Person& p){cout << "Person& operator=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}// 析构~Person(){cout << "~Person()" << endl;}protected:string _name; // 姓名
};
1、我们不写,默认生成的函数行为是什么?是否符合需求
2、不符合,我们要自己实现,如何实现?
本质:可以把派生类当做多一个的自定义类型成员变量(基类)的普通类——
class Student : public Person
{
public:Student(const char* name = "艾莉丝", int num = 18, const char* address = "武汉"):Person(name),_num(num),_address(address){cout << "Student()" << endl;}// 本质:可以把派生类当做多一个的自定义类型成员变量(基类)的普通类Student(const Student& s):Person(s), _num(s._num), _address(s._address){// 涉及到深拷贝,需要自己实现}Student& operator=(const Student& s){if (this != &s){Person::operator=(s);_num = s._num;_address = s._address;}return *this;// 涉及到深拷贝,需要自己实现}~Student(){//// 不用显示调用基类析构,编译器会在派生类析构结束之后自动调用析构//Person::~Person();//// ...// 隐式原因:为了安全性cout << "~Student()" << endl;}protected:int _num; // 学号string _address; // 地址//int* _ptr;
};
继承的基类成员变量(整体对象)+ 自己的成员变量(遵循普通的规则,跟类和对象部分一样)默认生成的构造,派生类自己的成员,内置类型不确定,自定义类型调用默认构造,基类部分调用默认构造本质上可以把派生类当做多了自定义类型成员变量(基类)的普通类总,跟普通类原则一样。派生类一般要自己的实现构造,不显示写析构、拷贝析构、赋值重载,除非派生类有深拷贝的资源。
4.3.2 【关键问题】多态环境下的析构函数陷阱(不重写就会内存泄漏)——析构两次
运行一下,发现会析构两次——
为什么会这样呢?
4.3.3 原因
(1)核心的原因就是因为不用显示调用基类析构,编译器会在派生类析构结束之后自动调用析构,如果显示调用父类析构,无法保障先子后父的析构顺序。
(2)隐式的原因就是为了安全性,如下图所示——
4.3.4 解决方案:构造与析构的顺序控制——【构造先父后子,析构先子后父】
4.3.5 方案验证测试
再来个main函数,我们测试一下构造和析构——
int main()
{Student s1;//Student s2("艾莉丝", 15);//Student s3(s2);//s1 = s3; //Person p = s1;return 0;
}
运行一下——
4.3.6 构造析构规则总结
1、跟普通类的原则基本上一样;
2、派生类一般要自己实现构造,不需要写拷贝构造、析构、赋值重载;
3、除非派生类有深拷贝的资源。
完整代码示例与实践演示
Test.cpp:
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
#include<vector>
#include<list>
using namespace std;//class Person
//{
//public:
// // 进入校园/图书馆/实验室刷二维码等身份认证
// void identify()
// {
// cout << "void identify()" << _name << endl;
// }
//
// void func()
// {
// cout << _age << endl;
// }
//
//protected:
// string _name = "张三"; // 姓名
// string _address; // 地址
// string _tel; // 电话
//
//private:
// int _age = 18; // 年龄
//};
//
//class Student : public Person
//{
//public:
// // 学习
// void study()
// {
// //// . . .
// //// 基类私有成员(类比“爸爸的私房钱”),派生类中不可见,语法上限制不能直接使用
// //cout << _age << endl;
//
// // 但是可以间接使用
// func();
// }
//
//protected:
// int _stuid; // 学号
//};
//
//class Teacher : public Person
//{
//public:
// // 授课
// void teaching()
// {
// // . . .
// }
//
//protected:
// string _title; // 职称
//};
//
//int main()
//{
// Student s;
// Teacher t;
// s.identify();
// s.study();
//
// return 0;
//}//namespace jqj
//{
// template<class T>
// class stack : public vector<T>
// {
// public:
// void push(const T& x)
// {
// // 但是模版是按需实例化的,调用了哪个成员函数,就实例化哪个
// // push_back等成员函数未实例化,所以找不到
// vector<T>::push_back(x);
// }
//
// void pop()
// {
// vector<T>::pop_back();
// }
//
// const T& top()
// {
// return vector<T>::back();
// }
//
// bool empty()
// {
// return vector<T>::empty();
// }
// };
//}
//
//int main()
//{
// jqj::stack<int> st;
// st.push(1);
// st.push(2);
// st.push(3);
// while (!st.empty())
// {
// cout << st.top() << endl;
// st.pop();
// }
//
// //// 但是模版是按需实例化的,调用了哪个成员函数,就实例化哪个
// //// 构造/析构/push_back会实例化,其他成员函数就不会实例化
// //vector<int> v;
// //v.push_back(1);
//
// return 0;
//}//class Person
//{
//protected:
// string _name; // 姓名
// string _sex; // 性别
// int _age; // 年龄
//};
//
//class Student : public Person
//{
//public:
// int _Nu; // 学号
//};
//
//int main()
//{
// // 类型转换
// int i = 1;
// double d = i;
// const double& rd = i;
//
// string s1 = "11111";
// const string& rs = "11111"; // 常量,临时对象
//
// Student s;
// Person p = s;
// // 特殊处理
// Person& rp = s;
// Person* ptr = &s;
//
// //// 不支持强制类型转换
// //s = (Student)p; // 强制类型转换
//
// return 0;
//}//class Person
//{
//protected:
// string _name = "艾莉丝"; // 姓名
// int _num = 1572; // 身份证号
//};
//
//class Student : public Person
//{
//public:
// void Print()
// {
// cout << _num << endl;
// cout << Person::_num << endl;
// }
//
//protected:
// int _num = 1517; // 学号
//};
//
//class A
//{
//public:
// void fun()
// {
// cout << "func()" << endl;
// }
//};
//
//class B : public A
//{
//public:
// void fun(int i)
// {
// cout << "void func(int i)" << i << endl;
// }
//};
//
//int main()
//{
// // 同名函数,只看函数名,不看参数,参数是否相同不重要
// Student s;
// s.Print();
//
// B b;
// b.fun(10);
// //b.fun(); // 报错
// b.A::fun(); // 解决方式
//
// // 实践中不建议定义同名函数,完全是坑自个儿
//
// return 0;
//}class Person
{
public:Person(const char* name):_name(name){cout << "Person()" << endl;}Person(const Person& p):_name(p._name){cout << "Person(const Person& p)" << endl;}Person& operator=(const Person& p){cout << "Person& operator=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}// 析构~Person(){cout << "~Person()" << endl;}protected:string _name; // 姓名
};// 1、我们不写,默认生成的函数行为是什么?是否符合需求
// 2、不符合,我们要自己实现,如何实现?class Student : public Person
{
public:Student(const char* name = "艾莉丝", int num = 18, const char* address = "武汉"):Person(name),_num(num),_address(address){cout << "Student()" << endl;}// 本质:可以把派生类当做多一个的自定义类型成员变量(基类)的普通类Student(const Student& s):Person(s), _num(s._num), _address(s._address){// 涉及到深拷贝,需要自己实现}Student& operator=(const Student& s){if (this != &s){Person::operator=(s);_num = s._num;_address = s._address;}return *this;// 涉及到深拷贝,需要自己实现}~Student(){//// 不用显示调用基类析构,编译器会在派生类析构结束之后自动调用析构//Person::~Person();//// ...// 隐式原因:为了安全性cout << "~Student()" << endl;}protected:int _num; // 学号string _address; // 地址//int* _ptr;
};// 构造
// 继承的基类成员变量(整体对象)+ 自己的成员变量(遵循普通的规则,跟类和对象部分一样)
// 默认生成的构造,派生类自己的成员,内置类型不确定,自定义类型调用默认构造,基类部分调用默认构造// 本质上可以把派生类当做多了自定义类型成员变量(基类)的普通类总,跟普通类原则一样// 派生类一般要自己的实现构造,不显示写析构、拷贝析构、赋值重载,除非派生类有深拷贝的int main()
{Student s1;//Student s2("艾莉丝", 15);//Student s3(s2);//s1 = s3; //Person p = s1;return 0;
}// 总结
// 1、跟普通类的原则基本上一样
// 2、派生类一般要自己实现构造,不需要写拷贝构造、析构、赋值重载
// 3、除非派生类有深拷贝的资源
结尾
往期回顾:
【C++模版进阶】如何理解非类型模版参数、特化与分离编译?
结语:都看到这里啦!那请大佬不要忘记给博主来个“一键四连”哦!
🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡
૮₍ ˶ ˊ ᴥ ˋ˶₎ა