什么是继承?
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特 性的基础上进行扩展,增加功能。这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构, 体现了由简单到复杂的认知过程。
简单地说,继承就是对代码的复用。
继承权限&访问限定符
继承的方式共有public、protected、private三种方式,不同的继承方式会导致子类的父类的访问属性不同。如下表:
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成 员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的 protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 不可见,只能通过基类接口访问 | 不可见,只能通过基类接口访问 | 不可见,只能通过基类接口访问 |
总结:可以将这个表总结为两点来记忆。
- 父类的private成员不管通过什么继承方式,其在子类都是不可见,也就是说子类就访问不父类的这个私有成员,若强行在子类中调用,那么代码在编译的时候就会报错;只能通过基类的接口访问。
- 若规定他们三者的权限大小为:public>protected>private;那么要确定基类成员继承后该成员在子类中的访问属性时,我们把继承方式和该成员在基类中的访问限定符作比较,权限小的访问限定符就是该成员在子类的访问限定符。
注意: - 一般使用都是public继承,极少场景下才会使用protected /private继承 。
- 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式 。
继承中的has-a和is-a关系原则
- is-a:继承
class Plant
{
//这里是包含Plant的成员变量,函数等等,来描述Plant这个类的属性和功能等等
};
class Tree:public Plant //Tree这个类继承Plant这个类
{
/这里是包含Tree的成员变量,函数等等,来描述Tree这个类的属性和功能等等
};
这种模型的特点就是:Tree这个类继承了Plant类后,不但有自己的一些独特的属性或功能(Plant这个类没有的属性或功能),而且拥有和Plant相同的属性或功能。这种关系就属于is-a关系,也就是说“树”是一种植物,但"植物"不是“树“。当我们要设计的时候,遇到一个具有类似这种关系的模型,就可以用is-a关系。
- has-a: 组合
class Tire
{
//这里是包含Tire的成员变量,函数等等,来描述Tire这个类的属性和功能等等
};
class Car:public Tire
{
//这里是包含Car的成员变量,函数等等,来描述Car这个类的属性和功能等等
private:
Tire a; //Car的私有成员里面有Tire的对象,因为Tire是Car的一部分
};
has-a关系模型的特点就是:父类的子类一部分,这样我们就可以在子类中定义基类的对象来构成完整子类应该具有的属性或功能;例如,我们在子类”汽车“中的成员变量定义基类“轮胎“的对象,这样做就可以将汽车这个实体包装完整,因为轮胎就是汽车的一个部件,汽车是由许许多多个部件构成。以后我们设计的时候就可以分析具体的实例来选择是否用它。
注意:当我们分析具体实例时,以上两种关系都存在时,那么有限使用的是 has-a关系,因为总软件工程的角度来讲,has-a的耦合度更低。
赋值兼容规则–public继承
class Person
{
public:
void Display()
{
cout << _name << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
int _num;
};
void Test()
{
Person p;
Student s;
p = s; // 1.子类对象可以赋值给父类对象(切割 /切片)
//s = p; // 2.父类对象不能赋值给子类对象
Person* p1 = &s; // 3.父类的指针/引用可以指向子类对象
Person& r1 = s;
//4.子类的指针/引用不能指向父类对象(可以通过强制类型转换完成)
Student* p2 = (Student*)&p;
Student& r2 = (Student&)p;
//5. 这里会发生什么?
p2->_num = 10;
r2._num = 20;
}
因为我们要在类外面通过test函数来完成一些赋值的操作,所以这里采用public继承;
我们将上面的5个问题逐个分析;
- p = s; 子类对象可以赋值给父类对象,这就是我们所谓的切割/切片。相当于将子类中和父类相同的部分赋值过去。
- //s = p; 父类对象不能赋值给子类对象 。因为父类是子类的一部分,子类多出的那一部分无法赋值。
- Person* p1 = &s; Person& r1 = s; 父类的指针/引用可以指向子类对象 。原因很简单,父类指针指向子类,访问时也就只能访问到子类的一部分成员,很合理。引用底层其实也是指针,所以他们两都可以。
- Student* p2 = (Student*)&p; Student& r2 = (Student&)p;子类的指针/引用不能指向父类对象(可以通过强制类型转换完成),子类指针指向父类,在访问时很明显会越界访问,但是VS编译器可以通过强转来实现,这里要注意,虽然这样编译可以通过,但是是存在隐患的。第五点就可以说明。
- p2->_num = 10; r2._num = 20; 这里会发生什么? 当通过强转将父类指针转换为子类指针时,运行第五点的两行代码在编译时也不会报错,但是运行时就会崩溃掉,因为越界访问;但非要访问的话,也是有办法的,这里就要涉及到另外一个知识点——RTTI(dynamic_cast),我们可以借用它来完成强转,并且在让程序不会崩溃的前提下。这会在我的下个博客中会详细说明。
RTTI — dynamic_cast操作符
继承中的作用域
- 隐藏(重定义) :子类和父类中有同名成员(同名成员包括成员变量和成员函数,成员函数只要函数名相同,就属于同名成员),子类成员将屏蔽父类对同名成员的直接访问。(在子类成员函数中,可以使用 基类::基类成员 访问),也就是说当不指明作用域访问时,默认是先访问子类的同名成员。
- 注意在实际中在继承体系里面最好不要定义同名的成员
- 隐藏和重载的区别:隐藏不同于重载,重载函数必须有相同的作用域,而构成隐藏的成员变量或函数必须是一个在基类,一个在子类。
派生类默认成员函数
继承体系下,派生类中没有显示定义这六个默认成员函数,编译器会合成 (区别于生成)这六个默认成员函数。
- 构造函数
- 拷贝构造函数
- 析构函数
- 赋值操作符重载
- 取地址操作符重载
- cons修饰的取地址操作符重载
生成:不依赖于任何东西,只是编译器根据类的定义简单生成基于基础类型的默认成员函数。
合成:必须依赖于基类,编译器根据基类的相应成员函数的行为来合成派生类的默认成员函数。
注意:
- 基类没有缺省构造函数,派生类必须要在初始化列表中显式给出基类名和参数列表。
- 基类没有定义构造函数,则派生类也可以不用定义,全部使用缺省构造函数 。
- 基类定义了带有形参表构造函数,派生类就一定定义构造函数。
面试题:实现一个不能被继承的类: 将基类的构造函数声明为private.
继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有成员和保护成员。
继承与static静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static 成员实例 。