从父类到子类:C++ 继承的奇妙旅程(1)

前言:

在前文,小编讲述了C++模板的进阶内容,下面我们就要结束C++初阶的旅行,开始进入C++进阶容的旅c程,今天旅程的第一站就是C++三大特性之一------继承的旅程,各位扶好扶手,开始我们今天的C++继承的奇妙旅程。

1.继承的概念和定义

1.1.继承的概念

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的⼿段,它允许我们在保持原有 类特性的基础上进行扩展,增加⽅法(成员函数)和属性(成员变量),这样产⽣新的类,称派生类。继承 呈现了⾯向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的 复⽤,继承是类设计层次的复用。

可能很多读者朋友会好奇继承设计出来的目的是什么,下面我通过一个简单的例子就可以说明为什么继承会被设计出来,我们在进入学校的一些建筑例如图书馆,实验室等等都需要进行身份识别,而学校里不仅仅就只有学生,还有老师,所以我们涉及出两个类来帮助我们记录学生和老师的信息,如下代码所示:

cpp 复制代码
class Student
{
    public:
    //不重要就不写了
  protected:  //对于这里为什么用protected而不是用private,等会我会解释
    string _name = "忘梓."; // 姓名
    string _address; // 家庭地址
    int _age = 20; // 年龄
    int _stuid; // 学号
};
class Teacher
{
    public:
    //不重要
  protected:
    string _name = "小李子"; // 姓名
    int _age = 18; // 年龄
    string _address; // 地址
    string _title; // 工号
};

通过上面小编对于两个类的书写,可能很多读者朋友感觉到了一个点,那就是两个类涉及了很多相似内容,就比如姓名,年龄,地址,电话,这四个都是两个类公有的,只有学号和工号是二者分别特有的,不得不说,这样写起来会感到有一些冗余,现在只有两个类还好,但之后我们如果想要写一个比较大一点的项目的话,我们就会书写很多个类,这些类可能会出现上面的情况,我们如果分别书写的话,就大大减少了我们写代码的效率,作为一个程序猿,保证效率高效是很重要的,所以此时C++的祖师爷就推出了继承这个概念(感觉这么说也不对,因为继承是面向对象语言公有的,但是这么说的话也有一定道理),继承就是为了解决这种重复出现的成员变量或者函数而生的,下面小编就讲述一下我们如何用继承来解决上述问题。

针对这种问题,我们可以把两个类公共的成员放到Person类里面,Student类和Teacher类都可以继承它,就可以复用这些成员,无须在每个类里面重复定义了,省去了许多的麻烦,虽然小编还没有说继承具体怎么用,但我先给各位展示一下它的用法:

cpp 复制代码
class Person
{
  protected:
    string _name = "忘梓"; // 姓名
    int _age = 18; // 年龄
    string _address; // 地址
};
​
class Student : public Person  //至于为什么这么写,我后面会进行解释。
{
  private:
     int _stuid; // 学号
};
​
class Teacher : public Person
{
  private:
    string _title //工号
};

1.2.继承的定义

1.2.1.定义的格式

如上图所示,我们可以知道Student是派生类,public是继承方式,继承方式也分为三种,等会小编讲述的时候再给各位展开,Person就是基类,因为翻译的原因,有时候把派生类也叫做子类,基类也叫做父类,毕竟子承父类,就比如小编标题就这么讲述的,但是在本文中我还是会用派生类和基类来给各位讲述,因为在很多地方都是用这两个名称来论述的,下面小编着重说一下在继承中基类成员访问方式的变化(这里比较复杂)。

首先我们需要把继承方式和访问限定符两个概念区分开,后者是之前小编在C++的初次见面中讲到过的,它就是我们日常写类的时候使用到的特殊词,它的作用就是限定之后我们实例化对象之后对象可以访问到的类中间的方法或者成员等等;而前者是我们接下来要讲到的。

1.2.2.继承基类成员访问方式的变化

通过上表我们就可以知晓继承方式的复杂程度,可能大多数读者朋友看到这个图的时候都感到吃力,因为它看上去很复杂,因为它是继承方式和访问限定符的"杂交版",子类在继承父类的时候,因为继承方式的不同从而导致访问限定符会发生改变。可能有些读者的C++老师要求背过这张表的,以我看来,这样的方式是不可取的,因为死记硬背不可取,短时间内是记住了,但如果不知道原理加上长时间不用,这个很快就会忘记的(PS:虽然长时间不看也是会忘掉的,但是通过短暂几分钟的回想是会想起来这部分内容的)。下面我将会以比较简单的方式帮助各位"记忆这张表"。

1.**基类private成员在派生类中无论以什么方式继承都是不可见的。**这里的不可见是指基类的私有成员还是被继承到派生类对象中,但是语法上限制了派生类对象不管是在类里面还是在类外面都不能访问它。所以说小编不建议各位以后用继承关系的时候,用private访问限定符,除非是真的想把内容保护起来,那就可以使用private限定符了,但此时读者朋友想知道,如果我不使用private类,那岂不是对象可以肆意妄为的去访问我的成员了?NONONO,可能有一个访问限定符被读者忘掉了,那就是:protected,它第一次出现的时候,我是这么说的:

2.**基类private成员在派生类中是不可以被访问,如果基类成员不想在类外直接被访问。**但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因为继承在出现的。这也算是把之前埋下的伏笔收回了(PS:我之前写的博客错别字是真的逆天,而且现在回看好羞耻...)

3**.实际上上面的表格我们进行一下总结会发现,基类的私有成员在派生类都是不可见的。基类的其他成员在派生类的访问方式==Min**(成员在基类的访问限定符,继承方式)。public->protected->private。这个是记住这个表至关重要的方式,因为它真的很好理解,就拿第一列为例:基类的public成员和继承方式public取最小值就是public;基类的protected成员和继承方式public取最小是就是protected,所以基类的protected的成员就是子类的protected成员;private成员不在这个关系中,它默认子类就是不可见的。所以说这样记忆十分的方便,并且更容易记住。

4.**使用关键字class默认的继承方式是private,使用struct时默认的继承方式是public。**不过最好显示的写出继承方式 。这个我之前说过,就不细说了,养成习惯就好了。

5**.在实际运用中一般使用的都是public继承,几乎很少使用protected/private继承,也不提倡使用他们。**因为这样做继承下来的成员只能在派生类的类里面使用,实际中扩展维护性是不强的。

光文字说我自认为很干巴,所以下面我用一个代码来简单的说一下上面的讲解。

cpp 复制代码
// 实例演⽰三种继承关系下基类成员的各类型成员访问关系的变化
class Person
{
public:
    void Print()
    {
        cout << _name << endl;
    }
protected:
    string _name; // 姓名
private:
    int _age; // 年龄
};
//class Student : protected Person
//class Student : private Person
class Student : public Person
{
protected:
    int _stunum; // 学号
};

经过上面代码的测试就可以知道继承关系和成员限定符的关系~这里小编就不自行测试了,有兴趣的读者朋友可以自己测试一下,特别是对Person中的_age的测试,看看你究竟可以不可以使用它,不过我相信测试完以后的读者朋友会出现下面的报错:

这样就可以证明1的结论是正确的,其他的自行测试即可。

1.3.继承类模版

类模版也是可以进行继承的,这里小编就拿之前我们曾经实现的栈的模拟实现举例,下面是通过继承实现的栈:

cpp 复制代码
namespace wang
{
    //template<class T>
    //class vector
    //{};
    // stack和vector的关系,既符合is-a,也符合has-a
    template<class T>
    class stack : public std::vector<T>
    {
    public:
        void push(const T& x)
        {
            // 基类是类模板时,需要指定⼀下类域,
            // 否则编译报错:error C3861: "push_back": 找不到标识符
            vector<T>::push_back(x);
        }
        void pop()
        {
            vector<T>::pop_back();
        }
        const T& top()
        {
            return vector<T>::back();
        }
        bool empty()
        {
            return vector<T>::empty();
        }
    };
} 

上面是通过继承实现的栈,小编直接用的组合的方式来进行,可能有一些读者搞不懂什么是继承,什么是组合,下面小编先来简单的阐述一下:

1. 继承(Inheritance)
概念
  • 继承 表示**"is-a"关系**,即子类是父类的一种特化。

  • 子类继承父类的属性和方法,并可以扩展或重写父类的行为。

  • 支持多态(通过虚函数实现)。

语法示例
复制代码
class Animal {
public:
    virtual void speak() { cout << "Animal sound" << endl; }
};
​
class Dog : public Animal { // Dog 是 Animal 的子类
public:
    void speak() override { cout << "Woof!" << endl; }
};
优点
  • 代码复用:子类可以直接复用父类的代码。

  • 多态性:通过基类指针/引用调用子类方法。

  • 天然表达分类关系(如 Dog 是一种 Animal)。

缺点
  • 高耦合:父类修改可能影响所有子类。

  • 可能导致复杂的类层次结构(过度继承)。

适用场景
  • 当需要表达明确的分类层次 (如 CircleShape)。

  • 需要利用多态特性时(如通过基类统一管理不同子类对象)。


2. 组合(Composition)
概念
  • 组合 表示**"has-a"关系**,即一个类包含另一个类的实例作为成员。

  • 通过将对象作为成员变量使用,实现功能的复用。

语法示例
复制代码
class Engine {
public:
    void start() { cout << "Engine started" << endl; }
};
​
class Car {
private:
    Engine engine;  // Car 包含 Engine 的实例
public:
    void startCar() { engine.start(); }
};
优点
  • 低耦合:内部类的修改不会直接影响外部类。

  • 灵活性高:可动态更换成员对象(如通过接口或指针)。

  • 符合单一职责原则:每个类专注于单一功能。

缺点
  • 需要显式调用成员对象的方法(需要手动委托)。

  • 可能增加代码量。

适用场景
  • 表达部分与整体 的关系(如 Car 包含 Engine)。

  • 需要动态替换组件时(如游戏角色装备不同武器)。

  • 优先选择组合以避免继承的耦合问题。


关键对比

继承 组合
关系类型 "is-a" "has-a"
耦合度 高(父类与子类紧密关联) 低(通过接口或成员对象交互)
灵活性 较低(需通过继承扩展) 高(可替换成员对象)
多态支持(之后会讲) 天然支持(虚函数) 需依赖接口或抽象类
设计原则 符合Liskov替换原则 符合合成复用原则

以上就是继承和组合的关系,简单来说,当我们不使用实例化对象进行类的书写的时候,我们使用的是继承的方法,这就是is-a,如果使用了实例化对象作为成员变量,那么这个就是组合,这就是has-a,各位理解好这个点就好。

可能很多读者朋友会很奇怪,为什么我使用基类的函数时,我需要表明类域,因为stack<int>实例化时,也实例化vector<int>了,但是模版是按需实例化,push_back等成员函数未实例化,所以找不到。简单来说,vector是实例化出来了,但是其内容并没有实例化出来,也就是说它是由外在,内在还没有展现出来,这就是为什么我需要表明类域。

相信不少读者觉着这么写比较简单(当然还有认为复杂的,这是正常的,因为每个人都有每个人自己的想法),不过小编并不推荐这个方法进行一些功能的实现,具体原因我会在继承和组合的对比中进行讲述,各位先明白这个点就好。

2.基类和派生类的转换

**public继承的派生类对象可以赋值给基类的指针/基类的引用。这里有个比较形象的说法,可以把它称之为切片或者是切割。寓意把派生类中基类的那部分切出来,基类指针或者引用指向的是派生类切出来的部分。**如下图所示:

上图就可以形象的表示什么是"切割",下面我简单的通过几句代码介绍它的用法:

cpp 复制代码
class Person
{
protected :
    string _name; // 姓名
    string _sex; // 性别
    int _age; // 年龄
};
class Student : public Person
{
public :
    int _No ; // 学号
};
int main()
{
    Student sobj ;
    // 1.派⽣类对象可以赋值给基类的指针/引⽤
    Person* pp = &sobj;
    Person& rp = sobj;
    // 派生类对象可以赋值给基类的对象是通过调⽤后⾯会讲解的基类的拷贝构造完成的
    Person pobj = sobj;  //这里也是把基类中的父类的成员函数切割给了基类
    //2.基类对象不能赋值给派⽣类对象,这⾥会编译报错
    sobj = pobj;
    return 0;
}

上面的代码就阐述了我们如何使用切割来进行基类和派生类之间的转换,可能有些读者会疑问:为什么会有这个转换呢?这和我们之后讲解的多态有关,详细内容敬请期待我之后的多态的博客。

基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者是引用,但是必须是基类的指针是指向派生类对象时才是安全的。这里的基类如果是多态类型的话,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。(这要等着后面的类型转换的博客,那里会进行会专门的讲解,小小的埋下伏笔)。

以上就是基类和派生类之间的转换,下面我来简单的阐述一下本篇博客的最后一部分内容:继承中的作用域。

3.继承中的作用域

3.1.隐藏规则

1.在继承中基类和派生类都有独特的作用域 ,这里常常会让一些读者摸不清头脑,因为有的读者会认为,当派生类继承基类的时候,理所应当基类的内容也是包含在派生类的作用域中,这样的观点是错误的。虽然派生类可以访问 基类的成员(取决于继承方式和访问权限),但这并不意味着基类的作用域被直接"合并"到了派生类的作用域中。它们的成员查找规则遵循分层作用域模型,理解这一点能避免许多常见的编程错误。就比如上面的代码可以分为这两层:

2.派生类和基类中有同名成员,派生类讲屏蔽基类对同名成员的直接访问,这样的情况叫做隐藏。如果想要使用基类成员时,可以使用基类:基类成员 显示的进行访问,如果直接使用成员的话,默认会直接用派生类的成员,下面我将用一个代码解释这一个特性:

cpp 复制代码
class Person
{
public:
    Person():_age(12)
    {}
protected:
    int _age;
};
class Student : public Person
{
public:
    void Print()
    {
        cout << _age << endl;
    }
protected:
    int _age = 13;
};
int main()
{
    Student s1;
    s1.Print();  //猜猜会打印出什么
    return 0;
}

上面的代码就展现了派生类对同名的成员变量进行的隐藏,它是会优先去调用自己作用域的成员变量,如果想要使用基类的成员变量,那么可以用下面的方法,即可使用基类的成员变量。

cpp 复制代码
//上面代码的局部代码
void Print()
{
    cout << _age << endl;
    cout << Person::_age << endl;
}

**3.需要注意的是如果是对于成员函数的隐藏,只需要函数名相同就可以构成隐藏.。**这里需要和之前学习的函数的重载区分一下,函数的重载是在同一个作用域的,而隐藏是在不同作用域的,这点各位要知晓,下面我继续写一个示范让各位知晓成员函数隐藏。

cpp 复制代码
class Person
{
public:
    Person():_age(12)
    {}
    void Print()
    {
        cout << _age << endl;
    }
protected:
    int _age;
};
class Student : public Person
{
public:
    void Print()
    {
        cout << _age << endl;
    }
protected:
    int _age = 13;
};
int main()
{
    Student s1;
    s1.Print();  //对于派生类成员函数的使用
    s1.Person::Print(); //对于基类成员函数的使用
    return 0;
}

和成员变量一样,我们想要使用基类的成员函数,只有指明类域才可以使用基类的成员函数,才可以把隐藏破除。

4.**注意在实际的继承体系中最好不要定义同名的成员。**因为这样会让工程的复杂度提高,我们如果基类和派生类使用的是同名的函数,那么我们如果想要使用基类的成员函数会比较复杂,就比如上面的代码,我们需要指明类域,可能在一些简单的代码看不出来有什么不方便,不过以后我们接触的大型工程的代码肯定有上万行代码起步,如果有时候想要调用父类的成员函数的时候忘记指明类域了,那么找起错来就很难受了~

3.2.考察继承作用域的几个小题

1.A和B类中的两个func构成什么关系()

A.重载 B.隐藏 C.没关系

cpp 复制代码
class A
{
public:
    void func()
    {
        cout << "func()" << endl;
    }
};
class B : public A
{
public:
    void func(int i)
    {
        cout << "func(int i)" <<i<<endl;
    }
};

这个题其实很容易,因为我刚刚讲了什么是隐藏关系,观察A和B类的func函数,可以知晓一个来自基类,属于基类作用域;一个来自派生类,属于派生类的作用域;这是构成隐藏的第一个条件,并且它们的函数名相同,这就达成了第二个条件,所以构成了隐藏的关系。这个题目自然选择B.隐藏

2.下面的程序的编译运行结果是什么()

A.编译报错 B.运行报错 C.正常运行

cpp 复制代码
//类的写法在上面
int main()
{
    B b;
    b.func(10);
    b.func();
    return 0;
};

这个题目其实也是比较容易的,因为上一个题目刚说明了此时的func函数构成隐藏关系,此时的第一个语句是正确的,因为它符合了派生类中的func函数的定义,而第二个调用就不对了,因为如果想要使用基类成员函数,我们需要指明基类的类域,此时没有指明,默认的基类的成员函数被派生类隐藏了,所以此时相当于我们无中生有调用了func函数,既没有定义也没有声明,所以自然而然的,这个题目选择A.编译报错。

4.总结

【🌌小编的拖延症忏悔录】

各位代码骑士们,这篇继承的奇妙漂流终于靠岸啦!不过说出来你们可能不信------这篇教程的诞生过程,简直比C++的编译错误还曲折!

去年十月,我雄心壮志敲下第一行字,结果中途: 1️⃣ 被"拖延症Boss"连击KO 2️⃣ 在Linux森林里迷路三个月 3️⃣ 甚至偷偷跑去Python岛摸鱼(小声)

直到最近复习C++时,突然被"Segmentation fault"现实暴击------好家伙,继承知识全还给祖师爷Bjarne了!于是连夜翻出这篇"祖传代码",边哭边补完了这个技术债(程序员の泪.jpg)

⚠️前方高能预警: 下期我们将勇闯《继承の黑暗森林》------ 🔸 钻石继承堪比迷宫 🔸 代码量像老板的需求一样膨胀

不过别慌!本导游已备好: 🍵 提神咖啡 🐞 驱bug圣剑 💡 祖传防秃指南

让我们相约下篇,继续在C++的星辰大海里浪到飞起!毕竟------ "程序员不摸鱼,和咸鱼有什么区别?"

(摸鱼归摸鱼,记得三连保命啊各位亲!)🐟✨

相关推荐
怡人蝶梦20 分钟前
Java后端技术栈问题排查实战:Spring Boot启动慢、Redis缓存击穿与Kafka消费堆积
java·jvm·redis·kafka·springboot·prometheus
瓯雅爱分享24 分钟前
MES管理系统:Java+Vue,含源码与文档,实现生产过程实时监控、调度与优化,提升制造企业效能
java·mysql·vue·软件工程·源代码管理
bubiyoushang88825 分钟前
matlab雷达定位仿真
开发语言·matlab
鬼多不菜1 小时前
一篇学习CSS的笔记
java·前端·css
深色風信子1 小时前
Eclipse 插件开发 5.3 编辑器 监听输入
java·eclipse·编辑器·编辑器 监听输入·插件 监听输入
yezipi耶不耶1 小时前
Rust入门之并发编程基础(一)
开发语言·后端·rust
lichenyang4531 小时前
使用react进行用户管理系统
数据库
Blossom.1181 小时前
人工智能在智能健康监测中的创新应用与未来趋势
java·人工智能·深度学习·机器学习·语音识别
木子.李3472 小时前
数据结构-算法学习C++(入门)
数据库·c++·学习·算法