前言
从继承开始就开始C++进阶了,
这一块需要好好学习,这块知识很重要,
坑有点多,所以是面试笔试的常客。
基本概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,
它允许程序员在保持原有类特性的基础上进行扩展,增加功能,
这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,
体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,
继承是类设计层次的复用。
举例:
学校的老师和同学,
他们具有一些相同的属性,
比如:年龄,姓名,性别等等,同时,
也具备一些不同的属性,
如:学生的学号,老师的工号等等
这样我们就可以把相同的属性提取出来,
写到一个类中去,而老师,学生的专属信息则写到自己的类中,
然后将相同的属性继承过来。
师生共同信息:
struct Person
{
string name;
string sex;
int age;
}
学生专属信息:
class Student : public Person
{
protected:
int _stuid; // 学号
};
老师专属信息:
class Teacher : public Person
{
protected:
int _jobid; // 工号
};
我们通常把被继承的类叫做基类/父类,
把继承类的类叫做子类/派生类
继承关系和访问限定符
继承方式和访问限定符各有三种:
继承的方式不同,那么子类中继承
到的父类的变量的访问权限就不同
大概有几点:
- 父类的private成员在子类是不可见的!
(继承下来了但不能使用) - 不使用继承,protected与private没有区别
- 使用继承,private:类内访问:可以访问;类外访问:不可以访问;子类访问:不可以访问。protected:类内访问:可以访问;类外访问:不可以访问;子类访问:可以访问。public:类内访问:可以访问;类外访问:可以访问;子类访问:可以访问。
- 使用时一般使用public
- 使用关键字class时默认的继承方式是private
使用struct时默认的继承方式是public
继承中的作用域
1. 在继承体系中基类和派生类都有独立的作用域。
2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 注意在实际中在继承体系里面最好不要定义同名的成员
在main函数中定义student对象
后再打印_num默认为子类中的_num
若想打印父类中的_num,需要指定类域
但是函数名相同的话
不应该是构成函数重载吗?是的,在同一
作用域下,函数名相同确实构成函数重载但是父子类是不同作用域,这里是构成隐藏!
父子类赋值兼容规则
子类对象可以赋值给父类对象,基类的对象 / 基类的指针 / 基类的引用(切片)
父类对象不能直接赋值给子类对象
注意这里能够赋值不是隐式类型转换!
子类的默认成员函数
我们知道类的六个默认成员函数,
不显示写系统会自动生成的
子类的默认成员函数有哪些特殊的行为?
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。
如果基类没有默认的构造函数,
则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构。
7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同那么编译器会对析构函数名进行特殊处理,
处理成destrutor(),所以父类析构函数不加virtual的情况下,
子类析构函数和父类析构函数构成隐藏关系。
继承与友元,继承与静态变量
继承与友元
友元关系不能继承
也就是说基类友元不能访问子类私有和保护成员
继承于静态变量
基类中定义的静态成员被整个继承体系共享
整个继承体系里面只有一个这样的成员
无论派生出多少个子类
都只有一个static成员实例
菱形继承和虚拟继承
菱形继承是一个大坑,为了解决这个大坑祖师爷掉了不少头发
先看一下单继承、多继承、菱形继承的形式:
单继承:
多继承:
菱形继承:
类B继承了类A,类C也继承了类A
然而类D继承了类B和C
此时会有一个问题,类D的实例化对象中
有类B和类C,然而B类和C类都有A类
所以说D类对象中的A类成员就重复了!
class A
{
int _a = 1;
};
class B :public A
{
int _b = 2;
};
class C :public A
{
int _c = 3;
};
class D :public B, A
{
int _d = 4;
};
D对象中有两个_a,一个在B类一个在C类
这就造成了数据冗余,
使用域访问限定符可以勉强解决二义性,但是解决不了数据冗余,
但是可以使用虚拟继承
来解决这一问题:
虚拟继承:在继承前加上virtual关键字
class A
{
int _a = 1;
};
class B :virtual public A
{
int _b = 2;
};
class C :virtual public A
{
int _c = 3;
};
class D :public B, A
{
int _d = 4;
};
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。虚拟继承不要在其他地方去使用
注意,只用腰部的类加上virtual即可(少用)!
虚拟继承原理
下图是菱形继承的内存对象成员模型:这里可以看到数据冗余
下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下
面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指
向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量
可以找到下面的A。
可以看到,BC里面多存一个地址,不再是存储a的值了,
这个地址用来指向右边的表,表里面第一个值暂且不管(不是目前的内容)
第二个值则表示偏移量,
例:B里面3上面,然后通过地址找到偏移量,例:B,14,然后地址加偏移量找到a
也就是继承的是同一份a
当然,如果只看当前问题,这个位置直接存偏移量或者地址是没有问题的,
但是如果有多个子类呢,明显还是这种方法更佳!
下面是上面的Person关系菱形虚拟继承的原理解释
继承和组合
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。1.继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称
为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的
内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很
大的影响。派生类和基类间的依赖关系很强,耦合度高。
2.对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象
来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复
用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。
组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被
封装。
3.实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有
些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用
继承,可以用组合,就用组合。住:优先使用对象组合,而不是类继承 。
// Car和BMW Car和Benz构成is-a的关系
class Car{
protected:
string _colour = "白色"; // 颜色
string _num = "陕ABIT00"; // 车牌号
};
class BMW : public Car{
public:
void Drive() {cout << "好开-操控" << endl;}
};
class Benz : public Car{
public:
void Drive() {cout << "好坐-舒适" << endl;}
};
// Tire和Car构成has-a的关系
class Tire{
protected:
string _brand = "Michelin"; // 品牌
size_t _size = 17; // 尺寸
};
class Car{
protected:
string _colour = "白色"; // 颜色
string _num = "陕ABIT00"; // 车牌号
Tire _t; // 轮胎
};
总结
继承是多态的基础,而笔试面试的时候继承和多态是考察的很多的,
同时这里也有很多坑,稍不注意就会掉进去,
这块知识的学习一定要仔细认真。