C++ 继承进阶:默认成员函数、多继承问题与继承组合选型

目录

🎬 云泽Q个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列

⛺️遇见安然遇见你,不负代码不负卿~


前言

大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~

一、派生类的默认成员函数

1.1 4个常见默认成员函数

6个默认成员函数,默认的意思就是指我们不写,编译器会为我们自动生成一个,下面说一下在派生类中,下面结合代码说一下这几个成员函数是怎么生成的

cpp 复制代码
#include<iostream>
using namespace std;

class Person
{
public:
	//Person(const char* name = "peter")
	Person(const char* name)
		:_name(name)
	{
		cout << "Person()" << endl;
	}
	Person(const Person& p)
		:_name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
	Person& operator=(const Person& p)
	{
		cout << "Person operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;
		return *this;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name;//姓名
};

class Student : public Person
{
public:
	Student(const char* name = "张三", int num = 18, const char* address = "西安")
		//:_name(name) 这里写法错误,基类要当作一个整体,不能单独初始化
		//这里就算不写:_name(name),默认也会调用基类的构造,只不过基类现在没有默认构造调不到

		//这里需要显示调用基类的构造
		:Person(name)
		,_num(num)
		,_address(address)
	{
		cout << "Student()" << endl;
	}

	//一般情况下派生类不需要自己写拷贝构造
	//比如说现在有一个成员涉及深拷贝,需要自己写
	Student(const Student& s)
		//基类对象引用派生类对象引用的是派生类对象中基类的一部分
		:Person(s)
		,_num(s._num)
		,_address(s._address)
	{
		cout << "Student()" << endl;
	}

	//一般情况也不用自己写赋值重载,但在深拷贝场景下需要自己实现
	//这里不涉及深拷贝,之所以写只是为了作演示
	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			//operator=(s);//运算符重载可以显示调用
			//上面写法会报错到库中,并显示Stack overflow,一般无限递归会发生栈溢出
			//(栈虽然不大,但完全够用,只有无限递归,调用函数,创建栈帧,才会栈溢出)
			
			//原因是这里的赋值构成了隐藏,需指定类域
			Person::operator=(s);
			_num = s._num;
			_address = s._address;
		}
		return *this;
	}

	~Student()
	{
		//这里是显示调用不到父类的析构的,原因是和父类析构构成了隐藏
		//~Person(); //destructor()
		//指定类域才可以,但是这里也不用显示调用基类析构,编译器会在派生类析构结束后自动调用基类析构
		Person::~Person();
		//...
		cout << "~Student()" << endl;
	}

protected:
	int _num;//学号
	string _address;//地址
	int* _ptr;
};

//构造
//继承的基类成员变量(整体对象) + 自己的成员变量(遵循普通类 的规则,跟类和对象部分一样)
//默认生成的构造对派生类自己的成员,内置类型不确定,自定义类型调用默认构造,基类部分调用基类默认构造
//本质可以把派生类当作多了一个自定义类型成员变量(基类)的普通类,跟普通类原则基本一样
//派生类一般要自己实现构造,不需要显示写析构、拷贝构造、赋值重载、除非派生类有深拷贝的资源需要处理

int main()
{
	//构造
	Student s1;
	//Student s2("小明", 10);
	////拷贝构造
	////这里对内置类型num调用值拷贝,对自定义类型string调用其拷贝构造,对基类Person调用Person的拷贝构造
	//Student s3(s2);
	////赋值重载
	//s1 = s3;

	//Person p = s1;
	return 0;
}

一、派生类的构造函数(初始化逻辑)

  1. 派生类构造必须调用基类构造,初始化基类子对象;
  2. 如果基类没有默认构造(无参 / 带默认参数的构造),派生类必须在初始化列表显式调用基类的带参构造;
  3. 初始化顺序固定:先基类构造 → 再初始化派生类成员 → 最后执行派生类构造函数体
cpp 复制代码
// 基类Person的构造是带参的(无默认参数),因此没有默认构造
Person(const char* name) : _name(name) { ... }

// 派生类Student的构造必须显式调用基类构造
Student(const char* name = "张三", int num = 18, const char* address = "西安")
    : Person(name)  // ✅ 显式调用基类带参构造,初始化基类子对象
    , _num(num)     // 初始化自身内置类型成员
    , _address(address) // 初始化自身自定义类型成员(string调用默认构造)
{
    cout << "Student()" << endl;
}
  • ❌ 错误写法:_name(name)
    基类是一个 "整体子对象",不能直接初始化基类的成员变量,必须通过基类的构造函数来初始化。
  • ✅ 运行验证:Student s1; 会先打印 Person()(基类构造),再打印 Student()(派生类构造),完全符合 "先基类后派生" 的初始化顺序。
  • 🔹 编译器生成的「合成默认构造」
    如果 Student 不手写构造函数,编译器会生成合成默认构造,但逻辑是:
    1. 调用基类的默认构造(如果基类无默认构造,直接编译报错);
    1. 对自身成员:自定义类型调用其默认构造,内置类型不初始化(值为随机值)。
      代码中 Person 没有默认构造,因此必须手写 Student 的构造函数,否则编译报错。

二、派生类的拷贝构造函数(拷贝初始化逻辑)

派生类拷贝构造必须调用基类的拷贝构造,完成基类子对象的拷贝初始化。

cpp 复制代码
Student(const Student& s)
    : Person(s)  // ✅ 调用基类拷贝构造,拷贝基类子对象(s的基类部分)
    , _num(s._num)  // 拷贝自身内置类型成员
    , _address(s._address)  // 拷贝自身自定义类型成员(string调用拷贝构造)
{
    cout << "Student(const Student& s)" << endl;
}
  • ✅ 隐式转换支持:Person(s) 中,s 是 Student 对象,会隐式转换为 const Person&,匹配基类拷贝构造的参数。
  • 💡 何时需要手写:一般不需要,除非派生类有深拷贝成员(如代码中的 _ptr 如果是动态分配的内存,需要手动拷贝指针指向的资源)。
  • 编译器生成的合成拷贝构造:会自动调用基类拷贝构造,再拷贝自身成员(内置类型值拷贝,自定义类型调用其拷贝构造)。

三、派生类的赋值运算符重载(赋值逻辑)

  1. 派生类 operator= 必须调用基类的 operator=,完成基类子对象的赋值;
  2. 派生类的 operator= 会隐藏基类的 operator=,因此显式调用时必须指定基类作用域(Person::operator=)。
cpp 复制代码
Student& operator=(const Student& s)
{
    if (this != &s)
    {
        // ❌ 错误写法:operator=(s);
        // 原因:派生类的operator=隐藏了基类的,直接调用会递归调用自己,导致栈溢出
        Person::operator=(s); // ✅ 显式指定基类作用域,调用基类赋值重载
        _num = s._num;        // 赋值自身内置类型成员
        _address = s._address;// 赋值自身自定义类型成员
    }
    return *this;
}
  • ✅ 核心注意:必须先调用基类的 operator=,再赋值自身成员,否则基类子对象的赋值会被遗漏。
  • 💡 何时需要手写:一般不需要,除非派生类有深拷贝成员(如 _ptr 指向动态内存,需要手动处理资源的赋值)。

还有一个查找栈溢出的方法就是打开调用堆栈

可以看到272行在不断地调用赋值

四、派生类的析构函数(清理逻辑)

因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个会在后续多态的文章中写)。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系

  1. 派生类析构完成后,编译器会自动调用基类析构,保证清理顺序:先派生类成员 → 再基类成员;
cpp 复制代码
~Student()
{
    // ❌ 错误写法:~Person();
    // 原因:析构函数名被处理为destructor(),派生类析构隐藏了基类的,直接调用会报错
    // Person::~Person(); // 可以显式调用,但完全没必要,编译器会自动调用
    cout << "~Student()" << endl;
}
  • ✅ 清理顺序验证:s1 销毁时,先执行 ~Student()(打印 ~Student()),然后编译器自动调用 ~Person()(打印 ~Person()),符合 "先派生后基类" 的清理顺序。
  • 💡 何时需要手写:一般不需要,除非派生类有需要手动释放的资源(如 _ptr 是 new 出来的,需要在析构中 delete)。

如图可以看出,这种显示调用基类析构的写法是错误的,调用了两次父类的析构。前面的构造,拷贝构造,赋值重载等等都可以显示调用基类对应的成员函数去完成,但是析构这里不可以显示调,编译器会自动调用

构造和析构的顺序可以简单的认为是一个规定,更深层次的理解就是:

初始化列表初始化的顺序是按照声明的顺序 (在内存当中放的先后顺序),继承可以当作一个整体声明在最前面,所以构造的顺序是先父后子,依旧符合按照内存中存放的顺序初始化

若析构的时候是先父后子,先把父析构了,里面就是随机堆,随机值了,此时去调用子类的析构,但是子类的析构是能访问父类的成员,但是父类的成员此时是随机值,就有可能会出现问题。先子后父就不会出现这样的问题,先析构子,子类的析构有可能会访问父类的成员,但是父类的成员此时还未析构,就不会访问到随机值,再析构父,父类的析构不会访问到子,就不会出现问题。这种先子后父的析构方式可以避免一些野指针/随机值之类的风险

1.2 实现一个不能被继承的类

二、继承与友元

三、继承与静态成员

四、多继承及菱形继承问题

4.1 继承模型

4.2 虚继承

4.3 多继承中指针偏移问题

4.4 IO库中的菱形虚拟继承

五、继承和组合

5.1 继承和组合


结语

相关推荐
源代码•宸2 小时前
Golang原理剖析(defer、defer面试与分析)
开发语言·经验分享·后端·面试·golang·defer·开放编码
越甲八千2 小时前
FastAPI传参类型
开发语言·python·fastapi
南山乐只2 小时前
Java并发原生工具:原子类 (Atomic Classes)
java·开发语言·后端
一颗青果2 小时前
C++下的atomic | atmoic_flag | 内存顺序
java·开发语言·c++
Sylvia-girl2 小时前
Java之异常
java·开发语言
郝学胜-神的一滴2 小时前
Python对象的自省机制:深入探索对象的内心世界
开发语言·python·程序人生·算法
说私域2 小时前
全民电商时代下的链动2+1模式与S2B2C商城小程序:社交裂变与供应链协同的营销革命
开发语言·人工智能·小程序·php·流量运营
跃渊Yuey2 小时前
【Linux】Linux进程信号产生和保存
linux·c语言·c++·vscode
好评1242 小时前
【C++】AVL树:入门到精通全图解
数据结构·c++·avl树