【C++】——继承详解
如意.759 2024-10-07 10:05:01 阅读 96
目录
1、继承的概念与意义
2、继承的使用
2.1继承的定义及语法
2.2基类与派生类间的转换
2.3继承中的作用域
2.4派生类的默认成员函数
<1>构造函数
<2>拷贝构造函数
<3>赋值重载函数
<4析构函数
<5>总结
3、继承与友元
4、继承与静态变量
5、菱形继承及菱形虚拟继承
6、继承与组合
1、继承的概念与意义
什么是继承?
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派生类。
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复用,继承是类设计层次的复用。
通过继承联系在一起的类构成了一种层次关系,在这种关系中有一个基类(base class),其他类则是直接或间接地从基类继承过来的,这些继承来的类可以称为派生类(drived class)。基类通常有着层次关系中所有类共同拥有维护的成员,而每个派生类也有着自己各自特定的成员。
一个简单的例子:一个学习管理系统,那么成员必定有学生,老师等等,这些是身份,归根到底是个人(基类)包含着名字、年龄、地址等基础信息。这些需要共同维护的就是基类的成员。
<code>//共同维护的成员部分->基类
class Person
{
public:
// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
void identity()
{
cout << "void identity()" << _name << endl;
}
protected:
string _name = "qsy"; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄
};
class Student : public Person
{
public:
// 学习
void study()
{
// ...
}
protected:
int _stuid; // 学号
};
class Teacher : public Person
{
public:
// 授课
void teaching()
{
//...
}
protected:
string title; // 职称
};
int main()
{
Student s;
Teacher t;
s.identity();
t.identity();
return 0;
}
可以看到派生类可以访问基类成员
如果没有继承这种结构关系的话 Student和Teacher 都有姓名/地址/ 电话/年龄等成员变量,都有identity身份认证的成员函数,设计到两个类里面就是冗余的。更好地体现了继承是类设计层次的复用。
2、继承的使用
2.1继承的定义及语法
这就是继承的语法格式
继承方式与访问限定符号一样有着三种,不同的继承方式与不同的类成员组合会是不同的情况
总结一下规律:
<1>基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
将年龄变为私有验证一下是否继承到了派生类对象
可以看到继承下来了但是不可以访问!!
<2>如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为 protected。可以看出保护成员限定符是因继承才出现的。
如果想要访问 private 成员可以在基类中成员函数访问,这样派生类可以间接访问到 private成员
<3>基类的私有成员在派生类都是不可见。基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
Tip:class默认继承方式是 private,struct默认继承方式是public。最好显示写出继承方式
<4>在实际运用中⼀般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实 际中扩展维护性不强。
2.2基类与派生类间的转换
基类与派生类之间是否有着类型的转换呢?
答案是可以的! public继承中有一个 is-a 概念:每个派生类都是一个特殊的基类对象
• public继承的派生类对象 可以赋值给 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。
• 基类对象不能赋值给派生类对象
• 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。
2.3继承中的作用域
继承体现中也有各自的作用域规则并且引出来一个隐藏概念,隐藏影响的只是编译器查找规则
1. 在继承体系中基类和派生类都有独立的作用域。
2. 派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。 (在派生类成员函数中,可以使用 基类::基类成员显示访问)
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。(区分重载)
4. 注意在实际中在继承体系里面最好不要定义同名的成员。
<code>// Student的_num 和 Person的_num 构成隐藏关系,可以看出这样代码虽然能跑,但是⾮常容易混淆
class Person
{
protected:
string _name = "小徐"; // 姓名
int _num = 111; // ⾝份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 身份证号:" << Person::_num << endl;
cout << " 学号:" << _num << endl;
}
protected:
int _num = 999; // 学号
};
int main()
{
Student s1;
s1.Print();
return 0;
};
访问的是哪个 _num 呢?
可以看到派生类成员隐藏了基类的同名成员,直接访问了派生类的 _num
同理,函数也有隐藏的现象
A和B类中的 fun 两个函数构成什么关系呢??根据前面的知识可以知道继承体系中函数名相同就构成隐藏关系
2.4派生类的默认成员函数
6个默认成员函数,默认的意思就是指我们不写,编译器会自动生成⼀个,那么在派生类中,这 几个成员函数是如何生成的呢?
四个常见默认成员函数:
<1>构造函数
派生类的构造函数必须调用基类的构造函数初始化基类的那⼀部分成员。
<code>class Person
{
public:
Person(const char* name="xxc") //全缺省函数,默认构造code>
:_name(name)
{
cout << "Person()" << endl;
}
protected:
string _name;//姓名
};
class Student :public Person
{
public:
//不显示实现默认构造,编译器生成的
// 1. 内置类型->不确定
// 2. 自定义类型->调用自定义类型的显示写的默认构造
// 3. 基类成员看作一个整体,要求调用基类的默认构造
protected:
int _num;//学号
string _addrss;//地址
};
int main()
{
Student s1;
return 0;
}
如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
写一个 Student的构造函数
还是报错!前面提到 需要把基类成员当成一个对象调用基类的构造函数
如何实现一个不能被继承的类呢?
方法1:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构成函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。
方法2:C++11新增了⼀个final关键字,final 修改基类,派生类就不能继承了。
<2>拷贝构造函数
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
可以看到,没有资源申请的时候 Student 并不需要自己显示实现拷贝构造,因为编译器默认拷贝构造会调用基类的拷贝构造
那么怎么自己实现拷贝构造呢?(Tip:基类对象是最先声明(内存顺序)的,初始化列表中第一个初始化)
<code>Person(const Person& p)
: _name(p._name)
{
cout << "Person(const Person& p)" << endl;
}
Student(const Student& s)
:Person(s)
,_num(s._num)
,_addrss(s._addrss)
{
//深拷贝
}
Person(s) 这个 s 是派生类对象的引用为什么可以传给基类呢? 涉及基类与派生类间的转换概念——切片
如果显示写了拷贝构造但是不显示调用基类的拷贝构造的会,编译器会自动调用默认构造而非调用基类的拷贝构造
补充一下缺省值构成默认构造,运行一下发现调用的就是默认构造而非拷贝构造
<3>赋值重载函数
派生类的operator=必须要调用基类的operator=完成基类的复制。
赋值重载与拷贝构造类似一般编译器默认生成的就已经够用了,如果有资源申请的话才需要显示实现
<code>Student& operator=(const Student& s)
{
if (this != &s)
{
operator=(s);//派生类切片基类成员
_num = s._num;
_addrss = s._addrss;
}
return *this;
}
栈溢出,无限递归调用,我们不是想要调用基类的赋值函数吗?为什么调用了派生类的呢?
需要注意的是派生类的 operator= 隐藏了基类的operator= ,所以显示调用基类的operator= ,需要指定基类作用域
<code>Student& operator=(const Student& s)
{
if (this != &s)
{
//基类和派生类的赋值构成了隐藏关系 需要指定作用域
Person::operator=(s);//派生类切片基类成员
_num = s._num;
_addrss = s._addrss;
}
return *this;
}
<4析构函数
析构函数可以显示调用,那么可以在派生类显示调用基类的析构函数来清理基类成员
可是为什么调不动呢?这里派生类和基类的析构函数构成了隐藏关系
因为多态中⼀些场景析构函数需要构成重写,重写的条件之一是函数名相同,那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加 virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。
想要调用就标明作用域:
<code>Person::~Person()
但是像上述这样写,会有一个问题,基类的析构会调用两次!!!
其实,派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。所以我们不必在派生类的析构函数中进行调用基类的析构函数,不然就会重复释放同一块空间,导致报错!
可以怎么理解派生类析构自动调用基类的析构呢? 先子后父保证析构顺序!显示调用不一定保证先子后父的析构顺序
<5>总结
派生类和基类的层次关系逻辑基础还是类和对象
派生类的默认成员函数的注意事项:
<1>派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
<2>派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
<3>派生类的operator=必须要调用基类的operator=完成基类的复制。
<4>派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员(不需要显示和调用基类析构)。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
<5>派生类对象初始化先调用基类构造再调派生类构造。派生类对象析构清理先调用派生类析构再调基类的析构。
<6>因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系
3、继承与友元
友元关系不能继承,也就是说基类友元不能访问派生类私有和保护成员。比如爸爸的朋友可以说是你的朋友吗?
<code>
class Student;//前置声明
class Person
{
public:
friend void Display(const Person& p, const Student& s);//需要前置声明否则报错招不到 Student
protected:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
}
int main()
{
Person p;
return 0;
}
如果访问派生类的私有和保护成员呢?
可以看见是不可访问的 在派生类同样设置一个友元就可以解决这个问题了。
4、继承与静态变量
基类定义了 static 静态成员,则整个继承体系里面只有⼀个这样的成员。无论派生出多少个派生类,都只有⼀个static 成员实例。
验证一下:
<code>class A
{
public:
static int _a;
int _aa;
};
class B :public A
{
public:
int _b;
};
// static int _a = 1;报错
int A::_a = 1;//注意定义的方式
int main()
{
A a;
B b1;
B b2;
//这⾥的运行结果可以看到非静态成员_aa的地址是不⼀样的
// 说明派生类继承下来了,⽗类派生类对象各有⼀份
cout << &a._aa << endl;
cout << &b1._aa << endl;
cout << endl;
// 这⾥的运行结果可以看到静态成员 _a 的地址是⼀样的
//说明派生类和基类共用同⼀份静态成员
cout << &a._a << endl;
cout << &b1._a << endl;
cout << &b2._a << endl;
cout << endl;
//公有情况下 基类派生类都可以访问静态成员变量
cout << a._a << endl;
cout << b1._a << endl;
cout << b2._a << endl;
return 0;
}
也就说明他们共用一个_a变量,所以无论派生出多少个子类,都只有一个static成员实例
这个特性可以带来一种思路统计实例化类的数量个数,只需在构造函数中加入一个增加该静态变量的语句即可:
<code>class Person
{
public:
Person() { ++_count; }//子类的构造会调用父类构造
protected:
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
int main()
{
Student s1;
Student s2;
Student s3;
cout << " 人数 :" << Person::_count << endl;
Student::_count = 0;
cout << " 人数 :" << Person::_count << endl;
return 0;
}
这样我们就可以知道该继承体系中实例化了多少个类了!!!
5、菱形继承及菱形虚拟继承
首先声明一下,由于C++的历史缘故,其一致行走在语言发展的前端,一直在尝试新的内容。在发展过程中,有些内容加入到C++的时候,还没有发现其弊端。而后来发现的时候,为了向上兼容,只能打补丁,所以不开避免的不会有一些弊端,会有复杂的语法和复杂的特性。总要有先驱者走前前面,而C++就是!!!
单继承:⼀个派生类只有⼀个直接基类时称这种继承关系为单继承
多继承:⼀个派生类有两个或以上直接基类时称这种继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派生类成员在放到最后面。
菱形继承:菱形继承是多继承的⼀种特殊情况。
菱形继承的问题,从上面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题,在Assistant的对象中Person成员会有两份。
<code>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;
}
那该如何解决数据冗余的问题呢??可以借用虚拟继承!!
虚拟继承(virtual)可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。
这是什么原理呢?测试一下!
菱形继承不虚拟继承的情况
#include<iostream>
#include<string>
using namespace std;
class A
{
public:
int _a;
};
class B : public A
//class B : virtual public A
{
public:
int _b;
};
class C : public A
//class C : virtual public A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
调试一下:
通过这个逐语句调试的内存变化,我们可以确定大致的内存情况:
不使用虚拟继承就是这样的内存情况,也好理解为什么同名变量的两份是如何储存的了。
接下来我们来看虚拟继承下的菱形继承是怎么个情况:
内存分布:
a储存在最下面,而B,C部分的原有储存_a的位置现在是什么呢???
其实是个指针,那我们来看看指针指向的空间储存着什么吧:
???怎么对应位置是00 00 00 00为什么是零?往下看看:
分别储存着16进制数字14 0c转换为10进制数字20 12,然后对应B,C原本的指针位置(006FFB6C)加上这个值(偏移量),都会指向到A _a的空间!!!这个00 00 00 00到多态的部分再来进行讲解,知道原地址加上下面的值就是A _a的空间就可以了!!!
这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。
即原本B,C中_a的位置储存这一个指针,指针指向的位置有一个偏移量,原位置的地址加上偏移量就会指向A的空间!!!
那这样进行拷贝切片的时候是怎样的呢?一样是把D中B对象的部分切片,然后通过虚基表的方式来找到_a。但这样也带来了一些代价:(PS:内存中的储存顺序就是声明的顺序,先继承谁,谁就在前面)
多继承指针偏移问题(切片)
p1和p2指向哪里呢???
内存分布中,先继承的放前面!
因为切片的概念p2指向 base2开始但是只能看见 base2 那一部分
6、继承与组合
public继承是一种is-a(谁是什么)的关系。也就是说每个派生类对象都是一个基类对象。组合是一种has-a(谁有什么)的关系。假设B组合了A,每个B对象中都有一个A对象(也就是把A作为B的成员变量)
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse 能看见,不安全,耦合度高)。术语 “白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse 不能能看见,安全,耦合度低),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
有关继承的经典面试题
<1>C++有多继承,为什么java等语言没有?
历史原因!C++是先驱者(人的直觉认为多继承很合理,我感觉正常人都会想到多继承),并且c++中的多继承处理起来十分复杂,访问基类变量的过程就会很复杂!!!java等后来发展的语言见到c++中多继承的复杂,就干脆放弃了。
<2>什么是菱形继承?多继承的问题是什么?
菱形继承如字面意思(两个父类的父类是同一个类就会发生菱形继承),多继承本身没什么问题,真正的问题是有多继承就可能发生菱形继承。菱形继承就有问题了:变量的二义性和继承冗杂。解决办法很简单就是虚拟继承,但是这样就会大大降低效率。
<3>继承和组合的区别?什么时候用继承?什么时候用组合?
继承:通过扩展已有的类来获得新功能的代码复用方法
组合:新类由现有类的对象合并而成的类的构造方式
如果二者间存在一个“是”的关系,并且一个类要对另外一个类公开所有接口,那么继承是更好的选择
如果二者间存在一个“有”的关系,那么首选组合
!能用组合就用组合!!!能用组合就用组合!!!能用组合就用组合!!!
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。