本节中所有情况与代码与解释都是VS2019的x86环境下进行的。其它编译环境下,可能会有一些些底层实现的小细节不一样。
面向对象程序设计中最重要的一个概念是继承。继承允许我们依据另一个类来定义一个类,这使得创建和维护一个应用程序变得更容易。这样做,也达到了重用代码功能和提高执行效率的效果。
当创建一个类时,不需要重新编写新的数据成员和成员函数,只需指定新建的类去继承一个已有的类即可。这个已有的类称为基类(父类),新建的类称为派生类(子类)。
继承(inheritance)机制是面向对象程序设计中使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称为派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
如果我们需要描述一个学校里面的学生和教职工,有姓名、性别、专业/部门、学号/工号等信息,该怎么做呢?把学生和教职工中共有的信息拿出来创建一个 Person 类,新建两个类 Student 和 Teacher,继承 Person 类,它们就拥有了 Person 类中的数据成员和成员函数,实现代码复用。
// 基类
class Person {
// _name 姓名
// _age 性别
};
// 派生类
class Student : public Person {
// _stuID 学号
// _major 专业
};
// 派生类
class Teacher : public Person {
// _teaID 工号
// _department 部门
};
继承方式有以下三种,通过不同的访问限定符来指定继承的方式。
但我们几乎不使用 protected 或 private 继承,通常使用 public 继承。
继承产生了多种组合,实际使用中,我们重点记住黄色标记的。
继承方式 / 基类成员 | 基类的 [ 公有 ] 成员 | 基类的 [ 保护 ] 成员 | 基类的 [ 私有 ] 成员 |
---|---|---|---|
公有继承(public) | 成为派生类的公有成员 | 成为派生类的保护成员 | 在派生类中不可见 |
保护继承(protected) | 成为派生类的保护成员 | 成为派生类的保护成员 | 在派生类中不可见 |
私有继承(private) | 成为派生类的私有成员 | 成为派生类的私有成员 | 在派生类中不可见 |
我们如何给基类成员设置合适的访问限定符:
【总结】
基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见并不是说不存在,而是指基类的私有成员还是被继承到了派生类中(派生类对象的内存空间中存在),只是语法上限制派生类对象不管在类里面还是类外面都不能直接去访问它。只能通过调用基类的公有和保护成员函数来访问。
基类 private 成员在派生类中是不能被访问,如果基类成员不想让它在类外被直接访问,但需要在派生类中能访问,就定义为 protected。可以看出保护成员限定符是因继承才出现的。(我们之前说过,创建类时,不想在类外被访问的成员设置成 private / protected,可私有和保护到底有啥区别?继承这里就正好体现出来了)。
实际上面的表格我们进行一下总结会发现,基类的私有成员在派生类都是不可见。基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符与继承方式比较,取权限更小的),public > protected > private。
使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显示的写出继承方式。
在实际运用中一般使用都是 public 继承,几乎很少使用 protetced / private 继承,也不提倡使用 protetced / private 继承,因为 protetced / private 继承下来的成员都只能在派生类的类里面使用,甚至不能使用,实际中扩展维护性不强。
一个派生类继承了所有的基类方法,但下列情况除外:
派生类继承了基类的所有成员,那么我们可以把派生类对象赋值给基类吗?
注意:==我们这里讨论都是公有继承的情况下!==因为保护和私有继承后,「派生类中的基类数据成员」访问权限可能会发生变化,赋值过去后,基类又可以按照它原来的访问权限来操作「派生类中的基类数据成员」,这是有问题的!!!
派生类对象 可以赋值给 基类对象 / 基类对象指针 / 基类对象引用。这里有个形象的说法叫切片或者切割。寓意把派生类中基类那部分数据成员切来赋值过去。(切的是数据成员赋值过去,因为同一个类的各对象中只储存各自的数据成员,成员函数共用一份,单独存放在对象之外的另一段存储空间中)
基类对象不能赋值给派生类对象。
基类对象指针(或引用)可以通过强制类型转换赋值给派生类对象指针。但是必须是基类指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(Run-Time Type Information)的 dynamic_cast 来进行识别后进行安全转换。
代码如下:
#include
#include
using namespace std;
// 基类
class Person {
protected:
string _name; //姓名
string _sex; //性别
int _age; //年龄
};
// 派生类
class Student : public Person {
protected:
int id; //学号
};
int main() {
Student stu;
// 1.派生类对象赋值给基类对象、基类指针、基类引用
Person per = stu;
Person* ptr = &stu;
Person& ref = stu;
// 2.基类对象不能赋值给派生类对象
// stu = per;
// 3.基类的指针可以通过强制类型转换赋值给派生类的指针,但最好用dynamic_cast进行转换,这样才是安全的
return 0;
}
在继承体系中 基类 和 派生类 都有 独立的作用域。
子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员
显示访问)
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
建议自己实际使用中,在继承体系里面最好不要定义同名的成员。
【实例1】如下代码中, Student 的 _num 成员和 Person 的 _num 成员构成隐藏关系
// 当派生类和基类有同名成员时,派生类会隐藏基类成员,可以看出这样代码虽然能跑,但是非常容易混淆
// 基类
class Person {
protected:
int _num = 10; //序号
};
// 派生类
class Student : public Person {
public:
void print() {
cout << _num << endl; //打印的是派生类的
cout << Person::_num << endl; //打印基类的必须指明类域
}
protected:
int _num = 20; //序号
};
int main() {
Student stu;
stu.print();
return 0;
}
【实例2】如下代码中, B 的 fun(int i) 成员函数和 A 的 fun() 成员函数构成隐藏关系
// B中的fun和A中的fun不构成函数重载,因为在不同作用域
// B中的fun和A中的fun构成隐藏关系,在继承中,只要函数名相同就构成隐藏
// 基类
class A {
public:
void fun() {
cout << "fun()" << endl;
}
};
// 派生类
class B : public A {
public:
void fun(int i) {
cout << "fun(int i)" << endl;
}
};
int main() {
B b;
b.A::fun(); // 调用基类的fun()必须指明类域
b.fun(1); // 调用派生类的fun()
return 0;
}
6个默认成员函数,默认 的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
1)派生类的「构造函数」(没写编译器会自动生成),在初始化列表中,它会做这三件事:
2)派生类的「拷贝构造函数」(没写编译器会自动生成),在初始化列表中,它会做这三件事:
3)派生类的「赋值重载函数」(没写编译器会自动生成),它会做这三件事:
4)派生类的「析构函数」(没写编译器会自动生成),它会做这三件事:
对于类中的内置类型成员 ------> 不处理
对于类中的自定义类型成员 —> 调用它的析构函数完成清理工作
对于继承的基类成员 ------------> 把它作为一个整体,派生类的析构函数调用完成后,会自动调用基类的析构函数完成清理工作
5)派生类的「取地址重载」(分为普通对象和 const 对象),平时很少自己去实现,了解下就好
代码如下:
// 基类
class Person {
public:
// 默认构造函数
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;
}
protected:
string _name; //姓名
};
// 派生类
class Student : public Person {
public:
// 自己实现派生类的构造函数
// 需要注意的是,继承的基类成员是作为一个整体,调用基类的构造函数进行初始化
Student(const char* name, int id)
:Person(name) // 显示调用基类的构造函数
,_id(id)
{}
// 如果我们要自己实现派生类的拷贝构造,就要像下面这样写
// 但一般编译器默认生成的就可以,如果派生类中存在深拷贝,才需要自己实现
Student(const Student& s)
:Person(s) // 必须显示调用基类的拷贝构造(这里会发生切片)
,_id(s._id)
{}
// 自己实现派生类的赋值重载函数
Student& operator=(const Student& s)
{
if (this != &s)
{
_id = s._id;
Person::operator=(s); // 必须显示调用基类的赋值重载(这里会发生切片)
}
return *this;
}
~Student()
{
// 先清理自己的资源……
} // 结束后会自动调用父类的析构函数
protected:
int _id; //学号
};
int main() {
Student stu1("张三", 1); // 调用构造函数
Student stu2(stu1); // 调用拷贝构造函数
Student stu3("李四", 2);
stu1 = stu3; // 调用重载赋值函数
return 0;
}
【总结 & 一些要注意的细节】
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。(多继承时,按照基类声明顺序(继承顺序)依次初始化)
构造函数初始化列表阶段会自动调用基类的默认构造函数,如果基类没有不传参的默认构造函数,则必须在派生类构造函数的初始化列表阶段显示调用,否则会报错。
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的那一部分成员的拷贝初始化。
如果你要自己实现派生类的拷贝构造函数,
就必须在实现的派生类拷贝构造函数的初始化列表中显示调用基类的拷贝构造,
如果你自己不显示调用,
初始化列表阶段会自动调用基类的默认构造函数(因为拷贝构造和构造都是构造函数,而编译器只会自动调用不传参的默认构造),所以就不会去调用基类的拷贝构造了,导致无法正常完成拷贝工作。
派生类的 operator=( ) 必须要显示调用基类的 基类::operator=( ) 完成基类的复制。
派生类的析构函数会在被调用完成后,会自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员,再清理基类成员的顺序。(要符合先定义的先析构)
派生类的析构函数和基类的析构函数构成隐藏关系。
– 因为编译期会对析构函数名做特殊处理,所有类的析构函数名都会被处理成统一的名字: destructor()
为什么编译器会做这个处理呢?-- 因为析构函数要构成多态的重写(重写有个要求就是函数名要相同)
子类的析构函数在执行结束后会自动调用父类的析构函数。
– 因为创建派生类对象时,先创建初始化了基类成员,再创建初始化了派生类成员。所以派生类对象析构清理先调用派生类析构函数清理派生类成员后,再调用基类析构函数清理基类成员。
派生类对象初始化先调用基类构造再调派生类构造。
【核心要点】
核心思想是把派生类中继承的基类成员当作一个整体,就像一个自定义类型的变量一样来处理它,所以对于派生类,不管是构造、拷贝构造、赋值重载、析构,都要调用基类的对应函数才成完成相应操作。
友元关系不能继承,也就是说基类友元不能访问派生类的私有和保护成员。相当于你爹的朋友不一定是你的朋友。
// 声明派生类
class Student;
// 基类
class Person
{
public:
friend void Display(const Person& p, const Student& s); // 友元函数
protected:
string _name; // 姓名
};
// 派生类
class Student : public Person
{
protected:
int _id; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl; // 访问基类的保护成员
cout << s._id << endl; // 不能访问派生类的保护成员,因为友元关系不能继承下来
}
int main()
{
Person per;
Student stu;
Display(per, stu);
return 0;
}
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一
个 static 成员实例。(静态成员不会被继承下来,为该基类和它下面的派生类的所有对象共享)
// 基类
class Person
{
public:
Person() { ++_count; }
public:
static int _count; // 统计人的个数
protected:
string _name; // 姓名
};
int Person::_count = 0; // 定义静态数据成员(必须在类外定义)
// 派生类
class Student : public Person
{
protected:
int _id; // 学号
};
int main()
{
Person p;
Student s;
cout << "共创建了" << Person::_count << "个对象" << endl; // 使用基类域访问
cout << "共创建了" << Student::_count << "个对象" << endl; // 使用派生类域访问
cout << "共创建了" << p._count << "个对象" << endl; // 使用基类对象访问
cout << "共创建了" << s._count << "个对象" << endl; // 使用派生类对象访问
return 0;
}
菱形继承:菱形继承是多继承的一种特殊情况。
多继承本身没有什么问题,但因为C++支持多继承,就可能会出现菱形继承,从而引发一些问题,我们在这里会探究下具体问题和解决办法以及解决办法的原理,需要注意的是,实际中几乎不会去设计菱形继承和菱形虚拟继承的。
菱形继承的问题:观察上图,以及从下面的对象成员模型中可以看出菱形继承有数据冗余和二义性的问题。Assistant 的对象中会有两份 Person 成员。
如果 Person 成员中内容很多,会造成数据冗余。Person 成员有两份,赋值时不知道到底是给谁赋值,会造成二义性。
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; // 主修课程
};
int main()
{
// 这样会有二义性无法明确知道访问的是哪一个
Assistant a;
a._name = "张三"; // error:对_name访问不明确
// 需要显示指定访问的是哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "张三-学生";
a.Teacher::_name = "张三-老师";
return 0;
}
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在 Student 和 Teacher 继承 Person 时使用 virtul 虚拟继承,即可解决问题。
需要注意的是,实际使用中,很少设计菱形继承,也不建议大家使用菱形继承。
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; // 主修课程
};
int main()
{
Assistant a; // 虚拟继承后,Assistant的对象中就只有一份Person成员了
// 下面三种访问方式访问到的都是同一个成员
a.Student::_name = "张三-学生";
a.Teacher::_name = "张三-老师";
a._name = "张三";
return 0;
}
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员模型。
代码如下:
class A
{
public:
int _a;
};
class B : virtual public A
// class B : public A
{
public:
int _b;
};
class C : virtual public A
// class C : 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;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
【分析】
通过调试,观察内存窗口可看到:
下图是菱形继承的内存对象成员模型:这里可以看到数据冗余,有两份 A 成员。
下图是菱形 虚拟 继承的内存对象成员模型:这里可以分析出在 D 对象中将「虚基类 A 的成员 _a」放到了对象组成的最下面,这个 A 的成员同时属于 B 和 C,那么 B 和 C 如何去找到公共的 A 呢?这里是通过了 B 和 C 中的两个指针,指向的一张表。这两个指针叫「虚基表指针」,这两个表叫「虚基表」。虚基表中存的「偏移量」。通过虚基表指针加偏移量来计算找到下面的 A 的成员 _a。
在菱形 虚拟 继承体系中,新创建的 B 对象或 C 对象的内存对象模型中「虚基类 A 的成员 _a」也是放到了对象组成的最下面,都要通过虚基表指针加偏移量来计算找到下面的 A 的成员 _a。
只有菱形继承体系的腰部 B 和 C 是 virtul 虚拟继承的 A( class B : virtual public A
、class C : virtual public A
),所以只有 B 和 C 才通过偏移量去找虚基类「虚基类 A 的成员 _a」,而底部 D 是不需要通过偏移量去找「虚基类 A 的成员 _a」的。
所以C++弄这个多继承,反而还搞复杂了。
【思考】
可能有小伙伴还会有疑问为什么不把偏移量直接存到虚基表指针的那个位置,而是需要通过虚基表指针去找偏移量呢?这是因为虚基表是一个表,不止存放偏移量,还要存其它东西(记录虚基表指针和虚函数指针之间的相对位置,反正这个我是不研究了),后面会有其应用场景。
可能有小伙伴会有疑问为什么 D 对象中的 B 和 C 部分要去找属于自己的 A?那么大家看看当下面的赋值发生时,d 是不是要去找出 B / C 成员中的 A 才能切片赋值过去?
D d;
B b;
B* p1 = &d; // B对象指针 -> D对象,把D对象切片给B对象指针
p1->_a; // 指针访问虚基类A的成员_a
B* p2 = &b; // B对象指针 -> B对象
p2->_a; // 指针访问虚基类A的成员_a
// 指针是无法识别自己指向的是哪个类的对象,即可能指向自己,也可能指向子类,比如上面代码,B对象和D对象中虚基类成员_a的偏移量是不一样的,所以也只能通过偏移量来计算_a的位置
B或C的对象、对象指针、对象引用访问继承的虚基类 A 的对象中的成员 _a,都要取偏移量计算 _a 的位置。
【结论】
可以看到,虚继承后,解决了菱形继承,但是同时,对象模型更复杂了,其次访问虚基类成员也付出了一定的效率代价。
下面是上面9.3的 Person 关系菱形 虚拟 继承的原理解释:
面向对象系统中功能复用的两种最常用技术是 类继承 和 对象组合 :
public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象(例如,教师是人,学生是人;哺乳动物是动物,狗是哺乳动物,因此,狗是动物,等等)。
class B : public A
组合是一种 has-a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。
参考文章:优先使用对象组合,而不是类继承。
class D // q
{
protected:
A a; // 轮胎
B b; // 车身
};
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。因为对象只能通过接口访问,所以我们并不破坏封装性;组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
实际尽量多去用组合。组合的耦合度1低,代码维护性好。不过继承也有用武之地的,有些关系(is-a)就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系(is-a/has-a)可以用继承,可以用组合,就用组合。
比如:脸和眼睛、车和轮胎,这样的关系就适合用组合;人和司机、人和学生,这样的关系就适合用继承。
耦合 和 内聚:
思考一下:
各模块之间的独立性 ↩︎