C++继承-上
目录
继承介绍
1.继承概念
2.继承的方式
2.1.继承定义格式
2.2.继承关系和访问限定符
基类和派生类对象赋值转换
1.赋值转换规则
2.子类对象赋值给父类对象相关情况
3.父类对象赋值给子类对象相关情况
4.赋值转换的注意问题
5.总结
继承中你的作用域
1.同名成员变量的隐藏情况
2.同名成员函数的隐藏情况
3.总结
派生类(子类)的默认成员函数
1.子类构造函数
2.子类拷贝构造函数
3.子类赋值重载函数
4.子类析构函数
5.总结
继承与友元
1.友元
1.1.友元函数
1.2.友元类
2.继承与友元
继承与静态成员
1.静态成员
2.继承与静态成员的关系
实现一个不能被继承的类
1.思路及原理
2.代码实现
继承介绍
1.继承概念
继承是 C++ 面向对象编程的三大特性之一(另外两个是封装和多态),本质上是一种代码复用机制。当多个类具有共性(如相同的成员变量和成员函数)时,为避免每个类单独实现带来的繁琐,可将共性抽取到一个基类(父类)中,其他类(派生类,子类)通过继承基类来复用这些共性,同时可以扩展自身特有的功能。
例如,在一个简单的学校人员管理系统中,学生、教师等类都具有一些共同的属性,如姓名(_name)、年龄(_age)等。如果每个类都单独定义这些属性,会导致代码冗余。通过继承,可将这些共性抽取到一个Person
基类中,如下所示:
这样,Student
类和Teacher
类就可以复用Person
类中的_name
和_age
属性,减少了代码重复,提高了开发效率。
继承的意义与作用
- 代码复用:继承最大的优势在于避免了重复代码的编写。多个派生类可以共享基类中已经实现的成员变量和成员函数,减少了开发过程中的工作量,提高了代码的可维护性。例如,基类中实现了一个用于打印人员基本信息的
Print
函数,所有派生类都可以直接使用该函数,而无需再次编写。 - 类型层次结构的建立:继承有助于构建清晰的类型层次结构,反映出类与类之间的关系,体现了从一般到特殊的认知过程。例如,在上述学校人员管理系统中,
Person
类代表了一般的人员概念,而Student
类和Teacher
类则是更具体的人员类型,它们继承自Person
类,形成了一个层次分明的结构。 - 多态的基础:继承是实现多态的重要基础。通过继承,派生类可以重写基类的虚函数,从而在运行时根据对象的实际类型来调用相应的函数版本,实现动态绑定,为程序提供更加灵活和强大的功能。
不同继承方式下,子类底层物理结构(内存存储结构):
- 单继承指的是一个子类仅继承自一个父类。在这种情况下,子类的内存布局是先放置父类的成员变量,接着是子类自身的成员变量。
- 多继承是指一个子类继承自多个父类。子类的内存布局是按照继承的顺序依次存放各个父类的成员变量,最后是子类自身的成员变量。
- 注意:菱形继承使用虚继承前后,子类底层物理结构(内存存储结构详细见C++继承 — 下文章。
2.继承的方式
2.1.继承定义格式
(1)显示指定继承方式
下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类。
在 C++ 中,使用class
关键字来定义派生类时,可以通过class 派生类名 : 继承方式 基类名
的形式来显式地指定继承方式。其中,继承方式主要有public
(公有继承)、protected
(保护继承)和private
(私有继承)三种。以public
继承为例,例如,class Student : public Person,这里Person是基类,Student是派生类,public为继承方式。在这种方式下,派生类Student可以按照public继承的规则,继承基类Person的成员。
class Person
{
protected:
string _name = "peter";
int _age = 18;
};
class Student : public Person
{
public:
int _stuid; //学号
int _major; //专业
};
(2)不显示指定继承方式
注意事项:使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
①使用class
关键字:默认采用private
继承。
即class Student : Person等价于class Student : private Person。在这种情况下,基类的公有和保护成员在派生类中都会变为私有成员,外部无法直接访问。例如,若在Person类中有公有成员函数Print,在Student类采用默认私有继承时,外部通过Student类对象调用Print函数会报错。
#include<iostream>
#include<string>
using namespace std;
class Person
{
public:
void Print()
{
cout << "name: " << _name << ", age: " << _age << endl;
}
protected:
string _name = "peter";
int _age = 18;
};
class Student : Person //默认private继承
{
protected:
int _stuid; //学号
};
②使用struct
关键字:默认采用public
继承。
例如struct Student : Person,此时Student类对Person类的继承方式等同于class Student : public Person 。
#include<iostream>
#include<string>
using namespace std;
class Person
{
public:
void Print()
{
cout << "name: " << _name << ", age: " << _age << endl;
}
protected:
string _name = "peter";
int _age = 18;
};
struct Student : Person //默认public继承
{
protected:
int _stuid; //学号
};
2.2.继承关系和访问限定符
(1)基本概念:C++ 中有public
(公有)、protected
(保护)、private
(私有)三种访问限定符,同时这三种关键字也可用作继承方式,不同的组合会影响基类成员在派生类中的访问权限。
(2)继承方式和访问限定符的组合
- 公有继承:基类的
public
成员在派生类中仍为public
,protected
成员在派生类中仍为protected
,private
成员在派生类中不可见。这意味着派生类对象在外部可以访问从基类继承来的公有成员,在派生类内部可以访问公有和保护成员,但无法访问私有成员。 - 保护继承:基类的
public
成员和protected
成员在派生类中都变为protected
,private
成员在派生类中不可见。这种方式使得基类的公有成员在派生类外部也不能被访问,增强了数据的保护性。 - 私有继承:基类的
public
成员和protected
成员在派生类中都变为private
,private
成员在派生类中不可见。此时,派生类对象在外部不能访问从基类继承来的任何成员(除了通过基类的公有成员函数间接访问),基类成员的访问范围被严格限制在派生类内部。
(3)不同继承方式会改变父类成员在子类外部的访问权限
注意:
- 访问限定符优先级:
public > protected > private
(优先级越高,权限越宽松)。 - 访问限定符仅限制 类外部 对成员的访问,不影响 类内部 成员(成员函数、成员变量)之间的访问。例如:子类成员函数可访问子类中继承而来的父类公有 / 保护成员(即使其访问权限因继承方式改变)。
原则:
- 无论子类以
public
/protected
/private
哪种方式继承,父类私有成员在子类内外部均不可直接访问,仅能通过父类的公有 / 保护成员函数间接访问。 - 父类公有 / 保护成员在子类外部的访问权限取决于父类成员本身的访问限定符和子类继承方式中的最小值。
公式:父类公有/保护成员在子类外部访问权限 = Min (父类成员本身访问限定符 public/protected,子类继承方式)。
(3)总结
- 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
- 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
(4)案例
基类和派生类对象赋值转换
注意:下面讨论的基类和派生类对象赋值转换规则,默认基于公有继承方式。这是因为在实际编程中,公有继承最为常用,而其他继承方式下的赋值转换规则较为复杂且应用场景较少。
1.赋值转换规则
在 C++ 的公有继承体系中,派生类(子类)对象是一种特殊的基类(父类)对象,它们之间存在特定的赋值转换关系:子类对象 / 指针 / 引用可以赋值给父类对象 / 指针 / 引用,但父类对象 / 指针 / 引用通常不能直接赋值给子类对象 / 指针 / 引用 。
2.子类对象赋值给父类对象相关情况
案例:
#include<iostream>
#include<string>
using namespace std;
// 基类(父类) Person,表示人
class Person
{
// 在继承场景中,父类通常较多使用公有和保护成员,较少使用私有成员。
// 在非继承情况下,保护和私有访问限定符对于类外部而言效果相同,都限制外部直接访问。
// 但在继承体系中,私有访问限定符会使基类成员在派生类内外都不可见;
// 而保护访问限定符允许基类成员在派生类内部直接访问,在派生类外部不可直接访问。
// 由于在继承时,通常期望基类部分成员能在派生类内部被访问,所以一般优先使用保护访问限定符,而非私有访问限定符。
// private: // 在子类中不可见(在子类内外部都不能直接访问),既防外部访问,也限制子类访问。
protected: // 在子类中可见(在子类内部可以直接访问),防止外部直接访问,但子类可访问。
string _name = "peter"; // 姓名
string _sex = "man"; // 性别
int _age = 18; // 年龄
};
// 子类:学生类 Student,公有继承自 Person 类
// 在实际继承应用中,公有继承较为常用,因为它能较好地保留基类的接口特性;
// 而保护继承和私有继承相对较少使用,因为它们会改变基类成员在派生类中的访问权限,增加代码理解和维护的难度。
class Student : public Person
{
public:
int _No = 1; // 学号
};
int main()
{
// 1. 不同类型赋值会存在类型转换,而类型转换过程会产生与左操作数类型相同的临时变量,
// 然后用这个临时变量赋值给左操作数。需要注意的是,赋值过程中右操作数的类型和值不会发生改变。
double d = 1.1;
int i = d; // 隐式类型转换,将 double 类型的 d 转换为 int 类型,创建整形临时变量进行赋值。
// 这里会截断小数部分,i 的值为 1。
const int& ri = d;
// 隐式类型转换过程会产生整形临时变量,临时变量具有常属性。
// 由于 ri 是临时对象的别名,这里必须使用 const 引用。
// 如果不使用 const 引用,会出现权限放大的问题,导致编译报错。
// 因为普通引用不能绑定到临时对象,而 const 引用可以延长临时对象的生命周期。
// 2. 子类对象可以赋值给父类对象/引用/指针
// (以下默认讨论的是在公有继承下子类对象和父类对象的赋值规则)
Student s;
// 2.1 子类对象赋值给父类对象
// 在公有继承中,子类对象是特殊的父类对象,因为子类对象包含了父类对象的所有成员(继承而来的部分)。
// 所以子类对象赋值给父类对象的过程,理论上虽可认为有隐式类型转换,但实际上是天然支持的,不存在类型转换,也不会产生临时对象。
// 具体过程是:将子类对象中从父类继承的那部分成员切割出来,调用父类的拷贝构造函数赋值给父类对象,这一过程也称为赋值兼容转换或切片/切割。
// 此操作不会破坏子类对象本身,只是一个拷贝行为。
Person p = s;
// 2.2 子类指针赋值给父类指针
// 当把子类对象的地址(指针)赋值给父类指针 ptrp 后,ptrp 就指向子类对象中从父类继承的那部分内存。
// 通过对指针 ptrp 解引用,就可以访问子类对象中从父类继承的成员,并进行读写操作。
Person* ptrp = &s;
// 2.3 子类引用赋值给父类引用
// 子类对象赋值给父类引用时,不会发生类型转换,也不会产生临时对象。
// 此时父类引用 rp 是子类对象中从父类继承部分的别名,
// 可以通过父类引用 rp 访问到子类对象中从父类继承的那部分内容,并进行读写操作。
// 由于没有产生临时对象,所以即使这里不使用 const 引用也不会报错。
Person& rp = s;
// 3. 基类对象不能直接赋值给派生类对象
// 因为子类对象除了包含从父类继承的成员外,还可能有自己特有的成员,
// 而父类对象中没有子类特有的成员,直接赋值会导致子类对象中特有的成员无法被正确初始化,
// 所以 s = p; 这行代码会编译报错。
// 4. 基类指针可以通过强制类型转换赋值给派生类指针,但不建议这样做
// 在单继承中,子类对象的内存布局是父类成员在前,子类成员在后。
// 在多继承(非菱形继承)中,类对象内存布局是按照继承顺序依次存放各个父类成员,子类成员在最后面。
// 4.1 把子类对象地址赋值给父类指针后,通过父类指针访问子类对象的成员
// 当把子类对象的地址赋值给父类指针 ptrp 时,ptrp 指向子类对象中属于父类的那一部分。
// 此时若要通过类似子类指针的方式访问子类对象的所有成员,需要进行强制类型转换。
// 因为父类指针默认只能访问父类部分的成员,强制转换后,指针就可以按照子类对象的内存布局去访问所有成员。
// 例如这里,由于父类成员存储在子类对象内存的最前面,实际 ptrp 指向子类对象的起始位置,
// 通过强制转换后的 ptrs1 就可以直接访问子类对象的所有成员。
ptrp = &s;
Student * ptrs1 = (Student*)ptrp;
ptrs1->_No = 10;
// 4.2 把父类对象地址强制转换成子类指针并访问子类成员
// 当 ptrp 指向父类对象 p 时,
// 将父类对象地址强制转换成子类指针,然后试图通过子类指针访问子类对象中的成员,这种行为是危险的。
// 因为父类对象中没有子类特有的成员,这样的访问一定会发生越界访问的问题,导致程序错误。
// 虽然在语法上可以进行这种强制类型转换,但会存在运行时错误的风险。
ptrp = &p;
Student* ptrs2 = (Student*)ptrp;
ptrs2->_No = 10; // 这里会发生越界访问错误
return 0;
}
注意事项:在 C++ 公有继承下,子类指针 / 引用赋值给父类指针 / 引用后,父类指针 / 引用虽指向子类对象空间,但仅能访问子类中从父类继承的成员,无权直接访问子类特有成员。如需访问,通常要做安全的类型转换。
(1)子类对象赋值给父类对象
过程与原理:当执行子类对象赋值给父类对象的操作时,实际上会发生一个被称为赋值兼容转换或切片(切割)的过程。具体来说,子类对象中从父类继承而来的那部分成员会被 “切割” 出来,然后通过调用父类的拷贝构造函数,将这部分成员的值赋值给父类对象。这个过程是被 C++ 语言天然支持的,在整个操作中不存在类型转换的过程,也不会产生临时对象,同时也不会对原有子类对象本身造成破坏。
例如:
本质原因:子类对象包含了父类对象的所有成员(继承而来的部分),从内存布局角度看,子类对象的内存空间中包含了父类对象的内存布局,因此可以将子类对象中父类的部分直接赋值给父类对象。
(2)子类指针赋值给父类指针
过程与原理:当把子类对象的地址赋值给父类指针时,父类指针会指向子类对象中从父类继承的那部分内存区域。后续通过对该父类指针进行解引用操作,就可以访问到子类对象中从父类继承的成员,并对其进行读写等操作。
例如:
(3)子类引用赋值给父类引用
过程与原理:子将子类对象赋值给父类引用,实际上是让父类引用成为子类对象中从父类继承部分的别名。通过这个父类引用,能够访问到子类对象中从父类继承的内容,并对其进行读写操作。值得注意的是,在这种情况下,即使不使用const
引用修饰,也不会出现报错的情况。这是因为该赋值过程不存在类型转换,也不会产生临时对象,所以不会引发权限放大等问题。
例如:
3.父类对象赋值给子类对象相关情况
(1)父类对象不能直接赋值给子类对象
原因:子类对象在继承父类成员的基础上,还可能拥有自己独特的成员变量或函数。而父类对象中并不包含这些子类特有的成员,如果直接将父类对象赋值给子类对象,那么子类对象中特有的成员将无法得到正确的初始化,从而导致数据不完整的问题。从内存角度来看,子类对象的内存空间大于父类对象,父类对象无法完全填充子类对象的内存区域。因此,在 C++ 中,这种直接赋值的操作是不被允许的,会导致编译错误。
例如:
(2)父类指针通过强制类型转换赋值给子类指针(不建议)
过程与原理:在单继承的情况下,子类对象的内存布局遵循父类成员在前,子类成员在后的顺序。基于此,理论上可以将父类指针强制类型转换为子类指针。但是,这种操作存在很大的风险。当通过父类指针访问子类对象成员时,如果没有进行正确的强制类型转换,就很可能会发生越界访问的情况,从而导致程序出现未定义行为甚至崩溃。例如:
注意事项:这种强制类型转换破坏了类型系统的安全性,如果父类指针指向的并非真正的子类对象,那么通过转换后的子类指针访问子类特有的成员时,就会导致程序崩溃或出现未定义行为。只有在确保父类指针确实指向一个子类对象时,才可以谨慎使用强制类型转换 。
4.赋值转换的注意问题
- 类型兼容性:在进行赋值转换时,要时刻牢记子类对象与父类对象之间的类型关系,子类对象可以安全地赋值给父类对象 / 指针 / 引用,但反向赋值通常需要谨慎处理,避免数据不完整或越界访问等问题。
- 强制类型转换风险:父类指针转换为子类指针时,强制类型转换虽然可行,但存在极大风险。在不确定父类指针指向的具体对象类型时,不要轻易进行此类转换,否则可能导致程序出现难以调试的错误。
- 引用使用:使用子类对象赋值给父类引用时,要理解引用本质是别名,合理利用引用进行对象操作,同时注意
const
引用的使用场景,虽然在子类对象赋值给父类引用时const
引用不是必需的,但在某些情况下(如防止意外修改引用对象),使用const
引用可以增强程序的健壮性。
5.总结
- 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
- 基类对象不能赋值给派生类对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。
继承中你的作用域
注意事项:
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
继承中作用域的基本概念:在 C++ 的继承体系里,基类和派生类(子类)各自拥有独立的作用域。这意味着不同作用域内可以定义同名的变量或函数 。
1.同名成员变量的隐藏情况
当基类和子类中存在同名成员变量时,在子类内部访问该变量,会优先访问子类自己的同名成员变量,从而屏蔽基类同名成员变量的直接访问,这种现象被称为隐藏,也叫重定义。例如:
在上述代码中,Student
类继承自Person
类,两者都有_num
成员变量。在Student
类的Print
函数中,通过Person::_num
可以访问父类的_num
,直接写_num
则访问子类自己的_num
。
2.同名成员函数的隐藏情况
隐藏规则:在 C++ 的继承体系中,成员函数的隐藏规则相对简单且严格:只要子类和父类的成员函数名相同,就会构成隐藏,而无需考虑函数的参数列表和返回值类型。例如:
这里A
类和B
类的fun
函数构成隐藏关系。
- 与函数重载的区别:函数重载要求在相同作用域内,通过不同的参数列表来区分同名函数。而在继承中,父类和子类是不同的作用域,即便子类的同名函数与父类同名函数参数不同,也不构成重载,而是隐藏。
- 隐藏后的访问方式:当父类和子类存在同名成员函数时,父类的同名成员函数在子类内外部都会被隐藏,但并非不可见。可以通过显示指定父类成员所属作用域来访问,即使用
基类名::函数名
的方式来访问被隐藏的父类成员函数。例如在上述B
类的fun
函数中,通过A::fun()
访问父类的fun
函数。 - 建议:在实际的 C++ 编程中,为了提升代码的可读性和可维护性,减少潜在的混淆和错误,建议尽量避免在子类和父类中定义同名成员函数。因为一旦出现同名函数,无论是在子类内部还是外部调用父类的同名函数,都需要使用显式访问的方式,这无疑增加了代码的复杂性和理解难度。尤其是在大型项目中,复杂的继承层次结构和同名函数的存在,可能会让代码的维护和调试变得异常困难。
3.总结
- 不同作用域内可以存在同名函数,而在相同作用域内,可利用函数重载解决同名函数的命名冲突问题。
- 当子类和父类存在同名成员(变量或函数)时,子类成员会屏蔽父类同名成员的直接访问。不过,可以通过显示指定作用域的方式访问父类同名成员。
派生类(子类)的默认成员函数
派生类默认成员函数概述:在 C++ 中,当我们定义一个派生类时,如果没有显式地定义某些成员函数,编译器会自动为派生类生成 6 个默认成员函数,分别是构造函数、拷贝构造函数、赋值运算符重载函数、析构函数、移动构造函数和移动赋值运算符重载函数。这些默认成员函数在派生类的对象创建、复制、赋值和销毁等操作中起着关键作用。理解它们的生成机制和行为对于正确使用派生类至关重要。
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的operator=必须要调用基类的operator=完成基类的复制。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象初始化先调用基类构造再调派生类构造。
- 派生类对象析构清理先调用派生类析构再调基类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
案例
#include<iostream>
#include <string>
using namespace std;
//基类(父类)
class Person
{
public:
//构造函数
Person(const char* name = "peter")
: _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; // 姓名
};
//子类:学生类,继承自 Person 类
class Student : public Person
{
public:
//子类构造函数说明:
//1.在 C++ 中,子类对象包含父类的部分和自己的部分。
//2.子类构造函数必须调用父类构造函数来初始化子类中属于父类的那部分成员。
//3.如果父类没有默认构造函数(即只有带参构造函数),则必须在子类构造函数初始化
//列表中显式调用父类带参构造函数并传入合适的参数。
//4.若父类有默认构造函数,在子类构造函数初始化列表中可以不显示调用,此时编译器会自动调用
//父类默认构造函数来初始化子类中属于父类的那部分成员,但使用的是缺省值,这可能不是我们想要的结果。
//所以建议在子类构造函数的初始化列表中显式调用父类构造函数。
Student(const char* name, int num)
: Person(name) //4.显式调用父类构造函数对父类部分进行初始化
, _num(num) //5.子类自己的成员变量通过手动赋值完成初始化
{
cout << "Student()" << endl;
}
//子类拷贝构造函数说明:
//1.子类拷贝构造函数需要完成两部分的拷贝:父类部分和子类自己的部分。
//2.必须在初始化列表中显式调用父类拷贝构造函数来完成父类部分的拷贝初始化。
//3.若不显示调用,编译器不会自动调用父类拷贝构造函数,这样父类部分将不会被正确拷贝。
Student(const Student& s)
: Person(s) //4.显式调用父类拷贝构造函数。由于调用父类拷贝构造函数要求参数是父类对象,
//但在子类拷贝构造函数中只有形参子类引用 const Student& s,没有父类对象。
//由于子类对象/引用/指针可以直接赋值给父类对象/引用/指针,所以可以把子类对象中
//属于父类的那一部分当作父类对象来使用。此时在子类拷贝构造函数初始化列表中,
//可以直接传形参子类对象别名 const Student& s 给父类拷贝构造函数的形参父类引用 const Person& 接收,
//这样形参子类对象别名 const Student& s 指向子类对象中属于父类的那一部分就可以当作父类对象来使用,
//从而成功显式调用父类拷贝构造函数完成父类的拷贝初始化。
, _num(s._num) //5.子类自己的成员变量通过手动赋值完成拷贝
{
cout << "Student(const Student& s)" << endl;
}
//子类赋值运算符重载说明:
//1.子类赋值运算符重载函数需要完成两部分的赋值:父类部分和子类自己的部分。
//2.必须在函数内部显式调用父类赋值运算符重载函数来完成子类中属于父类那部分成员的赋值操作。
//3.若不显示调用,编译器不会自动调用父类赋值运算符重载函数,这样父类部分将不会被正确赋值。
Student& operator=(const Student& s)
{
if (this != &s) //4.防止自我赋值,避免不必要的操作
{
//5.由于子类和父类的赋值运算符重载函数同名,父类的赋值运算符重载函数在子类中被隐藏(屏蔽)。
//所以需要通过指定父类作用域来显式调用父类赋值运算符重载函数,完成子类中属于父类那部分成员的赋值操作。
Person::operator=(s);//6. 注:当我们想要调用父类赋值重载函数完成对子类中父类成员的赋值操作时,
//若没有显式调用父类赋值重载函数,即把Person::operator=(s)写成operator=(s) ,
//由于子类和父类的赋值重载函数同名,父类的赋值重载函数会被隐藏(屏蔽)。
//此时operator=(s)访问的是子类赋值重载函数,这将导致在子类赋值重载函数中不断递归
//调用自身。随着递归调用层数不断增加,函数调用栈不断增长,最终会导致栈溢出 ,
//程序因陷入死循环而崩溃。
_num = s._num; //7.子类自己的成员变量通过手动赋值完成赋值操作
}
cout << "Student& operator=(const Student& s)" << endl;
return *this;
}
//析构函数说明:
//1.析构函数会被编译器处理成统一的名称(如 destructor),子类和父类析构函数同名,构成隐藏关系。
//2.构造子类对象时,先构造父类成员,再构造子类自己的成员。
//4.由于栈的后进先出特性,析构顺序是先调用子类析构函数,再调用父类析构函数。
//5.不需要在子类析构函数中显式调用父类析构函数,编译器会自动调用,以保证析构顺序的正确性。
~Student()
{
/* 6.注意事项:若将 Person::~Person(); 直接写成 ~Person(),代码将无法通过编译。
原因分析:在 C++ 中,为了实现多态特性,子类和父类的析构函数会被编译器处理成相同的名称,通常是 "destructor"。
这样一来,子类和父类的析构函数就变成了同名函数,在子类的作用域中,父类的析构函数会被隐藏。
解决办法:若要在子类析构函数中显式调用父类析构函数,必须使用作用域解析运算符,即写成 Person::~Person();*/
/*7.注意事项:若去掉 Person::~Person(); 的注释,编译会报错。
原因分析:从子类对象的构造顺序来看,在创建子类对象时,会先调用父类的构造函数来初始化子类中属于父类的那部分成员,
然后再调用子类自身的构造函数来初始化子类特有的成员。根据栈的“后进先出”(LIFO)原则,析构的顺序应该与构造的顺序相反。
也就是说,子类对象在销毁时,应该先调用子类的析构函数,再调用父类的析构函数。若在子类析构函数中手动显式调用父类析构函数,
就可能破坏这种先子类后父类的析构顺序,导致未定义行为,因此编译器不允许这样做。
解决办法:在子类析构函数中,我们不应该手动显式调用父类析构函数,编译器会在子类析构函数执行完毕后,自动调用父类的析构函数,
以此保证析构顺序的正确性。*/
//Person::~Person(); //8.无论是否显式定义子类析构函数,只要不手动显式调用父类析构函数,编译器都会自动调用父类的析构函数
//输出信息,表明子类对象正在被析构
cout << "~Student()" << endl;
}
//h子类析构函数完成时,会自动调用父类析构函数,保证先析构子再析构父
protected:
int _num; // 学号
};
int main()
{
//创建一个 Student 对象 s1
Student s1("张三", 18);
//使用拷贝构造函数创建一个新的 Student 对象 s2
Student s2(s1);
//子类对象赋值给父类对象:可以直接赋值,没有类型转换,没有产生临时对象。
//这个过程实际上是调用父类拷贝构造函数来完成赋值操作,将子类对象中属于父类的
//那部分内容作为参数传递给父类拷贝构造函数,初始化出一个新的父类对象 p。
Person p = s1;
//子类对象之间的赋值操作
s1 = s2;
return 0;
}
1.子类构造函数
1.1.子类构造函数与成员初始化的相关问题
(1)未显式编写构造函数和析构函数的情况:当子类没有显式编写构造函数和析构函数时,编译器会自动调用父类的构造函数和析构函数。也就是说,此时子类会自动利用父类的相关函数来完成对象的构造和析构操作 。例如:
在上述代码中,Student
类没有显式编写构造函数和析构函数,程序运行时会自动调用Person
类的构造函数和析构函数,输出Person()
和~Person()
。
(2)为何子类构造函数需调用父类构造函数初始化父类成员
在 C++ 中,当显式编写子类构造函数时,不能在初始化列表中手动通过赋值给子类中父类成员进行初始化,而需调用父类构造函数,原因如下:
- 对于子类来说,从父类继承的成员本质上是属于父类的,只有父类的构造函数才知道如何正确地初始化这些成员。父类构造函数内部有一系列的逻辑和操作来设置父类成员变量的初始状态,包括可能涉及到的内存分配、资源初始化等操作。例如
Person
类的构造函数Person(const char* name)
,它内部可能会对_name
进行内存分配(如果_name
是动态分配的字符串等情况),或者进行一些其他的初始化逻辑。子类构造函数通过在初始化列表中调用父类构造函数(如Person(name)
) ,可以让父类按照其既定的逻辑去初始化这些继承过来的成员,从而保证对象状态的一致性和正确性 。 - 在创建子类对象时,子类中父类部分先被创建,然后在此基础上创建子类特有的成员,整个子类对象才算创建完成。因此,在初始化子类成员(包括从父类继承的成员和子类特有的成员)时,是先初始化父类成员,再初始化子类特有的成员。
- 由于先有父类对象被创建和初始化,才能在此基础上创建和初始化子类对象,所以先调用父类构造函数来初始化父类部分是合理的,这样可以确保子类对象中的父类成员处于正确的初始状态,为子类对象的完整构建奠定基础。
1.2.子类构造函数对成员变量的初始化规定
(1)初始化方式:子类构造函数对自身成员变量和从父类继承的成员变量的初始化方式是不同的。对于子类自身成员变量,通常是在构造函数体内手动赋值进行初始化;而对于从父类继承的成员变量,必须调用父类构造函数来完成初始化 。例如:
在上述代码中,Student
类构造函数通过Person(name)
在初始化列表中显式调用父类Person
的构造函数,来初始化从父类继承的_name
成员;同时通过_num(num)
对自身的_num
成员进行初始化。
(2)父类默认构造函数的调用
不管子类是否显式编写构造函数,都会调用父类默认构造函数来完成子类中父类那部分成员的初始化。若父类没有默认构造函数,则必须在子类构造函数初始化列表中,像匿名对象的格式那样显式调用父类合适的构造函数来完成对父类成员的初始化 。例如:
2.子类拷贝构造函数
2.1.父类拷贝构造函数的调用规则
子类拷贝构造函数和子类构造函数不同,在子类拷贝构造函数的初始化列表中,若不显式调用父类拷贝构造函数,编译器不会自动调用。因此,必须在子类拷贝构造函数的初始化列表中显式调用父类拷贝构造函数,来完成对父类成员的初始化 。以Person
类和Student
类(Student
继承自Person
)为例:
2.2.子类对象和父类对象赋值转换在实现子类拷贝构造函数的应用
(1)参数传递与父类拷贝构造调用:子类拷贝构造函数的初始化列表中,由于父类拷贝构造函数的形参是父类对象引用const Person& p
,所以在子类拷贝构造函数初始化列表中要显式调用父类拷贝构造函数来初始化子类中属于父类的那部分成员 。但子类拷贝构造函数的形参通常是子类对象引用const Student& s
,当没有父类对象时,需要一种方式将子类对象当作父类对象传递给父类拷贝构造函数。因为子类对象 / 引用 / 指针可以赋值给父类对象 / 指针 / 引用,所以可以通过赋值方式把子类当作父类来使用,即将子类对象中属于父类的那部分拷贝给父类 。具体来说,把子类拷贝构造函数的形参(子类对象引用const Student& s
)传递赋值给父类拷贝构造函数的形参(父类对象引用const Person& p
),这样就能解决将子类对象当作父类对象使用的问题 。
(2)子类对象赋值给父类对象的实现:子类对象赋值给父类对象是通过调用父类拷贝构造函数来完成赋值操作的。父类拷贝构造函数的形参(父类引用const Person& p
)可以成为子类对象中属于父类那一部分的别名 。例如在图片中Person p = s;
(s
是Student
类对象),这一过程会调用父类Person
的拷贝构造函数,将 s
中属于父类Person
的部分成员拷贝给p
。
3.子类赋值重载函数
(1)编译器与父类赋值重载函数调用
编译器不会自动调用父类的赋值重载成员函数。因此,在子类的赋值重载成员函数operator=
中,必须显式调用父类的赋值重载成员函数operator=
,以此完成子类中属于父类那部分成员的赋值操作 。以Person
类和Student
类(Student
继承自Person
)为例:
(2)赋值重载函数同名引发的隐藏问题及后果
在继承关系中,子类和父类的赋值重载成员函数同名(均为operator=
),这会导致父类的赋值重载成员函数在子类内部和外部被隐藏(遮蔽) 。当在子类赋值重载函数中没有显式调用父类的赋值重载成员函数operator=
时,编译器会认为调用的是子类自身的赋值重载成员函数,从而导致在实现子类赋值重载成员函数时发生栈溢出。其原因如下:
- 死循环形成:由于子类和父类的
operator=
函数同名,它们构成隐藏关系。在子类的operator=
函数内部调用operator=(s)
时(这里s
是同类型对象),实际上调用的是子类自己的operator=
函数,这就导致子类的operator=
函数一直被重复调用,进而形成死循环。 - 栈溢出后果:随着死循环不断调用函数,函数调用栈不断增长,最终导致栈溢出,程序崩溃。
(3)解决方式
为避免上述问题,在子类的赋值重载函数中,需要显式调用父类的赋值重载函数,具体代码如下:
通过Person::operator=(s)
明确指定调用父类的赋值重载函数,先处理父类成员的赋值,再处理子类自身成员的赋值,从而打破死循环,保证程序正确执行。
//子类赋值重载成员函数
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.子类析构函数
4.1.父类析构函数调用顺序
子类构造函数、拷贝构造函数、赋值重载函数在执行时,都遵循先调用父类相应构造 / 拷贝 / 赋值成员函数,再调用子类自己对应成员函数的顺序 。以Person
类和Student
类(Student
继承自Person
)为例:
结论:子类的构造函数、拷贝构造函数、赋值运算符重载函数都是先调用父类相应的函数,然后再调用子类自身的函数。析构函数的调用顺序则相反,先调用子类析构函数清理子类成员,再调用基类析构函数清理基类成员。
4.2.子类析构函数中调用父类析构函数的问题
(1)错误调用方式及报错原因
在子类析构函数中,如果没有利用正确方式(而是直接)调用父类析构函数会发生报错。因为在 C++ 中,析构函数的调用由编译器管理,父类和子类的析构函数都有destructor
处理机制。若子类和父类析构函数同名,子类的析构函数会隐藏父类的析构函数(由于名字遮蔽规则)。此时若想在子类析构函数中调用父类析构函数,必须在子类析构函数内部通过作用域解析符::
指定所属具体类域来显示调用父类析构函数 。
例如,若在Student
类析构函数中错误地直接写~Person()
(而不是Person::~Person()
),会引发编译错误,类似出现如 “没有与这些操作数匹配的‘~’运算符” 等报错信息 。
(2)显示调用父类析构函数的问题
即便在子类析构函数中使用Person::~Person()
这样的形式显示调用父类析构函数,也会存在问题。原因是这样会导致连续调用两次父类析构函数 。编译器在处理子类对象析构时,本身会自动调用父类析构函数,若程序员再手动显示调用,就会违背正常的析构顺序,造成错误。
(3)编译器自动调用父类析构函数的原理
编译器规定在子类构造 / 拷贝 / 赋值成员函数中,我们需显示调用父类对应构造 / 拷贝 / 赋值成员函数。但对于子类析构函数,编译器不让我们显示调用父类析构函数,而是自动调用。这是为了保证正确的析构顺序,即先析构子类,再析构父类 。
子类对象的成员变量结构是先父类成员,后子类自己的成员。在构造对象时,先构造子类中的父类成员,再构造子类自己成员;而由于栈的后进先出特性,析构时后定义的先出作用域先析构,所以正确的析构顺序是先调用子类析构函数析构子类,再调用父类析构函数析构父类 。例如:
(4)保证先析构子再析构父的原因
在 C++ 的继承体系中,若析构顺序为先析构父类再析构子类,可能会引发问题。以父类Person
包含指针成员_ptr
为例,当父类析构函数被调用时,它会释放_ptr
所指向的资源。此时,_ptr
就成为了野指针。
若此时子类尚未析构,且子类中存在代码访问或使用了这个父类的_ptr
指针成员(例如,子类可能有一些基于_ptr
所指向资源的后续操作逻辑) ,就会导致严重错误。因为此时_ptr
指向的资源已被释放,再次访问不仅可能引发程序崩溃,还可能出现重复释放资源的情况(即对已释放的资源再次尝试释放)。
而遵循先析构子类再析构父类的顺序则不会出现这类问题。因为父类在设计时并不知道子类的具体实现,父类中不存在访问子类特有成员的逻辑,所以在子类析构之后再析构父类,不会出现父类访问到无效子类资源的情况,从而保证了析构过程的安全性和程序的稳定性。
5.总结
- 构造函数中的父类调用:若显式编写子类构造函数,但在子类构造函数初始化列表中没有显式调用父类构造函数,编译器会自动调用父类默认构造函数。当父类没有默认构造函数时,则必须在子类构造函数的初始化列表中显式调用父类构造函数 。
- 拷贝与赋值重载函数调用:若显式编写子类的拷贝构造函数、赋值重载函数时,内部不显式调用父类拷贝构造、赋值重载函数,编译器不会自动调用父类的拷贝构造、赋值重载函数。建议调用父类相应的默认成员函数,否则子类中属于父类那部分成员就不能正常完成初始化 / 拷贝 / 赋值等操作 。
- 指针成员与浅拷贝:当子类成员变量不涉及动态内存分配的指针时,可以不显式编写子类拷贝构造函数、赋值重载函数,因为编译器会默认生成并自动调用父类相关函数进行浅拷贝。但当子类成员变量涉及动态内存分配的指针时,子类必须显式编写拷贝构造函数、赋值重载函数,进行深拷贝操作。否则直接浅拷贝会在析构时造成内存错误,引发报错 。
继承与友元
1.友元
在 C++ 里,友元函数与友元类属于特殊的机制,其作用是允许类外部的函数或者类访问该类的私有和保护成员。需要注意的是,友元机制会破坏类的封装性,所以要谨慎使用。只有在必要的情况下,才考虑使用友元函数或者友元类。
1.1.友元函数
(1)友元函数介绍
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在
类的内部声明,声明时需要加friend关键字。
说明:
- 友元函数可访问类的私有和保护成员,但不是类的成员函数
- 友元函数不能用const修饰
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制
- 一个函数可以是多个类的友元函数
- 友元函数的调用与普通函数的调用原理相同
(2)友元函数应用
①问题:现在尝试去重载operator<<,然后发现没办法将operator<<重载成成员函数。因为cout的
输出流对象和隐含的this指针在抢占第一个参数的位置。this指针默认是第一个参数也就是左操作
数了。但是实际使用中cout需要是第一个形参对象,才能正常使用。所以要将operator<<重载成
全局函数。但又会导致类外没办法访问成员,此时就需要友元来解决。operator>>同理。
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{}
//d1 << cout; -> d1.operator<<(&d1, cout); 不符合常规调用
//因为成员函数第一个参数一定是隐藏的this,所以d1必须放在<<的左侧
ostream& operator<<(ostream& _cout)
{
_cout << _year << "-" << _month << "-" << _day << endl;
return _cout;
}
private:
int _year;
int _month;
int _day;
};
②解决方式:把operator<<、operator>>声明成类的友元函数
注意:友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加friend关键字。
class Date
{
friend ostream& operator<<(ostream& _cout, const Date& d);
friend istream& operator>>(istream& _cin, Date& d);
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& _cout, const Date& d)
{
_cout << d._year << "-" << d._month << "-" << d._day;
return _cout;
}
istream& operator>>(istream& _cin, Date& d)
{
_cin >> d._year;
_cin >> d._month;
_cin >> d._day;
return _cin;
}
int main()
{
Date d;
cin >> d;
cout << d << endl;
return 0;
}
1.2.友元类
友元类是指被授予访问另一个类的私有和保护成员权限的类。在类的定义里,要使用friend
关键字对友元类进行声明。友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
说明:
- 友元关系是单向的,不具有交换性。
比如下面的Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行。 - 友元关系不能传递
如果C是B的友元, B是A的友元,则不能说明C时A的友元。 - 友元关系不能继承。
class Time
{
//声明日期类为时间类的友元类,则在日期类中就直接访问Time类
//中的私有成员变量
friend class Date;
public:
Time(int hour = 0, int minute = 0, int second = 0)
: _hour(hour)
, _minute(minute)
, _second(second)
{ }
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{ }
void SetTimeOfDate(int hour, int minute, int second)
{
// 直接访问时间类私有的成员变量
_t._hour = hour;
_t._minute = minute;
_t._second = second;
}
private:
int _year;
int _month;
int _day;
Time _t;
};
2.继承与友元
(1)注意事项
①友元关系不可继承:友元关系是一种特殊的类间关系,它不具备继承特性。具体来说,即便一个类或函数被声明为父类的友元,它也不会自动成为该父类派生的子类的友元。总的来说,若一个类/函数是父类的友元,则该类/函数不是子类的友元。
②保护(protected
)和私有(private
)访问限制符共同点与区别:
- 在不存在继承关系的普通类中,保护(
protected
)和私有(private
)访问限制符有相似之处。从类的外部来看,这两种访问权限修饰的成员都无法被直接访问。 - 在继承体系下,保护(
protected
)和私有(private
)访问限制符存在明显差异。在公有继承中,父类的私有成员在子类的内部和外部都无法被直接访问;而父类的保护成员在子类内部可以被访问,但在子类外部不能被访问。
③常用访问限制符选择:在继承场景中,通常更倾向于使用公有(public
)和保护(protected
)访问限制符,较少使用私有(private
)。这是因为在公有继承时,父类的私有成员对子类来说访问受限极大,几乎无法在子类中直接利用,使得私有成员在继承体系中的作用相对有限。
(2)案例:以 Person 类和继承自它的 Student 类为例,普通函数 Display 是 Person 类的友元函数。由于友元关系不能继承,在 Display 函数中直接访问 Student 类的私有或保护成员(如 Student 类的学号 _stuNum )会引发编译错误 。示例代码如下:
- 解决方式:若希望
Display
函数能够访问子类Student
的私有或保护成员,需要在子类Student
内部使用friend
关键字,将Display
函数声明为子类Student
的友元函数。这样,Display
函数就获得了访问子类Student
私有和保护成员的权限 。
继承与静态成员
1.静态成员
(1)概念:声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化
(2)静态成员特性
- 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
- 静态成员变量必须在类外定义,定义时不添加static关键字。静态成员变量在类中只是声明。
- 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问
- 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制
(3)注意事项
- 静态成员函数不能直接调用非静态成员函数。原因:静态成员函数没有 this 指针,非静态成员函数依赖 this 指针访问对象成员,静态成员函数因缺少 this 指针无法直接调用非静态成员函数。
- 非静态成员函数能调用类的静态成员函数。原因:静态成员函数属于类,不依赖特定对象实例,只要访问权限许可,非静态成员函数在对象作用域内可直接调用 。
2.继承与静态成员的关系
(1)注意事项
- 子类继承父类时,从继承层面讲,子类不会继承父类的静态成员变量,即子类中从父类继承的成员部分不包含父类静态成员变量。
- 在整个继承体系中,无论父类被继承多少次,仅存在一个父类静态成员变量。父类静态成员变量被父类及其所有子类共享,创建多个父类或子类对象时,静态成员变量只有一个副本。
- 从访问域角度,子类可以访问父类静态成员变量。且无论父类还是子类对父类静态成员变量进行操作,操作的都是同一个变量。
- 父类不能直接访问子类的静态成员变量。因为父类在设计时,无法预知子类的具体实现。
- 父类静态成员变量属于整个类体系,不仅属于父类,也为所有子类共享。
- 子类的静态成员变量独立于父类静态成员变量,仅属于子类本身。
(2)案例
基类定义了static
静态成员,则整个继承体系中只有一个这样的成员实例。无论派生出多少个子类,所有派生类访问基类静态成员时,访问的都是同一个实例。示例如下:在整个继承体系中,使用基类、派生类对象来访问基类静态成员_count
并打印静态成员变量地址时,会发现地址都是一样的。这表明在整个继承体系中,无论派生出多少个子类,基类的静态成员只有一个实例,所有派生类访问基类静态成员时永远访问的都是这同一个实例。
结论:在继承体系中,父类的静态成员变量会被其子类继承 ,整个继承体系中仅存在一个父类静态成员变量实例,它被父类及其所有子类共享。换言之,无论创建多少个父类或子类对象,父类静态成员变量仅有一个副本。子类能够访问并操作父类静态成员变量,且这种操作针对的是同一个静态成员变量。但严格来说,该静态成员变量属于父类,并不属于子类本身。
(3)静态成员在继承的应用:统计整个继承体系中基类、派生类对象被创建数量
#include <iostream>
#include <string>
using namespace std;
//基类(父类):Person类,用于表示人物相关信息
class Person
{
public:
//构造函数,每次创建Person类及其派生类对象时都会调用
//因为派生类构造函数在初始化自身成员前会先调用父类构造函数
//通过对父类静态成员变量_count执行++_count操作
//可以统计整个继承体系(包括基类、派生类等)中对象被创建的数量
Person()
{
++_count;
}
public:
string _name; //用于存储人物的姓名
public:
static int _count; //用于统计整个继承体系中创建的对象个数
};
//静态成员变量只能在类外部进行初始化,这里将_count初始化为0
int Person::_count = 0;
//子类:学生类,继承自Person类,在人物信息基础上增加学生相关属性
class Student : public Person
{
protected:
int _stuNum; //用于存储学生的学号
};
//学生类的子类:研究生类,继承自Student类,进一步增加研究生相关属性
class Graduate : public Student
{
protected:
string _seminarCourse; //用于存储研究生的研究科目
};
int main()
{
//创建基类Person的对象p
Person p;
//创建派生类Student的对象s
Student s;
//创建派生类Graduate的对象g,它继承了Person和Student的成员
Graduate g;
//普通成员变量的继承特性说明
//父类和子类的普通成员变量是相互独立的实例
//这里输出p、s、g对象的_name成员的地址,会发现地址不同
//说明父类和子类各自访问的_name不是同一个变量
cout << &(p._name) << endl;
cout << &(s._name) << endl;
cout << &(g._name) << endl;
cout << endl;
//静态成员变量的继承特性说明
//父类的静态成员变量在整个继承体系中是共享的
//这里输出p、s、g对象的_count成员的地址,会发现地址相同
//说明父类和子类访问的_count是同一个父类静态成员变量
cout << &(p._count) << endl;
cout << &(s._count) << endl;
cout << &(g._count) << endl;
cout << endl;
//通过指定类域(作用域)访问父类静态成员变量
//由于父类静态成员变量属于整个类族(包括父类和所有子类)
//所以可以通过父类类域(Person::_count)或子类
//类域(Student::_count、Graduate::_count)进行访问
//访问的都是同一个静态成员变量
cout << Person::_count << endl;
cout << Student::_count << endl;
cout << Graduate::_count << endl;
//结论:父类静态成员变量是所有继承的派生类共享的
return 0;
}
实现一个不能被继承的类
1.思路及原理
(1)实现一个不能被继承的类思路
构造函数和析构函数是创建和销毁对象的关键。若这些函数是私有的,那么除了类自身和其友元,其他地方都无法调用。所以,将基类的构造函数和析构函数设为私有,就导致派生类无法调用基类的构造和析构函数,最终导致无法正常创建和销毁派生类对象。
结论:只要基类的构造函数或者析构函数中的一个被私有化,这个基类就不能被正常继承。
(2)基类的构造函数和析构函数设为私有后,派生类就无法继承基类的原因
①
- 创建对象阶段:在创建派生类对象时,编译器会先调用基类的构造函数,接着才调用派生类自身的构造函数。这是因为派生类对象包含了基类的部分,要先对基类部分进行初始化,之后才能对派生类特有的部分进行初始化。
- 销毁对象阶段:当销毁派生类对象时,顺序与创建对象时相反。先调用派生类的析构函数,再调用基类的析构函数。这是为了保证派生类特有的资源先被释放,然后再释放基类部分的资源。
- 访问权限控制:当把基类的构造函数和析构函数设为私有后,只有基类自身的成员函数和友元函数能够调用它们。派生类不再能直接调用基类的构造函数和析构函数。
②无法继承的原因:由于派生类对象的创建需要调用基类的构造函数,销毁时需要调用基类的析构函数,而私有访问权限使得派生类无法直接调用这些函数,所以在创建派生类对象时,编译器会因为无法调用基类的构造函数而报错;在销毁派生类对象时,也会因无法调用基类的析构函数而报错。这就导致无法正常创建和销毁派生类对象,从而在实际效果上实现了基类不能被继承。
2.代码实现
#include <iostream>
using namespace std;
//实现一个不能被继承的类
//基类(父类)
class A
{
//私有化构造函数
public:
//创建类对象函数
static A CreateObj()
{
//直接返回匿名对象
//由于匿名对象 A() 是在类内部定义,因此可以调用私有化的构造函数
//访问限定符不限制类内部成员之间的访问,只会限制类外部对类内部成员的访问
return A();
}
private:
//父类 A 的构造函数私有化,外部无法直接调用
//这样做可以阻止派生类调用基类的构造函数,从而实现不能被继承的目的
A()
{}
};
//子类(继承父类 A)
class B : public A
{};
int main()
{
//由于父类构造函数不可见,子类永远调用不到父类的构造函数
//B bb; //编译错误,父类 A 构造函数私有化之后,子类 B 无法调用父类 A 的构造函数完成对子类中属于父类那一部分成员的初始化
//CreateObj() 定义为静态成员函数的原因:
//要调用普通成员函数需要先创建类对象,而创建类对象又需要调用构造函数,这里会形成死循环
//为了解决这个问题,将 CreateObj() 定义为静态成员函数,这样就可以直接通过类名调用该函数来创建类 A 对象
A::CreateObj(); //调用静态成员函数 CreateObj() 创建对象
return 0;
}