文章目录
- 一、基本概念
-
- [1. 概念](#1. 概念)
- [2. 语法](#2. 语法)
- 二、父子类对象的赋值兼容转换
- 三、继承中的作用域
- 四、子类的默认成员函数
- 五、继承与友元
- 六、继承与静态成员
- 七、菱形继承与虚拟继承
-
- [1. 概念](#1. 概念)
- [2. 数据冗余和二义性](#2. 数据冗余和二义性)
- [3. 虚拟继承](#3. 虚拟继承)
-
- [3.1 使用](#3.1 使用)
- [3.2 原理](#3.2 原理)
-
- [1. 简单判断数据是否冗余(监视窗口看值是否同步变化)](#1. 简单判断数据是否冗余(监视窗口看值是否同步变化))
- [2. 类对象成员内存模型](#2. 类对象成员内存模型)
- [3. 原理](#3. 原理)
- 总结
一、基本概念
1. 概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
子类继承父类,
父类的成员拷贝一份给子类
父类的成员函数与子类共用(存放在公共代码区)
构造函数相互独立
2. 语法
定义格式
基类又称父类,派生类又称子类
继承关系和访问限定符
总结一下,父类的私有成员在子类都是"不可见"。父类的其他成员在子类的
访问权限 == Min(成员在基类的访问限定符,继承方式权限)
,public > protected > private。
- 不可见是指父类的私有成员确实被继承给子类(子类中实际存在这一份拷贝),但是语法上限制子类不能直接访问(可以通过调用父类的成员函数间接访问)
- private 成员可以在类内访问,但不能在类外访问。如果只有 private 和 public 两种权限限定方式,那么继承后的成员要么子类的类中和类外都能访问(public),要么子类类中和类外都不能访问(private)。如果有一种需求是 父类的成员要能在子类的类中可以访问,类外不能访问呢?protected 访问限定因继承而产生
使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显示的写出继承方式。
在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡使用 protetced/private 继承,因为 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
二、父子类对象的赋值兼容转换
子类对象 可以赋值给 父类的对象 / 父类的指针 / 父类的引用 。这里有个形象的说法叫切片或者切割。寓意把子类中父类那部分切来赋值过去。
- 这不是类型转换,中间并没有产生临时对象(可以理解为一种特殊规则)
- 类似于将子类中父类的那部分成员切割给 父类的对象/指针/引用(只指向切割出的那部分)
父类对象不能赋值给子类对象。
三、继承中的作用域
在继承体系中父类和子类都有独立的作用域(两个不同的类域)
子类和父类中有同名 成员,优先查找当前类域,即在子类中优先访问子类中的同名成员,这种情况叫做隐藏,也称重定义。 但可以通过域作用限定符改变编译器搜索规则指定访问父类成员
父类名::同名成员
注意在实际中在继承体系里面最好不要定义同名的成员,容易混淆
- 示例1
cpp
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; //父类同名成员,身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 身份证号:" << Person::_num << endl; //指定父类域访问父类的同名成员
cout << " 学号:" << _num << endl; //优先访问所在类域的同名成员
}
protected:
int _num = 999; // 子类同名成员,学号
};
void Test()
{
Student s1;
s1.Print();
};
- 示例2 (区分重载与隐藏)
函数重载是在同一个作用域,隐藏是两个不同的类域
- 示例3
隐藏只要求同名,所以此时构成隐藏,子类B对象优先在子类中寻找fun函数,找到后参数无法匹配,编译报错(不会再去父类寻找同名函数看参数是否匹配,这也是和重载的区别之一)
四、子类的默认成员函数
6个默认成员函数,"默认"的意思就是指我们不写,编译器会变我们自动生成一个,那么在子类
中,这几个成员函数是如何工作的呢?
构造 / 拷贝构造 / 赋值重载
子类中继承的父类那部分,使用父类的默认成员函数,子类自己独有的部分使用子类自己的默认成员函数(如果显式写,也必须显式调用父类的默认成员函数)
调用顺序:先调用父类构造初始化继承的父类成员,再调用子类构造(语法规定)
原因:
- 创建子类对象时,先创建继承的父类成员,再创建子类自己独有的(语法规定)
- 子类构造可能会使用父类成员,所以规定继承的父类成员先创建,先初始化
注意同名构成隐藏时,需要指定父类域调用(显式实现时)
析构
父类部分使用父类的析构,子类部分使用子类的析构
调用顺序:子类独有成员先析构,再析构继承的父类成员(语法规定)
原因:子类析构中可能会调用父类成员
不需要显式调用父类析构,在子类析构后编译器会自动调用父类析构
如果在子类构造中显式调用,就不能保证先析构父类,所以调用父类析构的工作交给编译器自动干了
cpp
class Person
{
public:
//Person(const char* name = "")
Person(const char* name = "")
: _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;
delete[] _str;
}
protected:
string _name; // 姓名
char* _str = new char[10]{ 'x','y','z'};
};
class Student : public Person
{
public:
// 父类构造显示调用,可以保证先父后子
Student(const char* name = "", int x = 0, const char* address = "")
:_x(x)
,_address(address)
,_name(Person::_name+'x')
, Person(name)
{}
Student(const Student& st)
:Person(st)
,_x(st._x)
, _address(st._address)
{}
Student& operator=(const Student& st)
{
if (this != &st)
{
Person::operator=(st);
_x = st._x;
_address = st._address;
}
return *this;
}
// 由于多态,析构函数的名字会被统一处理成destructor(),
// 具体原理在多态时讲解,这里直接上结论
// 父类析构不能显示调用,因为显示调用不能保证先子后父
~Student()
{
// 析构函数会构成隐藏,所以这里要指定类域
//Person::~Person();
cout << "~Student()" << endl;
// delete [] _ptr;
cout << _str << endl;
}
protected:
int _x = 1;
string _address = "西安高新区";
string _name;
//int* _ptr = new int[10];
};
// 子类默认生成的构造
// 父类成员(整体) -- 默认构造
// 子类自己的内置成员 -- 一般不处理
// 子类自己的自定义成员 -- 默认构造
// 子类默认生成的拷贝构造 赋值重载跟拷贝构造类似
// 父类成员(整体) -- 调用父类的拷贝构造
// 子类自己的内置成员 -- 值拷贝
// 子类自己的自定义成员 -- 调用他的拷贝构造
// 一般就不需要自己写了,子类成员涉及深拷贝,就必须自己实现
// 子类默认生成的析构
// 父类成员(整体) -- 调用父类的析构
// 子类自己的内置成员 -- 不处理
// 子类自己的自定义成员 -- 调用析构
//
int main()
{
Student s1;
Student s2("张三", 1, "西安市碑林区");
//Student s3 = s2;
//s1 = s3;
return 0;
}
五、继承与友元
友元关系不能继承,也就是说父类友元不能访问子类私有和保护成员
cpp
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; //报错,不可访问
}
int main()
{
Person p;
Student s;
Display(p, s);
}
六、继承与静态成员
父类定义了static静态成员,则整个继承体系里面只有一个这样的成员,父类和所有子类共有。
cpp
class Person
{
public:
Person() { }
protected:
string _name;
public:
static int _count; //父类的静态成员变量
};
int Person::_count = 6;
class Student : public Person
{
protected:
int _stuNum;
};
class Graduate : public Student
{
protected:
string _seminarCourse; // 研究科目
};
void TestPerson()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
cout << " 人数 :" << Person::_count << endl;
Student::_count = 0; //父子类共有静态成员,指定父类域和子类域访问的都是同一个
cout << " 人数 :" << Person::_count << endl;
}
七、菱形继承与虚拟继承
1. 概念
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况。
2. 数据冗余和二义性
菱形继承具有数据冗余和二义性的问题。如下图所示,西红柿类 继承了蔬菜类和水果类,而蔬菜类和水果类又继承了植物类,则蔬菜类和水果类中各继承了一份植物类的成员,这就导致西红柿类中会有两份植物类的成员。
cpp
class Person
{
public:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _num; //学号
};
class Teacher : public Person
{
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
string _majorCourse; // 主修课程
};
void Test()
{
Assistant a;
a._name = "peter"; //二义性,访问student类的_name,还是techer类的_name
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
}
3. 虚拟继承
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用,会有开销。
3.1 使用
cpp
class Person
{
public:
string _name; // 姓名
};
class Student : virtual public Person
{
protected:
int _num; //学号
};
class Teacher : virtual public Person
{
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
string _majorCourse; // 主修课程
};
void Test()
{
Assistant a;
a._name = "peter";
}
3.2 原理
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助监视和内存窗口观察对象成员的模型。
cpp
class A
{
public:
int _a;
};
class B : public A
//class B : virtual public A
{
public:
int _b;
};
class C : public A
//class C : virtual public A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2; //最后一次修改_a值为2
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
1. 简单判断数据是否冗余(监视窗口看值是否同步变化)
不使用虚拟继承:
可以看到d中存储了两份A类的_a成员,分别继承于B类和C类,数据冗余
使用虚拟继承:
利用监视窗口可以看到d中只有一份_a成员了(值相同,说明是同一份)解决了数据冗余问题
2. 类对象成员内存模型
- 如何查看:利用VS2022查看类的内存布局功能
不使用虚拟继承,类对象内存模型
继承的父类成员优先放置在类对象的靠前区域,然后才放置子类独有的成员
使用虚拟继承,类对象内存模型(父类没有虚函数)
子类独有的成员优先放置在类对象的靠前区域,继承的父类的成员产生偏移,放置在靠后区域
注意:这里显示的内存布局并不完全,没有显示出虚指针,实际虚指针应该存放在偏移0的区域,在子类独有成员之前。
d对象中将_a成员放到的了对象组成的最下面,这个_a成员同时属于d中继承的父类B,C
d.B::_a == d.C::_a
虚拟继承,父类含有虚函数
cppclass person { public: virtual void test_function() = 0; //父类有虚函数 protected: int _id; }; class Student : virtual public person { public: void test_function(); protected: int stu_id; }; class Techer : virtual public person { public: void test_function(); protected: int tec_id; }; class teaching_assistant : public Student, public Techer { public: void test_function(); protected: int ta_id; };
此时我们可以清晰地观察到虚指针的存在,且有两个,一个指向虚函数表(多态),一个指向虚基表
3. 原理
虚拟继承要解决的就是菱形继承时出现的数据冗余的问题,也就是去重。
我们先回顾一下继承的概念,继承的子类中,父类部分和子类独有部分是独立的两个部分(它们开辟空间和初始化的顺序都不相同,详见本文第四部分子类的默认成员函数)
如果要将成员_a作为公共成员,那只能将其"移出"父类部分,也就是实际上_a物理空间上既不属于B类部分,也不属于C类部分,那要保证逻辑上_a既属于B类部分,也属于C类部分,怎么办?c++使用了偏移量这一概念,即公共成员与其原先父类部分的偏移距离,通过给使用虚拟继承的类创建虚基表和虚指针,使得继承后的B类部分和C类部分能得到偏移量,找到公共成员
虚基表:用于存储公共成员的偏移量
虚基表指针:存在于类对象中,用于指向虚基表
- 虚基表不存储在类对象中,类对象中只存储虚基表指针(节省空间)
- 每个类都有属于自己的虚基表和虚基表指针
上文中,B类和C类属于虚拟继承,则B类和C类中各有自己的虚基表指针,则D类继承B,C后也会继承它们的虚基表指针,于是D类对象中继承的B,C部分就可以通过这个虚基表指针找到各自的虚基表,从而找到偏移量,找到该公共成员
代价:
虚拟继承会增加虚指针的数量(占用空间),有开销
总结
本文讲解了C++继承相关知识,尤其是虚拟继承和类对象内存模型部分比较深入底层,晦涩难懂,尽管修改了多次,但由于水平有限,难免有不足甚至错误之处,敬请各位读者来评论区批评指正。