一、多继承
C++ 支持多继承,即一个派生类可以同时拥有多个直接基类,从而组合多个基类的属性和行为。这是面向对象编程中实现代码复用的重要方式,但也带来了二义性等问题,需要特别注意。
1.1 多继承的语法
多继承的声明语法与单继承类似,只需在派生类名后用逗号分隔多个基类,并分别指定每个基类的访问限定符:
class 派生类名 : 访问限定符1 基类名1, 访问限定符2 基类名2, ... {
// 派生类新增成员
};
- 访问限定符 :与单继承一致,包括
public、protected、private,默认值为private(类声明时)。 - 构造与析构顺序 :先按继承声明的顺序调用所有基类的构造函数,再调用派生类的构造函数;析构顺序与构造顺序相反。
示例:助教类同时继承学生和老师
助教既具有学生的属性(学号、专业),又具有老师的属性(工号、授课课程),适合用多继承实现:
cpp
#include <iostream>
#include <string>
using namespace std;
// 基类1:学生类
class Student {
protected:
string studentID; // 学号
string major; // 专业
public:
Student(string id, string m) : studentID(id), major(m) {
cout << "Student构造函数调用" << endl;
}
void showStudentInfo() {
cout << "学号:" << studentID << ",专业:" << major << endl;
}
~Student() {
cout << "Student析构函数调用" << endl;
}
};
// 基类2:老师类
class Teacher {
protected:
string teacherID; // 工号
string course; // 授课课程
public:
Teacher(string id, string c) : teacherID(id), course(c) {
cout << "Teacher构造函数调用" << endl;
}
void showTeacherInfo() {
cout << "工号:" << teacherID << ",授课课程:" << course << endl;
}
~Teacher() {
cout << "Teacher析构函数调用" << endl;
}
};
// 派生类:助教类,同时继承Student和Teacher
class TeachingAssistant : public Student, public Teacher {
private:
string department; // 所属院系
public:
// 派生类构造函数:必须初始化所有直接基类
TeachingAssistant(string sID, string m, string tID, string c, string d)
: Student(sID, m), Teacher(tID, c), department(d) {
cout << "TeachingAssistant构造函数调用" << endl;
}
void showInfo() {
cout << "所属院系:" << department << endl;
showStudentInfo();
showTeacherInfo();
}
~TeachingAssistant() {
cout << "TeachingAssistant析构函数调用" << endl;
}
};
int main() {
TeachingAssistant ta("2024001", "计算机科学与技术", "T2024001", "C++程序设计", "计算机学院");
ta.showInfo();
return 0;
}
运行结果:
Student构造函数调用
Teacher构造函数调用
TeachingAssistant构造函数调用
所属院系:计算机学院
学号:2024001,专业:计算机科学与技术
工号:T2024001,授课课程:C++程序设计
TeachingAssistant析构函数调用
Teacher析构函数调用
Student析构函数调用
可以看到,基类构造函数按继承顺序(先Student后Teacher)调用,析构顺序相反。
1.2 多继承的应用场景
多继承适用于一个类需要组合多个独立类的功能的场景:
- 公司员工体系:
Boss类同时继承Employee(员工)、Manager(经理)、Developer(开发者),拥有考勤、管理项目、编写代码等多种行为。 - 图形界面组件:
Button类同时继承Widget(控件)、Clickable(可点击)、Drawable(可绘制)。 - 游戏角色:
Warrior类同时继承Character(角色)、MeleeAttacker(近战攻击者)、ArmorBearer(护甲携带者)。
1.3 菱形继承与二义性问题
多继承最常见的问题是菱形继承(钻石继承) ,即两个派生类继承自同一个基类,而最终派生类又同时继承这两个派生类,导致最终派生类中存在多份间接基类的子对象,从而产生二义性。
问题示例:未使用虚继承的菱形继承
cpp
#include <iostream>
#include <string>
using namespace std;
// 顶层基类:人
class Person {
protected:
string name; // 姓名
int age; // 年龄
public:
Person(string n, int a) : name(n), age(a) {}
void showBasicInfo() {
cout << "姓名:" << name << ",年龄:" << age << endl;
}
};
// 中间派生类1:学生
class Student : public Person {
protected:
string studentID;
public:
Student(string n, int a, string id) : Person(n, a), studentID(id) {}
};
// 中间派生类2:老师
class Teacher : public Person {
protected:
string teacherID;
public:
Teacher(string n, int a, string id) : Person(n, a), teacherID(id) {}
};
// 最终派生类:助教
class TeachingAssistant : public Student, public Teacher {
public:
TeachingAssistant(string n, int a, string sID, string tID)
: Student(n, a, sID), Teacher(n, a, tID) {}
};
int main() {
TeachingAssistant ta("张三", 25, "2024001", "T2024001");
// 错误:二义性!不知道调用哪个基类的showBasicInfo()
// ta.showBasicInfo();
// 错误:二义性!不知道访问哪个基类的name成员
// cout << ta.name << endl;
return 0;
}
上述代码中,TeachingAssistant对象包含两份Person子对象 (一份来自Student,一份来自Teacher),因此直接访问Person的成员时,编译器无法确定要访问哪一份,导致编译错误。
1.4 虚继承:解决菱形继承的二义性
C++ 通过虚继承解决菱形继承的二义性问题。虚继承的核心是让中间派生类共享同一份间接基类(虚基类)的子对象,从而避免多份副本的产生。
虚继承的语法
在中间派生类的继承声明前加上virtual关键字:
class 中间派生类名 : virtual 访问限定符 虚基类名 { ... };
关键注意事项:最终派生类必须直接调用虚基类的构造函数
由于虚基类的子对象是共享的,其初始化责任由最终派生类承担。如果虚基类没有默认构造函数,最终派生类必须在构造函数初始化列表中显式调用虚基类的构造函数;中间派生类对虚基类构造函数的调用会被忽略。
修正后的菱形继承示例
cpp
#include <iostream>
#include <string>
using namespace std;
// 虚基类:人
class Person {
protected:
string name;
int age;
public:
Person(string n, int a) : name(n), age(a) {
cout << "Person构造函数调用" << endl;
}
void showBasicInfo() {
cout << "姓名:" << name << ",年龄:" << age << endl;
}
~Person() {
cout << "Person析构函数调用" << endl;
}
};
// 中间派生类1:学生,虚继承Person
class Student : virtual public Person {
protected:
string studentID;
public:
Student(string n, int a, string id) : Person(n, a), studentID(id) {
cout << "Student构造函数调用" << endl;
}
~Student() {
cout << "Student析构函数调用" << endl;
}
};
// 中间派生类2:老师,虚继承Person
class Teacher : virtual public Person {
protected:
string teacherID;
public:
Teacher(string n, int a, string id) : Person(n, a), teacherID(id) {
cout << "Teacher构造函数调用" << endl;
}
~Teacher() {
cout << "Teacher析构函数调用" << endl;
}
};
// 最终派生类:助教
class TeachingAssistant : public Student, public Teacher {
private:
string department;
public:
// 必须显式调用虚基类Person的构造函数!
TeachingAssistant(string n, int a, string sID, string tID, string d)
: Person(n, a), Student(n, a, sID), Teacher(n, a, tID), department(d) {
cout << "TeachingAssistant构造函数调用" << endl;
}
void showInfo() {
cout << "所属院系:" << department << endl;
showBasicInfo(); // 不再有二义性
cout << "学号:" << studentID << ",工号:" << teacherID << endl;
}
~TeachingAssistant() {
cout << "TeachingAssistant析构函数调用" << endl;
}
};
int main() {
TeachingAssistant ta("张三", 25, "2024001", "T2024001", "计算机学院");
ta.showInfo();
return 0;
}
运行结果:
Person构造函数调用
Student构造函数调用
Teacher构造函数调用
TeachingAssistant构造函数调用
所属院系:计算机学院
姓名:张三,年龄:25
学号:2024001,工号:T2024001
TeachingAssistant析构函数调用
Teacher析构函数调用
Student析构函数调用
Person析构函数调用
可以看到:
Person构造函数只被调用一次,说明最终派生类中只有一份Person子对象;- 直接访问
showBasicInfo()和name不再有二义性; - 析构函数仍按与构造相反的顺序调用。
二、C++ 的多态性
多态性是面向对象编程的三大核心特性之一(封装、继承、多态),指同一操作作用于不同对象时,会产生不同的行为结果 。C++ 中的多态分为静态多态 和动态多态两类。
2.1 多态性的分类
| 类型 | 确定时机 | 实现方式 | 示例 |
|---|---|---|---|
| 静态多态 | 编译时 | 函数重载、运算符重载 | add(int, int)和add(double, double) |
| 动态多态 | 运行时 | 继承 + 重写基类虚函数 | 基类指针指向不同派生类对象调用draw() |
2.2 静态多态
静态多态在编译阶段就确定了要调用的函数,因此也称为编译时多态。
2.2.1 函数重载
函数重载指同一作用域内,函数名相同但参数列表(个数、类型、顺序)不同的多个函数,编译器根据实参类型匹配对应的函数。
cpp
#include <iostream>
using namespace std;
// 重载1:两个int相加
int add(int a, int b) {
return a + b;
}
// 重载2:两个double相加
double add(double a, double b) {
return a + b;
}
// 重载3:三个int相加
int add(int a, int b, int c) {
return a + b + c;
}
int main() {
cout << add(1, 2) << endl; // 调用重载1,输出3
cout << add(1.5, 2.5) << endl; // 调用重载2,输出4
cout << add(1, 2, 3) << endl; // 调用重载3,输出6
return 0;
}
2.2.2 运算符重载
运算符重载是对已有运算符赋予新的含义,使其能作用于自定义类型,本质也是函数重载。
cpp
#include <iostream>
using namespace std;
// 复数类
class Complex {
private:
double real; // 实部
double imag; // 虚部
public:
Complex(double r=0, double i=0) : real(r), imag(i) {}
// 重载+运算符,实现复数加法
Complex operator+(const Complex& other) const {
return Complex(real + other.real, imag + other.imag);
}
void show() const {
cout << real << (imag >=0 ? "+" : "") << imag << "i" << endl;
}
};
int main() {
Complex c1(1, 2), c2(3, -4);
Complex c3 = c1 + c2; // 等价于c1.operator+(c2)
c3.show(); // 输出4-2i
return 0;
}
2.3 动态多态(核心)
动态多态在程序运行阶段才确定要调用的函数,是面向对象编程中实现 "接口统一、行为多样" 的关键。
2.3.1 动态多态的实现条件
动态多态必须同时满足以下 3 个条件:
- 存在继承关系:基类和派生类之间形成继承层次;
- 派生类重写基类的虚函数:重写要求函数签名(函数名、参数列表、const 属性)完全一致(协变返回类型除外);
- 用基类指针或引用指向派生类对象:通过基类指针 / 引用调用虚函数时,会根据实际指向的对象类型调用对应的重写函数。
2.3.2 虚函数与重写
- 虚函数 :在基类中用
virtual关键字声明的成员函数,告诉编译器该函数需要支持动态绑定。 - 重写(覆盖):派生类中定义与基类虚函数签名完全一致的函数,覆盖基类的实现。
注意:重写与重载的区别
- 重载:同一作用域,函数名相同、参数列表不同,编译时确定;
- 重写:不同作用域(基类和派生类),函数名、参数列表、const 属性完全相同,运行时确定。
经典示例:图形类的多态
cpp
#include <iostream>
#include <cmath>
using namespace std;
// 基类:图形
class Shape {
public:
// 虚函数:计算面积
virtual double getArea() const {
cout << "Shape::getArea() 被调用" << endl;
return 0;
}
// 虚函数:绘制图形
virtual void draw() const {
cout << "绘制图形" << endl;
}
// 基类析构函数必须声明为虚函数!避免内存泄漏
virtual ~Shape() {
cout << "Shape析构函数调用" << endl;
}
};
// 派生类1:圆形
class Circle : public Shape {
private:
double radius; // 半径
public:
Circle(double r) : radius(r) {}
// 重写基类的getArea()
double getArea() const override { // C++11推荐加override关键字显式声明重写
return M_PI * radius * radius;
}
// 重写基类的draw()
void draw() const override {
cout << "绘制圆形,半径:" << radius << endl;
}
~Circle() {
cout << "Circle析构函数调用" << endl;
}
};
// 派生类2:矩形
class Rectangle : public Shape {
private:
double width; // 宽
double height; // 高
public:
Rectangle(double w, double h) : width(w), height(h) {}
double getArea() const override {
return width * height;
}
void draw() const override {
cout << "绘制矩形,宽:" << width << ",高:" << height << endl;
}
~Rectangle() {
cout << "Rectangle析构函数调用" << endl;
}
};
// 派生类3:三角形
class Triangle : public Shape {
private:
double a, b, c; // 三边长
public:
Triangle(double a, double b, double c) : a(a), b(b), c(c) {}
double getArea() const override {
double p = (a + b + c) / 2;
return sqrt(p * (p-a) * (p-b) * (p-c)); // 海伦公式
}
void draw() const override {
cout << "绘制三角形,三边长:" << a << ", " << b << ", " << c << endl;
}
~Triangle() {
cout << "Triangle析构函数调用" << endl;
}
};
int main() {
// 基类指针数组,指向不同的派生类对象
Shape* shapes[] = {
new Circle(5),
new Rectangle(4, 6),
new Triangle(3, 4, 5)
};
int size = sizeof(shapes) / sizeof(shapes[0]);
// 统一接口调用,运行时确定具体行为
for (int i = 0; i < size; i++) {
shapes[i]->draw();
cout << "面积:" << shapes[i]->getArea() << endl << endl;
}
// 释放内存,基类析构函数为虚函数,会调用对应派生类的析构函数
for (int i = 0; i < size; i++) {
delete shapes[i];
}
return 0;
}
运行结果:
cpp
绘制圆形,半径:5
面积:78.5398
绘制矩形,宽:4,高:6
面积:24
绘制三角形,三边长:3, 4, 5
面积:6
Circle析构函数调用
Shape析构函数调用
Rectangle析构函数调用
Shape析构函数调用
Triangle析构函数调用
Shape析构函数调用
关键注意事项:基类虚析构函数
如果基类析构函数不是虚函数,当用基类指针指向派生类对象并执行delete时,只会调用基类的析构函数,导致派生类部分的资源无法释放,造成内存泄漏。因此,只要类中存在虚函数,就应该将析构函数声明为虚函数。
2.3.3 动态多态的工作原理(虚表与虚指针)
C++ 通过 ** 虚函数表(vtable)和虚指针(vptr)** 实现动态多态:
- 每个包含虚函数的类(或其基类包含虚函数)都会生成一个虚函数表,表中存储该类所有虚函数的地址;
- 该类的每个对象都会包含一个隐藏的虚指针,指向该类的虚函数表;
- 当通过基类指针 / 引用调用虚函数时,程序会先通过对象的虚指针找到对应的虚函数表,再从表中查找实际要调用的函数地址,从而实现运行时绑定。
2.4 纯虚函数与抽象类
在很多场景中,基类的虚函数无法给出有意义的实现,只能定义接口规范,这时可以将其声明为纯虚函数 。包含纯虚函数的类称为抽象类。
纯虚函数的语法
virtual 返回值类型 函数名(参数列表) = 0;
抽象类的特性
- 抽象类不能实例化对象,只能作为基类被继承;
- 派生类必须重写抽象类中的所有纯虚函数,否则派生类也会成为抽象类,无法实例化。
示例:将 Shape 改为抽象类
cs
// 抽象基类:图形
class Shape {
public:
// 纯虚函数:定义接口规范,无具体实现
virtual double getArea() const = 0;
virtual void draw() const = 0;
virtual ~Shape() {} // 虚析构函数
};
// 派生类必须重写所有纯虚函数才能实例化
class Circle : public Shape {
// ... 其他成员不变,重写getArea()和draw()
};
此时Shape无法创建对象(Shape s;会编译错误),但可以声明指针或引用指向派生类对象,实现多态。
2.5 多态的优势
- 提高代码可扩展性:新增派生类时,无需修改原有调用多态函数的代码,只需重写对应的虚函数即可,符合 "开闭原则"(对扩展开放,对修改关闭)。
- 统一接口:基类定义统一的接口规范,派生类提供不同实现,上层代码只需面向基类编程,无需关心具体派生类的细节。
- 简化代码逻辑 :避免大量的
if-else或switch分支判断,通过多态自动匹配对应的行为。