什么是继承?
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
我们称被继承的类为基类(父类),称继承的类叫做派生类(子类)。
继承
继承方式
继承有三种方式,分为public继承,protected继承,private继承,每种继承都有不同之处,我们用一张图来很好的总结它们的区别
public继承
public继承是我们最常用的继承方式,而大部分情况下我们也只会用到这种继承方式。
class Person { //基类(父类)
public:
string _name = "张三";
protected:
string _tel;
private:
string _sex;
};
class Student : public Person{ // 派生类(子类)
};
public继承对于基类的public成员继承给派生类仍然是public成员,
对于基类的protected成员继承给派生类仍然是protected成员,
但是对于基类的private成员,继承给是派生类是不可见状态,你无法在派生类访问到基类的private成员,就像是在类外一样。
继承对于基类的private成员是特殊的,无论是什么继承,基类的private成员在派生类中都是不可见的!
protected继承
class Person { //基类(父类)
public:
string _name = "张三";
protected:
string _tel;
private:
string _sex;
};
class Student : protected Person{ // 派生类(子类)
};
protected继承对于基类的public成员继承给派生类会变为protected成员,
对于基类的protected成员继承给派生类仍然是protected成员,
对于基类的private成员,继承给是派生类是不可见状态。
private继承
class Person { //基类(父类)
public:
string _name = "张三";
protected:
string _tel;
private:
string _sex;
};
class Student : private Person{ // 派生类(子类)
};
了解了上面两种继承,是否掌握了规律?
private继承对于基类的public成员继承给派生类会变为private成员,
对于基类的protected成员继承给派生类会变为private成员,
对于基类的private成员,继承给是派生类是不可见状态。
默认继承
class和struct有各自的默认继承方式,既然是默认继承,那么就说明可以不指定继承方式
class Person { //基类(父类)
public:
string _name = "未知";
};
class Student : Person { // class的默认继承方式是private继承
protected:
int _id;
};
class Teacher : Person { // struct的默认继承方式是public
protected:
int _workid;
};
即使有默认继承方式,我们也推荐显式写上它的继承方式。
总结
1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私
有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面
都不能去访问它。
2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在
派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他
成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected
> private。
4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过
最好显示的写出继承方式。
5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡
使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里
面使用,实际中扩展维护性不强
基类的隐藏
当基类和派生类存在同名成员变量或者同名成员函数会发生什么?
会使得基类的同名成员构成隐藏,使得基类的同名成员无法直接访问,需要指定基类的类域才能访问!
class Person { //基类(父类)
public:
void func()
{
cout << "Person" << endl;
}
string _name = "未知";
};
class Student : private Person { // 派生类(子类)
public:
void func()
{
cout << "Student" << endl;
}
string _name = "张三";
};
如果我们不指定类域直接访问
会发现都是访问的派生类的同名成员。
那么有没有办法访问到基类的同名成员呢? 指定类域!
从这里也可以发现,派生类是会储存基类的数据的。
派生类对基类的赋值转换
派生类对象可以赋值给 基类的对象 / 基类的指针 / 基类的引用。 而我们称这种现象叫做“切片”,为什么叫切片呢? 我们看下面这张图
这种赋值对于基类来讲,就相当于派生类把自己特有的那一部分切掉变成了基类。
而这种赋值,并不是我们之前遇到的赋值,会发生隐式类型转换。
这种赋值更适合叫它为一种特殊的转换。
我们也可以借着切片来了解派生类的结构
我们调用内存监控,就可以发现我们这里的被转化的基类Person ps1在内存中只占了4个字节,相对于派生类的8个字节被切掉了派生类本身自有的4个字节。
而对于基类指针和基类引用的转换
可以发现 基类指针 和 基类引用 的地址与派生类的地址是一致的。说明它们会访问同一块空间,只不过Person* 和 Person& 访问的空间有限,只能访问自己(基类)的成员。
那么,基类对象能否转化为派生类对象呢?
答案是不能的! 不过基类指针可以通过强制类型转换 变为派生类指针 。(这会存在隐患,会有非法访问的问题)
派生类的默认成员函数
继承的派生类的默认成员函数相对于没有继承的类的默认成员函数是有不同的,为什么呢?
首先要明白派生类的地址结构
既然一个派生类要基类和派生类的成员,那么对于基类的成员,派生类怎么操作呢?
默认构造函数
class Person { //基类(父类)
public:
Person(string name)
:_name(name)
{}
public:
string _name = "未知";
};
class Student : public Person { // 派生类(子类)
public:
Student(string name,int id = 2023, int age = 18)
:Person(name) //是通过这样的格式来给基类成员初始化的
,_id(id)
, _age(age)
{}
void Print()
{
cout << "姓名:" << _name << " 学号:" << _id << " 年龄:" << _age << endl;
}
//protected:
public:
int _id;
int _age;
};
int main()
{
Student st("张三");
st.Print();
return 0;
}
通过构造函数的初始化列表来给基类成员进行初始化!
注意:我们这里模拟的是编译器默认生成的默认构造函数。对于派生类的构造函数,我们可以调用基类的构造函数来完成对基类成员的初始化!
拷贝构造函数
class Person { //基类(父类)
public:
Person(const Person& st)
:_name(st._name)
{}
public:
string _name = "未知";
};
class Student : public Person { // 派生类(子类)
public:
Student(const Student& st)
:Person(st) //这里就用了基类的赋值转换知识
,_id(st._id)
,_age(st._age)
{}
void Print()
{
cout << "姓名:" << _name << " 学号:" << _id << " 年龄:" << _age << endl;
}
//protected:
public:
int _id;
int _age;
};
注意:我们这里模拟的是编译器默认生成的默认拷贝构造函数。对于派生类的拷贝构造函数,我们可以调用基类的拷贝构造函数来完成对基类成员的初始化!
赋值重载函数
class Person { //基类(父类)
public:
Person& operator=(const Person& ps)
{
if (this != &ps)
{
_name = ps._name;
}
return *this;
}
public:
string _name = "未知";
};
class Student : public Person { // 派生类(子类)
public:
Student& operator=(const Student& st)
{
if (this != &st)
{
Person::operator=(st);
_id = st._id;
_age = st._age;
}
return *this;
}
void Print()
{
cout << "姓名:" << _name << " 学号:" << _id << " 年龄:" << _age << endl;
}
//protected:
public:
int _id;
int _age;
};
这里的赋值重载就与 刚刚写的构造函数和拷贝构造函数不一样了,他需要在派生类中指定基类的类域来访问基类的赋值重载函数,并且这里也运用了切片的知识!
析构函数
class Person { //基类(父类)
public:
~Person()
{}
public:
string _name = "未知";
};
class Student : public Person { // 派生类(子类)
public:
~Student()
{
//Person::~Person(); //这里我们需要手动去调基类的析构函数吗?
//-> 不需要,因为派生类的析构函数结束时会自动调用基类的析构函数!
}
//protected:
public:
int _id;
int _age;
};
派生类的析构函数需要特别注意,在派生类析构函数结束的时候,会自动去调用基类的析构函数,所以我们就不需要手动去调用基类的析构函数,不然的话会调用两次析构。
而如果你想手动去调用 则跟赋值重载一样指定类域即可。(一般不会这么做)
注意:调用析构的话,析构顺序是先析构派生类的成员,然后再析构基类的成员。
继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
class Person { //基类(父类)
public:
friend void func();
protected:
string _name;
};
class Student : public Person { // 派生类(子类)
public:
void Print()
{
cout << "姓名:" << _name << " 学号:" << _id << " 年龄:" << _age << endl;
}
protected:
int _id;
int _age;
};
void func()
{
Person ps;
ps._name = "张三"; //因为是友元,所以可以直接进行访问
cout << ps._name << endl;
Student st;
st._id = 2023; //友元关系无法被继承
cout << st._id << endl;
}
继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。
class Person { //基类(父类)
public:
string _name;
static int _count;
};
int Person::_count = 0;
class Student : public Person { // 派生类(子类)
public:
protected:
int _id;
int _age;
};
继承体系中的静态成员是共享的。
多继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
class Animal {
public:
string _species = "human";
};
class Person { //基类(父类)
public:
string _name;
};
class Student : public Person ,public Animal{ // 派生类(子类)
public:
protected:
int _id;
int _age;
};
菱形继承
菱形继承是基于多继承的一种复杂且特殊的情况
菱形继承就会出现这样一个问题,继承的B类和C类同时拥有A类的结构,如果B、C再多继承给D类,就会出现D类拥有两个A类的结构,这会导致什么问题? 二义性和数据冗余
class A {
public:
int _num;
};
class B : public A{
public:
int _Bid;
};
class C : public A {
public:
int _Cid;
};
class D : public C, public B {
public:
};
二义性问题
如果不指明类域,就会有二义性问题,所以指定类域可以解决这种问题
从这张图可以看出来,C空间的数据是先存储的,其次是B空间的数据,最后才是D空间的数据,而导致这个顺序的原因就是我们是先继承的C,再继承的B。
虽然说指定类域可以解决二义性的问题,但是实际我们真的需要两个A类的数据吗?
一般是不需要的! 既然如此,这是不是就属于浪费了空间!
为了解决这个问题,C++专门有一个关键字来处理这个问题————virtual(虚拟继承)
虚拟继承(virtual)
使用方法: 在菱形继承体系的腰部加入virtual关键字
class A {
public:
int _num;
};
class B : virtual public A{
public:
int _Bid;
};
class C : virtual public A{
public:
int _Cid;
};
class D : public C, public B {
public:
int _Did;
};
如果使用了虚拟进程,那么D的数据结构就会发生变化
先来看看C空间的数据,本该存放它的_num的区域存放了一个像是指针的数据。(其实就是一个指针,等会讲这个指针存放了什么)
B空间的数据与C一样,本该存放它的_num的区域也存放了一个像是指针的数据。
再看_num的数据竟然是存放在最后面!
如此看来,虚拟继承的数据结构就与正常继承的数据结构有了很大的区别。
首先就引出一个问题 ,这个时候如果我们指定了类域去访问_num,它怎么去找到存放真正的_num的地址?现在B,C空间本该存放_num的地址存放了一个奇怪的指针。
我们这就探讨这个指针到底存放了什么东西
这时候发现了什么,这个指针竟然存放着与存放着真正的_num地址的偏移量!
冗余问题
我们看上面的例子,虚拟继承貌似并没有解决代码冗余的问题,它不是仍然存在着数据的浪费吗?
不仅浪费了,甚至还多开了一部分空间来存放数据。
其实不然,这里举例是A只有了一个int,但是如果不仅仅只是一个int数据,或者是一个数组,那么就很好的节约了空间。
class A {
public:
int _num;
int _num1;
int _num2;
int arr[10];
};
class B : virtual public A {
public:
int _Bid;
};
class C : virtual public A {
public:
int _Cid;
};
class D : public C, public B {
public:
int _Did;
};
这种情况就节约了一大部分空间
继承的总结
C++的继承是比较复杂的,因为它有多继承,从而衍生出了十分复杂的菱形继承,所以可以理解多继承就是C++的一个缺陷。
而相对于java的继承来讲,java并没有那么多种继承方式,java只有一种public继承,并且也没有多继承的概念,也就更没有了菱形继承。
但是,C++作为走在最前沿的语言,是肯定要踩一些坑的,这些无可厚非。