Ⅰ . 继承
01 知识回顾
先回顾一下面向对象的三大特性:封装、继承、多态
同时,面向对象还有其他特性:反射、抽象
① C++ stack 类设计和 C 设计 stack 对比,封装更好、访问限定符 + 类狭义
② 迭代器设计,如果没有迭代器,容器访问只能暴露底层结构。-> 使用复杂、使用成本高,对使用者要求极高。
封装了容器底层结构,不暴露底层结构的情况,提供统一的访问容器的方式,降低使用成本,简化使用。
③ stack/queue/priority_queue 的设计----适配器模式
今天来看的是继承
02 继承的概念
继承机制是面向对象程序设计使代码可以复用的最重要的手段
它允许程序员在保持原有类特性的基础上进行扩展,以增加功能。这样产生新的类,称为派生类。
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程
以前我们接触的复用都是函数复用,而继承是类设计层次的复用
举例:比如我们要设计一个图书管理系统,每个角色的权限是不同的,比如学生、保安、老师、
后勤等等,为了区分这些角色,我们就要设计一些类出来:
class Student
{
string _name;
string _tel;
string _address;
int _age;
string _stuID; //学号
};
class Teacher
{
string _name;
string _tel;
string _address;
int _age;
string _workID; //工号
};
不难发现存在大量冗余部分,有些信息是公共的,有些信息是每个角色独有的
对于有些数据和方法是每个角色都具有的,我们每次都写一次,这就导致设计重复了
解决方案:设计一个 person 类 把共有的东西写进去
class Person
{
string _name;
string _tel;
string _address;
int _age;
};
然后使用继承去把这些大家共有的东西运送给每个角色
class Person
{
/* 共有信息 */
string _name;
string _tel;
string _address;
int _age;
};
class Student : public Person
{
string _stuID; //学号
};
class Teacher : public Person
{
string _workID; //工号
};
这就是继承,在需要成为子类的类的类名后加上冒号,并跟上继承方式和父类类名即可
比如我们这里希望 Student 以 public 的继承方式继承 Person
为了演示继承的效果,我们给 Person 类加上个 Print 打印函数:
class Person
{
public:
void Print()
{
cout << "name: " << _name << endl;
cout << "age: " << _age << endl;
cout << endl;
}
/* 共有信息 */
string _name = "user";
string _tel;
string _address;
int _age = 0;
};
class Student : public Person
{
string _stuID; //学号
};
class Teacher : public Person
{
string _workID; //工号
};
int main()
{
Person p;
p.Print();
Student s;
s.Print();
Teacher t;
t.Print();
return 0;
}
运行结果如下:
03 继承的定义格式
我们还是拿刚才的 Person 和 Student 举例:
派生类 继承方式 基类
👇 👇 👇
class Student : public Person {
public:
string _stuID; // 学号
};
Student 是 子类,我们也称之为派生类。 Person 是 父类,我们也称之为基类。
我们可以把 Person 和 Student 看作是父子关系,继承资产,这里的继承方式是 public,
即公有继承,还有其他的继承方式
04 访问限定符:public / protected / private
三种访问限定符,分别是 public(公有)、protected(保护)、private(私有)。
这一听名字就能知道,公有就是随便玩,保护和私有就是藏起来一点点不让你随便玩得到。
① public 修饰的成员,可以在类外面随便访问
② protected 和 private 修饰的成员,不能在类外面随便访问
③ 定义成 protected 可以让父类成员不能在类外直接访问,但可以在子类中访问
public、protected、private 不仅仅是访问限定符,它们也可以表示继承的三种继承方式:
05 继承基类成员访问方式的变化
三种访问限定符和三种继承方式相碰撞,就产生了9种情况:
① 父类的 private 成员在子类中无论以哪种方式继承都是不可见的。这里的不可见是指父类的私有成员还是被继承到了子类对象中,但是语法限制了子类对象不管在类里面还是类外面都不能访问父类的 private 成员。
② 父类的 private 成员在子类中不能被访问,如果父类成员不想在类外面直接被访问,但是想让它们在子类中能被访问,可定义为 protected。不难看出,保护成员限定符是因为继承才出现的。
③ 实际上,上面的表格我们通过观察不难发现,父类的私有成员在子类都是不可见的,父类的其他成员在子类的访问方式 == min(成员在父类的访问限定符,继承方式):
④ 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,但最好还是显示的写出继承方式,提高代码可读性。
⑤ 一共 9 种组合,实际中父类成员基本都是保护和公有,继承方式基本都是用公有继承,几乎很少使用 protected / private 继承。而且也不提倡使用 protected / private 继承,因为 protected / private 继承下来的成员都只能在子类中使用,实际扩展维护性不强。
06 父类和子类对象赋值转换
子类对象可以赋值给父类的对象、父类的指针、父类的引用
class Person {
protected:
string _name;
string _age;
};
class Student : public Person
{
public:
string _stuID; // 学号
};
int main()
{
Student s;
// 子类对象可以赋值给父类对象/指针/引用
Person p = s;
Person* pp = &s;
Person& rp = s;
return 0;
}
这种操作我们称之为”切割“(或切片),寓意是把子类中父类的那部分切过来赋值过去。
注意事项:
① 父类对象不能赋值给子类对象。
Student s; // 子类
Person p; // 父类
s = p; ❌
② 父类的指针可以通过强转赋值给子类的指针,但是必须是父类的指针是指向子类对象时才是安全的。这里父类如果是多态类型,可以使用 RTTI (Run-Time Type Information,运行时类型识别)的 dynamic_cast 来进行识别后进行安全转换。
Student s;
Person* pp = &s;
// 父类的指针可以通过强制类型转换赋值给子类的指针
pp = &s;
Student* ps1 = (Student*)pp;
ps1->_stuID = 10001;
pp = &p;
Student* ps2 = (Student*)pp; // 这种情况虽然可以,但是会存在越界访问问题
ps2->_stuID = 20002;
07 继承中的作用域
继承体系中的父类和子类都有独立的作用域,如果子类和父类有同名成员,
此时子类成员会屏蔽父类对同名成员的直接访问,这种情况叫做隐藏。
在子类成员函数中,可以使用下面的方式进行显示访问:
基类::基类成员
注意事项:
① 如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
② 实际运用中在继承体系里最好不要定义同名的成员,父类成员名不要和子类成员名冲突。
代码演示:
class Person {
protected:
string _name = "小明"; // 姓名
string _num = "320103xxxxxxxxxx14"; // 身份证号
};
class Student : public Person {
public:
void Print() {
cout << "姓名:" << _name << endl;
cout << "身份证号: " << Person::_num << endl; // 指定是Person的_num
cout << "学号:" << _num << endl; // 默认在自己作用域内找_num
}
protected:
string _num = "10001"; // 学号
};
int main()
{
Student s1;
s1.Print();
return 0;
}
运行结果如下:
思考:对于下面的代码,A::func 和 B::func 的关系是重载还是隐藏呢?
class A
{
public:
void func()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void func(int i)
{
A::func();
cout << "func(int i) -> " << i << endl;
}
};
int main()
{
B b;
b.func(10);
return 0;
}
运行结果如下:
解答:函数重载要求在同一作用域,我们说了,子类和父类都有独立的作用域,因为不是在同一个作用域, B 中的 func 和 A 中的 func 不可能构成重载,正确答案是构成隐藏。 B 中的 func 和 A 中的 func 构成隐藏,成员函数满足函数名相同就构成隐藏。
08 继承和友元
友元关系不能继承,也就是说父类友元不能访问子类私有和保护成员
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);
return 0;
}
运行结果:
“Student::_stuNum”: 无法访问 protected 成员(在“Student”类中声明)
09 继承与静态成员
父类定义了 static 静态成员,则整个继承体系里面中有一个这样的成员。
可以理解为共享,父类的静态成员可以在子类共享,父类和子类都能去访问它。
无论派生多少个子类,都只有一个 static 成员实例:
class Person
{
public:
Person()
{
++_count;
}
protected:
string _name; // 姓名
public:
static int _count; // 统计人的个数
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
string _seminarCourse; // 研究科目
};
void TestPerson()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
Person s;
cout << "大家都可以访问" << endl;
cout << "人数 : " << Person::_count << endl;
cout << "人数 : " << Student::_count << endl;
cout << "人数 : " << s4._count << endl;
cout << "大家也都可以变动" << endl;
s3._count = 0;
cout << "人数 : " << Person::_count << endl;
cout << "并且他们的地址也都是一样的,因为所有继承体系中只有一个" << endl;
cout << "人数 : " << &Person::_count << endl;
cout << "人数 : " << &Student::_count << endl;
cout << "人数 : " << &s4._count << endl;
}
int main()
{
TestPerson();
return 0;
}
运行结果如下:
Ⅱ . 子类默认成员函数
01 默认成员函数
我们知道,对于默认成员函数,如果不主动实现,编译器会自动生成一份。
那么这些默认成员函数在子类中,又是如何生成的呢?
02 子类构造函数
① 父类成员需要调用自己的构造完成初始化。即子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员。
② 如果父类没有默认的构造函数,则必须在子类构造函数的初始化列表阶段显示调用。
③ 子类对象初始化先调用父类构造再调用子类构造。
代码演示:
class Person
{
public:
/* 父类构造函数 */
Person(const char* name = "foxny")
: _name(name)
{
cout << "Person()" << endl;
}
protected:
string _name;
};
class Student : public Person
{
public:
/* 子类构造函数 */
Student(const char* name, int num)
: Person(name) // 父类成员,调用自己的构造完成初始化
, _num(num)
{
cout << "Student()" << endl;
}
protected:
int _num; // 学号
};
void test()
{
Student s1("小明", 18);
}
int main()
{
test();
return 0;
}
运行结果如下:
调用父类构造函数初始化继承来自父类的成员,自己再初始化自己的成员
析构、拷贝构造、赋值重载也类似。
那么如何可以设计一个不能被继承的类呢?
将父类的构造函数私有化
class A {
private: // 将A的构造函数私有化
A() {}
};
class B : public A {
};
int main()
{
B b;
return 0;
}
父类 A 的构造函数私有化后 B 就无法构造对象,因为 B 的构造函数必须调用父类 A 的构造函数。
但这样的话 A 也没办法构造了。
但是我们可以这么玩:
class A
{
public:
static A CreateObject()
{ // 提供一个获取对象的方式
return A();
}
private:
A() {}
};
class B : public A {};
int main()
{
A a = A::CreateObject();
return 0;
}
此时提供一个获取对象的成员函数即可。
03 子类拷贝构造函数
子类的拷贝构造函数必须调用父类的拷贝构造完成拷贝初始化。
代码演示:
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;
}
protected:
string _name;
};
class Student : public Person
{
public:
/* 子类构造函数 */
Student(const char* name, int num)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
/* 子类拷贝构造 */
Student(const Student& s)
: Person(s) // 子类的拷贝构造函数必须调用父类的拷贝构造完成拷贝初始化。
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
protected:
int _num; // 学号
};
void test() {
Student s1("小明", 18);
Student s2(s1);
}
int main()
{
test();
return 0;
}
运行结果如下:
04 子类的赋值重载
子类的 operator= 必须要调用父类的 operator= 完成父类的赋值。
代码演示:
class Person
{
public:
/* 父类构造函数 */
Person(const char* name = "小明")
: _name(name)
{
cout << "Person()" << endl;
}
/* 父类赋值重载 */
Person& operator=(const Person& p)
{
cout << "Person& operator=(const Person& p)" << endl;
if (this != &p) {
_name = p._name;
}
return *this;
}
protected:
string _name;
};
class Student : public Person
{
public:
/* 子类构造函数 */
Student(const char* name, int num)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
/* 子类赋值重载 */
Student& operator=(const Student& s)
{
cout << "Student& operator=(const Student& s)" << endl;
if (this != &s) {
// 子类的 operator= 必须要调用父类的 operator= 完成父类的复制
Person::operator=(s);
_num = s._num;
}
return *this;
}
protected:
int _num; // 学号
};
void test() {
Student s1("小明", 18);
Student s3("小红", 17);
s1 = s3;
}
int main()
{
test();
return 0;
}
运行结果如下:
05 子类的析构函数
为了保证子类对象先清理子类成员再清理父类成员,子类对象的析构要先调用子类析构再调用父类析构。
子类析构函数完成后会自动调用父类的析构函数,不需要显示调用。
代码演示:
class Person
{
public:
/* 父类构造函数 */
Person(const char* name = "小明")
: _name(name)
{
cout << "Person()" << endl;
}
/* 父类析构 */
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name;
};
class Student : public Person
{
public:
/* 子类构造函数 */
Student(const char* name, int num)
: Person(name) // 父类成员,调用自己的构造完成初始化
, _num(num)
{
cout << "Student()" << endl;
}
/* 子类析构 */
~Student()
{
cout << "~Student()" << endl;
} // -> 自动调用父类析构函数
protected:
int _num; // 学号
};
void test()
{
Student s1("小明", 18);
}
int main()
{
test();
return 0;
}
运行结果如下:
Ⅲ . 多继承与钻石继承
01 多继承的概念
我们先说说单继承,刚才我们讲的其实就是单继承。
单继承:一个子类只有一个直接父类,我们称这种继承关系为单继承。
多继承:一个子类有两个或以上直接父类,我们称这种继承关系为多继承。
一个子类继承多个父类的情况也挺合理的,比如有的角色,既是学生也是老师;
房车,既是房子也是车;微软 Surface 二合一设备,既是平板也是电脑……
但实际慢慢用起来后问题就慢慢显现出来了,有多继承就会产生 "钻石继承",我们继续往下看。
02 钻石继承的概念
钻石继承,又称菱形继承(diamond-inheritance),是多继承的一种特殊情况。
举个例子:研究生助教继承了学生和老师,学生和老师又都继承了人
这时候就产生了经典的钻石继承,此时会带来一些问题,我们下面会详细探讨。
为什么叫做钻石继承呢?看图你就知道为什么了:
钻石继承的问题:钻石继承存在数据冗余和二义性的问题
代码演示:演示二义性带来的问题
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";
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
}
但是二义性通过指定作用域是可以解决的,告诉编译器是从学生那继承的还是从老师那继承的。
但是数据冗余没有解决,数据冗余带来的最大问题是空间的浪费:
class Person
{
public:
string _name;
int _hugeArr[10000]; // 如果数据很大,浪费的可不是一点点了
};
03 通过虚拟继承解决钻石继承问题
代码演示:
class Person
{
public:
string _name;
int _hugeArr[10000];
};
// 虚继承
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;
};
加上 virtual 表示虚继承,此时就能完美解决了钻石继承带来的数据冗余问题。
再配合刚刚我们讲的指定作用域,二义性也可以得到很好的解决:
void Test()
{
// 显示指定访问哪个父类的成员解决二义性
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
}
04 继承和组合
继承和组合 public继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。
组合是一种 has-a 的关系。假设B组合了A,每个B对象中都有一个A对象。
优先使用对象组合,而不是类继承 。
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。 继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关 系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对 象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse), 因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系, 耦合度低。优先使用对象组合有助于你保持每个类被封装。实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适 合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
class A
{
// ...
};
// 继承
class B : public A {};
class C
{
// ...
};
// 组合
class D
{
C _c;
};
⬛ 黑箱复用:C 对象公有成员 D 可以直接用,C 对象保护成员 D 不能直接用。
⬜ 白箱复用:C 对象公有成员 D 可以直接用,C 对象保护成员 D 也可以直接用。
我们举个旅游的例子来讲解:
团体出行:人和人之间关系太紧密 —— 耦合度高
自由出行:人和人之间关系松散的,没有很多具体要求 —— 耦合度低
继承就是团体出行,A 任何成员的修改都有可能影响 B 的实现。
组合就是自由出行,C 只要不修改公有,就不会对 D 有影响。
总结:适合 is-a 关系,建议继承。适合 has-a 关系,建议组合。都可以,建议组合。
// Car和BMW Car和Benz构成is-a的关系
class Car {
protected:
string _colour = "白色"; // 颜色
string _num = "苏KBD246"; // 车牌号
};
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 = "苏KBD246"; // 车牌号
Tire _t; // 轮胎
};