ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++语言原理与实践(十五):继承机制全解析

C++语言原理与实践(十五):继承机制全解析

本篇目标:
学会C++继承的使用

一.继承的概念及定义

1. 继承的概念

继承就是:把多个类中共同的成员放到一个基类中,派生类在复用这些成员的基础上,增加自己独有的成员。

以Student和Teacher为例:

类共同的成员独有的成员
Student姓名、地址、电话、年龄、身份认证函数学号、学习函数
Teacher姓名、地址、电话、年龄、身份认证函数职称、授课函数

没有继承时,两个类都要分别定义这些共同成员,造成代码重复。

例如:

#include <string> using namespace std; class Student { public: // 进入校园、图书馆、实验室时,刷二维码进行身份认证 void identity() { // ... } // 学习 void study() { // ... } protected: string _name = "peter"; // 姓名 string _address; // 地址 string _tel; // 电话 int _age = 18; // 年龄 int _stuid; // 学号 }; class Teacher { public: // 进入校园、图书馆、实验室时,刷二维码进行身份认证 void identity() { // ... } // 授课 void teaching() { // ... } protected: string _name = "张三"; // 姓名 int _age = 18; // 年龄 string _address; // 地址 string _tel; // 电话 string _title; // 职称 }; int main() { return 0; }

有了继承,就可以把共同成员放到Person类中:

#include <iostream> #include <string> using namespace std; class Person { public: // 进入校园、图书馆、实验室时,刷二维码进行身份认证 void identity() { cout << "void identity()" << _name << endl; } protected: string _name = "张三"; // 姓名 string _address; // 地址 string _tel; // 电话 int _age = 18; // 年龄 }; class Student : public Person { public: // 学习 void study() { // ... } protected: int _stuid; // 学号 }; class Teacher : public Person { public: // 授课 void teaching() { // ... } protected: string title; // 职称 };

这里,Person是基类(父类),Student和Teacher是派生类(子类)。它们通过继承获得基类的成员,再添加自己的成员。

注意:复用的是成员的定义,不是让所有对象共用同一份数据。每个学生、老师对象都有各自的姓名、年龄等数据,修改一个学生的姓名,不会影响其他对象。

2.继承的定义

举一个例子:下⾯我们看到Person是基类,也称作父类。Student是派生类,也称作子类。

3.访问方式

特点:

<1>. 基类的 private 成员不能被派生类直接访问

无论采用哪种继承方式,派生类都不能直接访问基类的private成员。对于私有数据成员,它们仍然存在于派生类对象的基类子对象中,只是受到访问权限的限制。

派生类可以通过基类提供的可访问接口,间接读取或修改这些私有数据。

<2>. protected 成员适合提供给派生类使用

如果某个基类成员不希望在类外被直接访问,但需要让派生类的成员函数访问,可以将其定义为protected。

因此,protected可以在隐藏内部实现的同时,为派生类提供必要的访问权限。

<3>. 成员权限与继承方式的关系

基类的private成员始终不能被派生类直接访问。对于基类的public和protected成员,其在派生类中的访问权限,取决于原有访问权限和继承方式中限制更严格的一方。

按照下面的权限顺序理解:

public > protected > private

例如,基类的public成员经过protected继承后,在派生类中成为protected成员。

<4>. class 与 struct 的默认继承方式不同

使用class定义派生类时,默认继承方式为private;使用struct时,默认继承方式为public。

为了让代码含义更加清晰,建议显式写出继承方式。

<5>. 实际开发中通常使用 public 继承

public继承通常用于表达“派生类是一种基类”的关系,并保留基类的公开接口,便于代码复用和扩展。

protected和private继承使用较少,主要用于特定的实现复用场景,并非不能使用。需要注意,protected继承后的受保护成员仍可被后续派生类访问;private继承后的私有成员则不能被后续派生类直接访问。

注意:在基类中的protected成员,在派生类中可以使用,但是在类外不可以使用

例如:

#include<iostream> using namespace std; class Person { public: void identity() { cout << _tel << endl; } //保护成员在子类与父类里才可以使用,在类外不可以使用 protected: string _name; private: string _tel; }; class Student :public Person { public: void printf_Person() { } private: int _stuid; }; int main() { Student s1; s1.identity(); s1.printf_Person(); return 0; }

解释:

① 为什么identity()可以访问_tel?

因为identity()是Person的成员函数,它可以访问Person自己的私有成员。

即使通过派生类对象调用:

s1.identity();

函数体仍然属于Person,因此其中对_tel的访问合法。调用者是派生类对象,不会改变函数本身所属的类。

② 为什么printf_Person()可以访问_name,却不能访问_tel?

因为它是Student的成员函数:

  • _name是基类的protected成员,派生类成员函数可以访问。
  • _tel是基类的private成员,派生类成员函数不能直接访问。

_tel仍然存在于s1的基类子对象中,不能直接访问,不代表它不存在。

③ 为什么在main()中连_name也不能直接访问?

因为main()是类外的普通函数,不能通过s1直接访问其受保护成员或私有成员,但可以调用公开接口。

另外,代码中的_tel没有赋予具体内容,默认是空字符串,所以identity()输出的是一个空行。

4. 继承类模板

#define Container std::vector<T> #include<iostream> #include<vector> using namespace std; template<class T> class stack:public Container { public: void push_back(const T& x) { Container::push_back(x); } void pop() { Container::pop_back(); } const T& top() { return Container::back(); } size_t size() { return Container::size(); } bool empty() { return Container::empty(); } }; int main() { stack<int> st; st.push_back(1); return 0; }

注意:如果是push_back(x),就有错误,基类是类模板时,需要指定⼀下类域,否则编译报错: error C3861 : “push_back”:找不到标识符,因为stack<int>实例化时,也实例化vector<int>了但是模版是按需实例化,push_back等成员函数未实例化,所以找不到 。

二.继承间的转换与作用域

1.继承间的转换

假设Student公开继承Person。

① 派生类对象可以交给基类指针或引用。

Student s; Person* p = &s; Person& r = s;

这里的p和r直接指向、引用s中的Person 基类部分,没有创建临时对象,因此不需要额外加const。

与下面的情况不同:

int a = 1; const double& d = a;

这里需要先把int转成一个临时的double对象,再让引用绑定它,所以需要使用const引用。

② 派生类对象可以赋值给基类对象,但只复制基类部分。

Student s; Person p = s; // 拷贝构造 Person p2; p2 = s; // 赋值

学生既有姓名、年龄,也有学号;但复制给Person时,只复制基类部分,学生自己新增的学号不会被复制过去。这叫作对象切片。

注意:原来的s仍然完整,切片不会删除它的成员。

③ 基类对象不能直接赋值给派生类对象。

Person p; Student s = p; // 错误

因为一个普通的Person对象中,没有Student完整的派生类部分。

④ 基类指针、引用转回派生类时,要确认对象的实际类型。

基类指针可能指向普通的Person,也可能指向Student。只有它确实指向对应派生类对象的基类部分时,转回去才安全。

如果基类含有虚函数,可以使用dynamic_cast检查。指针转换失败时会返回nullptr,比直接强制转换更稳妥(这个先了解)。

例如:

#include<iostream> using namespace std; class Person { private: string _name = "张三"; // 姓名 string _sex = "男"; // 性别 int _age = 20; // 年龄 }; class Student : public Person { private: int _No; // 学号 }; int main() { Student s; Person* ptr=&s; Person& ref=s; return 0; }

2.作用域

隐藏规则:

<1>.在继承体系中基类和派生类都有独立的作用域。

<2>. 派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。 (在派生类成员函数中,可以使用基类::基类成员显示访问)

<3>. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。

建议:在继承体系里面最好不要定义同名的成员

示例一:

#include <iostream> #include <string> using namespace std; class Person { protected: int _num = 111; // 身份证号 string _name = "小李子"; // 姓名 }; class Student : public Person { public: void Print() { cout << "姓名:" << _name << endl; cout << "身份证号:" << Person::_num << endl; cout << "学号:" << _num << endl; } protected: int _num = 999; // 学号 }; int main() { Student s1; s1.Print(); return 0; }

运行结果:

示例二:

#include <iostream> using namespace std; class A { public: void fun() { cout << "func()" << endl; } }; class B : public A { public: void fun(int i) { cout << "func(int i)" << i << endl; } }; int main() { B b; b.fun(10); b.fun(); return 0; }

我们要思考这两个func构成什么关系?程序的编译运行结果是什么?

答案:其实根据函数名字的相同就可以得知构成隐藏关系,而这个由B对象来调用无参的fun而不是自己内部有参的fun函数,那么必然会导致编译报错,我们要想调用A中的fun需要这么用:

b.A::fun();

三.派生类的默认成员函数

1.4个常见默认成员函数

<1>. 派生类构造函数负责初始化整个对象,基类部分交给基类构造函数处理

Student 对象包含两部分:

  • 基类部分:Person 中的 _name;
  • 派生类新增部分:Student 中的 _num。

因此,构造 Student 时,让 Person 初始化姓名,让 Student 初始化学号:

Student(const char* name, int num) : Person(name) , _num(num) { cout << "Student()" << endl; }

其中:

Person(name) // 调用基类构造函数,初始化基类部分 _num(num) // 初始化派生类新增的成员

如果没有写 Person(name),编译器会尝试调用 Person 的默认构造函数。如果基类没有可用的默认构造函数,就必须在初始化列表中显式指定调用哪个基类构造函数。

注意,你的基类构造函数:

Person(const char* name = "peter")

虽然带有参数,但所有参数都有默认值,因此可以不传参数调用,它也属于默认构造函数。

<2>. 派生类的拷贝构造,需要分别完成基类部分和派生类新增部分的拷贝

例如:

Student s2(s1);

不仅要复制 s1 的学号,还需要复制它的姓名:

Student(const Student& s) : Person(s) , _num(s._num) { cout << "Student(const Student& s)" << endl; }

这里:

Person(s) // 使用s中的Person部分,调用基类的拷贝构造函数 _num(s._num) // 复制学号

Person 的拷贝构造参数是 const Person&,而 s 是 const Student&,这里会绑定到 s 中的基类部分。

自己编写拷贝构造函数时,如果漏掉 Person(s),基类部分不会自动执行拷贝构造,而是尝试默认构造。在你的例子中,新对象的姓名会变成默认的 "peter"。

如果使用编译器生成的拷贝构造函数,则会自动拷贝基类部分和各个成员。

<3>. 派生类的赋值运算符,需要处理基类部分的赋值

例如:

s1 = s3;

两个对象都已经存在,因此执行的是赋值,而不是构造:

Student& operator=(const Student& s) { cout << "Student& operator=(const Student& s)" << endl; if(this != &s) { Person::operator=(s); // 给基类部分赋值 _num = s._num; // 给派生类新增成员赋值 } return *this; }

这里需要使用:

Person::operator=(s);

显式指定调用基类版本。因为派生类的 operator= 隐藏了基类的同名函数,直接写:

operator=(s);

会再次调用 Student 自己的赋值运算符,造成无限递归。

自己编写赋值运算符时,基类部分需要自己处理;使用编译器生成的版本时,则会自动对基类部分和成员进行赋值。

<4>. 基类析构函数会被自动调用,不需要手动调用

你的派生类析构函数只需要写:

~Student() { cout << "~Student()" << endl; }

执行完函数体后,会先销毁 Student 自己的成员,再自动调用 Person 的析构函数。

所以,即使没有显式写:

Person::~Person();

基类部分也会正常清理。这与自己编写拷贝构造、赋值运算符时的处理方式不同。

<5>. 构造顺序:先基类,再派生类

执行:

Student s1("jack", 18);

主要过程是:

  1. 调用 Person(name),完成基类部分的构造;
  2. 初始化 Student 自己的 _num;
  3. 执行 Student 构造函数体。

因此输出:Person() Student()

这个顺序由语言规则决定,不会因为调整初始化列表的书写顺序而改变。

<6>. 析构顺序:先派生类,再基类

销毁一个 Student 对象时,输出为:~Student() ~Person()

可以理解为:构造时先把基类部分搭好,再构造派生类部分;销毁时按照相反的顺序清理。

另外,你的 main() 中依次创建了:

Student s1("jack", 18); Student s2(s1); Student s3("rose", 17);

离开作用域时,对象按照创建顺序的相反顺序销毁,即s3、s2、s1,每个对象都先执行派生类析构,再执行基类析构。

2.final关键字

方法1:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构成函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。

例如:

#include <iostream> using namespace std; class A { private: A(int num=20) :_num(num) { } private: int _num; }; class B :public A { public: B(int num,int a) :A(num), _a(a) { } private: int _a; }; int main() { B b(100,20); return 0; }

运行结果:

方法2:C++11新增了⼀个final关键字,final修改基类,派⽣类就不能继承了

例如:

#include <iostream> using namespace std; class A final { public: A(int num = 20) :_num(num) {} private: int _num; }; class B :public A { public: B(int num, int a) :A(num), _a(a) {} private: int _a; }; int main() { B b(100, 20); return 0; }

运行结果:

四.友元与静态成员

1.继承与友元

特点:友元关系不能继承,也就是说基类友元不能访问派生类私有和保护成员

例如:

#include<iostream> using namespace std; //编译器只会从上往下找,有了前置声明后,就会往下找 class B; class A { public: //如果没有class B这个前置声明,那么就会导致语法错误: 缺少“,”(在“&”的前面) friend void Display(const A& a, const B& b); A(int age = 19) :_age(age) {} private: int _age; }; class B :public A { public: B(string name = "张三") :_name(name) {} protected: string _name; }; void Display(const A& a, const B& b) { cout << a._age << endl; cout << b.name<< endl; } int main() { A a(20); B b; Display(a, b); return 0; }

运行结果:

2.继承与静态成员

特点:基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个派生类,都只有⼀个static成员实例。

例如:

#include <iostream> #include <string> using namespace std; class A { public: // 静态成员变量:整个继承体系中只有一份实例 static int _count; A(int age = 19) : _age(age) {} int _age; }; // 静态成员变量必须在类外进行初始化 int A::_count = 1; class B : public A { public: B(string name = "张三") : _name(name) {} private: string _name; }; int main() { B b("李四"); A a; // 1. 测试静态成员 (Shared) cout << "a._count: " << a._count << endl; cout << "b._count: " << b._count << endl; // 修改 a 的静态成员,b 也会跟着变 a._count = 100; cout << "修改后 b._count: " << b._count << endl; // 2. 测试普通成员 (Independent) cout << "a._age: " << a._age << endl; cout << "b._age: " << b._age << endl; return 0; }

运行结果:

五.多继承及其菱形继承问题

1.继承模型

特点:

<1>.继承:⼀个派生类只有⼀个直接基类时称这个继承关系为单继承。

<2>.多继承:⼀个派生类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派生类成员在放到最后面。

例如:

#include <iostream> #include <string> using namespace std; class Person { public: string _name; // 姓名 }; class Student : public Person { protected: int _num; //学号 }; class Teacher : public Person { protected: int _id; // 职工编号 }; int main() { return 0; }

<3>.菱形继承:菱形继承是多继承的⼀种特殊情况。菱形继承的问题,从下面例子中的对象成员模型构造,可以 看出菱形继承有数据冗余和二义性的问题,在Assistant的对象中Person成员会有两份。支持多继承就 ⼀定会有菱形继承,像Java就直接不支持多继承,规避掉了这里的问题,所以实践中我们也是不建议设计出菱形继承这样的模型的。

例如:

#include <iostream> #include <string> using namespace std; 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; // 主修课程 };

那么如果我们直接这样子使用:

Assistant a; a._name = "peter";

是会直接报错的,报错结果如下:

因为这个Assistant是菱形继承,导致里面既有Student中的_name字符串,又有Teacher中的_name字符串,编译器无法确定我们要访问哪一份_name,于是产生了二义性。这里并不是访问权限不够,而是存在两个可以访问的同名成员,无法确定选择哪一个。所以我们想要正确的赋值,需要指明是那个基类中的_name,代码如下:

a.Student::_name = "xxx"; a.Teacher::_name = "yyy";

显式指定作用域可以解决访问二义性,但对象中仍然保留着两份Person。如果希望两条继承路径共享同一份Person基类子对象,就需要引入虚继承。

2.虚继承

特点:很多人说C++语法复杂,其实多继承就是⼀个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂,性能也会有⼀些损失,所以最好不要设计出菱形继承。多继承可以认为是C++的缺陷之⼀,后来的⼀些编程语都没有多继承,如Java。

建议:我们可以设计出多继承,但是不建议设计出菱形继承,因为菱形虚拟继承以后,无论是使用还是底层都会复杂很多。当然有多继承语法支持,就⼀定存在会设计出菱形继承,像Java是不支持多继承的, 就避开了菱形继承。

在继承列表中,virtual表示虚继承,主要用来解决菱形继承中的基类数据冗余和访问二义性。

结合你刚才的例子:

  • 普通继承:Assistant中有两份Person,因此有两份_name。
  • 虚继承:Student和Teacher共享同一份Person基类子对象,因此Assistant中只有一份_name。

需要修改的是这两条继承关系:

class Student : virtual public Person { protected: int _num; // 学号 }; class Teacher : virtual public Person { protected: int _id; // 职工编号 };

Assistant的继承方式保持不变:

class Assistant : public Student, public Teacher { protected: string _majorCourse; // 主修课程 };

此时就可以直接访问:

Assistant a; a._name = "peter"; // 正确:只有一份_name,不再存在二义性

即使分别通过两条路径访问,操作的也是同一个成员:

a.Student::_name = "peter"; cout << a.Teacher::_name << endl; // peter

注意位置:virtual要加在Student、Teacher继承Person的地方,使Person成为共享的虚基类。Student自己的学号和Teacher自己的职工编号仍然分别保留。

3.指针偏移

假如面试有这么一道题:

class Base1 { public: int _b1; }; class Base2 { public: int _b2; }; class Derive : public Base1, public Base2 { public: int _d; }; int main() { Derive d; Base1* p1 = &d; Base2* p2 = &d; Derive* p3 = &d; return 0; }

问题:

下⾯说法正确的是() A:p1==p2==p3 B:p1<p2<p3 C:p1==p3!=p2 D:p1!=p2!=p3

相信有不少的人会选A这个选项,但是正确答案是C选项,解释如下:

基类在继承列表中的先后顺序,会影响本题中的存储顺序

这里写的是:

class Derive : public Base1, public Base2

Base1在前,Base2在后。在本题采用的常见布局下,对象内部从低地址到高地址依次为:

存储顺序对象中的内容
第一部分Base1子对象,即_b1
第二部分Base2子对象,即_b2
第三部分Derive自己的成员_d
Derive* p3 = &d;

p3指向整个Derive对象,保存整个对象的起始地址。

Base1* p1 = &d;

p1指向d中的Base1子对象。由于Base1位于第一部分,其起始地址与整个对象的起始地址相同,所以p1和p3保存的地址相同。

Base2* p2 = &d;

p2指向d中的Base2子对象。由于它位于Base1后面,编译器需要调整地址,让p2越过前面的Base1部分,指向Base2部分。

指向如图所示:

六.继承和组合

1. public 继承表示 is-a,也就是是一种的关系。

例如,学生是一种人,所以 Student 可以 public 继承 Person。派生类对象中包含基类部分,可以通过基类的指针或引用访问这一部分。因此,在需要 Person 的地方,通常也可以使用 Student。

class Person { public: void eat() { cout << "吃饭" << endl; } }; class Student : public Person { public: void study() { cout << "学习" << endl; } };

2. 组合表示 has-a,也就是拥有的关系。

例如,汽车拥有发动机,所以可以把 Engine 对象作为 Car 的成员。汽车通过调用发动机的接口完成相应功能,从而复用发动机的代码。

class Engine { public: void start() { cout << "发动机启动" << endl; } }; class Car { public: void run() { _engine.start(); cout << "汽车行驶" << endl; } private: Engine _engine; };

3. 继承属于白箱复用,派生类容易依赖基类的内部实现,因此耦合度较高。

白箱是指派生类除了使用基类提供的公开接口,还可以直接访问基类的 protected 成员。例如:

class Person { protected: string _name; }; class Student : public Person { public: void introduce() { cout << _name << endl; } };

这里 Student 直接使用了基类内部的 _name。如果 Person 将 _name 改名、改成 private,或者改变姓名的存储方式,Student 的代码也可能需要修改。

这就是继承在一定程度上削弱封装的含义:基类的部分内部细节暴露给了派生类,基类难以独立修改。当然,基类的 private 成员仍然不能被派生类直接访问。

耦合度高,则表示双方联系紧密,一方改变容易牵动另一方。所以准确的说法是:继承可能削弱封装,增加基类与派生类之间的耦合。

4. 组合属于黑箱复用,通过接口使用对象,通常耦合度较低。

例如,Car 内部拥有一个 Engine 对象,行驶前调用:

_engine.start();

Car 只需要知道 start() 能启动发动机,无须了解发动机内部有哪些成员、执行哪些步骤。

只要 start() 的接口及其行为约定保持稳定,发动机内部实现发生变化,Car 通常不需要修改。内部变化被限制在 Engine 内部,这有助于保持封装,也减少了修改对其他类的影响。

不过,组合仍然存在依赖。如果 Engine 删除了 start(),Car 同样需要修改,所以组合是通常耦合较低,并非完全没有耦合。

5. 选择时优先考虑组合,但要以实际关系为依据。

如果只是想借助另一个类的功能,通常使用组合。例如,栈可以组合 vector 存储数据,而不必继承 vector,对外只提供栈需要的操作。

如果两个类确实具有“是一种”的关系,并且派生类能够合理地作为基类使用,就适合 public 继承。例如,学生和人、猫和动物。

总结:

继承是 C++ 实现代码复用的重要方式,派生类可以在基类的基础上增加自己的成员,但必须遵守访问权限、同名隐藏和类型转换规则。派生类对象构造时先构造基类部分,析构时按相反顺序清理;自己编写拷贝构造和赋值运算符时,也要正确处理基类部分。

多继承中,基类指针指向对象中对应的基类子对象,转换时可能发生地址调整;菱形继承可能带来数据冗余和访问二义性,可以通过虚继承共享同一份基类子对象。

设计类时,“是一种”关系适合 public 继承,“拥有”关系适合组合。如果只是为了复用功能,通常优先考虑组合,以减少对内部实现的依赖,让代码更容易维护。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进