🐱作者:一只大喵咪1201
🐱专栏:《C++学习》
🔥格言:你只管努力,剩下的交给时间!
C++是面向对象的编程语言,它有很多的特性,但是最重要的就是封装,继承,多态三大特性,封装本喵就不介绍了,前面我们一直都在使用,这里本喵来详细介绍继承。
继承
- 🙀继承的概念和定义
- 😸继承关系和访问限定符
- 🙀基类和派生类的赋值转换
- 😸特性
- 🙀继承中的作用域
- 🙀派生类的默认成员函数
- 😸构造函数
- 😸拷贝构造函数
- 😸赋值运算符重载函数
- 😸析构函数
- 🙀继承与友元
- 🙀继承与静态成员
- 🙀菱形继承
- 😸多继承
- 😸虚拟继承
- 🙀继承和组合
- 🙀总结
🙀继承的概念和定义
- 继承:是面向对象程序设计使代码可以复用的最重要手段,它运行程序员在保持原有类特性的基础上进程扩展。
- 继承是类设计层次的复用。
如上图,这几个类都是在描述人扮演的不同角色,分别是学生,老师,其他职业的人。
每一个角色在描述的时候都有自己特有的属性,学生有学号班级,老师有工号和所教科目,其他职业的人也有工号和具体的岗位。
- 但是不同的角色之间也有共同的属性,比如姓名,性别,年龄。
- 将那些无论扮演什么角色都必须有的属性看作是人共有的属性。
此时,在使用不同类型的类描述不同的角色时,都需要复用到人的属性。为了方便,创建一个Person的类型来描述人,在描述不同角色创建新的类时,只需要增加Person这个成员即可,然后再添加各自的属性。
下面3个蓝色框中的类复用红色框Person这个类的过程就叫做继承。
具体到代码上来看:
class Person
{
public:
void Print()
{
cout << _name << endl;
cout << _age << endl;
}
protected:
string _name;//姓名
int _age;//年龄
};
//继承
class Student : public Person
{
protected:
int _stuid;//学号
};
Student类继承了Person类。
- Student:称为基类,也叫做父类。
- Person:称为派生类,也叫做子类。
- public:是继承方式。
在创建了Student对象后,该对象中不仅有class Student中的_stuid成员,还有class Person中的_name,_age成员,以及class Person中的Print()成员函数。
- 基类中的一切都被派生类继承了下来。
😸继承关系和访问限定符
访问限定符之前本喵讲解过,继承方式也是有这三种。使用不同继承方式继承不同权限的基类成员,排列组合后共有9中继承结果。
类成员\继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
看起来非常复杂,但是有规律可循。
基类中的private成员:
- Person中是private成员。
- 在Student继承Person的时候,分别采用public,protected,private三种继承方式。
在调试窗口中可以看到,每个派生类对象中是有基类成员的,但是基类成员的前面有个小锁(途中可能看不清),表示派生类中的基类成员无法访问。
派生类对象在访问基类成员的时候,编译器会报不可访问的错误。
基类中的private成员无论以什么方式继承都是不可见的。
- 这里的不可见是指:基类的成员仍然被继承到了派生类中,但是在语法上不让派生类去访问。
- 无论是在派生类的内部访问还是外部访问,都是不可以的。
基类中的protceted成员:
- Person中是protected成员。
- 在Student继承Person的时候,分别采用public,protected,private三种继承方式。
在调试窗口中可以看到,每个派生类中是有基类成员的,而且没有小锁,说明此时在派生类中的基类成员是可见的,也就是可以访问的。
- 用派生类的成员函数是可以访问基类中的成员的。
- 在派生类的外部访问基类成员会报错。
无论什么继承方式,在派生类的内部是可以访问基类中的保护成员的,但是在派生类的外部不能访问基类的保护成员。
基类中的public成员:
- Person中是public成员。
- 在Student继承Person的时候,分别采用public,protected,private三种继承方式。
在调试窗口中可以看到,派生类中有基类的成员,并且没有小锁,说明是可见的。
- 派生类内部都可以访问基类的成员。
- 派生类外部:
共有继承的公有成员:派生类外部可以访问基类成员。
保护继承的公有成员:派生类外部不可以访问基类成员。
私有继承的公有成员:派生类外部不可以访问基类成员。
无论哪种继承方式,派生类内部都可以访问基类公有成员,保护继承和私有继承,派生类只能在内部访问基类成员。
规律总结:
在刚学习类和对象的时候,本喵说过将访问限定符private和protected暂时看成是一样的,现在就可以介绍他俩的区别了。
三个访问限定符的访问权限大小关系如下:
- 访问权限:public > protected > private
继承关系和访问限定符的组合可以分为两大类:
- 基类中是private成员:无论哪种继承方式,基类成员对于派生类都是不可见的,不可访问。
- 基类中的其他权限成员:继承方式和基类成员访问限定符二者取权限小的作为派生类中成员的权限。
例如,基类中是protected成员,使用public继承方式,那么继承下来的基类成员在派生类中的访问权限就是protected。而此时protected就和private是一样了,在类的内部可以访问,在类的外部无法访问。
再例如,基类中的public成员,使用private继承方式,那么继承下来的基类成员在派生类中的访问限定符就是private。
可以看出,protected访问限定符就是因为继承才出现的。所以,protected和private的区别在继承中才得以体现。
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承。
因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
- 继承方式可以省略不写,采用默认继承方式:
- class定义的类,内部如果不写访问限定符,成员的默认权限是private。
- class定义的派生类,如果不写继承方式,默认的继承方式也是private。
- struct定义的类,内部如果不写访问限定符,所有成员的默认访问权限是public。
- struct定义的派生类,如果不写继承方式,默认的继承方式也是public。
不过,最好还是显示写出继承方式。
🙀基类和派生类的赋值转换
- 派生类对象可以赋值给基类的对象/基类的指针/基类的引用。
class Person
{
public:
void Print()
{
}
protected:
string _name;//姓名
string _sex;//性别
int _age;//年龄
};
class Student : public Person
{
public:
int _No;
};
基类和派生类如上。
可以看到,派生类对象赋值给基类的对象/基类的指针/基类的引用全部都可以。
派生类赋值给基类的原理如上图所示。派生类中基类有的变量保留,其余的舍弃,_No就是派生类特有的,所以在赋值的时候会舍弃。
- 这里有个形象的说法叫切片或者切割。寓意把派生类中基类那部分切来赋值过去。
😸特性
- 派生类对象赋值给基类对象不存在类型转换。
double类型的变量赋值给int&就会报错,这是因为:
- 类型转换时,会产生中间变量
- double值会先赋给int类型的临时变量,再将临时变量的值赋给int类型的变量。
上面代码中,int&变量是临时变量的引用,而临时变量具有常性,此时相当于权限放大了,所以会报错。
在引用变量前加const就可以解决这个问题。
而派生类对象赋值给基类的引用时就没有这个问题,可以之间赋值,所有说派生类对象赋值给基类对象不存在类型转换。
没有类型转换可以很大程度上节省系统的开销。
注意: 基类对象不能赋值给派生类对象。
🙀继承中的作用域
class Person
{
protected:
string _name = "张三";
int _num = 150;
};
class Student : public Person
{
public:
void Printf()
{
cout << "姓名:" << _name << endl;
cout << "身份证号:" << _num << endl;
cout << "学号:" << _num << endl;
}
protected:
int _num = 370;
};
基类和派生类中都有变量_num,在派生类的成员函数中,既想打印基类中的_num,也想打印派生类中的_num。
- 派生类对象中,既有自己的_num,也有基类中的_num。
- 在使用的时候,直接使用_num时,发现使用的是派生类中的_num。
- 派生类中的_num和基类中的_num属于不同作用域。
- 默认情况下,使用的是派生类中的_num。
当基类和派生类中的成员变量名相同时,基类中的成员变量会被隐藏,也叫做重定义。
- 要想使用被隐藏的基类中成员变量,需要在变量前加上域名和域作用限定符(显示访问)。
基类和派生类中各有一个成员函数,而且成员函数名相同。此时这俩个函数之间构成的关系是隐藏/重定义,而不是函数重载。
- 函数重载的前提是:同名函数在同一个作用域中。
此时基类和派生类中的同名函数显然不在同一个作用域,所以不能构成重载,同样是隐藏关系。
默认情况下同样调用的是派生类中的成员函数。
要想使用基类中被隐藏的成员函数,也是需要加域名和域作用限定符(显示访问)。
注意:
- 如果是成员函数的隐藏,只需要函数名相同就构成隐藏,因为作用域不同,不构成重载。
- 在继承体系里面最好不要定义同名的成员。
🙀派生类的默认成员函数
先回顾一下普通类的默认成员函数:
派生类同样只看四个默认成员函数。
😸构造函数
class Person
{
public:
Person(const char* name = "张三")
:_name(name)
{
cout << "Person(const char* name = \"张三\")" << endl;
}
protected:
string _name;
};
class Student : public Person
{
protected:
int _num;//学号
};
Person有显示定义的默认构造函数,Student没有显示定义的默认构造函数。
在创建派生类对象的时候,发现基类的默认构造函数被调用了。
在派生类中显示定义默认构造函数,在创建派生类对象的时候,发现先调用了基类的默认构造函数,再调用了派生类的默认构造函数。
我们知道,派生类中继承了基类中的成员,那么能不能在派生类的构造函数中去初始化基类的成员呢?
此时就报错了,说明派生类的构造函数不能直接去初始化基类的成员。
但是可以在派生类的构造函数中显示调用基类的构造函数来初始化基类。
当基类中没有默认构造函数时,必须在派生类的构造函数中显示调用基类的构造函数进行初始化。
结论:
- 派生类对象在创建的时候,先调用基类的构造函数来初始化基类在派生类中的成员,再调用派生类的构造函数初始化自己的成员。
- 派生类只能通过显示调用基类的构造函数来控制基类初始化,不能直接去初始化从基类中继承下来的成员。
- 如果基类中没有默认构造函数,派生类必须在构造函数中显示调用基类的构造函数,并且传值。
派生类相比于普通类的构造函数,多了一步对基类成员的处理。
😸拷贝构造函数
class Person
{
public:
Person(){}//默认构造函数
//拷贝构造函数
Person(const Person& p)
:_name(p._name)
{
cout << "Person(const Person& p)" << endl;
}
protected:
string _name;
};
class Student : public Person
{
public:
Student(){}//默认构造函数
//拷贝构造函数
Student(const Student& s)
:_num(s._num)
, Person(s)
{
cout << "Student(const Student& s)" << endl;
}
protected:
int _num;//学号
};
拷贝构造函数是构造函数的重载,所以它们的特性几乎是一样的。
- 派生类的拷贝构造函数先调用基类的拷贝构造函数。
- 派生类的拷贝构造函数不能直接处理基类的成员,必须显示调用基类的拷贝构造函数。
- 派生类的拷贝构造函数在显示调用基类的拷贝构造函数时,传的值是派生类对象。
- 基类的拷贝构造函数的形参是基类对象。
- 类型不同,但是没有发生类型转换,而是发生了切割。
😸赋值运算符重载函数
class Person
{
public:
Person& operator=(const Person& p)
{
cout << "Person& operator==(const Person& p)" << endl;
if (this != &p)
{
_name = p._name;
}
return *this;
}
protected:
string _name;
};
class Student : public Person
{
public:
Student& operator=(const Student& s)
{
cout << "Student& operator=(const Student& s)" << endl;
if (this != &s)
{
Person::operator=(s);
_num = s._num;
}
return *this;
}
protected:
int _num;//学号
};
派生类的赋值运算符重载函数中,调用基类的赋值运算符重载函数赋值从基类继承下来的那部分成员,然后再初始化自己的、这里同样发生了派生对象给基类对象赋值时的切割现象。
- 由于基类的运算符重载函数是在派生类的运算符重载函数内部调用的,所以在给派生类对象赋值时,会先调用派生类的,在派生类运算符重载函数中再调用基类的。
- 同样不可以在派生类的运算符重载函数中自行处理基类的成员,必须使用基类的运算符重载函数去处理。
基类和派生类的运算符重载函数构造了隐藏/重定义。
在派生类中调用基类的operator=()时,必须指明作用域,否则会默认调用派生类的,此时就会造成栈溢出。
😸析构函数
按照之前几个默认成员函数的做法,在派生类的析构函数中显示调用基类的析构函数,但是发现基类的析构函数一共调用了两次。
这显然是不行的,一块动态空间只能被释放一次。
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name;
};
class Student : public Person
{
public:
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num;//学号
};
在派生类的析构函数中没有显示调用基类的析构函数,发现父类和子类的析构函数各调用了一次。
- 析构函数第一怪:派生类析构函数会自动调用基类析构函数,这是必然发生的,所以无需显示调用基类的析构函数。
- 先调用派生类的析构函数,再调用基类的析构函数。
- 这样做是为了保证先清理派生类成员再清理基类成员。
- 析构函数第二怪:派生类析构函数和基类析构函数构成隐藏关系。(由于多态关系需求,所有析构函数都会特殊处理成destruct函数名,以后会讲解)。
派生类相比于普通类的四类默认成员函数,多了一步对基类成员的处理,而且只能通过基类的默认成员函数去处理,不能由派生类自行处理。
上图表示了派生类和基类对象的行为。
🙀继承与友元
定义一个Display函数,它是基类的友元函数,可以访问基类内部的保护成员。
- 由于基类中的友元声明中包含派生类,但是编译器只会向上寻找,所以必须在友元声明之前加上派生类的声明。
- 否则会报Student未声明的错误。
在调用基类的友元函数访问派生类中的成员时报错了,不让访问。因为:友元关系不继承。
- 若想让基类中的友元也成为派生类中的友元,需要在派生类中也进行友元声明。
注意: 一般不建议使用友元,因为它会破坏类的封装。
🙀继承与静态成员
class Person
{
public:
int _Pnum = 1;
static int _cout;
};
int Person::_cout = 0;
class Student : public Person
{
public:
int _Snum = 1;//学号
};
基类中有一个int类型的变量,有一个static变量,派生类中自己有一个int类型的变量。
- 创建两个对象,一个基类对象,一个派生类对象。
- 派生类会继承基类中的成员,所以基类对象和派生类对象中都有成员变量_Pnum。
两个对象创建后,分别将基类对象和派生类对象中的_Pnum打印出来,发现它们的值是一样的。
- 将基类对象中的_Pnum加1,然后打印出来,发现值加1。
- 将派生类对象中的_Pnum加1,然后打印出来,发现值加1。
- 基类对象和派生类对象中的_Pnum中的值是相互独立的,也就是有两个值。
- 派生类同样会继承基类中的静态变量。
- 基类对象中对静态变量加1,发现值加1.
- 派生类对象中对静态变量加1,发现值相对于刚创建时加了2.
也就是说,基类对象对静态变量的值加1,同样作用到了派生类对象中的静态变量上。
- 它们两个对象中的静态变量不是独立的。
在基类对象和派生类对象中的静态变量是同一个变量。
因为静态变量同样存放在数据段,基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例。
🙀菱形继承
😸多继承
首先需要知道的是单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
如上图,虽然最后的子类中有不止一个父类中的成员,但是每个子类都只有一个父类。
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。
如上图,子类只有一个,但是父类有两个,这种就属于多继承。
- 多继承中,一个子类可以有多个父类。
但是由于多继承的存在,就会引起菱形继承的问题。
- Student继承自Person,Teacher也继承自Person。
- Assistant继承自Student和Teacher。
class Person
{
public:
string _name;//姓名
};
class Student : public Person
{
public:
int _num;//学号
};
class Teacher : public Person
{
public:
int _id;//工号
};
class Assistant : public Student, public Teacher
{
public:
string _majorCourse;
};
在设计上,菱形继承完全是合理的,学生和老师属于人,所以继承人的属性,而助教既是老师也是学生,所以继承老师和学生的属性。
但是菱形继承会存在问题。
从上面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份。
数据冗余:
可以看到,Assistant中有两个_name成员变量,一个是继承自Student的,一个是继承自Teaher的,但是都是继承自Person的。
- 也就是相同的值存在了两份,实际上只有一个就够用了。
二义性:
如上图所示,在访问_name的时候,编译器也不知道你要访问的是Student作用域中的还是Teacher作用域中的,所以就造成了二义性。
二义性的解决办法之一:
可以通过指定作用域的方法来解决二义性的问题,如上图所示,但是并不符合实际情况,一个人虽然有多种角色,但是名字怎么会有两个甚至多个呢?
😸虚拟继承
虚拟继承就是专门用来解决菱形继承导致的数据冗余和二义性问题的。
- 在腰部位置使用虚拟继承(virtual)。
哪里造成了菱形继承的问题就在哪里使用虚拟继承,毫无疑问,是在腰部位置造成的,所以腰部的两个派生类都使用虚拟继承。
下面本喵来给大家分析一下,虚拟继承是如果解决数据冗余和二义性问题的。
为了方便分析,我们创建几个简单的类:
class A
{
public:
int _a;
};
class B : virtual public A
{
public:
int _b;
};
class C : virtual public A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
上面代码菱形继承的关系如上图所示。
数据冗余和二义性的解决:
首先我们来看,不使用虚拟继承时的内存模型:
在D对象创建后,通过内存窗口来看它内部的成员分别情况,如上图所示。
- 最外边的蓝色框是整个d对象,它一共有5个int类型的变量。
- 中间的红色框中的成员是从B中继承下来的,有两个int类型的变量。
- 中间的律师框中的成员是从C中继承下来的,有两个int类型的变量。
- 红色框和绿色框中橘色细框中的变量都是从A中继承下来的。
此时可以清除的看到,d对象中有两个_a变量,分布在B域和C域中。
再看使用菱形继承后的内存模型:
- 最外部的蓝色框是整个d对象,他一共有6个四字节的数据。
- 中间的红色框中的成员是从B继承下来的,有2个四字节的数据。
- 中间的绿色框中的成员是从C继承下来的,有2个四字节的数据。
- 最下边的红色细框中的变量是从A继承下来的。
先不管多了什么东西,单看从A中继承下来的变量,发现只有一个了。从原理的两个变成了一个,解决了数据冗余的问题。
菱形继承中原本冗余的成员最后只有一个,而且放在最终派生类对象中的最后位置。
此时数据冗余和二义性是解决了,因为派生类对象中只有一个从A继承下来的成员了,但是相比原来不用虚拟继承多出来4个字节不说,还将原本是成员所在位置变成了奇奇怪怪的东西。
- B中黄色框中地址中的数据是一个地址,在新内存窗口中输入该地址,得到一个新的黄色框。
- C中紫色框中地址中的数据是一个地址,在新内存窗口中输入该地址,得到一个新的紫色框。
- 本喵用的机器是小端存储方式,按照小端模式得到d对象中存放的两个地址。
在两个新内存窗口中看到的两个新的框被称为虚基表。
黄色虚基表:
- 虚基表中,第一个int类型的数据存放的是0,具体什么意义在多态的时候再讲。
- 虚基表中第二个int类型的数据存放的是0x14,它是一个偏移量。
再看d对象的内存模型:
- 从B继承下来的成员,起始地址是0x0055FA90。
- 从A继承下来的成员,它的地址是0x0055FAA4。
这两个地址之间相差0x14,也就是20。
- 当使用d.B::_a来访问A继承下来的成员时,就从B继承下来的成员的起始地址处,根据偏移量去访问具体的_a。
紫色虚基表:
- 虚基表中第二个int类型的数据存放的是0x0c,同样是一个偏移量。
再看d对象的内存模型:
- 从C继承下来的成员,起始地址是0x0055FA98。
- 从A继承下来的成员,它的地址是0x0055FAA4。
这两个地址之间相差0x0c,也就是12。
- 当使用d.C::_a来访问A继承下来的成员时,就从C继承下来的成员的起始地址处,根据偏移量去访问具体的_a。
再看派生类对象的内存窗口:
B区域和A的偏移量是20,C区域和A的偏移量是12。
从汇编中也可看到,使用d.B::_a和d.C::_a步骤都比直接访问其他成员多,因为这两种方式虽然访问的都是一个地址,但是需要根据偏移量去计算。
由于使用了虚拟继承,所以B对象和C对象同样采用有虚基表的结构,将从A继承下来的成员放在最后,原本的位置存放对应虚基表的地址,虚基表中存放偏移量。
虚基表存在的原因:
现在有个疑问,为什么要根据偏移量来找从A中继承下来的那个成员?B对象C对象,或者是D对象,它们自己肯定会知道自己成员的位置啊。
- B的指针拿到的是对象b的地址时,解引用访问_a,此时只是在自己内部寻找,不用偏移量也可以理解。
- B的指针拿到的是对象d的地址时,此时会发生切片,但是d中的_a仍然会保留下来,但是此时站在B指针的角度来看,它根部不知道_a在哪里,因为这是d对象安排的。
- 所以此时就需要通过虚基表获取_a距离B的偏移量来访问_a。
所以虚基表还是很有必要的。这部分只要了解就好。
🙀继承和组合
组合我们之前其实一直都在使用,就是在一个类中,它的成员变量是其他类。继承和组合都是一种类设计层面的复用方式。
此时Student中同样有Person的成员变量,但是不是通过继承得到的,而是通过组合得到的。
- public继承是一种is-a的关系。
如上图中,学生是人,所以Student和Person就用继承关系比较好。
- 组合是一种has-a的关系。
如上图中,头上有眼睛,而不能说成头是眼睛,所以此时用组合更合适。
比较:
-
继承方式:派生类和基类耦合度比较高。基类中的成员变量发生改变后,对派生类的影响较大,除去private成员外,其他成员的改变都会影响派生类。
-
组合方式:最终的类和被组合的类耦合度比较低。被组合类中成员变量发生改变后,对最终类的影响较小,只有public成员改变,才会影响最终的类。
- 继承方式中,派生类中可以看到基类的所有成员,只是基类的private成员不可以访问,所以被称为白箱复用。
- 组合方式中,最终类只能看到被组合类的public成员,其他成员和细节都看不到,也无法访问,所以被称为黑箱复用。
结论: 在继承和组合都可以使用的情况下,尽量使用组合而不用继承。
🙀总结
很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。掌握好继承的各种特性,对于后面的多态非常有帮助。