继承
cpp三大特性:封装、继承、多态。
继承是类设计层次的复用。父类(基类),子类(派生类)。
概念及定义
定义方式
格式:class 子类名: 继承方式 父类名
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
class Student : public Person//公有继承1
{
protected:
int _stuid; // 学号
};
class Teacher : public Person//公有继承2
{
protected:
int _jobid; // 工号
};
继承方式
public、protected、private
访问限定符
public、protected、private
继承父类成员访问方式的变化
类成员/继承方式 | public | protected | private |
---|---|---|---|
父类的public成员 | 子类的public成员 | 子类的protected成员 | 子类的private成员 |
父类的protected成员 | 子类的protected成员 | 子类的protected成员 | 子类的private成员 |
父类的private成员 | 在子类中不可见 | 在子类中不可见 | 在子类中不可见 |
总结:
- 父类private成员在子类中无论以什么方式继承都是不可见的。这里的不可见是指父类的私有成员还是被继承到了子类对象中,但是语法上限制子类对象不管在类里面还是类外面都不能访问它。即父类成员不想给别人用的话就设置为private。
- 父类private成员在子类中是不能被访问,如果父类成员不想在类外直接被访问,但需要在子类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行一下总结会发现,1、父类的私有成员在子类都是不可见;2、父类的其他成员在子类的访问方式 == Min(成员在父类的访问限定符,继承方式),public > protected > private。
- 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
父类和子类对象赋值转换-向上转换
子类对象 可以赋值给 父类的对象 / 父类的指针 / 父类的引用。称为切片/切割。
父类对象不能赋值给子类对象,父类的指针或引用可以通过强制类型转换(会产生临时变量)赋值给子类的指针或引用。
子类对象可以赋值给父类对象,中间不存在类型转换(即不会产生临时变量),所有可以对父类可以取引用赋值。
Person p;//基类
Student s;//子类
p = s;//子类可以直接赋值给父类,不存在临时变量
Person& rp = s;//由于无临时变量,故可以直接取引用
Person* ptr_p = &s;//取指针
------------对比------------
int i = 1;
double d = 10.1;
i = d;//类型不同,强制类型转换后赋值,会产生临时变量
int& ri = d;//err,不可以直接取引用,因为引用的是临时变量,而临时变量在完成赋值后会被销毁,涉及权限的放大
const int& ri = d;//这样写才对
作用域-隐藏/重载/重写
父类和子类都有独立的作用域。
当子类和父类中有同名成员变量,优先访问该变量所在的作用域(就近原则)。如在子类访问同名成员时,就会优先访问子类的同名成员变量(即子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。)如果在子类成员函数中想要访问父类的同名成员,可以使用 父类::父类成员
显式访问。
前提是作用域不同!成员函数的隐藏,只要函数名相同就构成隐藏。区分:重载【作用域相同,且函数名相同,参数不同】、重写【多态的概念】、隐藏【作用域不同即分为父类和子类,且函数名相同】、重定义【重定义就是隐藏】。
注意在实际中在继承体系里面最好不要定义同名的成员。
在同一个类里面成员函数可以同名(此时构成了重载),但变量一定不能同名。
在继承的类中,函数和变量均可以同名,此时构成了隐藏。
子类的默认成员函数
复习:构造函数和析构函数,对内置类型不处理,对自定义类型调用其对应的构造函数和析构函数,一般都要写构造函数,析构函数视是否有资源要回收而定,如写了拷贝构造函数的一般都要写析构函数; 拷贝构造函数和赋值重载函数,对内置类型浅拷贝(值拷贝),对自定义类型调用对应的拷贝或赋值函数。
比较一下子类和普通类对象 构造/析构/拷贝构造/赋值重载4个默认成员函数所涉及的成员:
- 普通类的成员函数需考虑:内置类型;自定义类型
- 子类的四个成员函数需考虑:父类对象;子类自己的自定义类型;子类自己的自定义类型;
- 总结:涉及继承的子类对象需要增加考虑其内部父类对象的4个默认成员函数。
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个。子类的默认成员函数生成规则如下:
- 子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员。**如果父类没有默认的构造函数,则必须在子类构造函数的初始化列表阶段显式调用。**如下面的代码
Student(const char* name):Person(name),...{}
这里就是显式调用,Person父类用拷贝构造函数和子类对象完成初始化。 - 子类的拷贝构造函数必须调用基类的拷贝构造完成父类的拷贝初始化。
- 子类的**operator=**必须要调用基类的operator=完成父类的复制。
- 子类的析构函数会在被调用完成后,自动调用父类的析构函数清理父类成员,不能在子类析构函数中显式调用父类的析构函数。因为这样才能保证子类对象先清理子类成员再清理父类成员的顺序。
- 子类对象初始化先调用父类构造再调子类构造。
- 子类对象析构清理先调用子类析构再调父类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
先父类构造,再子类构造;先子类析构,再父类析构。
注意:父类必须把需要的默认成员函数写出来,供子类的默认成员函数调用。
class Person//父类
{
public:
Person(const char* name = "peter")
: _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; // 姓名
};
//在子类中
//属于父类的成员会调用父类的构造函数完成初始化,故所有父类必须要把默认构造函数写出来
//要先完成父类的构造,再完成子类的构造
class Student : public Person//子类
{
public:
Student(const char* name)
:Person(name)//必须调用父类的构造函数
, _stuid(20230101)
,_address("西安")
{
cout << "Student()" << endl;
}
//拷贝构造函数
Student(const Student& stu)//假设存在深拷贝,就要显式写出拷贝构造函数
:Person(stu)//直接拿子类对象给父类完成拷贝构造
, _stuid(stu._stuid)
{
cout << "Student(const Student& stu)" << endl;
}
//赋值构造函数
Student& operator=(const Student& stu)
{
if (this != &stu)
{
Person::operator=(stu);//显式调用父类的赋值构造函数(父类和子类的构成隐藏关系)
_stuid = stu._stuid;//子类的成员变量
}
cout << "operator=(const Student& stu)" << endl;
return *this;
}
~Student()
{
//怪象:1、子类析构函数和父类析构函数构成隐藏关系(由于多态关系需求,所有的析构函数都会被特殊处理为相同的函数->destructor()函数,故所有类的析构函数同名)
//2、子类的析构函数中不用显式写出父类的析构函数。因为要先析构子类对象,再析构父类对象
//Person::~Person();//要指定作用域
//~Person();//err这样写就报错
cout << "~Student()" << endl;
}
protected:
int _stuid; //学号
string _address;
};
子类-友元
友元关系不能继承,也就是说父类友元不能访问子类私有和保护成员。友元这个东西能不用就不用。
子类-静态成员
父类定义了static静态成员,则整个继承体系里只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。普通的成员变量是每个子类都会有一份的。
该静态成员只能通过父类作用域完成初始化。
Class Person
{
public:
void Print()
{
cout << this << endl;
cout << _name << endl;//err 当Person* p = nullptr; p->Print();这句代码不能访问_name,因为_name是对象p中的数据,而对象p为空
cout << _count << endl;//ok, Person* p = nullptr; p->Print();因为_count是存在静态区里面的
}
protected:
string _name;
public:
static int _count;
};
int Person::_count = 0;
//访问方式
Person p;
p._count;
Person::_count;
又到了辨析环节,一定要区分清楚!!
Person* ptr = nullptr;
cout << ptr->_name << endl; // no //此处->表示解引用,因为_name在ptr所指向的对象里
ptr->Print(); // ok //此处->不是解引用,因为Print()函数在代码段,不在对象里。【对象中只存成员变量,计算大小的时候只计算成员变量的,不计算成员函数的】
cout << ptr->_count << endl; // ok //此处->不是解引用,因为_count常量在静态区,不在对象里
(*ptr).Print(); // ok
cout << (*ptr)._count << endl; // ok
看一下对象p指针调用其成员函数的反汇编代码,如下
p->Print();
00F227CC mov ecx,dword ptr [p]
00F227CF call Person::Print (0F211AEh)
(*p).Print();
00F227D4 mov ecx,dword ptr [p]
00F227D7 call Person::Print (0F211AEh)
->
和*
都是为调用Print()
函数指定作用域,而不是去对象里找东西(解引用)!!!
复杂的菱形继承及菱形虚拟继承
单继承
一个子类只有一个直接父类时称这个继承关系为单继承。
多继承
一个子类有两个或以上直接父类时称这个继承关系为多继承。
菱形继承
菱形继承是多继承的一种特殊情况。菱形继承的问题:菱形继承有数据冗余和二义性的问题。如下在Assistant的对象中Person成员会有两份(一份是Teacher中的Person,一份是Student中的Person)。
class Person {public:_name;};
class Teacher: public Person {};
class Student: public Person {};
class Assistant: public Teacher, public Student{};
//assistant的对象要指定作用域访问Person中的成员变量
// 若直接访问,会有二义性无法明确知道访问的是哪一个
Assistant a ;
a._name = "peter";
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
引入虚继承virtual
的方式来解决二义性和数据冗余的问题。要把这个关键字写在第二层继承处。
class Person {public:_name;};
class Teacher: virtual public Person {};
class Student: virtual public Person {};
class Assistant: public Teacher, public Student{};
//通过虚拟继承就可以解决二义性
Assistant a ;
a._name = "peter";//可以直接访问
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
面试题:虚拟继承如何解决二义性和数据冗余的问题?
答:1、从对象模型上来说,把虚基类放到了最下面,让整个对象中只有一份虚基类。2、为了更方便地找到虚基类,在需要虚基类的类对象中各存了一个虚基表的指针,指向各自的虚基表,根据表中存放偏移量去找到虚基类,因为切片的情况存在,虚基类的成员变量是被哪个类作用域所指向的不确定,解决了语法
虚继承中利用虚基表来找到虚继承的父类对象的位置。因为有可能是切片对象来调用其虚继承的父类对象的成员变量。原普通继承存放父类对象数据的地方【在虚继承的情况下,虚继承的父类对象的成员变量不会显式】被改为指针了,指向一张虚基表,虚基表中存的偏移量(即表中存放的数据为距离虚基类对象的偏移量),通过偏移量可以找到该父类的父类。指针指向的空间存放偏移量。
下图为虚拟继承的内存图
下图为普通继承的内存图
可以看出确实解决了二义性和数据冗余的问题。
区分继承和组合
继承是is a的关系,组合是has a的关系。继承和组合都是复用。
继承中,子类还能访问父类的protected和public成员,无法访问private成员。而组合中,子类无法访问父类的protected成员,只能访问public成员。故继承又叫做白盒复用,组合叫做黑盒复用。
如果有一个场景继承和组合都能用,我们倾向于采用组合,因为组合的耦合度低。