目录
继承的概念及定义
继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类
继承的定义格式
继承关系和访问限定符
在公有继承的条件下,子类对象可以当作父类对象使用(兼容性):
- 子类对象可以直接赋值给父类对象。
- 子类对象可以直接初始化父类对象。
- 父类指针可以直接指向子类对象。
- 父类引用可以直接引用子类对象。
继承中的对象模型
总结:
- 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它,但是可以通过父类中使用该私有成员的 public 函数来间接访问私有成员
- 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出的。
- 基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
- 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是 public,不过最好显示的写出继承方式。
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
基类和派生类对象赋值转换
派生类中的成员,包含两大部分:
一类是从基类中继承过来的,一类是自己增加的成员
从基类中继承过来的表现其共性,自己增加的表现了其个性
子类赋值给父类时,会将属于父类的那一部分切片赋值给父类,属于自己的一部分则被切除,同时不会产生临时对象,可以直接赋值
引用和指针同理,引用将属于父类的那一部分作为别名传给父类的引用,指针将父类的那一部分的地址传给父类的指针
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。
继承是一种 is-a 关系,每个子类对象都是一种特殊的父类
父类对象不可以赋值给子类对象
继承中的作用域
在继承体系中基类和派生类都有独立的作用域
父类中所有的非静态成员都会被子类继承
父类中的私有成员属性,是被编译器隐藏了,因此访问不到,但确实是被继承下去了
子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义
想要访问父类中的成员,可以通过域作用限定符来指定父类进行访问
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏,在继承体系里面最好不要定义同名的成员。
这里有一道题:
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
A::fun();
cout << "func(int i)->" <<i<<endl;
}
};
int main()
{
B b;
b.fun(10);
};
//对于上面的代码,正确的是:
//A.编译错误 B.运行错误 C.两个fun函数构成重载 D.两个fun函数构成隐藏
//答案是D,因为在继承体系中基类和派生类都有独立的作用域,两个作用域并不会构成重载
派生类的默认成员函数
在子类中,对于内置类型,不做处理,对于自定义类型,和以前一样,直接调用自定义类型的构造函数,而对于父类成员,当成一个整体的自定义类型,调用父类的默认构造函数,如果默认构造函数不存在,则会报错
默认构造就是编译器自动生成或者全缺省的构造函数
class Person {
public:
Person(const char* name)
: _name(name)
{
cout << "Person()" << endl;
}
protected:
string _name;
};
class Student : public Person {
public:
Student() { }
Student(const char* name, int num)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
protected:
int _num;
};
int main()
{
Student s1;
}
上面这段代码会因为 Person 类没有提供默认构造函数而发生报错
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的operator=必须要调用基类的operator=完成基类的复制。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象初始化先调用基类构造再调派生类构造。
- 在继承中,先调用父类构造函数,再调用子类构造函数,析构则相反
- 派生类对象析构清理先调用派生类析构再调基类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同,那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
继承和友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuNum << endl;
}
void main()
{
Person p;
Student s;
Display(p, s);
}
继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子 类,都只有一个static成员实例
同名静态成员
访问方式:
1.通过对象访问
2.通过类名访问
同名静态函数
访问方式,与非静态相同
1.通过对象访问
2.通过类名访问
如果子类中出现和父类同名的静态成员函数,子类的同名成员会隐藏掉父类中所有的同名静态成员函数,包括重载的
如果想访问被隐藏的同名静态成员函数,需要加作用域
复杂的菱形继承及菱形虚拟继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况
菱形继承的问题
在上面的例子中可以看出,菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份
当两个子类中有相同的父类中的成员时,会导致资源浪费,如二义性和数据冗余
二义性:当两个或更多的基类都包含有相同名称的成员(属性或方法)时,派生类在访问这些成员时就可能会产生混淆
例:
class Base1 {
public:
void func();
};
class Base2 {
public:
void func();
};
class Derived : public Base1, public Base2 {
// ...
};
这段代码中func函数就是存在二义性的,当Derived类调用func函数时,编译器会因为不知道调用哪个func而报错,解决方式:通过作用域解析符 ::
明确指定
数据冗余:在多继承中,如果多个派生类继承同一个基类,那么就会产生多个基类的子对象,这样就会导致内存冗余
例:
class A {
// ...
};
class B : public A {
// ...
};
class C : public A {
// ...
};
class D : public B, public C {
// ...
};
这段代码中由于D类继承自B,C,而B,C又继承自A,因此D中会包含两个A类的子对象,造成空间浪费
解决数据冗余:虚继承
在继承方式前加一个virtual变为虚继承;继承公共虚类时加virtual
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。需要注意的是,虚拟继承不要在其他地方去使用。
- 虚继承的基类(如
Animal
)被称为 虚基类。 - 虚基类的成员在最终派生类中只有一份拷贝,避免冗余。
未使用虚继承:
class A
{
public:
int _a;
};
class B :public A
{
};
class C :public A
{
};
class D : public B,public C
{
};
这里的D中会有两份A中的_a,分别是B::A::_a和C::A::_a
使用了虚继承:
class A
{
public:
int _a;
};
class B :virtual public A
{
};
class C : virtual public A
{
};
class D : public B,public C
{
};
这里的D的内存模型大致为:
| B的虚基类表指针 | C的虚基类表指针 | D成员 | A::_a |
所有对 A::_a 的访问都指向唯一的内存位置,彻底消除二义性
其中虚基类表指针的顺序是由类的声明顺序决定的
如果B、C中还有其他的成员,则会在各自的虚基类表指针下面的内存中存放成员,
这些成员的访问直接通过域作用限定符来访问,
不依赖虚基类表,只有虚基类表中的成员才依赖虚基类表访问
class A
{
public:
int _a=0;
};
class B :virtual public A
{};
class C : virtual public A
{};
class D : public B,public C
{
public:
int _a;
int _b;
int _c;
};
int main()
{
D d;
B* pb = &d;
C* pc = &d;
pc->_a++;
cout << d._a;
pb->_a++;
cout << d._a;
}
这里的pb和pc都是使用的d中的同一个继承自A的_a,通过虚基类指针来访问
通过切片将D中自己的成员给切除,只留下继承的成员A::_a,
实现原理:
虚基类指针:
每个虚继承的子类都会产生一个指向自己虚基表的指针
虚基类指针指向了一个虚基类表,该表记录了虚基类与本类的偏移地址。
虚基类表:
虚基类表不占用类对象的存储空间,它是由编译器生成的。
虚基类表中存储了虚基类相对直接继承类的偏移量。这些偏移量用于在运行时确定虚基类成员的位置。这个偏移量表示从派生类对象的起始位置到虚基类成员实际位置的字节数
例:
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通过虚继承从B,C继承了A,此时D中会包含一个指向A的子类的虚基类指针vbptr以及B,C,D类的成员变量,vbptr将指向一个虚基表,该表记录了A类成员相对于D的起始位置的偏移量
虚基表指针:在有虚函数的类中用于指向虚函数表的指针
虚函数表:每一个有虚函数的类都会有一个虚基表,虚基表存储了虚函数的地址,当子类重写父类虚函数时,会在虚基表中覆盖掉原来父函数的地址,改为子类自己的虚函数地址
若子类没用重写父类虚函数,则子类虚基表中的该位置虚函数地址与父类中该虚函数地址相同
编译器通过以下方式实现虚继承:
(1) 虚基类表指针(vbcptr)
- 每个虚继承自虚基类的派生类会隐式添加一个 虚基类表指针(Virtual Base Class Table Pointer),指向一个虚基类表(类似虚函数表)。
- 该表记录了虚基类子对象相对于派生类对象的偏移量。
(2) 内存布局调整
- 虚基类的成员不再直接嵌入派生类对象中,而是通过指针间接访问。
- 派生类对象的内存布局中,虚基类的成员被放置在对象的末尾,由所有共享该虚基类的派生类共同维护。
组合
public继承是一种is_a关系,每一个派生类对象都是一个基类对象
而组合是一种has_a关系,加入b组合了a,表示每一个b对象中含有一个a对象
class A
{
public:
int _a;
};
class B
{
public:
int _a;
A a;
};//这里就是一种组合,B中有着A的对象
继承允许根据基类的实现来定义派生类的实现,基类的内部细节对派生类可见,称为白箱复用,这种属于高耦合
对象组合是另一种复用选择,要求被组合的对象有良好定义的接口,对象的内部细节不可见,这种称为黑箱复用,属于低耦合