C++小白的逆袭之路——进阶(第一章:继承)
1. 继承的概念和定义
1.1 继承的概念
继承的本质是复用:
-
继承(
inheritance
)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能。 -
通过继承产生的新类,称为子类或派生类。继承谁谁就是父类,又叫基类。当然子类和父类都是一个相对概念。
-
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
// 父类
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "Zhangsan"; // 姓名
int _age = 0; // 年龄
};
// 子类
class Student : public Person
{
public:
void fun()
{
Print(); // 在子类内使用父类方法
}
protected:
int _stuid = 0; // 学号
};
// 子类
class Teacher : public Person
{
protected:
int _thid = 0; // 教师编号
};
int main()
{
Student stu;
stu.Print(); // stu继承了父类的成员和方法
stu.fun();
Teacher th;
th.Print(); // th也继承了父类的成员和方法
return 0;
}
/*
输出:
name:Zhangsan
age:0
name:Zhangsan
age:0
name:Zhangsan
age:0
*/
-
我们通过调试来看一下:
-
发现
stu
和th
确实继承了Person
类的内部成员和方法。
1.2 继承的定义
1. 定义格式:
- 下面我们看到
Person
是父类,也称作基类。Student
是子类,也称作派生类。
2. 继承方式和访问限定符:
类成员/继承方式 | public 继承 | protected 继承 | private 继承 |
---|---|---|---|
基类的public 成员 | 变成派生类的public 成员 | 变成派生类的protected 成员 | 变成派生类的private 成员 |
基类的protected 成员 | 变成派生类的protected 成员 | 变成派生类的protected 成员 | 变成派生类的private 成员 |
基类的private 成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
理解上表:
-
无论以什么样的继承方式继承基类,基类中的
private
成员对派生类都是不可见的,也就是无法在派生类的类内访问,也无法被派生类实例化对象在类外访问。 -
基类的
protected
成员,被派生类用public
或protected
继承时,会变成派生类的protected
成员;被派生类用private
继承时,会变成派生类的private
成员。 -
基类的
public
成员,被派生类用public
继承,变成派生类的public
成员;被派生类用protected
继承,变成派生类的protected
成员;被派生类用private
继承,变成派生类的private
成员。
总结:
- 基类
private
成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。 - 基类
private
成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected
。可以看出保护成员限定符是因继承才出现的。 - 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),
public > protected > private
。 - 使用关键字
class
时默认的继承方式是private
,使用struct
时默认的继承方式是public
,不过最好显示的写出继承方式。 - 在实际运用中一般使用都是
public
继承,几乎很少使用protetced/private
继承,也不提倡使用protetced/private
继承,因为protetced/private
继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
2. 基类和派生类对象赋值转换
1. 概念:
- 派生类对象可以赋值给基类的对象/基类的指针/基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
- 如果是赋值给基类对象,这个基类对象中的成员和派生类中的成员分别存储在不同的地址空间中;如果是赋值给基类指针或基类引用,那么通过这个基类指针或基类引用改变基类成员时,派生类中的成员也会跟着改变,因为他们共用一块地址空间。
- 基类对象不能赋值给派生类对象。因为基类中无法切出派生类。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用
RTTI(RunTime Type Information)
的dynamic_cast
来进行识别后进行安全转换。(这个我们后面再讲解,这里先了解一下)
2. 代码演示
- 几个类的定义如下:
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _stuid; // 学号
};
class Teacher : public Person
{
public:
int _thid; // 教师编号
};
- 测试代码,通过调试验证:
void Test()
{
Student stu;
// 1. 子类对象可以赋值给父类对象/指针/引用
Person p = stu;
Person* pptr = &stu;
Person& pref = stu;
// 2. 基类对象不能赋值给派生类对象
Teacher th = p; // 报错
}
3. 派生类中的作用域
1. 概念:
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽对父类同名成员的直接访问,这种情况叫隐藏,也叫重定义。
- 在子类成员函数中,可以使用基类::基类成员显示访问。
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏,不管参数列表和返回值类型是否相同。
- 注意,在实际中继承体系里最好不要定义同名的成员。
2. 代码演示:
class Person
{
public:
void fun()
{
cout << "Person::fun()" << endl;
cout << _name << endl;
}
protected:
string _name = "Person::_name"; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
void fun()
{
cout << "Student::fun()" << endl;
cout << _name << endl; // Person中的_name被隐藏,默认调Student自己的_name
cout << Person::_name << endl; // 类内显示调Person中的_name
Person::fun(); // 类内显示调Person中的fun
}
protected:
string _name = "Student::_name"; // 重名变量_name
int _stuid; // 学号
};
void Test()
{
Student stu;
stu.fun(); // 父类中的fun()方法被隐藏,默认调Student中的fun()方法
cout << endl;
stu.Person::fun(); // 类外显示调Person中的fun
}
输出:
Student::fun()
Student::_name
Person::_name
Person::fun()
Person::_name
Person::fun()
Person::_name
4. 派生类的默认成员函数
1. 默认成员函数:
- 默认成员函数(共6个),就是我们不写,编译器会自动生成的函数。
2. 派生类中的默认成员函数:
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的
operator=
必须要调用基类的operator=
完成基类的复制。 - 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象初始化先调用基类构造再调派生类构造。我们都知道初始化列表的执行顺序是跟声明顺序有关的,所以可以理解成基类先声明,所以先调基类的构造函数。我们还会遇到多继承的情况,多继承中,先继承谁,相当于谁先声明,就先调谁的构造函数。
- 派生类对象析构清理先调用派生类析构再调基类的析构。因为某些场景下,我们可能要在派生类的析构函数中访问基类成员,比如把一些数据保存在文件中,所以基类成员还不能被释放,只能等派生类析构执行完后,再执行基类析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成
destrutor()
,所以父类析构函数不加virtual
的情况下,子类析构函数和父类析构函数构成隐藏关系。
class Person
{
public:
Person(const char* name = "Peter")
:_name(name)
{}
Person(const Person& p)
:_name(p._name)
{}
Person& operator=(const Person& p)
{
if (this != &p)
_name = p._name;
return *this;
}
~Person()
{
// 这里只是简单的打印一下,不做任何处理
cout << "~Person" << endl;
}
protected:
string _name;
};
class Student : public Person
{
public:
Student(const char* name = "Peter", int stuid = 0)
:Person(name)
,_stuid(stuid)
{}
Student(const Student& stu)
:Person(stu) // 这里显示调用父类拷贝构造函数,传参时进行了切片
,_stuid(stu._stuid)
{}
Student& operator=(const Student& stu)
{
if (this != &stu)
{
Person::operator=(stu); // 显示调用父类的赋值重载,传参时进行了切片
_stuid = stu._stuid;
}
return *this;
}
~Student()
{
cout << "~Student" << endl;
//Person::~Person(); 不用显示的调,编译器会自动调
}
/*
注意:
这里编译器会对析构函数名做特殊处理,处理成destrutor(),
所以父类的析构函数和子类的析构构成隐藏关系,
想显示的调基类析构只能通过 基类::基类析构函数
即Person::~Person()的方式调用,不能只写~Person(),编译器不认识。
*/
protected:
int _stuid;
};
4. 如何实现一个无法被继承的类?
- 只需要将这个类的构造函数私有即可,这样别的类在继承它时,就无法完成初始化,因为没有权限调用自己父类的构造函数。
class Person
{
private:
Person() {}
protected:
string _name;
int _age;
};
class Student : public Person
{
protected:
int _stuid;
};
void Test()
{
Student stu; // 报错
}
- 但是有人觉得这样的方式不够直观,所以在C++11后,引入了新关键字
final
,来标识一个无法被继承的类。
class Person final
{
protected:
string _name;
int _age;
};
class Student : public Person // 直接报错,不能将final类类型用作基类
{
protected:
int _stuid;
};
5. 继承和友元
友元关系是不能被继承了,即基类的友元函数不能访问派生类的保护和私有成员(爸爸的朋友不是我的朋友)。
想要让基类的友元函数也能访问派生类的保护和私有成员,必须在派生类中也加上一个该函数的友元声明。
class Student; // 在Person前声明
class Person
{
friend void Print(const Person& p, const Student& stu);
protected:
string _name = "Peter";
int _age = 0;
};
class Student : public Person
{
// 想让Print访问stu._stuid时不报错,必须放开下面的代码,在Student类中也加上友元声明
// friend void Print(const Person& p, const Student& stu);
protected:
int _stuid = 0;
};
void Print(const Person& p, const Student& stu)
{
cout << p._name << endl; // 可以访问
cout << p._age << endl; // 可以访问
cout << stu._stuid << endl; // 报错,不能访问
}
void Test()
{
Person p;
Student stu;
Print(p, stu);
}
6. 继承与静态成员
基类定义了static
静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static
成员实例 。
class Person
{
public:
Person()
{
_count++;
}
protected:
string _name;
int _age;
public:
static int _count; // 记录累积创建了多少个对象(包括基类和派生类)
};
int Person::_count = 0; // 类外定义
class Student : public Person
{
protected:
int _stuid;
};
void Test()
{
Person p;
Student stu;
// _cout被所有基类和派生类实例化出的对象共有
cout << stu._count << endl; // 输出2
Person::_count = 0;
cout << stu._count << endl; // 输出0
}
7. 复杂的多继承和菱形继承
1. 单继承:
- 一个子类只有一个直接父类时称这个继承关系为单继承。
2. 多继承:
- 一个子类有两个或以上直接父类时称这个继承关系为多继承。
3. 菱形继承:
- 菱形继承是多继承的一种特殊情况。
4. 菱形继承存在的问题:
class Person
{
public:
int _age;
};
class Student : public Person
{
protected:
int _stuid = 10;
};
class Teacher : public Person
{
protected:
int _thid = 20;
};
class Assistant : public Student, public Teacher
{
protected:
int _majorCourseId = 28; // 主修课程
};
void Test()
{
Assistant a;
// a._age; // 有二义性,报错
a.Student::_age = 1;
a.Teacher::_age = 2;
}
-
执行完
Test
函数后查看内存:
-
可以发现,菱形继承具有数据冗余和二义性的问题:
Assistant
中存在两份Person
类的成员,存在数据冗余;- 无法直接访问
a._age
,因为不知道访问的是Student
中的_age
还是Teacher
中的_age
,存在二义性,必须加上作用域,显示访问。
5. 虚拟继承:
- 虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在
Student
和Teacher
继承Person
时使用虚拟继承,关键字virtual
,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
class Person
{
public:
int _age;
};
class Student : virtual public Person
{
protected:
int _stuid = 10;
};
class Teacher : virtual public Person
{
protected:
int _thid = 20;
};
class Assistant : public Student, public Teacher
{
protected:
int _majorCourseId = 28; // 主修课程
};
void Test()
{
Assistant a;
// 下面三次访问的是同一块空间中的数据
a._age;
a.Student::_age = 1;
a.Teacher::_age = 2;
}
-
菱形继承中,在哪里分叉了,就在哪里使用虚拟继承:
-
在实际写代码时,一定要避免用菱形继承,这个结构是很复杂的,水很深,容易把握不住。菱形继承也是C++设计的一个坑,尽管祖师爷设计了虚拟继承来填这个坑,但是依然不建议使用。像java就干脆不支持多继承,你C++跳了这个坑,替我探路了,我java就不踩了。
6. 拓展学习:虚拟继承的原理
- 为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员。(测试环境为VS2022)
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;
};
void Test()
{
D d;
d._b = 3;
d._c = 4;
d._d = 5;
d._a = 0;
}
-
执行完
Test
函数后,调试查看d的内存空间:
-
这里可以分析出D对象中将A放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。
-
有同学可能会想,为什么存地址偏移量,还要通过一个指针来找这个偏移量,太麻烦了,直接在前两个黑框框的位置存上
_a
的地址不就行了?这样的思路放在当下这个局部场景下是可行的,但是往后我们还要引入多态,情况就不一样了。细心的大家可以注意到虚基表中第二个位置是偏移量,第一个位置好像还没有数据,这就是为多态准备的。
8. 继承的总结和反思
- 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
- 多继承可以认为是C++的缺陷之一,很多后来的面向对象语言都没有多继承,如Java。
- 继承和组合
public
继承是一种is-a
的关系。也就是说每个派生类对象都是一个基类对象。组合是一种has-a
的关系。假设B组合了A,每个B对象中都有一个A对象。- 优先使用对象组合,而不是类继承 。
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。但是继承也并非一无是处,在实际应用中,我们还是要看两个类之间更符合哪种关系,是
is-a
还是has-a
,然后挑选更恰当的复用方式。
// Car和BMW Car和Benz构成is-a的关系
class Car
{
protected:
string _colour = "白色"; // 颜色
string _num = "陕ABIT00"; // 车牌号
};
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 = "陕ABIT00"; // 车牌号
Tire _t; // 轮胎
};