C++ | 继承

目录

继承的概念

继承的定义

继承的格式

继承方式和访问限定符

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

基类和派生类对象赋值转换

继承中的作用域

派生类的默认成员函数

继承与友元

继承与静态成员

继承的方式

单继承:一个子类只有一个直接父类时称这个继承关系为单继承

多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承

菱形继承:菱形继承是多继承的一种特殊情况。

菱形虚拟继承

虚拟继承的原理

​编辑

拓展:

如何设计一个不能被继承的类?

方法1

方法2:

方法3:

为什么「构造必须先父后子」?

为什么「析构必须先子后父」?

再延伸

继承的总结和反思


继承的概念

继承 (inheritance) 机制是面向对象程序设计 使代码可以复用 的最重要的手段,它允许程序员在
持原有类特性的基础上进行扩展 ,增加功能,这样产生新的类,称派生类。继承 呈现了面向对象
程序设计的层次结构 ,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,
承是类设计层次的复用。
如下:Student类继承Person类,Tearcher类继承Person类

cpp 复制代码
// 父类Person
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "wangzn2016"; // 姓名
	int _age = 18; // 年龄
};


// 子类Student
class Student : public Person
{
protected:
	int _stuid; // 学号
};


// 子类Teacher
class Teacher : public Person
{
protected:
	int _jobid; // 工号
};

继承后,父类Person的成员,包括成员函数和成员变量,都会变成子类的一部分,也就是说,子类Student和Teacher复用了父类Person的成员。


继承的定义

继承的格式

其中基类也称作父类,派生类也称作子类


继承方式和访问限定符

访问限定符有以下三种:

  1. public访问
  2. protected访问
  3. private访问

而继承的方式也有类似的三种:

  1. public继承
  2. protected继承
  3. private继承

父类有三种访问限定符,子类有三种继承方式,两类组合共有9类。


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

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public成员 派生类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

总结:

  • 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它
  • 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在 派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的
  • 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他 成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
  • 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过****最好显示的写出继承方式
  • 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
cpp 复制代码
// 父类
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "wangzn2016"; // 姓名
	int _age = 18; // 年龄
};

// 子类
class Student : public Person
{
protected:
	int _stuid; // 学号
};

此时Print函数还是公有(类内类外都可访问),_name和_age还是保护(类内可以访问,类外不行)。当子类是私有继承时,公有和保护均变私有。

如果将基类的成员变量访问符由protected改为private,子类public继承,则Print函数还是共有,但_name和_age在子类内不可访问

注意,不可访问是说父类的私有成员也是继承到子类了,只是不可见(不能访问)。

综上:

  • protected/private成员对于基类 -- 一样的,类外不能访问,类里可以访问。
  • protected/private成员对于派生类 -- private成员不能用,protected成员类里面可以用。

既然父类的private私有不能在派生类和类外使用,但我可以间接使用,就像上述父类的Print函数,访问了私有成员_name和age,此时我即使派生类私有继承,我在派生类里仍然可以访问Print函数,同样我类外对于派生类对象s,仍然可以访问派生类里的公有成员函数func,从而间接访问父类私有成员_name和_age:

cpp 复制代码
// 父类
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "wangzn2016"; // 姓名
	int _age = 18; // 年龄
};


// 子类
class Student : public Person
{
public:
    void func()
    {
        Print();
    }
protected:
	int _stuid; // 学号
};

int main()
{

    Student s;
    s.func();  // name: wangzn2016  age: 18
    return 0;
}

基类和派生类对象赋值转换

  • 派生类对象可以赋值给 基类的对象**/基类的指针/**基类的引用。这里有个形象的说法叫切片 或者切割。寓意把派生类中父类那部分切来赋值过去。
  • 基类对象不能赋值给派生类对象。
  • 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类 的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。(ps:c++11的特性,没学过的话就当知道有这么个回事就行,不影响后续内容)
cpp 复制代码
// 父类
class Person
{
protected:
	string _name; // 姓名
	string _sex; // 性别
	int _age; // 年龄
};


// 子类
class Student : public Person
{
public:
	int _No; // 学号
};


int main()
{
	Student sobj;
	// 1.子类对象可以赋值给父类对象/指针/引用
	Person pobj = sobj;
	Person* pp = &sobj;
	Person& rp = sobj;
 
	// 2.基类对象不能赋值给派生类对象
	// sobj= pobj; //err
	
	// 3.基类的指针可以通过强制类型转换赋值给派生类的指针,后面详细介绍,这里了解
    pp = &sobj
    Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
    ps1->_No = 10;
    
    pp = &pobj;
    Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问
题
    ps2->_No = 10;

    return 0;
}
  • 补充:

在之前的学习中,不同类型之间的互相赋值,中间会产生一个临时对象,而这里子类对象给父类的对象、指针、引用都是语法原生支持,没有类型转换,也不会产生临时对象。

  • 子类对象 → 父类指针 / 父类引用 :✅ 无类型转换、无临时对象、无拷贝、零开销,就是语法原生支持的直接赋值,本质是「指针 / 引用的指向绑定」;
  • 子类对象 → 父类对象 :✅ 语法原生支持,有拷贝、无类型转换、无临时对象,这个拷贝叫「切片拷贝」,不是临时对象导致的拷贝;

C++ 规定:公有继承的本质是「is a」关系 ------ Student is a Person(学生 是 人),这个语义不是程序员约定的,是编译器原生认可的语法规则

这个「is a」关系,是 C++ 给公有继承开的「语法绿灯」:编译器看到子类赋值给父类,不会认为是「类型不匹配」,而是认为「合理的类型兼容」,所以不会触发任何类型转换,也不会创建临时对象

这个规则,专门针对「子类→父类」的赋值,是 C++ 里独有的、优先级最高的赋值规则,和普通的「不同类型赋值」没有任何关系!


继承中的作用域

  1. 在继承体系中 基类派生类 都有 独立的作用域
  2. 子类和父类中有 同名成员子类成员将屏蔽父类对同名成员的直接访问, 这种情况叫隐藏,
    也叫重定义。 (在子类成员函数中,可以 使用 基类 :: 基类成员 显示访问
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  4. 注意在实际中在 继承体系里 面最好 不要定义同名的成员
    示例:
  • 成员变量的隐藏
cpp 复制代码
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected:
	string _name = "小李子"; // 姓名
	int _num = 111; // 身份证号
};
class Student : public Person
{
public:
	void Print()
	{
		cout << " 姓名:" << _name << endl;
		cout << " 学号:" << _num << endl;  // 999
        // 指定输出Person的_num成员,默认是Student的_num成员,因为构成了隐藏
		cout << " 身份证号:" << Person::_num << endl;  // 111   
protected:
	int _num = 999; // 学号
};
int main()
{
	Student s1;
	s1.Print();
};
  • 成员函数的隐藏
cpp 复制代码
// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
class A
{
public:
	void fun()
	{
		cout << "A::fun()" << endl;
	}
};

class B : public A
{
public:
	void fun(int i)
	{
		cout << "B::fun(int i)->" << i << endl;
	}
};
int main()
{
	B b;
	b.fun(10);  //构成隐藏,屏蔽父类,调用子类的 B::fun(int i)->10
	b.A::fun();  //指定作用域调用父类   A::fun()
    //b.fun();  //编译报错,构成隐藏,必须指定父类作用域或传参
};

A::fun 和 B::fun的关系为隐藏(因为函数名相同),**函数重载要求是在同一作用域,而这里很显然不是。**对象b在访问fun函数的时候,子类会屏蔽父类的fun函数,因此最终的访问结果为B::fun(int i)->10。当然也可以加上指定作用域以此访问父类的fun函数。


派生类的默认成员函数

6个默认成员函数,"默认"的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?(子类中父类成员变量当成整体对象,作为子类自定义类型成员看待)

  • 1、派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  • 2、派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  • 3、派生类的operator=必须要调用基类的operator=完成基类的复制。
  • 4、派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  • 5、派生类对象初始化先调用基类构造再调派生类构造。
  • 6、派生类对象析构清理先调用派生类析构再调基类的析构。
  • 7、因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。

以Person作为基类,派生出Student类,根据先前派生类默认成员函数的书写规则:

cpp 复制代码
// 父类
class Person
{
public:
    // 构造函数
	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
{
	//子类构造函数原则:a、调用父类构造函数初始化继承父类的成员。b、自己再初始化自己的成员
	//析构、拷贝构造、赋值重载也是类似
public:
/*1、构造函数*/
	Student(const char* name = "", int num = 10)
		:Person(name)//调用基类的构造函数初始化基类的那一部分成员
		, _num(num)//初始化派生类成员
	{
		cout << "Student(const char* name = "", int num = 10)" << endl;
	}
/*2、拷贝构造函数*/
	Student(const Student& s)
		:Person(s)//调用基类的拷贝构造函数完成基类成员的拷贝构造,此时会发生切片,因为此时子类传给父类的引用
		,_num(s._num)//拷贝构造派生类成员
	{
        //深拷贝需要自己写,否则默认生成的就够了
		cout << "Student(const Student& s)" << endl;
	}
/*3、赋值运算符重载函数*/
	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			//子类operator=和父类operator=构成隐藏,为了避免无线递归,要加上指定父类作用域
			Person::operator=(s);//调用基类的operator=完成基类成员的赋值,此时会发生切片,因为此时子类传给父类的引用
			_num = s._num;//完成派生类的赋值
		}
		cout << "Student& operator=(const Student& s)" << endl;
		return *this;
	}
/*4、析构函数*/
	//父子类的析构函数构成隐藏关系 -- ps:后面多态的需要,析构函数名统一会被处理成destructor(),因此构成隐藏
	//为了保证析构顺序(先子后父)。子类的析构函数完成后会自动调用父类的析构函数,所以不需要我们显示调用
    //构造顺序先父后子,析构顺序先子后父
	~Student()
	{
        //Person::~Person();不需要我们调用
		cout << "~Student()" << endl;
	}//自动调用父类的析构函数
protected:
	int _num; //学号
};

继承与友元

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

cpp 复制代码
class Student;//注意前置声明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)
{
	//Display是父类的友元,不是子类的友元
	cout << p._name << endl;//可以访问父类
	// cout << s._stuNum << endl; error 不能访问子类
}
int main()
{
	Person p;
	Student s;
	Display(p, s);
}

若想让Display函数也能够访问派生类Student的私有和保护成员,只能在派生类Student当中进行友元声明。


继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子

类,都只有一个static成员实例 。

cpp 复制代码
class Person
{
public:
	Person() { ++_count; }
	string _name;  // 姓名
	static int _count;  // 统计人的个数。
};
int Person::_count = 0;

class Student : public Person
{
protected:
	int _stuNum; // 学号
};

class Graduate : public Student
{
protected:
	string _seminarCourse; // 研究科目
};


int main()
{
	Student s1;
	cout << " 人数 :" << Person::_count << endl;// 人数 :1

	Student s2;
	Student s3;
	cout << " 人数 :" << Student::_count << endl;// 人数 :3

	Graduate s4;
	cout << " 人数 :" << s4._count << endl;// 人数 :4

	Student::_count = 0;
	cout << " 人数 :" << Person::_count << endl;// 人数 :0

    return 0;
}

继承的方式

**单继承:**一个子类只有一个直接父类时称这个继承关系为单继承

多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承

注意,C++的多继承存在致命缺陷,数据冗余、成员二义性冲突(非菱形场景也会出现)以及内存布局混乱导致的维护成本增加。

成员二义性冲突

cpp 复制代码
class A {
public:
    void show() { cout << "A的show" << endl; }
};
class B {
public:
    void show() { cout << "B的show" << endl; }
};
class C : public A, public B {};

int main() {
    C c;
    c.show(); // 编译器直接报错:request for member 'show' is ambiguous
    return 0;
}

补救方案:手动指定作用域 c.A::show(),但这只是「规避」不是「解决」,会让代码变得臃肿且丑陋,违背了面向对象的封装性。

菱形继承:菱形继承是多继承的一种特殊情况。

当一个子类同时继承两个父类 ,而这两个父类又共同继承同一个基类时,就形成了「菱形结构」:

**菱形继承的问题:**从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份。

cpp 复制代码
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()
{
	// 这样会有二义性无法明确知道访问的是哪一个
	Assistant a;
//	a._name = "wangzn2016"; error 存在二义性的问题
	//存在数据冗余问题
}

Student和Teacher都继承了Person,都有成员_name,而Assistant是Student和Teacher的子类,直接用Assistant的对象去访问_name会导致二义性的问题,不知访问哪个,但是可以指定类域进行访问,如下:

cpp 复制代码
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";

虽然该方法可以解决二义性的问题,但仍然不能解决数据冗余的问题。因为在Assistant的对象在Person成员始终会存在两份。

菱形虚拟继承

为了解决菱形继承的二义性和数据冗余问题,出现了虚拟继承。虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在StudentTeacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地****方去使用。

菱形继承的书写方式,即在出现"数据冗余"和"成员二义性"的中间类的继承方式前加上"virtual"关键字,如下面的Stuedent类和Teacher类

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
	string _name; //姓名
};

class Student : virtual public Person //虚拟继承
{
protected:
	int _num; //学号
};

class Teacher : virtual public Person //虚拟继承
{
protected:
	int _id; //职工编号
};

class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; //主修课程
};


int main()
{
	Assistant a;
	a._name = "wangzn2016"; //无二义性
	return 0;
}

此时就可以直接访问Assistant对象的_name成员了,并且之后就算我们指定访问Assistant的Student父类和Teacher父类的_name成员,访问到的都是同一个结果,解决了二义性的问题。

cpp 复制代码
cout << a.Student::_name << endl; //wangzn2016
cout << a.Teacher::_name << endl; //wangzn2016

打印Assistant的Student父类和Teacher父类的_name成员的地址时,显示的也是同一个地址,解决了数据冗余的问题。

cpp 复制代码
cout << &a.Student::_name << endl; //0126D75C
cout << &a.Teacher::_name << endl; //0126D75C

那么,问题来了,以下代码运行后,a对象的name是哪个呢?

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

class Person
{
public:
	Person(const char* name)
		:_name(name)
	{
		cout << "Person() name: " << name << endl;
	}
	string _name; // 姓名
};
class Student : virtual public Person
{
public:
	Student(const char* name, int num)
		:Person(name)
		, _num(num)
	{
	}
protected:
	int _num; //学号
};
class Teacher : virtual public Person
{
public:
	Teacher(const char* name, int id)
		:Person(name)
		, _id(id)
	{
	}
protected:
	int _id; // 职⼯编号
};

class Assistant : public Student, public Teacher
{
public:
	Assistant(const char* name1, const char* name2, const char* name3)
		:Person(name3)
		, Student(name1, 1)
		, Teacher(name2, 2)
	{
	}
protected:
	int num = 3;
};
int main()
{
	// 思考⼀下这⾥a对象中_name是"张三", "李四", "王五"中的哪⼀个?
	Assistant a("张三", "李四", "王五");
	return 0;
}

上结果: name为"王五",并且Person只构造了一次

注意:从局部变量分析窗口上看,仍存在数据冗余,是因为VS2026做了优化,方便开发人员监控,实际上只存在一份Person

虚拟继承的原理

以下面的代码来分析,首先采用菱形非虚拟继承的方式

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

class B : public A
//class B : virtual public A
{
public:
	int _b;
};

class C : public A
//class C : virtual public A
{
public:
	int _c;
};

class D : public B, public C
{
public:
	int _d;
};;
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

打开内存监控,输入&d,从内存分布上来看,存在公共基类A::_a的冗余数据

再采用菱形虚拟继承的方式,和上述同样操作

可以发现不存在数据冗余了,公共基类A::_a的数据放在了最下面,而原本位置的数据反而像是个指针

那我们继续进行内存监视

可以发现,两个指针指向的地方的数据,刚好是到公共基类A::_a的地址的偏移量。因此可以判断出,采用了虚拟继承后,内存分布如下:

实际上,通过了 B C 的两个指针,指 向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量 可以找到下面的 A


拓展:

如何设计一个不能被继承的类?

方法1

只需要把父类的构造函数设计成私有即可。因为此时继承后没有构造函数就不能定义对象了,因为子类的构造函数必须调用父类的构造函数,而私有不可见,所以不能被继承,那么派生类就无法实例化出对象

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

// 设计:构造函数私有化 → 不能被继承
class NonInherit {
private:
    // 核心:构造函数设为私有
    NonInherit() { cout << "NonInherit 构造" << endl; }
    ~NonInherit() { cout << "NonInherit 析构" << endl; }

public:
    // 提供静态成员函数,作为创建对象的唯一入口(外部能正常使用该类)
    static NonInherit CreateObj() {
        return NonInherit();
    }
};


// 编译报错!继承后子类无法调用基类的私有构造函数
// error: calling a private constructor of class 'NonInherit'
// class Son : public NonInherit {
// };

int main() {
    

    // 正常使用:通过静态函数创建对象,不受影响
    NonInherit obj2 = NonInherit::CreateObj();
    return 0;
}

核心原理:

派生类继承基类后,创建子类对象时,编译器会强制调用基类的构造函数(先父后子),由于基类的构造函数被设为private,子类的成员函数无法访问基类的私有构造,编译器直接编译报错。因此这个类完全不能被继承,达到禁止继承的目的。

问题 1:构造私有后,外部怎么创建这个类的对象?

像代码里一样,给类提供一个 公有静态成员函数 作为「对象创建入口」即可,外部通过类名::静态函数名()创建对象,内部能访问私有构造,完美解决「禁止继承 + 正常使用」的需求。

问题2:main里 NonInherit obj2 = NonInherit::CreateObj(); 是拷贝,会不会有问题?

这里是 编译器的「返回值优化 (RVO)」,不会创建多余的临时对象,直接在main栈帧上创建obj2,效率拉满,无任何问题。

问题3:static NonInherit CreateObj() 为什么能调用私有构造?

静态成员函数属于「类自身」 ,不是属于对象,类的自身作用域内,有权限访问本类所有的private成员(构造、析构、成员变量 / 函数都可以)。所以 CreateObj() 内部写 return NonInherit(); 是合法的,这也是给外部留的「唯一对象创建入口」

问题4:问题1所提出来的写法存在的问题是什么?

当栈上的obj2生命周期到期时,自动调用析构函数,但是析构函数已经私有化,应该优化成:

cpp 复制代码
class NonInherit {
private:
    NonInherit() {}
    ~NonInherit() {} // 析构也私有
public:
    static NonInherit* CreateObj() {
        return new NonInherit(); // 堆上创建
    }
    static void DestroyObj(NonInherit* p) {
        delete p; // 内部调用私有析构
    }
};
// 调用方式
int main() {
    NonInherit* p = NonInherit::CreateObj();
    NonInherit::DestroyObj(p);
    return 0;
}

方法2:

C++11新增了一个final关键字,final修改基类,派生类就不能继承了

不过需要注意,仅支持 C++11 及以上版本,兼容 C++98 的老旧项目无法使用。

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

// 被final修饰的类:绝对不能被继承,编译器直接拦截
class NonInherit final { 
public:
    NonInherit() { cout << "NonInherit 构造" << endl; }
    ~NonInherit() { cout << "NonInherit 析构" << endl; }
};

// 编译报错:cannot derive from 'final' base type 'NonInherit'
class Son : public NonInherit { 
};

int main() {
    NonInherit obj; // 正常创建对象,不受任何影响
    return 0;
}

拓展:final 不仅能修饰类,还能修饰虚函数(后面的知识) ,表示「该虚函数不能被子类重写」,比如 virtual void func() final;

方法3:

一种技巧性写法,设计到后面的知识,了解即可。

核心思路

定义一个空的基类,让目标私有虚继承这个空积累,利用虚继承的底层规则让子类无法继承。

cpp 复制代码
class Base{};
// 私有虚继承空基类,该类无法被继承
class NonInherit : virtual private Base{};

// 编译报错,无法继承NonInherit
class Son : public NonInherit{};

核心原理

虚继承的子类,必须在构造函数的初始化列表中调用「虚基类的构造函数」,而NonInherit私有虚继承 Base,子类Son无法访问NonInherit的私有继承权限,也就无法调用虚基类构造,最终编译报错。

为什么「构造必须先父后子」?

很简单,子类「依赖」父类的成员,颠倒则子类初始化失败!

C++ 中,子类的内存布局是「父类成员在前,子类成员在后」 ,子类对象的本质是:一个完整的父类对象 + 子类自己的扩展成员

  • 子类的成员函数,在运行时可能随时访问父类的成员变量 / 成员函数
  • 如果先调用子类构造、再调用父类构造:子类构造执行时,父类的成员还完全没有初始化,是一片随机的「垃圾内存 / 野值」;
  • 此时子类如果在构造中访问了父类的成员(比如父类的_age),就是访问未初始化的野内存,程序直接崩溃,或者产生随机的错误值。

为什么「析构必须先子后父」?

父类「支撑」子类的成员,颠倒则子类成员变成野指针!这个逻辑和构造完全相反,也是最核心的底层设计,记住一句话:子类的成员,是「挂靠」在父类的内存之上的

  • 子类析构时,清理的是「自己独有的成员」(比如子类的_No、子类自己开辟的堆内存、句柄等);
  • 父类析构时,清理的是「父类的成员」,本质也是清理子类对象中「父类部分的内存」;
  • 如果颠倒顺序:先调用父类析构,父类的内存被释放 / 清理掉 → 此时子类的析构函数还没执行,子类的成员还存在,但是子类的成员「挂靠」的父类内存已经没了,子类成员就变成了野指针 / 野对象
  • 后续执行子类析构时,访问的就是「野内存」,直接触发内存越界、程序崩溃,甚至内存泄漏。

再延伸

如果理解理解了C/C++的内存管理的话,派生类对象 析构先子后父构造先父后子 的规则,底层根本原因就是【栈的先进后出 (FILO)】原则

  • 构造的「先父后子」 = 栈的【压栈】过程(先进):父类成员 / 构造先入栈,子类成员 / 构造后入栈;
  • 析构的「先子后父」 = 栈的【出栈】过程(后出):后入栈的子类成员 / 析构先出栈,先入栈的父类成员 / 析构后出栈;

不过注意,类的成员变量,在构造时按「声明顺序」压栈,和初始化列表顺序无关


继承的总结和反思

什么是菱形继承?菱形继承的问题是什么?

菱形继承是多继承的一种特殊情况,两个子类继承同一个父类,而又有子类同时继承这两个子类,我们称这种继承为菱形继承。

菱形继承因为子类对象当中会有两份父类的成员,因此会导致数据冗余和二义性的问题。

什么是菱形虚拟继承?如何解决数据冗余和二义性?

菱形虚拟继承是指在菱形继承的腰部使用虚拟继承(virtual)的继承方式,菱形虚拟继承对于D类对象当中重复的A类成员只存储一份,然后采用虚基表指针和虚基表使得D类对象当中继承的B类和C类可以找到自己继承的A类成员,从而解决了数据冗余和二义性的问题。

继承和组合的区别?什么时候用继承?什么时候用组合?

继承是一种is-a的关系,而组合是一种has-a的关系。如果两个类之间是is-a的关系,使用继承;如果两个类之间是has-a的关系,则使用组合;如果两个类之间的关系既可以看作is-a的关系,又可以看作has-a的关系,则优先使用组合。

很多人觉得C++复杂,其实多继承就是一个体现,有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承,那怕IO库采用的也是菱形继承的方式, 否则在复杂度及性能上都有问题。

多继承可以认为是C++的缺陷之一,很多后来的OO(Object Oriented)语言都没有多继承,如Java。

继承和组合

继承是一种is-a的关系,也就是说每个派生类对象都是一个基类对象;而组合是一种has-a的关系,若是B组合了A,那么每个B对象中都有一个A对象。

例如,车类和宝马类就是is-a的关系,它们之间适合使用继承。

cpp 复制代码
class Car
{
protected:
	string _colour; //颜色
	string _num; //车牌号
};
class BMW : public Car
{
public:
	void Drive()
	{
		cout << "this is BMW" << endl;
	}
};

而车和轮胎之间就是has-a的关系,它们之间则适合使用组合。

cpp 复制代码
class Tire
{
protected:
	string _brand; //品牌
	size_t _size; //尺寸
};
class Car
{
protected:
	string _colour; //颜色
	string _num; //车牌号
	Tire _t; //轮胎
};

若是两个类之间既可以看作is-a的关系,又可以看作has-a的关系,则优先使用组合。

原因如下:

  • 继承允许你根据基类的实现来定义派生类的实现,这种通过生成派生类的复用通常被称为白箱复用(White-boxreuse)。术语"白箱"是相对可视性而言:在继承方式中,基类的内部细节对于派生类可见,继承一定程度破坏了基类的封装,基类的改变对派生类有很大的影响,派生类和基类间的依赖性关系很强,耦合度高。
  • 组合是类继承之外的另一种复用选择,新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口,这种复用风格被称之为黑箱复用(Black-box reuse),因为对象的内部细节是不可见的,对象只以"黑箱"的形式出现,组合类之间没有很强的依赖关系,耦合度低,优先使用对象组合有助于你保持每个类被封装。
  • 实际中尽量多使用组合,组合的耦合度低,代码维护性好。不过继承也是有用武之地的,有些关系就适合用继承,另外要实现多态也必须要继承。若是类之间的关系既可以用继承,又可以用组合,则优先使用组合。
相关推荐
郝学胜-神的一滴2 小时前
深入理解Qt中的坐标系统:鼠标、窗口与控件位置详解
开发语言·c++·qt·程序人生
程序员zgh2 小时前
汽车以太网协议 —— DDS
c语言·开发语言·c++·网络协议·udp·汽车·信息与通信
华如锦2 小时前
MongoDB作为小型 AI智能化系统的数据库
java·前端·人工智能·算法
机器学习之心HML2 小时前
GSABO(通常指混合了模拟退火SA和天牛须搜索BAS的改进算法)与BP神经网络结合,用于爆破参数优选
人工智能·神经网络·算法·爆破参数优选
superman超哥2 小时前
Rust 异步编程的终极考验:Tokio 资源管理与清理
开发语言·rust·编程语言·rust异步编程·tokio资源管理与清理
王老师青少年编程2 小时前
2025年12月GESP真题及题解(C++八级): 猫和老鼠
c++·gesp·csp·信奥赛·八级·csp-s·提高组
轻微的风格艾丝凡2 小时前
数织求解脚本技术文档
算法·matlab
前天的五花肉2 小时前
D3.js研发交互模型指标柱形图
开发语言·javascript·交互
你怎么知道我是队长2 小时前
C语言---强制类型转换
c语言·开发语言·算法