云计算百科
云计算领域专业知识百科平台

C++ 继承进阶:默认成员函数、多继承、虚继承与组合设计

C++ 继承进阶:默认成员函数、多继承、虚继承与组合设计

头像

🔥 星恒随风:
个人主页

❄️ 个人专栏:
《指针合集》

《C语言基础》

《数据结构》

《机器学习导论》

《前端基础》

《python基础》

《C++从入门到入土》

✨ 数据即知识,压缩即智能


文章目录

  • C++ 继承进阶:默认成员函数、多继承、虚继承与组合设计
    • 前言
    • 一、派生类对象由哪些部分组成?
      • 1.1 基类子对象和派生类成员
      • 1.2 对象初始化不是简单地执行构造函数体
    • 二、派生类构造函数如何初始化基类?
      • 2.1 基类有默认构造函数
      • 2.2 基类没有默认构造函数
    • 三、派生类的拷贝构造
      • 3.1 编译器默认生成时会处理基类部分
      • 3.2 手动编写时要显式拷贝基类
    • 四、派生类的赋值运算符
      • 4.1 赋值也要处理两部分
      • 4.2 可以优先使用 = default
    • 五、构造和析构顺序
      • 5.1 构造顺序
      • 5.2 析构顺序
    • 六、基类析构函数什么时候要加 virtual?
      • 6.1 通过基类指针删除派生对象
      • 6.2 哪些基类需要虚析构?
    • 七、如何禁止一个类被继承?
      • 7.1 使用 final
      • 7.2 私有构造函数的旧思路
    • 八、友元关系不会沿继承链传播
      • 8.1 基类友元只能访问基类
      • 8.2 友元也不具有传递性
    • 九、静态成员在继承体系中只有一份
      • 9.1 普通成员每个对象各有一份
      • 9.2 static 成员属于类
    • 十、多继承是什么?
      • 10.1 单继承
      • 10.2 多继承
      • 10.3 多继承中的同名成员二义性
    • 十一、多继承中的指针调整
      • 11.1 不同基类指针可能具有不同地址值
      • 11.2 不要依赖具体布局
    • 十二、菱形继承为什么麻烦?
      • 12.1 菱形结构
      • 12.2 数据冗余
      • 12.3 访问二义性
    • 十三、虚继承如何解决菱形问题?
      • 13.1 声明虚继承
      • 13.2 virtual 写在什么位置?
    • 十四、虚基类由谁负责构造?
      • 14.1 普通继承中的基类构造
      • 14.2 虚继承中的最派生类规则
      • 14.3 单独构造中间类时仍由中间类负责
    • 十五、标准库中的虚继承例子
    • 十六、继承和组合如何选择?
      • 16.1 继承表达 is-a
      • 16.2 组合表达 has-a
      • 16.3 白盒复用和黑盒复用
      • 16.4 优先组合,但不是拒绝继承
      • 16.5 一个简单判断流程
    • 十七、常见错误整理
      • 17.1 在构造函数体中“调用”基类构造
      • 17.2 手动写拷贝构造却忘记基类部分
      • 17.3 派生类赋值时只复制自己的成员
      • 17.4 多态基类没有虚析构函数
      • 17.5 把友元理解成可继承权限
      • 17.6 认为每个对象都有一份 static 成员
      • 17.7 用类域解决菱形继承后认为问题全部消失
      • 17.8 依赖多继承对象的具体内存排列
      • 17.9 为了复用实现随意使用 public 继承
    • 总结

前言

掌握继承的基本语法后,接下来会遇到一些更容易出错的问题:

  • 派生类构造时,基类部分由谁初始化?
  • 拷贝构造和赋值运算符是否需要处理基类?
  • 为什么构造顺序是从基类到派生类,析构顺序却正好相反?
  • 基类析构函数什么时候必须是虚函数?
  • 友元关系和静态成员会不会被继承?
  • 多继承为什么会产生二义性?
  • 菱形继承为什么会造成数据冗余?
  • 虚继承如何让多个路径共享同一个基类子对象?
  • 继承和组合到底应该如何选择?

这些内容看起来比较分散,实际上都围绕一个核心问题:

一个派生类对象中,不仅有派生类自己的成员,还包含基类子对象。

接下来我们就围绕着这点展开


一、派生类对象由哪些部分组成?

1.1 基类子对象和派生类成员

假设有下面两个类:

#include <string>
using namespace std;

class Person
{
protected:
string _name;
int _age = 0;
};

class Student : public Person
{
protected:
int _studentId = 0;
};

一个 Student 对象可以抽象成:

Student 对象
├── Person 基类子对象
│ ├── _name
│ └── _age
└── Student 自己的成员
└── _studentId

这里的 Person 不是独立于 Student 的另一个对象,而是 Student 对象内部的一个基类子对象。

因此,构造 Student 时,需要先让 Person 部分进入有效状态,再初始化学生自己的成员。 在这里插入图片描述

1.2 对象初始化不是简单地执行构造函数体

构造对象时,真正的顺序大致是:

虚基类
→ 直接非虚基类
→ 成员变量
→ 派生类构造函数体

其中:

  • 基类按照继承列表中的声明顺序初始化
  • 成员变量按照它们在类中的声明顺序初始化
  • 和初始化列表中的书写先后无关

因此不要故意把初始化列表写成与声明顺序不同的样子,虽然程序可能仍能编译,但阅读起来很容易产生误解。


二、派生类构造函数如何初始化基类?

2.1 基类有默认构造函数

#include <iostream>
#include <string>
using namespace std;

class Person
{
public:
Person()
{
cout << "Person()" << endl;
}

protected:
string _name;
};

class Student : public Person
{
public:
Student()
{
cout << "Student()" << endl;
}

private:
int _studentId = 0;
};

创建对象:

Student student;

输出顺序是:

Person()
Student()

编译器会先构造基类子对象,再执行派生类构造函数。

2.2 基类没有默认构造函数

假设基类只能通过姓名构造:

class Person
{
public:
explicit Person(const string& name)
: _name(name)
{
cout << "Person(const string&)" << endl;
}

protected:
string _name;
};

派生类必须在初始化列表中明确调用基类构造函数:

class Student : public Person
{
public:
Student(const string& name, int studentId)
: Person(name)
, _studentId(studentId)
{
cout << "Student(const string&, int)" << endl;
}

private:
int _studentId;
};

不能写成:

Student(const string& name, int studentId)
{
Person(name);
}

因为构造函数体开始执行时,基类子对象早就应该完成初始化了。

函数体中的:

Person(name);

只会创建一个临时的 Person 对象,并不能重新初始化当前对象中的基类部分。


三、派生类的拷贝构造

3.1 编译器默认生成时会处理基类部分

class Person
{
public:
Person(const Person& other)
: _name(other._name)
{
}

protected:
string _name;
};

class Student : public Person
{
private:
int _studentId = 0;
};

如果没有手动编写 Student 的拷贝构造,编译器生成的版本会依次拷贝:

Person 基类子对象
Student 自己的成员

3.2 手动编写时要显式拷贝基类

class Student : public Person
{
public:
Student(const string& name, int studentId)
: Person(name)
, _studentId(studentId)
{
}

Student(const Student& other)
: Person(other)
, _studentId(other._studentId)
{
}

private:
int _studentId;
};

其中:

Person(other)

负责调用基类的拷贝构造,复制基类部分。

如果忘记写这一项,编译器会尝试默认构造基类,而不是自动猜测你想调用基类拷贝构造。

如果基类没有默认构造函数,就会直接编译失败。


四、派生类的赋值运算符

4.1 赋值也要处理两部分

派生类赋值时,需要复制:

基类部分
派生类自己的部分

可以这样实现:

class Student : public Person
{
public:
Student(const string& name, int studentId)
: Person(name)
, _studentId(studentId)
{
}

Student& operator=(const Student& other)
{
if (this != &other)
{
Person::operator=(other);
_studentId = other._studentId;
}

return *this;
}

private:
int _studentId;
};

这里需要明确写:

Person::operator=(other);

原因是派生类自己的 operator= 会隐藏基类中的同名函数。

如果只写:

operator=(other);

很可能再次调用当前派生类的赋值函数,形成错误递归。 在这里插入图片描述

4.2 可以优先使用 = default

如果成员本身都支持正确的拷贝和赋值,通常不需要手动写这些函数。

class Student : public Person
{
public:
Student(const Student&) = default;
Student& operator=(const Student&) = default;

private:
int _studentId = 0;
};

编译器生成的版本会自动处理基类子对象和成员变量。

只有在类需要特殊资源管理、日志或额外约束时,才有必要手动实现。


五、构造和析构顺序

5.1 构造顺序

假设:

class Base
{
public:
Base()
{
cout << "Base()" << endl;
}
};

class Member
{
public:
Member()
{
cout << "Member()" << endl;
}
};

class Derived : public Base
{
public:
Derived()
{
cout << "Derived()" << endl;
}

private:
Member _member;
};

创建:

Derived object;

输出:

Base()
Member()
Derived()

构造顺序为:

基类
→ 成员对象
→ 派生类构造函数体

在这里插入图片描述

5.2 析构顺序

对象销毁时顺序反过来:

派生类析构函数体
→ 成员对象
→ 基类

如果加入析构输出,结果大致是:

~Derived()
~Member()
~Base()

为什么析构要反过来?

因为派生类可能还依赖基类提供的状态。只有先清理最外层的派生类,再逐层清理它依赖的内部部分,才能保持对象生命周期的完整性。

可以把它理解成搭积木:

搭建时:先底座,再上层
拆除时:先上层,再底座


六、基类析构函数什么时候要加 virtual?

6.1 通过基类指针删除派生对象

看下面的代码:

class Base
{
public:
~Base()
{
cout << "~Base()" << endl;
}
};

class Derived : public Base
{
public:
~Derived()
{
cout << "~Derived()" << endl;
}
};

如果执行:

Base* ptr = new Derived;
delete ptr;

基类析构函数不是虚函数时,通过基类指针删除派生对象会产生未定义行为。

正确设计通常是:

class Base
{
public:
virtual ~Base() = default;
};

此时:

Base* ptr = new Derived;
delete ptr;

会先调用:

Derived::~Derived()

再调用:

Base::~Base()

在这里插入图片描述

6.2 哪些基类需要虚析构?

如果一个类满足下面的情况:

它具有虚函数
它会被当作多态基类使用
外部可能通过 Base* 删除派生对象

那么它通常应该具有公有虚析构函数:

virtual ~Base() = default;

如果明确禁止通过基类指针删除对象,也可以把析构函数设计成保护的非虚函数,但这种设计需要更明确的约束。


七、如何禁止一个类被继承?

7.1 使用 final

C++11 提供了 final:

class Utility final
{
public:
void run()
{
}
};

下面的代码会编译失败:

class Derived : public Utility
{
};

final 明确表示:

当前类不允许继续派生。

它比通过特殊构造函数权限绕开继承更加直观。

7.2 私有构造函数的旧思路

在旧式设计中,也可以把基类构造函数设为私有:

class Base
{
private:
Base() = default;
};

派生类无法调用基类构造函数,因此通常无法正常构造。

不过这种写法表达意图不够直接,也会影响基类本身的创建方式。现代 C++ 中,禁止继承优先使用 final。


八、友元关系不会沿继承链传播

8.1 基类友元只能访问基类

class Student;

class Person
{
friend void display(const Person&, const Student&);

protected:
string _name;
};

class Student : public Person
{
protected:
int _studentId = 0;
};

函数:

void display(const Person& person, const Student& student)
{
cout << person._name << endl;

// cout << student._studentId << endl; // 错误
}

虽然 display 是 Person 的友元,但它不是 Student 的友元。

友元关系不会自动继承。

如果确实需要访问学生的非公有成员,需要在 Student 中再次声明:

class Student : public Person
{
friend void display(const Person&, const Student&);

protected:
int _studentId = 0;
};

8.2 友元也不具有传递性

即使:

A 是 B 的友元
B 是 C 的友元

也不能推出:

A 是 C 的友元

友元是一种由类明确授予的访问权限,不应当沿关系链自动扩散。


九、静态成员在继承体系中只有一份

9.1 普通成员每个对象各有一份

class Person
{
public:
string _name;
};

class Student : public Person
{
};

创建:

Person person;
Student student;

person._name 和 student._name 属于不同对象,地址也不同。 在这里插入图片描述

9.2 static 成员属于类

class Person
{
public:
static int _count;
};

int Person::_count = 0;

class Student : public Person
{
};

无论通过哪个类名访问:

Person::_count = 10;

cout << Person::_count << endl;
cout << Student::_count << endl;

访问的都是同一个静态成员。

也可以通过对象访问:

Person person;
Student student;

cout << &person._count << endl;
cout << &student._count << endl;

两个地址相同。

静态成员不属于某个具体对象,而属于整个类及其继承体系所引用的那一个声明。


十、多继承是什么?

10.1 单继承

一个派生类只有一个直接基类:

class Student : public Person
{
};

关系为:

Person

Student

10.2 多继承

一个派生类同时拥有两个或更多直接基类:

class Scanner
{
public:
void scan()
{
}
};

class Printer
{
public:
void print()
{
}
};

class AllInOneMachine : public Scanner, public Printer
{
};

此时:

Scanner Printer
\\ /
\\ /
AllInOneMachine

对象可以同时使用两个基类提供的接口:

AllInOneMachine machine;

machine.scan();
machine.print();

10.3 多继承中的同名成员二义性

如果两个基类都有同名函数:

class Scanner
{
public:
void start()
{
cout << "Scanner::start()" << endl;
}
};

class Printer
{
public:
void start()
{
cout << "Printer::start()" << endl;
}
};

class AllInOneMachine : public Scanner, public Printer
{
};

调用:

AllInOneMachine machine;

// machine.start(); // 错误:不知道调用哪个 start

需要明确指定路径:

machine.Scanner::start();
machine.Printer::start();

多继承会让名字查找、构造过程和对象转换变得更加复杂,因此不宜为了简单复用几个函数就随意使用。


十一、多继承中的指针调整

11.1 不同基类指针可能具有不同地址值

class Base1
{
public:
int _value1 = 1;
};

class Base2
{
public:
int _value2 = 2;
};

class Derived : public Base1, public Base2
{
public:
int _value3 = 3;
};

执行:

Derived object;

Base1* ptr1 = &object;
Base2* ptr2 = &object;
Derived* ptr3 = &object;

编译器会让每个基类指针指向它对应的基类子对象。

因此,从 Derived* 转换为某个基类指针时,编译器可能需要调整地址。

可以抽象理解为:

Derived 对象
├── Base1 子对象 ← Base1*
├── Base2 子对象 ← Base2*
└── Derived 成员

11.2 不要依赖具体布局

某些编译器中,第一个非虚基类子对象可能位于对象起始位置,第二个基类会产生偏移。

但具体对象布局和偏移属于实现相关细节,不能把某个编译器观察到的地址关系当成通用语言规则。

应当依赖 C++ 的类型转换,让编译器完成正确的指针调整,而不是手动计算地址。


十二、菱形继承为什么麻烦?

12.1 菱形结构

考虑下面的类层次:

class Person
{
public:
string _name;
};

class Student : public Person
{
protected:
int _studentId = 0;
};

class Teacher : public Person
{
protected:
int _teacherId = 0;
};

class Assistant : public Student, public Teacher
{
};

结构为:

Person
/ \\
Student Teacher
\\ /
Assistant

形状像一个菱形,因此称为菱形继承。 在这里插入图片描述

12.2 数据冗余

Student 中有一份 Person 子对象。

Teacher 中也有一份 Person 子对象。

因此 Assistant 中会出现两份普通的 Person 子对象:

Assistant
├── Student
│ └── Person
│ └── _name
└── Teacher
└── Person
└── _name

一个助教对象中出现两个姓名,通常不是我们真正想表达的模型。

12.3 访问二义性

Assistant assistant;

// assistant._name = "小王"; // 错误:访问路径不明确

编译器不知道要访问:

assistant.Student::_name

还是:

assistant.Teacher::_name

可以通过指定类域解决访问二义性:

assistant.Student::_name = "学生路径";
assistant.Teacher::_name = "教师路径";

但两份数据仍然存在,所以数据冗余没有消失。


十三、虚继承如何解决菱形问题?

13.1 声明虚继承

让中间层虚继承 Person:

class Student : virtual public Person
{
protected:
int _studentId = 0;
};

class Teacher : virtual public Person
{
protected:
int _teacherId = 0;
};

class Assistant : public Student, public Teacher
{
};

此时,最派生的 Assistant 对象中只保留一份共享的 Person 虚基类子对象:

Person
↗ ↖
Student Teacher
↘ ↙
Assistant

对象可以抽象成:

Assistant
├── Student 部分
├── Teacher 部分
├── Assistant 自己的成员
└── 共享的 Person 虚基类子对象

于是:

Assistant assistant;
assistant._name = "小王";

不再有两份普通 Person 子对象造成的二义性。 在这里插入图片描述

13.2 virtual 写在什么位置?

下面两种写法等价:

class Student : virtual public Person
{
};

class Student : public virtual Person
{
};

一般使用第一种或团队统一的风格即可。


十四、虚基类由谁负责构造?

这是虚继承中最容易忽略的规则。

14.1 普通继承中的基类构造

普通继承中,直接派生类负责构造自己的直接基类:

class Student : public Person
{
public:
Student(const string& name)
: Person(name)
{
}
};

在这里插入图片描述

14.2 虚继承中的最派生类规则

虚基类由最派生类负责初始化。

class Person
{
public:
explicit Person(const string& name)
: _name(name)
{
}

protected:
string _name;
};

class Student : virtual public Person
{
public:
Student(const string& name, int studentId)
: Person(name)
, _studentId(studentId)
{
}

private:
int _studentId;
};

class Teacher : virtual public Person
{
public:
Teacher(const string& name, int teacherId)
: Person(name)
, _teacherId(teacherId)
{
}

private:
int _teacherId;
};

最终派生类:

class Assistant : public Student, public Teacher
{
public:
Assistant(const string& name)
: Person(name)
, Student("Student 路径", 1001)
, Teacher("Teacher 路径", 2001)
{
}
};

创建:

Assistant assistant("最终姓名");

共享的虚基类 Person 使用的是:

Person(name)

也就是 Assistant 初始化列表中的参数。

Student 和 Teacher 初始化列表中对 Person 的构造,在创建最派生的 Assistant 对象时不会负责最终虚基类的初始化。

14.3 单独构造中间类时仍由中间类负责

如果直接创建:

Student student("学生姓名", 1001);

此时 Student 本身就是最派生类,所以它负责初始化虚基类 Person。

“最派生类”不是固定指某个类名,而是指这一次实际创建对象时最外层的完整类型。


十五、标准库中的虚继承例子

C++ 输入输出流体系是虚继承的经典例子。

可以简化理解为:

basic_ios
/ \\
basic_istream basic_ostream
\\ /
basic_iostream

输入流和输出流都需要共享基础的流状态。

如果采用普通菱形继承,basic_iostream 中会出现两份基础流状态。使用虚继承后,最派生流对象只保留一份共享的 basic_ios 虚基类子对象。

这说明虚继承并非完全不能使用。

但它适合真正存在共享基类语义的场景,而不是为了展示语法而主动设计复杂菱形结构。


十六、继承和组合如何选择?

16.1 继承表达 is-a

公有继承通常表达:

Derived is a Base

例如:

Student is a Person
Circle is a Shape
Dog is an Animal

派生类应当能够在需要基类的地方自然替代基类。 在这里插入图片描述

16.2 组合表达 has-a

组合表示一个类拥有另一个类对象:

class Tire
{
private:
string _brand = "Michelin";
};

class Car
{
private:
Tire _frontLeft;
Tire _frontRight;
Tire _rearLeft;
Tire _rearRight;
};

关系为:

Car has Tires
汽车拥有轮胎

汽车不是一种轮胎,因此不应该写:

class Car : public Tire
{
};

16.3 白盒复用和黑盒复用

继承有时被称为白盒复用。

派生类能够看到基类的公有和保护成员,容易依赖基类内部实现。基类一旦发生改变,派生类可能受到较大影响。

组合通常被称为黑盒复用。

组合类主要通过另一个对象的公有接口使用它,不需要了解其内部细节,耦合度通常更低。

16.4 优先组合,但不是拒绝继承

“优先组合而不是继承”不是说继承不能使用。

更准确的理解是:

只有确实存在稳定的 is-a 关系时,才使用 public 继承。
只是为了复用实现时,优先考虑组合。

例如,栈内部使用 vector:

template<class T>
class Stack
{
public:
void push(const T& value)
{
_data.push_back(value);
}

void pop()
{
_data.pop_back();
}

T& top()
{
return _data.back();
}

private:
vector<T> _data;
};

这里:

Stack has a vector

比:

Stack is a vector

更符合实际语义。

16.5 一个简单判断流程

设计类关系时,可以依次问:

1. 派生类能否自然替代基类?
能:考虑 public 继承

2. 当前类只是需要另一个类提供能力?
是:考虑组合

3. 只是为了复用几段实现代码?
是:优先组合或普通成员函数复用

4. 是否需要运行时多态?
是:通常需要基类接口和 public 继承


十七、常见错误整理

17.1 在构造函数体中“调用”基类构造

错误:

Student(const string& name)
{
Person(name);
}

正确:

Student(const string& name)
: Person(name)
{
}

17.2 手动写拷贝构造却忘记基类部分

错误:

Student(const Student& other)
: _studentId(other._studentId)
{
}

正确:

Student(const Student& other)
: Person(other)
, _studentId(other._studentId)
{
}

17.3 派生类赋值时只复制自己的成员

错误:

Student& operator=(const Student& other)
{
_studentId = other._studentId;
return *this;
}

正确:

Student& operator=(const Student& other)
{
if (this != &other)
{
Person::operator=(other);
_studentId = other._studentId;
}

return *this;
}

17.4 多态基类没有虚析构函数

如果会通过基类指针删除派生对象,应当使用:

virtual ~Base() = default;

17.5 把友元理解成可继承权限

基类的友元不是派生类的友元。

每个类都需要单独授予友元权限。

17.6 认为每个对象都有一份 static 成员

静态成员属于类,不属于具体对象。继承体系中引用的是同一个基类静态成员声明。

17.7 用类域解决菱形继承后认为问题全部消失

指定:

assistant.Student::_name

只能解决访问路径二义性。

普通菱形继承中的两份 Person 数据仍然存在。

17.8 依赖多继承对象的具体内存排列

不同编译器和 ABI 可能采用不同布局。

正确做法是通过语言提供的指针转换访问基类子对象,而不是手工计算偏移。

17.9 为了复用实现随意使用 public 继承

public 继承会建立公开的类型替代关系。

如果只需要另一个类提供的功能,组合通常更合适。


总结

继承进阶部分的规则虽然多,但都可以从“派生类对象包含基类子对象”这一点出发理解。

需要重点掌握:

  • 构造派生类时,必须先初始化基类子对象
  • 手动实现拷贝构造和赋值时,要同时处理基类部分
  • 构造顺序是基类到派生类,析构顺序相反
  • 通过基类指针删除派生对象时,基类通常需要虚析构函数
  • final 可以明确禁止一个类继续被继承
  • 友元关系不会继承,静态成员在继承体系中共享
  • 多继承可能产生名字二义性和指针调整
  • 普通菱形继承会产生基类数据冗余
  • 虚继承让多个路径共享一个虚基类子对象
  • 虚基类由最派生类负责初始化
  • public 继承表达 is-a,组合表达 has-a
  • 仅为了复用实现时,通常优先考虑组合
  • 继承本身不是越多越好。

    一个清晰、稳定的类层次可以减少重复并支持多态;一个勉强拼出来的继承体系,则会让访问权限、对象生命周期和依赖关系越来越复杂。

    设计继承关系时,先判断类型语义,再考虑代码复用,通常会更加稳妥。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » C++ 继承进阶:默认成员函数、多继承、虚继承与组合设计
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!