从零开始的C++之继承

一、继承的概念

    说到"继承"大家都应该都不陌生,我敢保证,你每天都在享受 “继承” 带来的巨大好处,只是你自己从来没意识到而已。
    咱们先不说编程,就说你手里的手机。
先看一个所有人都懂的故事

10 年前,你爸妈用的是那种按键老人机。它能干什么?
图片

  • 能打电话
  • 能发短信
  • 能充电、能开关机
  • 能调音量
    这 4 件事,是所有 “手机” 最最基本的功能,对吧?
    后来,苹果出了第一代 iPhone。你想想:iPhone 能不能打电话?能不能发短信?能不能充电开关机?能不能调音量?当然能! 这些功能,苹果的工程师根本没有 “重新发明” 一遍。他们直接把老人机已经验证了几十年的、成熟的、所有人都会用的功能,原封不动地拿了过来。
    但是,iPhone 又加了一堆老人机没有的新东西:
  • 触屏操作
  • 能上网
  • 能拍照
  • 能装微信、抖音、游戏
    再后来,出了 iPhone 4、iPhone 8、iPhone 15,直到你现在手里这台。你会发现:每一代新手机,都 100% 保留了上一代所有好用的功能,然后只加自己的新东西
    图片 图片图片
  • iPhone 15 不用重新发明 “打电话”
  • 不用重新发明 “触屏”
  • 不用重新发明 “微信” 怎么装
  • 它只需要把摄像头做得更好,把芯片做得更快,加个灵动岛就行
这,就是生活中的 “继承”

它的核心逻辑简单到离谱:

别人已经做好的、成熟的、通用的东西,我直接拿过来用。我只需要做我自己独有的、新的部分。
这就是人类进步的秘密。如果没有继承,每一代新手机都要从零开始,先研究怎么打电话、怎么发短信,那我们现在可能还在用大哥大。

再举一个例子,彻底搞懂

再看汽车:
图片图片

  • 所有汽车都有 4 个轮子、方向盘、发动机
  • 所有汽车都能 “开”、能 “刹车”、能 “加油”
    这是 “汽车” 这个大类共有的属性和能力。
    然后:
    图片 图片 图片
  • 轿车继承了汽车的所有特点,加了 “舒适的座椅”,适合家用
  • SUV 继承了汽车的所有特点,加了 “更高的底盘”,适合走烂路
  • 跑车继承了汽车的所有特点,加了 “超强的发动机”,适合跑得快
  • 货车继承了汽车的所有特点,加了 “巨大的货箱”,适合拉货
    你看,没有一个车厂会傻到每造一种新车,都重新发明一遍 “轮子” 和 “刹车”。他们都是先继承 “汽车” 这个基础,然后只做差异化的部分。
现在,我们一秒钟过渡到编程
    编程里的 “继承”,和刚才讲的手机、汽车,逻辑**完全一模一样**!

刚才的生活例子,对应到编程里就是:

生活中的东西 编程里的名字(不用记,先有个印象)
最基础的 “手机”/“汽车” 父类(也叫基类)
智能手机 / 轿车 / SUV / 跑车 子类(也叫派生类)
打电话 / 开车 / 刹车 方法(就是能干什么)
4 个轮子 / 触屏 / 摄像头 属性(就是有什么特征)
为什么编程非要搞 “继承”?

和生活中一样,就 3 个好处:

  1. 省事儿:不用每个类都写一遍 “打电话”“开车” 的代码,写一遍,所有子类都能用
  2. 统一:所有手机的打电话操作都一样,所有汽车的刹车操作都一样,不容易出错
  3. 好改:如果以后 “打电话” 功能要升级,只需要改最基础的 “手机” 父类,所有子类自动就升级了
反过来看:没有继承会有多惨?
    如果没有继承,你写一个 “微信” 类,要写一遍 “登录”“发消息”“发朋友圈”你再写一个 “QQ” 类,又要从头写一遍 “登录”“发消息”“发朋友圈”你再写一个 “钉钉” 类,还要再写一遍……不仅累死你,而且万一 “登录” 功能有 bug,你要改 3 个地方,很容易改漏。

有了继承就简单了:你先写一个 “社交软件” 父类,把 “登录”“发消息” 这些通用功能写好然后微信、QQ、钉钉都继承这个父类它们只需要写自己独有的功能:微信写 “公众号”,QQ 写 “QQ 秀”,钉钉写 “打卡”

开篇收尾
    所以你看,继承一点都不复杂,它就是编程世界里的 “拿来主义”。它不是什么高深的发明,只是程序员把生活中最朴素的智慧,用到了写代码上而已。
    接下来,我们就用几行最简单的代码,把刚才讲的 “手机继承” 的故事,原封不动地写出来。你会发现,代码和你刚才理解的故事,几乎是逐字对应的。

二、继承的语法

继承的固定格式:
      我们就拿最开始说的手机来做一个完整继承语法演示
// 父类(基类):基础老人机(所有手机通用的功能)
class BasePhone {
// 公有成员:所有人都能访问
public:
    string brand; // 品牌
    // 通用方法:打电话
    void call() {
        cout << brand << "正在打电话..." << endl;
    }
    // 通用方法:发短信
    void sendMessage() {
        cout << brand << "正在发短信..." << endl;
    }
};
// 子类(派生类):智能手机 【继承语法:class 子类名 : 继承方式 父类名】
class SmartPhone : public BasePhone {
// 子类新增的独有功能
public:
    int cameraPixel; // 摄像头像素(子类独有属性)
    // 独有方法:拍照
    void takePhoto() {
        cout << brand << "用" << cameraPixel << "万像素摄像头拍照" << endl;
    }
    // 独有方法:上网
    void browseInternet() {
        cout << brand << "正在浏览网页" << endl;
    }
};

可以看见基本语法为:

class 子类名 : 继承方式 父类名 {
    // 子类新增的成员
};

图片

自然引出:访问限定符与继承方式

刚才的代码中,brandcall()都放在了public:下面,所以子类和外部都能访问。但如果我把brand改成private:,你会发现子类 SmartPhone 里也访问不到它了
这就引出了 C++ 继承中最核心的两个概念:

  1. 类的访问限定符:控制类成员的可见范围
  2. 继承方式:控制父类成员在子类中的访问权限变化
    图片
1. 三个访问限定符(先记作用域)
访问限定符 类内部 子类 类外部(main 函数)
public
protected
private

🔑 关键区别:protected专门为继承设计的,它允许子类访问父类成员,但禁止外部直接访问。

2. 三种继承方式(权限传递规则)

继承方式决定了父类成员在子类中最终的访问权限,核心规则:子类不能提升父类成员的访问权限
图片

💡 实际开发中99% 的情况都用 public 继承,其他两种继承方式极少使用,初学者先重点掌握 public 继承即可。

总结一下

**1.**基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
**2.**基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出的。
**3.**上面的表格我们进行一下总结会发现,基类的私有成员在派生类都是不可见。基类的其他成员在派生类的访问方式==Min (成员在基类的访问限定符,继承方式),public >protected > private。
**4.**使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
三、深入基类与派生类

1.基类和派生类之间的转换

先来看看这几条关于继承的用法:
(1) public继承的派生类对象可以赋值给基类的指针/基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。
我们就拿一个person类来举例

class Person {
protected:
    string _name; // 姓名
    string _sex;  // 性别
    int _age;     // 年龄
public:
    // 虚析构函数(后续dynamic_cast必需)
    virtual ~Person() = default;
    
    void setInfo(string name, string sex, int age) {
        _name = name;
        _sex = sex;
        _age = age;
    }
    
    void showBaseInfo() {
        cout << "姓名:" << _name << ",性别:" << _sex << ",年龄:" << _age << endl;
    }
};
class Student : public Person {
public:
    int _No; // 学号(学生独有)
    
    void setStudentInfo(string name, string sex, int age, int no) {
        setInfo(name, sex, age);
        _No = no;
    }
    
    void showStudentInfo() {
        showBaseInfo();
        cout << "学号:" << _No << endl;
    }
};
int main()
{
	Student sobj;
	// 1 .派生类对象可以赋值给基类的指针/引用
		Person * pp = &sobj;
	Person & rp = sobj;
	
	// 生类对象可以赋值给基类的对象是通过调用后面会讲解的基类的拷贝构造完成的
	Person pobj = sobj;
	
	//2 .基类对象不能赋值给派生类对象,这里会编译报错
		sobj = pobj;
	
	return 0;
}
   这样让基类的指针/引用/对象指向派生类的操作 都是允许的 这又是为了后面我们学习多态作的铺垫

图片图片 关键概念:对象切片(Object Slicing)
当用派生类对象直接赋值给基类对象时,只会复制派生类中属于基类的那部分成员,派生类独有的成员会被 “切掉” 丢弃。
图示:
图片
生活类比:就像把一个智能手机放进一个只能装老人机的盒子里,盒子只能容纳老人机的部分,触屏、摄像头这些智能手机独有的功能就被 “切掉” 了,拿出来后就只能当老人机用。
(2) 基类对象不能赋值给派生类对象
图片
(3) 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic/_cast 来进行识别后进行安全转换。(ps:这个我们后面类型转换章节再单独专门讲解,这里先提一下)
向下转型(基类 → 派生类):不安全,需强制转换
向下转型是C++ 不默认支持的操作,因为一个基类对象不一定是一个派生类对象。

int main() {
    Person adult; // 这是一个普通成年人,根本没有学号
    adult.setInfo("李四", "女", 30);
    // Student* pStu = &adult; // x 编译直接报错!不能直接转换
    // C风格强制转换(极度危险!)
    Student* pStu = (Student*)&adult;
    pStu->showBaseInfo(); // √ 碰巧能调用,因为基类有这个方法
    pStu->_No = 2024003;  // x 严重错误!访问不存在的内存
    pStu->showStudentInfo(); // x 程序崩溃
    return 0;
}

图片
生活类比:你不能把一个纯老人机强行当成智能手机用,它根本没有摄像头,你非要让它拍照,它只能 “死机”。

2.基类和派生类的“隐藏”关系

2.1隐藏规则:

1.在继承体系中基类和派生类都有独立的作用域。
2.派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。(在派生类成员函数中,可以使用基类::基类成员显示访问)
3.需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4.注意在实际中在继承体系里面最好不要定义同名的成员。
继续看代码示例:

class Person
{
protected:
	string _name = "小李子"; // 姓名
	int _num = 111;        // 身份证号
};
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;
};

在Person类和Student类中,因为构成继承关系所以他们中都有的同名变量“/_num”构成隐藏
这里的Print输出的就是Student类中的/_num(学号)我们想要他同时实现就需要显示调用
图片
再来看看第三条的内容

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;
};

图片
这里我们调用的第二条语句 b.fun()就会报错,因为在b的作用域中找不到不需要参数的fun(),这也说明了在继承中父类的同名变量/函数都会构成隐藏。

3.派生类的默认成员函

前面我们讲了继承的基本语法、访问限定符和基类派生类的类型转换。很多同学学到这里会觉得:“继承好像也不难嘛,不就是子类复用父类的代码吗?”
但一到写代码或者面试的时候,就会被各种奇怪的问题难住:

  • 为什么我写子类构造函数的时候编译报错?
  • 为什么子类的赋值运算符会无限递归?
  • 为什么用基类指针 delete 派生类对象会内存泄漏?
    这些问题的根源,都在于你没有真正搞懂继承中默认成员函数的生成规则
    核心原则(先记这个,后面全是它的延伸)
    继承中所有默认成员函数的行为,都遵循一个最简单的原则:

派生类只负责初始化和清理自己新增的成员,基类的成员永远交给基类自己的函数来处理。
就像一个学生的档案,学校只负责填写学号、班级这些学生独有的信息,姓名、性别、年龄这些基本信息,永远是由户籍部门(基类)来填写和管理的。

一、构造函数:先造爸爸,再造儿子

1. 规则

  • 派生类的构造函数必须调用基类的构造函数来初始化基类部分的成员
  • 如果基类有默认构造函数,编译器会自动帮你调用
  • 如果基类没有默认构造函数,你必须在派生类构造函数的初始化列表中显式调用
    2.继续用Person类举例子
class Person {
protected:
    string _name;
    int _age;
public:
    // 基类构造函数(带默认参数,相当于同时提供了默认构造)
    Person(string name = "未知", int age = 0) 
        : _name(name), _age(age) {
        cout << "【Person 构造】" << _name << "," << _age << "岁" << endl;
    }
};
class Student : public Person {
private:
    int _stuNo; // 学生独有成员
public:
    // 派生类构造函数
    Student(string name = "未知", int age = 0, int stuNo = 0) 
        : Person(name, age), // 显式调用基类构造函数(必须写在初始化列表)
          _stuNo(stuNo) {
        cout << "【Student 构造】学号:" << _stuNo << endl;
    }
};
int main() {
    Student s1("张三", 18, 2024001);
    return 0;
}

3.输出:
图片
4. 注意点

  • 基类构造函数的调用必须写在初始化列表中,不能写在构造函数体内
  • 如果基类没有默认构造函数(把上面Person构造函数的默认参数去掉),不写Person(name, age)这行,编译直接报错
  • 调用顺序永远是:先基类构造,再派生类构造
二、拷贝构造函数:爸爸的部分爸爸来复制

1. 规则

  • 派生类的拷贝构造函数必须调用基类的拷贝构造函数来完成基类部分的拷贝
  • 这里利用了我们上面讲的向上转型:派生类对象可以传给基类的引用
    2.代码
class Person {
public:
    // 基类拷贝构造函数
    Person(const Person& p) 
        : _name(p._name), _age(p._age) {
        cout << "【Person 拷贝构造】复制 " << _name << endl;
    }
    Person(string name = "未知", int age = 0) 
        : _name(name), _age(age) {
        cout << "【Person 构造】" << _name << "," << _age << "岁" << endl;
    }
protected:
 string _name;
    int _age;
};
class Student : public Person {
public:
    // 派生类拷贝构造函数
    Student(const Student& s) 
        : Person(s), // 派生类对象s传给基类引用,调用基类拷贝构造
          _stuNo(s._stuNo) {
        cout << "【Student 拷贝构造】复制学号:" << _stuNo << endl;
    }
    Student(string name = "未知", int age = 0, int stuNo = 0) 
        : Person(name, age), // 显式调用基类构造函数(必须写在初始化列表)
          _stuNo(stuNo) {
        cout << "【Student 构造】学号:" << _stuNo << endl;
    }
private:
 int _stuNo; // 学生独有成员
};
int main() {
    Student s1("张三", 18, 2024001);
    Student s2(s1); // 调用拷贝构造
    return 0;
}

3.输出
图片
4. 常见错误
如果你忘记调用基类的拷贝构造函数,会发生什么?

// 错误写法
Student(const Student& s) 
    : _stuNo(s._stuNo) {
    // 编译器会自动调用Person()默认构造函数
    cout << "【Student 拷贝构造】复制学号:" << _stuNo << endl;
}

输出:图片
你会发现,s2的姓名变成了 “未知”,年龄变成了 0,这显然不是我们想要的结果。

三、赋值运算符重载:最容易踩坑的地方

1. 规则

  • 派生类的operator=必须显式调用基类的operator=来完成基类部分的复制
  • 必须指定基类作用域Person::),否则会因为名字隐藏导致无限递归
    2.代码示例
class Person {
public:
    // 基类拷贝构造函数
    Person(const Person& p)
        : _name(p._name), _age(p._age) {
        cout << "【Person 拷贝构造】复制 " << _name << endl;
    }
    Person(string name = "未知", int age = 0)
        : _name(name), _age(age) {
        cout << "【Person 构造】" << _name << "," << _age << "岁" << endl;
    }
    Person& operator=(const Person& p) {
        cout << "【Person 赋值】将 " << p._name << " 赋值给 " << _name << endl;
        if (this != &p) {
            _name = p._name;
            _age = p._age;
        }
        return *this;
    }
   
protected:
    string _name;
    int _age;
};
class Student : public Person {
public:
    // 派生类拷贝构造函数
    Student(const Student& s)
        : Person(s), // 派生类对象s传给基类引用,调用基类拷贝构造
        _stuNo(s._stuNo) {
        cout << "【Student 拷贝构造】复制学号:" << _stuNo << endl;
    }
    Student(string name = "未知", int age = 0, int stuNo = 0)
        : Person(name, age), // 显式调用基类构造函数(必须写在初始化列表)
        _stuNo(stuNo) {
        cout << "【Student 构造】学号:" << _stuNo << endl;
    } 
    Student& operator=(const Student& s) {
        cout << "【Student 赋值】开始赋值" << endl;
        if (this != &s) {
            // 必须加 Person:: 否则会调用派生类自己的operator=,造成无限递归
            Person::operator=(s);
            _stuNo = s._stuNo;
        }
        return *this;
    }
private:
    int _stuNo; // 学生独有成员
};
int main() {
    Student s1("张三", 18, 2024001);
    Student s2(s1); // 调用拷贝构造
    return 0;
}

3.输出
图片
4. 为什么必须加Person::
这是因为 C++ 的名字隐藏规则:只要派生类有一个和基类同名的函数(不管参数是否相同),基类的所有同名函数都会被隐藏。
如果你不加Person::,代码会变成这样:

// 错误写法:无限递归
Student& operator=(const Student& s) {
    if (this != &s) {
        operator=(s); // 等价于 this->operator=(s),调用自己,死循环
        _stuNo = s._stuNo;
    }
    return *this;
}

运行后程序会直接栈溢出崩溃,这是继承中最常见的坑之一。

四、析构函数:先拆儿子,再拆爸爸

1. 规则

  • 派生类的析构函数完成后,编译器会自动调用基类的析构函数
  • 调用顺序永远是:先派生类析构,再基类析构(和构造顺序相反)
  • 基类析构函数建议加上virtual关键字,避免多态场景下的内存泄漏
    2. 代码演示
class Person {
public:
    // 基类析构函数
    ~Person() {
        cout << "【Person 析构】" << _name << endl;
    }
    // 基类拷贝构造函数
    Person(const Person& p)
        : _name(p._name), _age(p._age) {
        cout << "【Person 拷贝构造】复制 " << _name << endl;
    }
    Person(string name = "未知", int age = 0)
        : _name(name), _age(age) {
        cout << "【Person 构造】" << _name << "," << _age << "岁" << endl;
    }
    Person& operator=(const Person& p) {
        cout << "【Person 赋值】将 " << p._name << " 赋值给 " << _name << endl;
        if (this != &p) {
            _name = p._name;
            _age = p._age;
        }
        return *this;
    }
   
protected:
    string _name;
    int _age;
};
class Student : public Person {
public:
    // 派生类析构函数
    ~Student() {
        cout << "【Student 析构】学号:" << _stuNo << endl;
        // 编译器会在这里自动插入:Person::~Person();
    }
    // 派生类拷贝构造函数
    Student(const Student& s)
        : Person(s), // 派生类对象s传给基类引用,调用基类拷贝构造
        _stuNo(s._stuNo) {
        cout << "【Student 拷贝构造】复制学号:" << _stuNo << endl;
    }
    Student(string name = "未知", int age = 0, int stuNo = 0)
        : Person(name, age), // 显式调用基类构造函数(必须写在初始化列表)
        _stuNo(stuNo) {
        cout << "【Student 构造】学号:" << _stuNo << endl;
    } 
    Student& operator=(const Student& s) {
        cout << "【Student 赋值】开始赋值" << endl;
        if (this != &s) {
            // 必须加 Person:: 否则会调用派生类自己的operator=,造成无限递归
            Person::operator=(s);
            _stuNo = s._stuNo;
        }
        return *this;
    }
private:
    int _stuNo; // 学生独有成员
};
int main() {
    Student s1("张三", 18, 2024001);
    return 0;
}

3.输出
图片
4. 最重要的知识点:虚析构函数
这是面试中被问得最多的问题,没有之一。
当我们用基类指针指向派生类对象时,如果基类析构不是虚函数,会发生什么?

int main() {
    Person* p = new Student("王五", 20, 2024003);
    delete p; // 只会调用Person::~Person(),不会调用Student::~Student()
    return 0;
}

输出结果:
图片
你会发现,Student的析构函数根本没有被调用!如果Student类中申请了堆内存,就会造成严重的内存泄漏。
解决方法:给基类析构函数加上virtual关键字:

class Person {
public:
    virtual ~Person() { // 虚析构函数
        cout << "【Person 析构】" << _name << endl;
    }
};

加上virtual后,运行结果变成:
图片
现在析构顺序就正确了,整个对象都被完整地清理了。

注意一下:只要一个类可能被当作基类,就应该把它的析构函数声明为虚函数。

五、总结

6个默认成员函数,默认的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
1.派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2.派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3.派生类的operator=必须要调用基类的operator=完成基类的复制。需要注意的是派生类的 operator=隐藏了基类的operator=,所以显示调用基类的operator=,需要指定基类作用域
4.派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5.派生类对象初始化先调用基类构造再调派生类构造。
6.派生类对象析构清理先调用派生类析构再调基类的析构。
7.因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们多态章节会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加 virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。
图片
图片
继承中的默认成员函数,看似复杂,其实核心逻辑非常简单:分工明确,各司其职。基类管基类的,派生类管派生类的,谁的孩子谁抱走。
只要你记住了这个核心原则,再结合上面的代码示例多写几遍,以后再遇到继承相关的问题,就再也不会懵了。
四、不能被继承的类

实现一个不能被继承的类
方法1:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构成函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。
方法2:C++11新增了一个final关键字,final修改基类,派生类就不能继承了。
图片
五、继承与友元

用一个流传千年的欧洲封建法则,彻底讲透继承中最容易被忽略的友元规则。
在讲完继承的基本语法、类型转换和默认成员函数后,很多同学会觉得:“继承好像就是子类全盘接收父类的一切嘛”。
但有一个东西是绝对不能继承的,那就是友元关系
这正好对应了中世纪欧洲最著名的封建法则:
图片
这句话完美概括了 C++ 中友元与继承的核心规则。今天我们就用这个法则,把这个知识点讲得明明白白。

一、先回顾:什么是友元?

友元是 C++ 中一种特殊的权限机制,它允许一个函数或类突破访问限定符的限制,访问另一个类的私有(private)和保护(protected)成员。
简单来说:友元就是我家的客人,我允许他进我的卧室(访问私有成员)。
基础示例(Person 类的友元)

class Person {
protected:
    string _name; // 保护成员
    int _age;     // 保护成员
private:
    string _idCard; // 私有成员:身份证号(最私密的信息)
public:
    Person(string name, int age, string idCard)
        : _name(name), _age(age), _idCard(idCard) {}
    // 声明:printPersonInfo是Person类的友元函数
    friend void printPersonInfo(const Person& p);
};
// 友元函数:可以访问Person的所有成员(包括private和protected)
void printPersonInfo(const Person& p) {
    cout << "姓名:" << p._name << endl;
    cout << "年龄:" << p._age << endl;
    cout << "身份证号:" << p._idCard << endl; // √ 可以访问私有成员
}
int main() {
    Person p("张三", 18, "5002342006XXXXXX");
    printPersonInfo(p);
    return 0;
}

输出:
图片

二、核心规则:友元关系不能继承

现在问题来了:如果 Student 类继承自 Person 类,那么 Person 的友元函数printPersonInfo,能不能访问 Student 类的私有成员呢?
答案是:绝对不能!
这就是 “我的附庸的附庸不是我的附庸” 的完美体现。

1. 封建法则与 C++ 规则的对应

我们用中世纪欧洲的封建制度来做个类比,保证你一辈子都忘不掉:

封建制度 C++ 友元与继承
国王 友元函数 / 友元类
公爵 基类(Person)
骑士 派生类(Student)
公爵宣誓效忠国王,国王可以进入公爵的城堡 基类声明友元,友元可以访问基类的私有成员
骑士宣誓效忠公爵,但不效忠国王 派生类继承基类,但友元不是派生类的友元
国王不能直接进入骑士的城堡 基类的友元不能访问派生类的私有成员
class Student : public Person {
private:
    int _stuNo; // 学生独有私有成员:学号
public:
    Student(string name, int age, string idCard, int stuNo)
        : Person(name, age, idCard), _stuNo(stuNo) {}
};
// 还是那个Person的友元函数
void printPersonInfo(const Person& p) {
    cout << "姓名:" << p._name << endl;
    cout << "年龄:" << p._age << endl;
    // cout << "学号:" << p._stuNo << endl; // x 编译错误!
    // 错误信息:'_stuNo' is a private member of 'Student'
}
int main() {
    Student s("张三", 18, "5002342006XXXXXX", 2024001);
    printPersonInfo(s); // 向上转型,只能访问Person部分的成员
    return 0;
}

关键解释

  • printPersonInfo是 Person 的朋友,所以它能看到 Person 的所有东西
  • 但它不是Student 的朋友,所以它看不到 Student 自己独有的东西
  • 即使 Student 继承了 Person,这个 “朋友关系” 也不会自动延续
2. 友元类也不能继承

同样的规则也适用于友元类:如果 Teacher 是 Person 的友元类,那么 Teacher 可以访问 Person 的私有成员,但不能访问 Student 的私有成员。

三、如果我就是想让友元也能访问派生类成员怎么办?

很简单:让派生类自己也声明这个函数 / 类为友元。
就像国王想要进入骑士的城堡,不能通过公爵的关系,必须得到骑士本人的允许。

class Student : public Person {
private:
    int _stuNo;
public:
    Student(string name, int age, string idCard, int stuNo)
        : Person(name, age, idCard), _stuNo(stuNo) {}
    // 派生类自己声明printPersonInfo为友元
    friend void printPersonInfo(const Student& s);
};
// 重载一个针对Student的友元函数
void printPersonInfo(const Student& s) {
    cout << "姓名:" << s._name << endl;
    cout << "年龄:" << s._age << endl;
    cout << "学号:" << s._stuNo << endl; // √ 现在可以访问了
}
int main() {
    Student s("张三", 18, "5002342006XXXXXX", 2024001);
    printPersonInfo(s);
    return 0;
}

四、友元的三个 “不” 原则

结合继承的特性,我们可以总结出友元的三个核心原则,这也是面试的高频考点:

  1. 友元关系不能继承
    • 基类的友元不是派生类的友元
    • 派生类的友元也不是基类的友元
  2. 友元关系是单向的
    • 如果 A 是 B 的友元,B 不一定是 A 的友元
    • 我把你当朋友,允许你进我家,但你不一定允许我进你家
  3. 友元关系不能传递
    • 如果 A 是 B 的友元,B 是 C 的友元,A 不一定是 C 的友元
    • 这和 “我的朋友的朋友不是我的朋友” 是一个道理

五、总结

  • 友元关系绝对不能继承,这是 C++ 继承中最容易被忽略的规则
  • 用 “我的附庸的附庸不是我的附庸” 这个比喻,可以完美记住这个规则
  • 友元还有两个重要特性:单向性和不可传递性
  • 如果需要让友元访问派生类成员,必须在派生类中单独声明
    六、静态成员 —— 全家族共享的公共财产

我们来看另一个和继承相关的特殊成员:静态成员
和友元正好相反,静态成员是可以被继承的。但它的继承方式和普通成员完全不同,它不是每个派生类对象都有一份,而是整个继承体系共享同一份
用一个生活中的比喻来说:

基类的静态成员,就像整个家族的公共财产。所有子孙后代(派生类)都共享这同一份财产,而不是每个人都单独继承一份。

一、先回顾:什么是静态成员?

在讲继承之前,我们先快速回顾一下静态成员的核心特性:

  • 静态成员属于整个类,而不属于某个具体的对象
  • 整个程序中只有一份静态成员的实例
  • 静态成员在程序启动时初始化,程序结束时销毁
  • 访问静态成员可以用类名::静态成员名,也可以用对象.静态成员名
    示例:
class Person {
public:
    // 静态成员变量:统计总人数
    static int _count;
    Person() {
        _count++; // 每创建一个Person对象,总人数+1
    }
    ~Person() {
        _count--; // 每销毁一个Person对象,总人数-1
    }
};
// 静态成员变量必须在类外初始化
int Person::_count = 0;
int main() {
    Person p1;
    Person p2;
    cout << "总人数:" << Person::_count << endl; 
    return 0;
}

输出:
图片

二、核心规则:静态成员被整个继承体系共享

这是静态成员在继承中最重要的规则:基类的静态成员,会被所有派生类共享,整个程序中只有一份实例。
代码验证(基于 Person→Student 类)

class Person {
protected:
    string _name;
    int _age;
public:
    // 静态成员变量:全家族共享的总人数
    static int _totalCount;
    Person(string name, int age) 
        : _name(name), _age(age) {
        _totalCount++;
        cout << "创建Person:" << _name << ",总人数:" << _totalCount << endl;
    }
    virtual ~Person() {
        _totalCount--;
        cout << "销毁Person:" << _name << ",总人数:" << _totalCount << endl;
    }
    // 静态成员函数
    static int getTotalCount() {
        return _totalCount;
    }
};
// 静态成员变量类外初始化
int Person::_totalCount = 0;
class Student : public Person {
private:
    int _stuNo;
public:
    Student(string name, int age, int stuNo)
        : Person(name, age), _stuNo(stuNo) {
        cout << "创建Student:" << _name << ",学号:" << _stuNo << endl;
    }
    ~Student() override {
        cout << "销毁Student:" << _name << ",学号:" << _stuNo << endl;
    }
};
int main() {
    cout << "初始总人数:" << Person::getTotalCount() << endl; 
    Person p("张三", 30);
    Student s("李四", 18, 2024001);
    // 三种方式访问静态成员,结果完全相同
    cout << "/n通过Person类访问:" << Person::_totalCount << endl;   
    cout << "通过Student类访问:" << Student::_totalCount << endl; 
    cout << "通过对象访问:" << s.getTotalCount() << endl;          
    return 0;
}

输出:
图片
你会发现:

  • 不管是创建Person对象还是Student对象,都会让同一个_totalCount加 1
  • 不管是通过Person::还是Student::访问,得到的都是同一个值
  • 整个程序中,_totalCount只有一份,被所有类和对象共享
    生活类比:这就像一个家族有一个公共的银行账户。不管是爷爷(基类)还是孙子(派生类)往里面存钱,账户余额都会增加。所有人看到的余额都是同一个数字。

三、派生类中的同名静态成员:隐藏而非重写

    如果派生类定义了一个和基类同名的静态成员,会发生什么?

答案是:派生类的静态成员会隐藏基类的同名静态成员,而不是重写。它们是两个完全独立的变量,各自有自己的存储空间。

四、静态成员函数与继承

    静态成员函数和静态成员变量一样,也会被派生类继承,并且也会被同名的派生类函数隐藏。

但有一个非常重要的限制:静态成员函数不能是虚函数
原因很简单:虚函数的作用是实现多态,需要根据对象的实际类型来调用对应的函数。而静态成员函数属于类,不依赖于任何对象,没有 “this 指针”,所以无法实现动态绑定。
七、多继承及其菱形继承问题

一、继承模型

    前面我们讲的都是单继承(一个子类只继承一个父类),但 C++ 还支持**多继承**—— 一个子类可以同时继承多个父类。

多继承虽然强大,但也带来了一个臭名昭著的问题:菱形继承
这是 C++ 继承中最复杂的知识点,也是面试的高频考点。很多 C++ 程序员工作多年,都没能真正搞懂菱形继承和虚继承的底层原理。
今天我们就用最通俗的语言和最直观的代码,把这个问题彻底讲透。
图片
图片图片

什么是菱形继承?

菱形继承是多继承的一种特殊情况,它的继承结构像一个菱形,因此得名。
助教既是学生(要上课),又是老师(要批改作业),所以需要同时继承 Student 和 Teacher 的属性和方法。
代码示例:

#include <iostream>
#include <string>
using namespace std;
// 顶层基类:人
class Person {
public:
    string _name; // 姓名
    int _age;     // 年龄
};
// 中间层:学生
class Student : public Person {
public:
    int _stuNo; // 学号
};
// 中间层:老师
class Teacher : public Person {
public:
    int _teaNo; // 工号
};
// 最底层:助教(同时继承学生和老师)
class Assistant : public Student, public Teacher {
public:
    string _department; // 所属院系
};
int main() {
    Assistant a;
    
    // 问题1:二义性——不知道访问哪个父类的_name
    // a._name = "张三"; // x 编译错误:ambiguous access to '_name'
    
    // 问题2:数据冗余——Assistant对象中有两份Person成员
    a.Student::_name = "张三";
    a.Teacher::_name = "张三";
    
    cout << "Student::_name: " << a.Student::_name << endl;
    cout << "Teacher::_name: " << a.Teacher::_name << endl;
    cout << "sizeof(Assistant): " << sizeof(Assistant) << endl;
    
    return 0;
}

输出:
图片

二、菱形继承的两大致命问题

从上面的代码可以看出,菱形继承会带来两个严重的问题:

1. 数据冗余

一个Assistant对象中会包含两份Person类的成员:

  • 一份来自Student继承的Person
  • 一份来自Teacher继承的Person
    这就导致一个助教有两个姓名、两个年龄,这显然不符合逻辑,也浪费了内存空间。
    生活类比:这就像一个人同时有两张身份证,上面的姓名和身份证号都一样,但却是两张不同的卡片。这不仅多余,还会带来很多麻烦。
2. 访问二义性

当我们直接访问a._name时,编译器不知道我们要访问的是Student继承来的_name,还是Teacher继承来的_name,所以会报 “二义性访问” 的错误。
我们必须显式指定作用域:a.Student::_namea.Teacher::_name,这显然非常不方便。

三、解决方案:虚继承(virtual inheritance)

为了解决菱形继承的问题,C++ 引入了虚继承的概念。
虚继承的核心思想是:让中间层基类(Student 和 Teacher)共享同一个顶层基类(Person)的实例,这样最底层派生类(Assistant)中就只会有一份 Person 的成员,从而避免了数据冗余和二义性。

1. 虚继承的语法

只需要在中间层基类继承顶层基类时,加上virtual关键字即可:

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

现在我们加上virtual后的运行结果:

#include <iostream>
#include <string>
using namespace std;
// 顶层基类:人
class Person {
public:
    string _name;
    int _age;
    
    Person(string name, int age) : _name(name), _age(age) {
        cout << "Person 构造" << endl;
    }
};
// 中间层:学生(虚继承Person)
class Student : virtual public Person {
public:
    int _stuNo;
    
    Student(string name, int age, int stuNo) 
        : Person(name, age), _stuNo(stuNo) {
        cout << "Student 构造" << endl;
    }
};
// 中间层:老师(虚继承Person)
class Teacher : virtual public Person {
public:
    int _teaNo;
    
    Teacher(string name, int age, int teaNo) 
        : Person(name, age), _teaNo(teaNo) {
        cout << "Teacher 构造" << endl;
    }
};
// 最底层:助教
class Assistant : public Student, public Teacher {
public:
    string _department;
    
    // 重点:虚继承下,最底层派生类必须直接调用虚基类的构造函数
    Assistant(string name, int age, int stuNo, int teaNo, string department)
        : Person(name, age), // 直接调用Person的构造函数
          Student(name, age, stuNo),
          Teacher(name, age, teaNo),
          _department(department) {
        cout << "Assistant 构造" << endl;
    }
};
int main() {
    Assistant a("张三", 22, 2024001, 1001, "计算机学院");
    
    // 现在可以直接访问_name了,没有二义性
    a._name = "张三三";
    cout << "姓名:" << a._name << endl;
    cout << "年龄:" << a._age << endl;
    cout << "学号:" << a._stuNo << endl;
    cout << "工号:" << a._teaNo << endl;
    cout << "sizeof(Assistant): " << sizeof(Assistant) << endl;
    
    return 0;
}
  • 现在Assistant对象中只有一份Person成员,直接访问a._name不会有二义性
  • 虚继承下,最底层派生类必须直接调用虚基类的构造函数
  • StudentTeacher的构造函数中对Person构造函数的调用会被忽略,因为Person已经被Assistant直接构造了

四、虚继承的底层原理(面试必问)

很多同学都好奇:虚继承到底是怎么实现的?为什么加上一个virtual关键字就能解决数据冗余的问题?
其实原理很简单:虚继承通过虚基类表(vbtable)和虚基类指针(vbptr)来实现共享虚基类实例

1. 普通继承的内存布局(非虚继承)

在普通继承下,Assistant对象的内存布局是这样的:

+---------------------+
| Student 部分        |
|   +-----------------+
|   | Person 部分     |  <-- 第一份Person
|   |   _name         |
|   |   _age          |
|   +-----------------+
|   _stuNo            |
+---------------------+
| Teacher 部分        |
|   +-----------------+
|   | Person 部分     |  <-- 第二份Person
|   |   _name         |
|   |   _age          |
|   +-----------------+
|   _teaNo            |
+---------------------+
| _department         |
+---------------------+

可以看到,有两份完全相同的Person部分,这就是数据冗余的来源。

2. 虚继承的内存布局

在虚继承下,StudentTeacher对象中不再包含完整的Person部分,而是增加了一个虚基类指针(vbptr),指向一个虚基类表(vbtable)
虚基类表中存储了从当前类对象地址到虚基类(Person)对象地址的偏移量。
Assistant对象的内存布局变成了这样:

+---------------------+
| Student 部分        |
|   vbptr_student     |  --> 指向Student的虚基类表
|   _stuNo            |
+---------------------+
| Teacher 部分        |
|   vbptr_teacher     |  --> 指向Teacher的虚基类表
|   _teaNo            |
+---------------------+
| _department         |
+---------------------+
| Person 部分         |  <-- 唯一的一份Person
|   _name             |
|   _age              |
+---------------------+

当我们访问a._name时,编译器会通过vbptr找到对应的vbtable,计算出Person部分的偏移量,然后访问到唯一的_name成员。

3. 虚继承的代价

虚继承虽然解决了菱形继承的问题,但也带来了一些代价:

  • 增加了内存开销:每个虚继承的类都多了一个虚基类指针
  • 降低了访问效率:访问虚基类成员需要通过指针和偏移量间接访问
  • 增加了构造函数的复杂性:最底层派生类必须直接调用虚基类的构造函数

五、虚继承的重要注意事项

  1. 虚基类的构造函数由最底层派生类调用
    • 这是虚继承最重要的规则
    • 中间层基类对虚基类构造函数的调用会被忽略
    • 这保证了虚基类只会被构造一次
  2. 虚继承与普通继承可以混合使用
    • 只有可能产生菱形继承的中间层才需要使用虚继承
    • 顶层基类和最底层派生类不需要加virtual
  3. 虚继承的使用场景非常有限
    • 虚继承主要就是为了解决菱形继承问题
    • 实际开发中,应该尽量避免使用多继承,尤其是菱形继承
    • 如果必须使用多继承,一定要仔细考虑是否会产生菱形继承,并及时使用虚继承

六、总结

问题 普通多继承(菱形) 虚继承
数据冗余 有,包含多份虚基类成员 无,所有派生类共享同一份虚基类成员
访问二义性 有,直接访问虚基类成员会报错 无,可以直接访问
内存布局 每个中间层都包含完整的虚基类部分 中间层包含虚基类指针,虚基类部分放在对象末尾
构造函数调用 每个中间层都会调用虚基类的构造函数 只有最底层派生类会调用虚基类的构造函数
性能开销 略大(需要间接访问)
    菱形继承和虚继承是 C++ 继承中最复杂的部分,也是很多初学者的噩梦。但只要你理解了它的核心思想 ——**共享虚基类实例**,再结合内存布局的分析,就会发现其实并没有那么难。
    不过,我还是要提醒大家:**在实际开发中,尽量避免使用多继承**。多继承虽然强大,但也会大大增加代码的复杂度和维护成本。大多数情况下,使用组合(composition)可以更好地解决问题。

学习资源

如果你是也准备转行学习网络安全(黑客)或者正在学习,这里开源一份360智榜样学习中心独家出品《网络攻防知识库》,希望能够帮助到你

**读者福利 |** CSDN大礼包:《网络安全入门&进阶学习资源包》免费分享 **(安全链接,放心点击)**![](https://i-blog.csdnimg.cn/img_convert/a6502ab41b1a86132b9ebb5aab9a2cdc.jpeg)

知识库由360智榜样学习中心独家打造出品,旨在帮助网络安全从业者或兴趣爱好者零基础快速入门提升实战能力,熟练掌握基础攻防到深度对抗。

1、知识库价值

深度: 本知识库超越常规工具手册,深入剖析攻击技术的底层原理与高级防御策略,并对业内挑战巨大的APT攻击链分析、隐蔽信道建立等,提供了独到的技术视角和实战验证过的对抗方案。

广度: 面向企业安全建设的核心场景(渗透测试、红蓝对抗、威胁狩猎、应急响应、安全运营),本知识库覆盖了从攻击发起、路径突破、权限维持、横向移动到防御检测、响应处置、溯源反制的全生命周期关键节点,是应对复杂攻防挑战的实用指南。

实战性: 知识库内容源于真实攻防对抗和大型演练实践,通过详尽的攻击复现案例、防御配置实例、自动化脚本代码来传递核心思路与落地方法。

2、 部分核心内容展示

360智榜样学习中心独家《网络攻防知识库》采用由浅入深、攻防结合的讲述方式,既夯实基础技能,更深入高阶对抗技术。

在这里插入图片描述

360智榜样学习中心独家《网络攻防知识库》采用由浅入深、攻防结合的讲述方式,既夯实基础技能,更深入高阶对抗技术。

内容组织紧密结合攻防场景,辅以大量真实环境复现案例、自动化工具脚本及配置解析。通过策略讲解、原理剖析、实战演示相结合,是你学习过程中好帮手。

1、网络安全意识

img

2、Linux操作系统

img

3、WEB架构基础与HTTP协议

img

4、Web渗透测试

img

5、渗透测试案例分享

img

6、渗透测试实战技巧

图片

7、攻防对战实战

图片

8、CTF之MISC实战讲解

图片

3、适合学习的人群

一、基础适配人群

  1. 零基础转型者‌:适合计算机零基础但愿意系统学习的人群,资料覆盖从网络协议、操作系统到渗透测试的完整知识链‌;
  2. 开发/运维人员‌:具备编程或运维基础者可通过资料快速掌握安全防护与漏洞修复技能,实现职业方向拓展‌或者转行就业;
  3. 应届毕业生‌:计算机相关专业学生可通过资料构建完整的网络安全知识体系,缩短企业用人适应期‌;

二、能力提升适配

1、‌技术爱好者‌:适合对攻防技术有强烈兴趣,希望掌握漏洞挖掘、渗透测试等实战技能的学习者‌;

2、安全从业者‌:帮助初级安全工程师系统化提升Web安全、逆向工程等专项能力‌;

3、‌合规需求者‌:包含等保规范、安全策略制定等内容,适合需要应对合规审计的企业人员‌;

因篇幅有限,仅展示部分资料,完整版的网络安全学习资料已经上传CSDN,朋友们如果需要可以在下方CSDN官方认证二维码免费领取【保证100%免费】

ec99731b01beda0f18.png)

3、WEB架构基础与HTTP协议

img

4、Web渗透测试

img

5、渗透测试案例分享

img

6、渗透测试实战技巧

图片

7、攻防对战实战

图片

8、CTF之MISC实战讲解

图片

3、适合学习的人群

一、基础适配人群

  1. 零基础转型者‌:适合计算机零基础但愿意系统学习的人群,资料覆盖从网络协议、操作系统到渗透测试的完整知识链‌;
  2. 开发/运维人员‌:具备编程或运维基础者可通过资料快速掌握安全防护与漏洞修复技能,实现职业方向拓展‌或者转行就业;
  3. 应届毕业生‌:计算机相关专业学生可通过资料构建完整的网络安全知识体系,缩短企业用人适应期‌;

二、能力提升适配

1、‌技术爱好者‌:适合对攻防技术有强烈兴趣,希望掌握漏洞挖掘、渗透测试等实战技能的学习者‌;

2、安全从业者‌:帮助初级安全工程师系统化提升Web安全、逆向工程等专项能力‌;

3、‌合规需求者‌:包含等保规范、安全策略制定等内容,适合需要应对合规审计的企业人员‌;

因篇幅有限,仅展示部分资料,完整版的网络安全学习资料已经上传CSDN,朋友们如果需要可以在下方CSDN官方认证二维码免费领取【保证100%免费】

img

更多推荐