【C++进阶】:(1)继承机制详解

前言

在学习完 C++ 的类和对象之后,这节课开始接触一个非常重要的内容------继承

刚开始看到继承时,我觉得它的语法并不复杂,无非就是:

cpp 复制代码
class Student : public Person
{
};

但真正学下来以后会发现,继承不仅仅是"让一个类使用另一个类的成员"这么简单,里面还涉及访问权限、对象转换、同名成员隐藏、派生类默认成员函数、多继承、菱形继承、虚继承等很多内容。

所以这篇博客主要对本节课中关于 C++ 继承 的知识进行一次系统整理。


一、继承的基本概念与访问权限

先来看一个比较简单的场景。

假设我们需要定义学生和老师两个类:

cpp 复制代码
class Student
{
private:
    string _name;
    int _age;
    int _stuId;
};
cpp 复制代码
class Teacher
{
private:
    string _name;
    int _age;
    string _job;
};

可以发现:

cpp 复制代码
_name
_age

这两个成员在 Student 和 Teacher 中都存在。

如果以后还有更多身份,比如医生、工人等,每个类都重新写一遍这些公共属性,就会产生很多重复代码。

因此可以把公共部分提取出来:

cpp 复制代码
class Person
{
protected:
    string _name;
    int _age;
};

然后让 Student 和 Teacher 继承 Person:

cpp 复制代码
class Student : public Person
{
private:
    int _stuId;
};
cpp 复制代码
class Teacher : public Person
{
private:
    string _job;
};

这里:

  • Person 称为基类,也叫父类;

  • StudentTeacher 称为派生类,也叫子类。

所以我对继承最直接的理解就是:

把多个类共有的内容提取到一个基类中,然后让其他类在这个基类的基础上继续扩展。


继承的基本格式为:

cpp 复制代码
class 派生类 : 继承方式 基类
{
};

C++ 一共有三种继承方式:

cpp 复制代码
public
protected
private

而类本身的成员访问权限也有:

cpp 复制代码
public
protected
private

这两个地方非常容易混。

可以先记住成员本身的权限:

public

类内、类外、派生类都可以访问。

protected

类内可以访问,派生类也可以访问,但是类外不能直接访问。

private

只有当前类内部可以直接访问,派生类不能直接访问。

注意:

基类中的 private 成员并不是从派生类对象中消失了,而是派生类没有权限直接访问。


再看三种继承方式。

public 继承

cpp 复制代码
class Derived : public Base
{
};

基类中的:

cpp 复制代码
public    -> public
protected -> protected
private   -> 不能直接访问

这是实际开发中最常见的一种继承方式。

protected 继承

cpp 复制代码
class Derived : protected Base
{
};

结果:

cpp 复制代码
public    -> protected
protected -> protected
private   -> 不能直接访问

private 继承

cpp 复制代码
class Derived : private Base
{
};

结果:

cpp 复制代码
public    -> private
protected -> private
private   -> 不能直接访问

我觉得比较好记的方法是:

继承方式相当于限制了继承之后能够达到的最高访问权限。

另外还要注意:

cpp 复制代码
class B : A
{
};

如果是 class,默认采用**private 继承**。

而:

cpp 复制代码
struct B : A
{
};

如果是 struct,默认采用**public 继承**。

不过平时写代码时最好还是把继承方式明确写出来。


二、派生类和基类之间的转换以及同名成员问题

继承里面有一个非常重要的特点:

public 继承情况下,派生类对象可以转换成基类对象、基类指针或者基类引用。

例如:

cpp 复制代码
class Animal
{
public:
    string _name;
};

class Dog : public Animal
{
public:
    int _age;
};

创建一个 Dog:

cpp 复制代码
Dog d;

可以这样写:

复制代码
Animal a = d;

也可以:

复制代码
Animal* p = &d;

还可以:

复制代码
Animal& ref = d;

这三种情况都是允许的。

为什么可以这样做?

因为一个派生类对象本身就可以理解成:

cpp 复制代码
派生类对象
=
基类部分
+
派生类自己新增的部分

(插入图片:Dog 对象的内存组成示意图。画一个大矩形表示 Dog,内部上半部分写"Animal 基类部分",下半部分写"Dog 自己新增的成员"。)

所以 Dog 对象中本来就包含一个 Animal 部分。

例如:

复制代码
Animal a = d;

就是把 Dog 中属于 Animal 的那部分取出来。

这种现象也叫:

对象切片。

因为 Dog 中自己新增的:

复制代码
_age

但是反过来就不能直接完成:

复制代码
Animal a;
Dog d = a;

这是不允许的。

因为 Animal 中并没有 Dog 新增加的那部分成员。

所以可以简单记成:

复制代码
派生类 -> 基类
通常可以

基类 -> 派生类
不能直接完成

这个知识点在以后学习多态的时候还会大量出现。


继承中另一个容易混淆的问题就是同名成员隐藏

例如:

cpp 复制代码
class Base
{
public:
    void Func()
    {
        cout << "Base::Func()" << endl;
    }
};
cpp 复制代码
class Derived : public Base
{
public:
    void Func(int x)
    {
        cout << "Derived::Func(int)" << endl;
    }
};

这里:

复制代码
Base::Func()

和:

复制代码
Derived::Func(int)

虽然参数不同,但是它们并不是函数重载。

因为函数重载要求:

函数必须位于同一个作用域。

而这里一个属于 Base,一个属于 Derived。

这种情况叫做:隐藏。

派生类中只要出现了和基类同名的成员,就会把基类中的同名成员隐藏起来。

如果还想调用基类中的函数,可以指定作用域:

cpp 复制代码
Derived d;

d.Func(10);

d.Base::Func();

成员变量也一样。

例如:

cpp 复制代码
class Base
{
public:
    int _num = 10;
};

class Derived : public Base
{
public:
    int _num = 20;

    void Print()
    {
        cout << _num << endl;
        cout << Base::_num << endl;
    }
};

输出:

复制代码
20
10

所以实际写代码的时候,最好尽量避免基类和派生类中出现大量同名成员。


三、派生类的构造、拷贝、赋值与析构

派生类对象并不是只有自己定义的成员,它里面还有基类部分。

因此派生类在构造、拷贝、赋值和析构的时候,都需要同时处理:

复制代码
基类部分
+
派生类自己的部分

例如:

cpp 复制代码
class Person
{
public:
    Person(string name)
        : _name(name)
    {
        cout << "Person()" << endl;
    }

protected:
    string _name;
};

派生类:

cpp 复制代码
class Student : public Person
{
public:
    Student(string name, int id)
        : Person(name)
        , _id(id)
    {
        cout << "Student()" << endl;
    }

private:
    int _id;
};

这里:

cpp 复制代码
Person(name)

就是负责初始化 Student 对象中的基类部分。

创建:

cpp 复制代码
Student s("Jack", 1001);

构造顺序是:

cpp 复制代码
Person
↓
Student

也就是说:

先构造基类,再构造派生类。

我觉得可以理解成盖房子:

复制代码
先打地基
再盖上层

拷贝构造也是一样。

例如:

复制代码
Student s1("Jack", 1001);
Student s2(s1);

可以写成:

cpp 复制代码
Student(const Student& s)
    : Person(s)
    , _id(s._id)
{
}

这里:

cpp 复制代码
Person(s)

负责调用基类的拷贝构造函数。


赋值运算符也要分别处理两个部分:

cpp 复制代码
Student& operator=(const Student& s)
{
    if (this != &s)
    {
        Person::operator=(s);
        _id = s._id;
    }

    return *this;
}

为什么要写:

cpp 复制代码
Person::operator=(s);

因为派生类自己的 operator= 会隐藏基类中的同名函数,所以要显式指定基类作用域。


析构顺序则和构造正好相反。

构造:

复制代码
Base
↓
Derived

析构:

复制代码
Derived
↓
Base

可以记成:

谁最后构造,谁最先析构。

就像搭积木一样,搭的时候先放下面,拆的时候先拆上面。


另外,如果一个基类以后要用于多态,一般会把析构函数写成虚函数:

复制代码
virtual ~Base()
{
}

否则下面这种情况:

复制代码
Base* p = new Derived;

delete p;

可能不能正确执行完整的派生类析构过程。

这一点后面学习多态时还会继续详细学习。


四、继承中的一些特殊规则

除了前面的基本继承规则,还有几个比较零散但比较重要的知识点。

首先是 final

如果一个类不希望再被其他类继承,可以写:

cpp 复制代码
class Config final
{
};

此时再写:

复制代码
class MyConfig : public Config
{
};

就会编译报错

所以:

cpp 复制代码
final

放在类名后面表示:

这个类不能再继续被继承。


第二个是友元

要记住:

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

例如某个函数是 Base 的友元:

cpp 复制代码
class Base
{
    friend void Func(Base& b);
};

即使:

cpp 复制代码
class Derived : public Base
{
};

这个函数也不会自动成为 Derived 的友元。

如果它想访问 Derived 自己的私有成员,还需要 Derived 单独声明。

cpp 复制代码
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;
 // 编译报错:error C2248: "Student::_stuNum": ⽆法访问 protected 成员 
 // 解决⽅案:Display也变成Student 的友元即可 
 Display(p, s);
 
 return 0;
}

还有 static 静态成员

基类定义了static静态成员,则整个继承体系⾥⾯只有⼀个这样的成员。⽆论派⽣出多少个派⽣类,都只有⼀个static成员实例。

例如:

cpp 复制代码
class Person
{
public:
    static int _count;
};

int Person::_count = 0;

然后:

cpp 复制代码
class Student : public Person
{
};

class Teacher : public Person
{
};

执行:

cpp 复制代码
Student::_count++;
Teacher::_count++;

cout << Person::_count << endl;

结果为:

复制代码
2

因为 static 成员属于整个类,而不是每一个对象单独保存一份。

在这个继承体系中,大家访问的是同一个 _count


五、多继承、菱形继承与虚继承

前面接触的继承基本都是:

复制代码
class Student : public Person

一个类只有一个直接基类,这叫:

单继承。

而 C++ 还支持一个类同时继承多个类。

例如:

cpp 复制代码
class Singer
{
};

class Dancer
{
};

class Performer : public Singer, public Dancer
{
};

这就是:

多继承。


但是多继承会带来一个非常经典的问题------菱形继承

例如:

代码:

cpp 复制代码
class Person
{
public:
    string _name;
};

class Student : public Person
{
};

class Teacher : public Person
{
};

class Assistant : public Student, public Teacher
{
};

这里 Student 有一个 Person 部分,Teacher 也有一个 Person 部分。

所以最终 Assistant 中就会存在:

复制代码
两份 Person

从而带来两个问题:

复制代码
数据冗余
访问二义性

例如:

cpp 复制代码
Assistant a;

a._name = "Tom";

编译器不知道到底应该访问:

cpp 复制代码
Student -> Person -> _name

还是:

复制代码
Teacher -> Person -> _name

因此会报二义性错误。

虽然可以通过:

cpp 复制代码
a.Student::_name = "Tom";

指定访问哪一份数据,但是对象中仍然存在两份 Person。

真正解决这个问题,需要使用:

虚继承。

把代码修改成:

cpp 复制代码
class Student : virtual public Person
{
};

class Teacher : virtual public Person
{
};

此时:

cpp 复制代码
class Assistant : public Student, public Teacher
{
};

Assistant 中的 Person 公共基类部分只会保留一份共享实例。

因此就解决了普通菱形继承中的:

复制代码
数据冗余
访问二义性

多继承中还有一个比较有意思的现象。

例如:

cpp 复制代码
class A
{
public:
    int _a;
};

class B
{
public:
    int _b;
};

class C : public A, public B
{
public:
    int _c;
};

然后:

cpp 复制代码
C obj;

A* p1 = &obj;
B* p2 = &obj;
C* p3 = &obj;

在常见的编译器对象布局中,可以理解成:

cpp 复制代码
C 对象起始地址
↓
┌──────────────┐  ← C*、A* 通常指向这里
│ A 基类子对象 │
├──────────────┤  ← B* 指向这里
│ B 基类子对象 │
├──────────────┤
│ C 自己的成员 │
└──────────────┘

所以通常是:

cpp 复制代码
C*  -> 0x1000
A*  -> 0x1000
B*  -> 0x1000 + 偏移量

原因是 A 是 C 的第一个基类子对象,通常放在 C 对象起始位置 ;B 排在 A 后面,因此 C* -> B* 时编译器需要自动调整地址。

不过严谨地说,C++ 标准并不要求所有编译器必须严格采用这种具体内存排列。这是主流 ABI/编译器下最典型、也是我们学习多继承指针偏移时通常采用的模型。


拓展:IO库中的菱形虚拟继承

cpp 复制代码
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits>
{};
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits>
{};

六、继承和组合应该怎么选择?

学完继承之后,很容易产生一种想法:

只要两个类之间有代码可以复用,就使用继承。

其实这种想法并不完全正确。

除了继承之外,还有一种非常常见的代码复用方式:

组合。

判断什么时候使用继承,什么时候使用组合,我觉得可以通过两个关系来理解。

如果两个类之间是:

复制代码
is-a

也就是:

A 是一种 B。

一般适合使用继承。

例如:

cpp 复制代码
Dog is an Animal
Student is a Person
BMW is a Car

所以可以:

cpp 复制代码
class BMW : public Car
{
};

如果两个类之间是:

复制代码
has-a

也就是:

A 拥有一个 B。

一般更适合使用组合。

例如:

cpp 复制代码
Car has an Engine
Computer has a CPU

那么可以写:

cpp 复制代码
class Engine
{
};

class Car
{
private:
    Engine _engine;
};

这里汽车"有一个"发动机,而不是汽车"是一种"发动机。


再比如实现一个栈。

如果直接继承 vector:

cpp 复制代码
template<class T>
class Stack : public vector<T>
{
};

虽然代码复用了,但是 vector 本身具有:

cpp 复制代码
insert
erase
operator[]

等很多接口。

而 Stack 按照数据结构的设计,真正应该提供的主要是:

cpp 复制代码
push
pop
top

如果直接继承 vector,就会把很多不应该暴露的接口也暴露出去。

更合理的方式是:

这里就是:

复制代码
Stack has a vector

采用组合更加合理

所以在继承和组合都能够满足需求的时候,一般更加推荐优先考虑组合,因为组合的耦合程度通常会更低。


七、本节总结

这节课关于继承的内容虽然比较多,但是整体还是可以按照一条主线串起来:

复制代码
为什么需要继承
        ↓
基类和派生类
        ↓
继承权限
        ↓
派生类向基类转换
        ↓
同名成员隐藏
        ↓
构造、拷贝、赋值、析构
        ↓
多继承和菱形继承
        ↓
虚继承
        ↓
继承与组合

我觉得学习继承最重要的并不是单纯记住:

cpp 复制代码
class Derived : public Base

这行语法,而是理解一个派生类对象实际上包含:

复制代码
基类部分
+
派生类自己的部分

理解这一点之后,很多知识都会顺下来。

比如为什么:

复制代码
Derived -> Base

可以转换;

为什么派生类构造之前要先构造基类;

为什么析构时又要反过来;

为什么多继承时不同基类指针可能存在地址偏移;

这些问题其实都和对象内部的组成结构有关。

另外,这一节还让我开始接触到一个比较重要的面向对象设计思想:

复制代码
is-a  -> 继承
has-a -> 组合

继承并不只是为了少写几行代码,更重要的是表达不同类之间真正存在的逻辑关系。

后面继续学习 多态、虚函数和虚函数表 时,还会继续用到这一节关于继承的大量知识。

相关推荐
听取WA声一片(无恶意)1 小时前
CSP-J/CSP-S 深度优先搜索(DFS)完全讲义
c++·算法·深度优先
码匠许师傅2 小时前
【C++ 面试真题】27. 聊聊 C++ 的内存泄漏与内存布局
java·c++·面试
PPPPickup2 小时前
金证秋招笔试
java·开发语言
不正经学生3 小时前
C语言结构体:自定义数据类型,让变量打包出行
java·c语言·开发语言·数据结构·算法
ycjunhua3 小时前
Spring AI 2.0 GA:ToolCallingAdvisor 重构与 Java Agent 新范式
java·人工智能·spring
开源必胜3 小时前
Future的异常处理以及CompletableFuture传播链、异常处理
java
加油码3 小时前
动静态库的链接底层原理(进阶版)
linux·c++
一木 之林3 小时前
三、内存管理、指针与堆栈
c语言·jvm·c++
林森lsjs4 小时前
队列 FIFO 原理 & 手撕两种队列实现(链表 + 循环数组)—数据结构陆
java·开发语言·数据结构·队列