大家好,这里是彩妙呀~

在彩妙之前文章,用两篇博客详细讲解了类与对象:

C++:类与对象https://blog.csdn.net/weixin_66776566/article/details/156678915?spm=1001.2014.3001.5501C++类与对象的详细使用https://blog.csdn.net/weixin_66776566/article/details/156775780?spm=1001.2014.3001.5501在有了一定基础知识(学习过基础模版,简单STL容器如:vector,list等),我们就要开始学习后面两大基石中的其中一个:继承。

目录

何为继承?

继承定义

定义格式

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

父类(基类)权限为private的成员“不可见”

父类(基类)权限为protected的成员,仅子类可见

父类(基类)权限为public的成员,谁都可以看见

(小概念)OOP的两种关系:继承与组合

is-a关系:公有继承的语义基础

has-a关系:表明对象的组合

继承类模板

派生类的构造与析构

构造顺序:必须从基类开始

为什么是从基类开始?(底层原理)

基类和派生类间的转换(切片)

向上转型:子类对象赋值给父类时,总是安全的。

向下转型:父类转型为子类,必须使用显式类型转换

C++派生类默认成员函数详解:编译器背后的自动生成逻辑

派生类的构造函数

派生类的拷贝构造函数

派生类的operator=

什么是隐藏?

隐藏的核心规则

名称查找优先级

隐藏影响所有同名成员

为什么会有隐藏?

那么,隐藏又有哪些问题?又如何解决问题?

问题1:意外隐藏有用的基类函数

解决方法:使用using声明引入基类名称

问题2:析构函数的特殊隐藏情况(适用于多态中)

解决方法2:显式指定作用域

解决方法3:合理设计继承层次

派生类的析构函数

构造与析构的顺序保证

析构函数的特殊处理(牵扯到多态知识)

C++11新增关键字:final(不能被继承的类)

继承与友元

继承与静态成员

多继承及其菱形继承问题(较为重要)

继承模型

单继承

多继承

有关多继承中指针偏移的问题:

菱形继承

解决方案:虚继承


何为继承?

在我们日常写代码中,可能会遇到下面的场景:我们要设计一个学校的认证系统,就不可避免的要去设计学生老师两个类。但我们写代码时发现,他们的成员变量高度相似甚至相同


class student {
    //进⼊校园/图书馆/实验室刷⼆维码等⾝份认证 的函数实现

protected:
    string _name = "张三"; // 姓名
    string _address; // 地址
    string _tel; // 电话
    int _age = 18; // 年龄
    
    int _stu_id = 00000001; // 学号
};

using namespace std;

class teacher {
    //进⼊校园/图书馆/实验室刷⼆维码等⾝份认证 的函数实现

protected:
    string _name = "王五"; // 姓名
    string _address; // 地址
    string _tel; // 电话
    int _age = 18; // 年龄
    
    string _title = "特技教师"; // 职称
};

在这种场景下,我们可不可以把这些相同的成员变量提取出来,专门设计一个类来存储这些相同的成员变量呢?

下面我们再看一个代码:


class person {
    
    protected:
    string _name ;      // 姓名
    string _address;    // 地址
    string _tel;        // 电话
    int _age = 18;      // 年龄
};
//类似于stack的实现,使用类似适配器的方法来复用这些相同变量

class student {
    //进⼊校园/图书馆/实验室刷⼆维码等⾝份认证 的函数实现

protected:
    person _person;     
    int _stu_id = 00000001; // 学号
};

using namespace std;

class teacher {
    //进⼊校园/图书馆/实验室刷⼆维码等⾝份认证 的函数实现

protected:
    person _person;
    string _title = "特技教师"; // 职称
};

在这个代码下,我们把这些相同的成员函数封装成一个类,那么当我们设计一个新类时就可以使用这个类来达到“代码复用”的目的。

“代码复用”说白了就是别重复造轮子(造轮子就是重复写已经有的但无法被别人使用的函数、类(通常情况下,这些函数和类都是被别人封装起来不让你看的),从而浪费时间精力,对咱的身心健康非常不友好。)。

写代码时,同一个功能,不要到处复制粘贴。咱可以把这一块需要重复使用的代码做成一个通用的零件(比如一个函数、一个组件、一个工具类),下次需要时,直接拿来用,或者稍微改改就能用。

这么做的好处:

  • 省时间:不用重写一样的逻辑。

  • 少出错:用之前测试好的、靠谱的代码,更稳。

  • 好维护:要修改时,只用改这一个“零件”,所有用到它的地方自动都改了,不会漏。

怎么搞复用?
最简单的就是把重复的代码抽成一个函数。复杂点就做成独立的模块、库,或者用现成的开源框架、组件库。核心思想就一句:写一次,到处用

当然,也别为了复用而强行复用。如果一个逻辑只是偶然相似,就别硬捆在一起,否则反而更麻烦。

这种方法在面向对象(OOP)中被设计者做成了一种方式,而这种技术就叫做继承继承的本质,其实就是 “代码复用”(或者说,继承的底层逻辑与最终目标就是“代码复用”)

简单说,就像是先做一个通用模板(父类),再基于它定制出更具体、更特别的版本(子类)

当我们写代码时,常常会发现好几个类有相似的特征和行为。这时候,我们就可以把这些“共同点”抽出来,单独做成一个类——它就变成了父类(也叫基类)。

然后,我们可以以这个父类为“模板”或“基础”,去创建新的类。新类会自动拥有父类的那些特征和行为,同时还能添加自己独有的东西,或者把父类的某个行为改成自己的版本。这样创建出来的,就是子类(也叫派生类)。

拓展一下:

有了上面知识的基础,我们发散思维想一下:假设我们有类似于多叉树形状的继承关系(这里使用二叉树来解释):

那么,这个数的根部就有他自己独特的名字:超基类

这个概念在C++中可能不怎么出现,但是学过JAVA与python的人来说,超基类是必须要有的:Java有 java.lang.Object,Python有 object有的时候用这个类当超基类来使用),它们都是所有类的隐式源头(JAVA中,所有类都必须有这个超基类),提供了如 toString 等基础方法。而C++没有这样的强制统一根类,其“基类”是用户自定义的(侧面反映了C++“比较自由”,但代价就是如果写的代码有错误,那就是“天崩”了~)。

在继承角度来看:C++和Python支持多重继承(一个类可有多个直接父类),而Java只支持单继承,但用“接口”实现类似功能。

术语上,在C++和Java中“超基类”通常指直接父类;在Python中,有时特指终极的 object 类。

有了基础的认知,彩妙就带着大家来看继承的基本格式与语法

继承定义

定义格式

在 C++ 中,继承的基本语法格式如下:

#include <iostream>
// 基类
class Animal {
public:
    std::string name;
    int age;
    void eat() {
        std::cout << name << " is eating." << std::endl;
    }
};
// 派生类,从Animal类公有继承
class Dog : public Animal {
public:
    void bark() {
        std::cout << name << " is barking." << std::endl;
    }
};
int main() {
    Dog dog;
    dog.name = "Buddy";
    dog.age = 3;
    dog.eat();  // 调用从基类继承的成员函数
    dog.bark(); // 调用派生类特有的成员函数
    return 0;
}

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

在彩妙之前的文章中, 我们提到:在我们设计类的时候,会提到类中的三种权限:

  1. public(公有) - 完全开放
  2. private(私有) - 内部专用
  3. protected(受保护) - 继承可见

理解这些规则的关键在于把握一个核心思想:继承方式就像一道“过滤网”,它会将基类成员的访问权限“收紧”到不松于继承方式本身的严格程度

而在设计类的继承关系时,对这三种访问权限决定了父类的成员对子类是否可见(是否可以继承)。对应的访问权限变换表如下:

父类中的原始访问权限公有继承 (public) 后
在子类中变为
保护继承 (protected) 后
在子类中变为
私有继承 (private) 后
在子类中变为
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private不可直接访问不可直接访问不可直接访问

实际上⾯的表格我们进⾏⼀下总结会发现,基类的私有成员在派⽣类都是不可⻅。

基类的其他成员在派⽣类的访问⽅式 == Min(成员在基类的访问限定符,继承⽅式):public > protected > private。

上面的表看着唬人,但是吃透下面几点,就可以掌握继承中的权限转换了

父类(基类)权限为private的成员“不可见”

无论采用何种继承方式,基类的private成员对于派生类来说都是完全不可见的。这不是说它们不存在,而是编译器在语法层面禁止了任何直接的访问尝试。

class Base {
private:
    int secret;
};

class Derived : public Base {
    void tryAccess() {
        secret = 10; // 错误:成员无法访问
    }
};

父类(基类)权限为protected的成员,仅子类可见

当我们设计类的时候,想让这个类既不能让外界访问,又想让子类能继承到这个类的成员时,就要使用protected来设计成员。

class Base {
protected:
    int only_child;
private:
    int secret;
};

class Derived : public Base {
public:
    void tryAccess() {
        only_child = 10;
        cout << only_child << endl;
    }
};

父类(基类)权限为public的成员,谁都可以看见

字面意思,但是有一点注意:当继承方式为前两种,那么权限就会跟着前两者改变(具体看表)。

有两点要着重注意一下:

  1. 默认继承方式:使用class关键字时默认是private继承,使用struct时默认是public继承。但最佳实践是显式写明继承方式,避免混淆。

  2. public继承是绝对主流:实践中95%以上的情况都应该使用public继承,因为它建立了直观的“is-a”关系。其他继承方式会扭曲类的接口,应谨慎使用。

(小概念)OOP的两种关系:继承与组合

在面向对象设计中,理解 " is-a " 和 " has-a " 这两种关系对于构建清晰的类层次结构至关重要。它们代表了完全不同的代码复用思路:

is-a关系:公有继承的语义基础

翻译为:“xx是一个xx” / “A是一个B”。这个关系所描述的是分类与特化(特化就可以牵扯到后面要讲的多态:多种形态),在代码中通过公有继承(public)实现。当我们说“A是一个B”,意味着A是B的一种特定类型。

举个例子:

  • “狗是一个动物” 

  • “鸟是一个动物” 

那么,这两个陈述在生物学和逻辑上都成立,狗和鸟都是动物的具体子类。所以,我们就可以设计出下面的代码:

// 基类:动物(Animal)
class Animal {
public:
    //virtual是虚函数,后面就是多态内容,在本篇不多讲
    virtual void move() = 0;  // 动物都会移动
    virtual void eat() = 0;   // 动物都需要进食
};

// 狗类:继承自动物
class Dog : public Animal {   // 公有继承:Dog is-an Animal
public:
    void move() override { cout << "用四条腿奔跑" << endl; }
    void eat() override { cout << "吃狗粮和骨头" << endl; }
    void bark() { cout << "汪汪叫!" << endl; } // 狗特有的行为
};

// 鸟类:继承自动物  
class Bird : public Animal {  // 公有继承:Bird is-an Animal
public:
    void move() override { cout << "拍打翅膀飞翔" << endl; }
    void eat() override { cout << "吃虫子和谷物" << endl; }
    void chirp() { cout << "叽叽喳喳!" << endl; } // 鸟特有的行为
};

has-a关系:表明对象的组合

翻译为:“xx有一个xx” / “A有一个B”。描述的是组成部分或属性,在代码中通过包含成员对象(组合)实现。这里表示的是一种组成关系,而不是类型关系。

举个例子:

  • “狗有一个尾巴” 

  • “鸟有一对翅膀” 

  • “狗有一个项圈” (这里就是可选属性)

// 组成部分类
class Tail {
public:
    void wag() { cout << "尾巴摇动" << endl; }
};

class Wing {
public:
    void flap() { cout << "翅膀拍打" << endl; }
};

class Collar {
public:
    void displayTag() { cout << "显示宠物信息" << endl; }
};

// 狗类:使用组合实现“有一个”关系
class Dog {
private:
    Tail tail;          // 狗有一个尾巴
    Collar* collar;     // 狗可能有一个项圈(指针表示可选)
    
public:
    void expressHappiness() {
        tail.wag();     // 通过尾巴表达情绪
    }
    
    void setCollar(Collar* c) { collar = c; }
};

// 鸟类:使用组合实现“有一个”关系  
class Bird {
private:
    Wing leftWing;      // 鸟有左翅膀
    Wing rightWing;     // 鸟有右翅膀
    
public:
    void fly() {
        leftWing.flap();
        rightWing.flap();
        cout << "鸟儿飞起来了!" << endl;
    }
};

典型的组成关系就是STL中stack与Queue的容器适配器(底层的容器适配器采用deque来实现),但对于这两个容器(stack与Queue)来说,他们对deque的关系既符合“is-a”也符合“has-a”,那么在两者都符合情况下,我们优先考虑“has-a”的组合关系

为什么考虑后者而不是前者?因为这里要牵扯到一个概念:耦合度与内聚性(最佳的代码实现追求的是是高内聚,低耦合)。

所以:在大多数情况下,优先考虑使用组合(“has-a”)来复用功能。它能带来低耦合、高内聚的优良特性。

继承类模板

关于模版知识,可以看下面的文章:

c++:详解:模版https://blog.csdn.net/weixin_66776566/article/details/156945543?spm=1001.2014.3001.5501那么,如果父类使用了模版参数,子类继承的时候也可以使用模版参数(这里也可以直接指定类型来继承):

template<class T>
class stack : public std::vector<T>
{
public:
    void push(const T& x)
    {
        // 基类是类模板时,需要指定⼀下类域,
        // 否则编译报错:error C3861: “push_back”: 找不到标识符
        // 因为stack<int>实例化时,也实例化vector<int>了
        // 但是模版是按需实例化,push_back等成员函数未实例化,所以找不到
        vector<T>::push_back(x);
        //push_back(x);
    }
    void pop()
    {
        vector<T>::pop_back();
    }
    const T& top()
    {
        return vector<T>::back();
    }
    bool empty()
    {
        return vector<T>::empty();
    }
};

int main()
{
    stack<int> st;
    st.push(1);
    st.push(2);
    st.push(3);
    while (!st.empty())
    {
        cout << st.top() << " ";
        st.pop();
    }
    return 0;
}

派生类的构造与析构

当创建一个派生类对象时,首先会调用基类的构造函数,这是因为派生类对象包含了基类的所有成员,需要先初始化基类部分。接着,会调用派生类中成员对象(如果有的话)的构造函数。最后,才调用派生类自身的构造函数,完成对象的初始化。而析构就是完全相反的逻辑。

想象一下盖房子:先打地基(基类),再盖上层建筑(派生类)。拆房子时则相反:先拆上层建筑,再清理地基。这就是派生类构造与析构的核心逻辑。

#include <iostream>
using namespace std;

class Base {
public:
    Base() { cout << "Base构造(地基打好)" << endl; }
    ~Base() { cout << "Base析构(地基清理)" << endl; }
};

class Derived : public Base {
public:
    Derived() { cout << "Derived构造(房子盖好)" << endl; }
    ~Derived() { cout << "Derived析构(房子拆除)" << endl; }
};

int main() {
    cout << "=== 开始创建Derived对象 ===" << endl;
    Derived d;  // 自动调用构造函数
    
    cout << "\n=== 对象d即将离开作用域 ===" << endl;
    // 自动调用析构函数
    return 0;
}

构造顺序:必须从基类开始

class Base {
public:
    int baseValue;
    Base() : baseValue(100) { }  // 先初始化
};

class Derived : public Base {
public:
    int derivedValue;
    Derived() : derivedValue(baseValue * 2) { }  // 依赖baseValue
    // 如果Base没先构造,这里会出问题!
};

为什么是从基类开始?(底层原理)

从物理内存角度看,一个派生类对象并不是“凭空出现”的。它其实是这样的结构:

派生类对象 = [父类所继承过来的成员对象] + [派生类自有所有的成员对象]

由于这样的物理关系。导致当我们要使用父类成员来初始化子类成员时,先要初始化父类(基类)对应的对象正常来说,我们只需要调用对应的构造函数就行),随后才能初始化自己(在初始化列表中详细说过,类的初始化只与声明对象顺序有关,所以要按照声明顺序来初始化):

class Base {
public:
    int baseData; // 这块内存在派生类对象开头
};

class Derived : public Base {
public:
    int derivedData; // 这块内存在 baseData 之后
};

// 从内存看,一个 Derived 对象大致是这样:
// [ baseData ][ derivedData ]
// ^
// |—— 构造必须从这里开始,先初始化这片区域

//为什么要必须先初始化父类呢?
class Base {
protected:
    int id; // 基类提供一个基础标识
public:
    Base(int initId) : id(initId) {}
};

class Derived : public Base {
    std::string tag; // 派生类成员
public:
    Derived(int num) 
        : Base(num), // 1. 必须先让 Base::id 有值
        tag("ID_" + std::to_string(id)) // 2. 然后才能安全地使用 id 来构造 tag
    {
        // 3. 构造函数体内也可以安全使用 id
        std::cout << "对象创建,ID为: " << id << std::endl;
    }
};

所以,由于初始化顺序的关系,从逻辑和功能上看,派生类通常是基类的特化或扩展。所以,如果要使用父类的成员来初始化子类成员时,不用要先初始化父类成员。

如果顺序颠倒,就会引发“鸡生蛋还是蛋生鸡”的悖论,导致未定义的行为。

总结一下:

  1. 物理上:基类部分是派生类对象内存的起始部分,必须先初始化。

  2. 逻辑上:派生类的构建行为依赖于一个已就绪、状态确定的基类子对象。

  3. 规则上:这是C++对象生命周期模型的强制规定,确保了对象从一开始就处于有效状态。

而对于析构而言,就是与构造相反的逻辑,就留给大家思考啦~

基类和派生类间的转换(切片)

两者的相互转化依靠的是指针或引用来实现。而这种方法在C++有非常形象的名称:切片/切割。

作为C++的OOP中,理解基类与派生类之间的赋值与转换语义至关重要,而这些规则直接关系到类型安全、内存完整性和多态行为的正确实现。

向上转型:子类对象赋值给父类时,总是安全的。

为什么要这么说?因为在转型过程中,会发生“切片”:当派生类对象直接赋值给基类对象时,基类对象仅保留派生类中 “基类子对象” 的部分,派生类独有的成员会被丢弃。

拆解上图,可以得到下面的逻辑关系图表:

大家可以通过代码调试来查看具体的细节:

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

// 基类:Person(人)
class Person {
protected:
    string _name;   // 姓名
    string _sex;    // 性别
    int _age;       // 年龄
    
public:
    // 构造函数
    Person(const string& name = "", const string& sex = "", int age = 0)
        : _name(name), _sex(sex), _age(age) {
        cout << "Person构造函数被调用" << endl;
    }
    
    // 显示人员信息
    void showInfo() const {
        cout << "姓名:" << _name << ",性别:" << _sex 
             << ",年龄:" << _age << endl;
    }
    
    // 修改信息的方法
    void setName(const string& name) { _name = name; }
    void setSex(const string& sex) { _sex = sex; }
    void setAge(int age) { _age = age; }
    
    // 获取信息的方法
    string getName() const { return _name; }
    string getSex() const { return _sex; }
    int getAge() const { return _age; }
};

// 派生类:Student(学生)
class Student : public Person {
private:
    string _No;     // 学号(Student特有成员)
    
public:
    // 构造函数:先初始化基类部分,再初始化派生类特有成员
    Student(const string& name = "", const string& sex = "", 
            int age = 0, const string& no = "")
        : Person(name, sex, age), _No(no) {
        cout << "Student构造函数被调用" << endl;
    }
    
    // 显示学生完整信息(包括学号)
    void showStudentInfo() const {
        showInfo();  // 调用基类方法显示基本信息
        cout << "学号:" << _No << endl;
    }
    
    // 设置和获取学号
    void setNo(const string& no) { _No = no; }
    string getNo() const { return _No; }
    
    // 学生特有的行为
    void study() const {
        cout << _name << "(学号:" << _No << ")正在学习..." << endl;
    }
};

int main() {
    cout << "=== 创建Student对象 ===" << endl;
    Student stu("张三", "男", 20, "20230001");
    
    cout << "\n=== 显示Student完整信息 ===" << endl;
    stu.showStudentInfo();
    stu.study();
    
    cout << "\n=== 切片发生:Student对象赋值给Person对象 ===" << endl;
    Person p = stu;  // 🚨 这里发生切片!
    
    cout << "\n=== 赋值后Person对象的信息 ===" << endl;
    p.showInfo();  // 只能看到Person部分的信息
    
    cout << "\n=== 验证切片效果 ===" << endl;
    cout << "Person对象是否还能调用Student的方法?" << endl;
    // p.showStudentInfo();  // ❌ 错误:Person类没有此方法
    // p.study();           // ❌ 错误:Person类没有此方法
    // cout << p.getNo();   // ❌ 错误:Person类没有此方法
    
    cout << "\n=== 修改Person对象不影响原Student对象 ===" << endl;
    p.setName("李四");  // 修改切片后的对象
    cout << "修改后Person的姓名: " << p.getName() << endl;
    cout << "原Student的姓名: " << stu.getName() << endl;
    cout << "原Student的学号: " << stu.getNo() << endl;  // 学号依然存在!
    
    cout << "\n=== 内存地址对比 ===" << endl;
    cout << "Student对象地址: " << &stu << endl;
    cout << "Person对象地址: " << &p << endl;
    cout << "说明:这是两个独立的对象,不是引用关系" << endl;
    
    return 0;
}

所以,“切片”的本质,其实是访问权限的重新限定。上面代码的方法是采用拷贝构造来实现的。当然,我们也可以使用指针或引用来实现切片:

class Base { /* 基类定义 */ };
class Derived : public Base { /* 派生类定义 */ };

int main(){
    Derived d;
    Base* bp = &d;     // ✅ 隐式向上转型,总是安全
    Base& br = d;      // ✅ 同样安全
}

bp br 仅能访问 d 对象中属于 Base 子对象的部分,派生类独有的成员在此视角下不可直接访问,但它们仍在内存中保持完整存在。

为什么说这种转变是绝对安全的呢?因为每个 Derived 对象都必然包含一个完整且正确构造的 Base 子对象(可以理解为权限的缩放),因此基类指针或引用总能找到有效的对应部分。

向下转型:父类转型为子类,必须使用显式类型转换

相反的转换方向——从基类到派生类——则存在固有的风险。因此,C++规定:必须要使用显式类型转换

//对象赋值:完全禁止隐式转换
Base b;
Derived d = b; // ❌ 编译错误:不允许隐式向下转型

此操作被明确禁止,因为编译器无法获知如何从基类对象构造出派生类对象所需的额外成员

所以,只能使用指针/引用来进行转换。但是,必须是基类的指针是指向派⽣类对象时才是安全的。

注意:对于指针或引用的向下转型,C++提供多种显式转换操作符,安全责任由开发者承担。

Base* bp = new Derived();  // bp实际指向Derived对象
Derived* dp = static_cast<Derived*>(bp); // ✅ 编译时转换

使用 static_cast 进行向下转型时,开发者必须确信 bp 实际指向的是 Derived 类型(或其它可转换类型)。若此前提不成立,将导致未定义行为

当基类是多态类型(至少包含一个虚函数)时,可使用 dynamic_cast 进行安全的运行时检查。

Base* bp = getObject(); // 可能返回Base*或Derived*
Derived* dp = dynamic_cast<Derived*>(bp);

if (dp != nullptr) {
    // 转换成功:bp实际指向Derived对象
    dp->derivedMethod();
} else {
    // 转换失败:bp不指向Derived对象
}

dynamic_cast 利用RTTI(运行时类型信息)验证转换的合法性。对于指针类型,失败时返回 nullptr;对于引用类型,失败时抛出 std::bad_cast 异常(现阶段理解这些知识太早,了解即可)。

这里给大家一段代码,自己尝试调试一下:

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

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; //没有匹配 Student 和 Person 类型的实参的operator=
    return 0;
}

总结一下:

转换方向转换方式安全性关键条件
派生类→基类隐式转换总是安全公有继承关系
基类→派生类static_cast条件安全开发者确保对象实际类型正确
基类→派生类dynamic_cast运行时验证基类为多态类型,失败有明确指示
  1. 优先使用向上转型:充分利用多态,通过基类接口操作对象。

  2. 谨慎使用向下转型:重新考虑设计,多态通常能提供更优雅的解决方案。

  3. 明确转换意图

    • 确定类型时使用 static_cast

    • 需要运行时验证时使用 dynamic_cast

  4. 确保基类为多态类型:如需使用 dynamic_cast,确保基类至少有一个虚函数(多态知识,通常应声明虚析构函数)。

C++派生类默认成员函数详解:编译器背后的自动生成逻辑

在C++中,当我们定义一个类而不显式编写某些特殊成员函数时,编译器会自动为它们生成默认版本。

而对于派生类,这些自动生成的函数有着与普通类不同的特殊行为逻辑,那么在派生类中,这些成员函数是如何生成的呢?

派生类的构造函数

派生类的构造函数必须要先调用父类的构造函数来初始化父类成员部分:

class Base {
public:
    Base(int x) : _x(x) {}  // 基类没有默认构造函数
private:
    int _x;
};

class Derived : public Base {
public:
    // 错误写法:编译器报错
    // Derived(int a, int b) : _a(a), _b(b) {}
    
    // 正确写法:必须显式调用基类构造函数
    Derived(int a, int b, int x) : Base(x), _a(a), _b(b) {}
    
private:
    int _a, _b;
};

两种情况

  • 基类有默认构造函数:派生类构造函数可以不显式调用,编译器会自动插入对基类默认构造函数的调用。

  • 基类没有默认构造函数:必须在派生类构造函数的初始化列表阶段显式调用基类的某个构造函数。

初始化顺序严格不变):

  1. 基类(父类)构造函数(按继承顺序)

  2. 成员变量的构造函数(按声明顺序)

  3. 派生类(子类)构造函数体

派生类的拷贝构造函数

派生类的拷贝构造函数必须调用基类的拷贝构造函数来完成基类部分的拷贝。

class Base {
public:
    Base() = default;
    Base(const Base& other) : _x(other._x) {
        cout << "Base拷贝构造被调用" << endl;
    }
private:
    int _x;
};

class Derived : public Base {
public:
    Derived() = default;
    
    // 编译器生成的默认拷贝构造函数等价于:
    // Derived(const Derived& d) : Base(d), _a(d._a), _b(d._b) {}
    
    // 如果手动实现,必须正确调用基类拷贝构造
    Derived(const Derived& d) 
        : Base(d),          // ✅ 正确:调用Base的拷贝构造函数
          _a(d._a), 
          _b(d._b) {
        cout << "Derived拷贝构造被调用" << endl;
    }
    
private:
    int _a, _b;
};

在这块,就要牵扯到前面所说的“切片”:当我们将Derived对象传递给Base(d)时,会发生隐式向上转型Base的拷贝构造函数接收到的是Derived对象中的基类子对象部分,而在我们之前将切片的论述中,这一过程是完全安全的。

派生类的operator=

派生类的operator=需要显式调用基类的operator=来完成基类部分的赋值。这里有一个关键细节:派生类的operator=会隐藏基类的operator=

class Base {
public:
    Base& operator=(const Base& other) {
        _x = other._x;
        cout << "Base::operator= 被调用" << endl;
        return *this;
    }
private:
    int _x;
};

class Derived : public Base {
public:
    // 编译器生成的默认operator=不会自动调用基类的operator=!
    // 如果需要正确拷贝基类部分,必须手动实现:
    
    Derived& operator=(const Derived& other) {
        if (this != &other) {
            // 必须显式指定作用域调用基类的operator=
            Base::operator=(other);  // ✅ 正确写法
            // 注意:直接写 operator=(other) 或 this->operator=(other)
            //       都会递归调用派生类自己的operator=,导致无限递归!
            
            _a = other._a;
            _b = other._b;
            cout << "Derived::operator= 被调用" << endl;
        }
        return *this;
    }
    
private:
    int _a, _b;
};

在派生类中定义名为operator=的函数后,它会隐藏基类中所有同名的operator=函数(包括参数类型不同的版本)。这是C++的名称查找规则决定的。

什么是隐藏?隐藏的规则又是什么?

在C++继承体系中,“隐藏”是一个易错的概念:它描述的是派生类中的名称(成员变量、成员函数)会优先于基类中的同名名称被查找和使用的现象。

什么是隐藏?

隐藏是指:当派生类定义了与基类同名的成员时,在派生类的作用域内,基类的同名成员变得不可直接访问,仿佛被“隐藏”起来了。

class Base {
public:
    void func() { cout << "Base::func()" << endl; }
    void func(int x) { cout << "Base::func(int)" << endl; }
};

class Derived : public Base {
public:
    void func() { cout << "Derived::func()" << endl; } // 隐藏了Base的所有func
};

int main() {
    Derived d;
    d.func();        // ✅ 输出: Derived::func()
    // d.func(42);   // ❌ 错误!Base::func(int)被隐藏了
    //虽然同名函数被隐藏起来了,但是我们依旧可以通过指定作用域来访问对应的函数
    d.Base::func(42); // ✅ 正确:通过作用域指定访问
    //同理,我们也可以通过指定作用域的方式来访问同名的成员变量
    return 0;
}

隐藏的核心规则

名称查找优先级

编译器在查找名称时遵循由内向外的顺序:

  1. 先在当前类作用域查找

  2. 找不到则向直接基类(父类)查找

  3. 依次向上,直到找到或到达继承链顶端

class A {
public:
    int value = 100;
};

class B : public A {
public:
    int value = 200; // 隐藏了A::value
};

int main() {
    B b;
    cout << b.value << endl;       // 200(使用B的value)
    cout << b.A::value << endl;    // 100(通过作用域访问A的value)
}
隐藏影响所有同名成员

派生类中的名称会一次性隐藏所有基类中同名的重载函数,而不仅仅是签名相同的那个。

class Base {
public:
    void process() { /* 版本1 */ }
    void process(int) { /* 版本2 */ }
    void process(double) { /* 版本3 */ }
};

class Derived : public Base {
public:
    // 这个函数隐藏了Base中所有的process函数
    void process() { /* 只覆盖无参版本 */ }
};

Derived d;
d.process();        // ✅ 调用Derived::process()
// d.process(10);   // ❌ 错误!Base::process(int)被隐藏
// d.process(3.14); // ❌ 错误!Base::process(double)被隐藏

为什么会有隐藏?

  1. 派生类优先原则:派生类更了解自己的需求,其定义应优先

  2. 避免意外调用:防止不小心调用到基类的“旧版本”函数

  3. 明确接口意图:如果派生类重新定义了某个函数,通常意味着它需要不同的行为

那么,隐藏又有哪些问题?又如何解决问题?

问题1:意外隐藏有用的基类函数
class Database {
public:
    void connect() { /* 建立连接 */ }
    void connect(string host) { /* 连接到指定主机 */ }
};

class MyDB : public Database {
public:
    void connect() { 
        // 忘记调用Database::connect()!
        setupSSL(); // 只做了SSL设置
    }
    // Database::connect(string) 被意外隐藏!
};

MyDB db;
db.connect();        // ✅ 但只做了SSL设置,没真正连接
// db.connect("mysql://host");  // ❌ 用户想用这个,但被隐藏了
解决方法:使用using声明引入基类名称
class Base {
public:
    void func() {}
    void func(int) {}
};

class Derived : public Base {
public:
    using Base::func;  // 引入Base中的所有func函数
    
    void func() {      // 仍然可以定义自己的版本
        Base::func();  // 调用基类版本
        // 添加额外功能
    }
    // 现在Base::func(int)也可用了
};

Derived d;
d.func();    // ✅ 调用Derived::func()
d.func(42);  // ✅ 调用Base::func(int)
问题2:析构函数的特殊隐藏情况(适用于多态中)
class Base {
public:
    ~Base() { cout << "~Base" << endl; }  // 非虚析构
};

class Derived : public Base {
public:
    ~Derived() { cout << "~Derived" << endl; }  // 隐藏Base::~Base()
    // 注意:编译器会将析构函数名统一处理为destructor()
};

int main() {
    Base* p = new Derived();
    delete p;  // 只输出 "~Base"!派生类析构没被调用
    // 内存泄漏!如果Derived分配了额外资源
}
解决方法2:显式指定作用域
class Base {
public:
    void important() { /* 关键逻辑 */ }
};

class Derived : public Base {
public:
    void important() { 
        // 先做自己的事情
        // ...
        // 然后调用基类版本
        Base::important();  // 明确指定作用域
    }
};
解决方法3:合理设计继承层次
// 好的设计:使用不同的名称
class Base {
public:
    virtual void doProcess() = 0;  // 主处理函数
};

class Derived : public Base {
public:
    void doProcess() override {    // 实现多态
        preProcess();              // 派生类特有步骤
        Base::doProcess();         // 调用基类核心逻辑
        postProcess();             // 派生类特有步骤
    }
    
private:
    void preProcess() {}  // 派生类特有,不会隐藏基类任何东西
    void postProcess() {}
};

派生类的析构函数

派生类的析构函数执行完后,会自动调用基类的析构函数来清理基类成员。这个顺序由编译器保证(这样才能保证派生类对象先清理,基类对象后清理这一逻辑顺序)。

class Base {
public:
    ~Base() {
        cout << "Base析构函数被调用" << endl;
        // 清理基类资源
    }
};

class Derived : public Base {
public:
    ~Derived() {
        cout << "Derived析构函数被调用" << endl;
        // 1. 先清理派生类自己的资源
        // 2. 然后编译器自动插入调用:Base::~Base();
    }
};

// 使用示例
int main() {
    Derived d;
    // 离开作用域时,析构顺序:
    // 1. ~Derived() 函数体执行
    // 2. 自动调用 ~Base()
    return 0;
}

析构顺序严格反向于构造顺序):

  1. 派生类析构函数体执行

  2. 派生类成员变量的析构函数(按声明逆序)

  3. 基类析构函数(按继承逆序)

构造与析构的顺序保证

派生类(子类)的初始化顺序要严格按照声明顺序执行(前面讲述过),同样,析构顺序也要按照声明顺序的倒序来执行。

class Base { /* ... */ };
class Derived : public Base { /* ... */ };

// 创建对象时的顺序:
Derived d;  // 实际执行顺序:
            // 1. Base::Base()     - 基类构造
            // 2. Derived::Derived() - 派生类构造

// 销毁对象时的顺序(自动发生):
            // 1. ~Derived()       - 派生类析构
            // 2. ~Base()          - 基类析构

这样就保证了:

  1. 构造时:基类成员先初始化,派生类可以安全使用基类功能

  2. 析构时:派生类资源先清理,避免基类资源被依赖时已释放

析构函数的特殊处理(牵扯到多态知识)

编译器会对析构函数名进行特殊处理,统一命名为destructor()。这带来一个重要后果:

class Base {
public:
    // 如果没有virtual,函数签名实际上是:~Base() -> destructor()
    ~Base() { cout << "~Base()" << endl; }
};

class Derived : public Base {
public:
    // 函数签名也是:~Derived() -> destructor()
    // 因此它隐藏了基类的destructor()函数
    ~Derived() { cout << "~Derived()" << endl; }
};

这里要牵扯到多态中重写的概念(彩妙后续会讲解多态),而重写的条件之一就是有相同函数(也就是函数名相同)。

那么,编译器会做特殊的处理:将所有的析构函数都重命名为destructor()(当然,底层逻辑不是这样的)。这样,在所有基类(父类)的析构函数如果没有加virtual虚拟化的情况下,由这个基类(父类)衍生出的所有派生类(子类)都构成隐藏关系

C++11新增关键字:final(不能被继承的类)

如果我们想让这个类不能被继承,有两种方法:

  1. 基类的构造函数私有,这样派生类尝试构造基类时,无法访问构造函数,就无法实例出基类的对象,从了达到基类无法被继承这一目的;
  2. C++11中新增关键字:fina,在声明类的时候,在类后加上这个关键字,那么这个基类就无法没继承。
#include <iostream>
using namespace std;

// C++11的⽅法
// class Base final
// {
// public:
//     void func5() { cout << "Base::func5" << endl; }
// protected:
//     int a = 1;
//private:
    // C++98的⽅法
    /*Base()
    {}*/
//};
class Base final{
public:
    void func5() { cout << "Base::func5" << endl; }
    

//private:
    Base() : a(1) {
        cout << "Base::Base" << endl;
    }
protected:
    int a = 1;
};

//不能从 'final' class 'Base'继承
class Derive :public Base
{
    void func4() { cout << "Derive::func4" << endl; }
protected:
    int b = 2;
};
int main()
{
    //C++98会报错:不能初始化Base类型的对象: 默认构造函数为非 public 构造函数
    Base b;

    Derive d;

    return 0;
}

继承与友元

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

#include <iostream>
using namespace std;


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成员实例。

#include <iostream>
using namespace std;

class Person
{
public:
    string _name;
    static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:
    int _stuNum;
};
int main() {
    Person p;
    Student s;
    // 这⾥的运⾏结果可以看到⾮静态成员_name的地址是不⼀样的
    // 说明派⽣类继承下来了,⽗派⽣类对象各有⼀份
    cout << &p._name << endl;
    cout << &s._name << endl;

    // 这⾥的运⾏结果可以看到静态成员_count的地址是⼀样的
    // 说明派⽣类和基类共⽤同⼀份静态成员
    cout << &p._count << endl;
    cout << &s._count << endl;

    // 公有的情况下,⽗派⽣类指定类域都可以访问静态成员
    cout << Person::_count << endl;
    cout << Student::_count << endl;
    return 0;
}

多继承及其菱形继承问题(较为重要)

上面我们说过一个概念:超基类。那么,现在我们就来谈谈:有关继承模型相关的概念。

继承模型

在继承模型中,我们分为三种继承:单继承,多继承以及菱形继承。

单继承

⼀个派⽣类只有⼀个直接基类时称这个继承关系为单继承(最底下为子类,箭头指向为继承关系):

多继承

允许一个派生类同时拥有多个直接基类,多继承对象在内存中的模型是:先继承的基类在前⾯,后⾯继承的基类在后⾯,派生类成员在放到最后⾯:

多继承的优缺点可以看这位大佬写的知乎:

C++的多继承有什么缺点和优点?https://www.zhihu.com/question/39927143总之,多继承算是C++设计出的缺陷之一,从而导致后面一些编程语言没有采用这种继承方式(比如Java)

有关多继承中指针偏移的问题:

这是多继承中最微妙的问题之一,涉及指针类型转换时的地址调整。

class Base1 {
public:
    int data1 = 100;
};

class Base2 {
public:
    int data2 = 200;
};

class Derived : public Base1, public Base2 {
public:
    int data3 = 300;
};

int main() {
    Derived d;
    
    Derived* pd = &d;
    Base1* pb1 = pd;     // ✅ 不需要偏移,Base1在开头
    Base2* pb2 = pd;     // 🔄 需要偏移!指向Derived中的Base2部分
    
    cout << "pd地址:   " << pd << endl;
    cout << "pb1地址:  " << pb1 << endl;  // 与pd相同
    cout << "pb2地址:  " << pb2 << endl;  // 比pd大sizeof(Base1)
    
    // 验证偏移
    cout << "偏移量: " << (void*)pb2 - (void*)pd << " 字节" << endl;
}

Derived对象内存布局:
0x00: [Base1子对象] data1
0x04: [Base2子对象] data2  ← pb2指向这里(pd + sizeof(Base1))
0x08: [Derived部分] data3

想这种小的知识点,考研爱考~

菱形继承

菱形继承是多继承的⼀种特殊情况,当多个基类继承自同一个更高级的基类时,会出现 菱形继承

注意:在我们写代码的时候,最好不要使用菱形继承!

菱形继承存在数据冗余和⼆义性这两方面的问题。支持多继承就意味着一点会出现菱形继承(JAVA就不支持多继承,斩草除根说是)。

数据冗余指同一份数据在内存中存储多次,浪费空间且可能导致不一致。

二义性指编译器无法确定应访问哪个继承路径中的同名成员,导致编译错误。

两者是菱形继承的核心问题,虚继承通过共享唯一基类副本来同时解决它们。

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

int main() {
    // 编译报错:error C2385: 对“_name”的访问不明确
    Assistant a;
    //a._name = "peter";
    // 需要显⽰指定访问哪个基类的成员可以解决⼆义性问题,但是数据冗余问题⽆法解决
    a.Student::_name = "xxx";
    a.Teacher::_name = "yyy";
    return 0;
}

解决方案:虚继承

虚继承通过确保虚基类只有一个副本来解决这两个问题:

// 使用虚继承
class Person { /* 同上 */ };

class Student : virtual public Person {  // 虚继承
    // 现在Student不包含完整的Person,只包含一个指针/偏移量
};

class Teacher : virtual public Person {  // 虚继承
    // 同上
};

class TeachingAssistant : public Student, public Teacher {
public:
    TeachingAssistant(string n, int a, string sid, string tid)
        : Person(n, a), Student(n, a, sid), Teacher(n, a, tid) {
        // Person由最派生类(TeachingAssistant)直接初始化
    }
    
    void showBenefits() {
        cout << "\n=== 虚继承的优势 ===" << endl;
        
        // ✅ 不再有二义性
        cout << "姓名: " << name << endl;  // 直接访问,只有一份
        cout << "年龄: " << age << endl;   // 直接访问,只有一份
        
        // ✅ 不再有数据冗余
        name = "李四";  // 只修改一次,两个路径都生效
        cout << "Student路径: " << Student::name << endl;  // 李四
        cout << "Teacher路径: " << Teacher::name << endl;  // 李四
        
        // ✅ 地址相同,证明是同一份数据
        cout << "&Student::name == &Teacher::name ? " 
             << (&Student::name == &Teacher::name ? "是" : "否") << endl;
    }
};

这里强烈建议大家:不要玩菱形继承!因为菱形虚拟继承以后,无论是使用还是底层都会复杂很多。而有菱形继承就会有虚继承,虚继承底层实现复杂且会损失性能,所以最好不要设计菱形继承

但是C++的I/O库中使用了菱形继承,但依然不推荐使用。


好啦,本篇博客到此就结束啦,下节我们会走进C++的OOP中多态之路啦,喜欢文章的小伙伴可以点点赞,关注彩妙,获取更多优质好文吧~

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐