继承的概念
继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象****程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继****承是类设计层次的复用。
需求: 许多子类想共用父类的成员时,为了简化代码(不让每个子类写同样的成员),就有了继承,通过类访问限定符对成员的划分,我们可以将父类的一些成员(成员变量+成员函数)继承到子类,使得子类也可以使用。
例子:对于学校中的学生信息与老师信息,我们需要进行管理,创建Student类管理学生,Teacher类管理老师,对于这两个公有的成员比如学生与老师的姓名,年龄等,我们将其写在另外一个类Member类中作为父类,使得子类可以使用父类的成员,而对于老师的工号与学生的学号不同,我们把它们分别写在各自的类中。
父类/基类
class Member
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "XiaoMing";//姓名
int _age = 18;//年龄
};
子类/派生类
class Teacher : public Member
{
protected:
int _jobid;//工号
};
子类/派生类
class Student : public Member
{
protected:
int _stuid; // 学号
};
父类Member的成员(成员函数+成员变量)都会变成子类的一部分。这里Student和Teacher复用了Person的成员。
继承的定义
定义格式
基类与普通类的格式一致。
派生类与普通类稍有差别,主要是类名后面加上了继承方式和父类,具体定义格式如下。
编辑
继承关系与访问限定符
对于派生类能访问基类的哪些成员,实际上需要上面的继承方式与访问限定符的组合,3×3=9种可能的情况。
这里给出一个顺序:public > protected > private
对于派生类中能否访问基类成员,我们给出这样一条规律:取子类继承方式和父类访问限定符较小的那一个作为访问方式。
注意: 所有的成员都可以被继承,但是继承后的访问关系是有规则的。
例子:
理解:
- 对于基类的private成员,我们在派生类中无论以什么方式继承都是不可见的(可以被继承但是不能被访问)。
- 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在 派生类中能访问,就定义为protected。可以看出protected成员限定符是因继承才出现的。
- 对于基类的public,protected成员,遵循上面那个小顺序:public > protected > private , 根据继承方式,选择较小的那个作为访问方式。
总结:
在实际应用中,用的最多的是基类访问限定符是public,protected,派生类的继承方式是public方式。
基类和派生类对象赋值转换——切片
派生类向基类转换
public继承下 派生类对象 可以赋值给 基类的对象/指针/引用,反过来不行 。(其他继承没有这样的特性)
Student s;
Member m1 = s;
Member* m2 = &s;
Member& m3 = s;
这里有个形象的说法叫切片或者切割。意为把派生类中基类那部分切来赋值过去。
它的原理是这样的。
对于不同类型的变量赋值时会进行类型转换,产生临时变量。而对于派生类对象赋值给基类对象时不会产生临时对象,通过切片的方式直接进行赋值。
int i=10;
double d = i;//产生了临时拷贝,进行类型转换
const double& k = i;//所以引用时需要加const
Student s;
Member m = s;//没有产生临时拷贝
//通过切片的方式把s对象中含有的基类成员变量直接赋值给基类对象m
Member &n = s;//是合法的,中间没有产生临时变量,不会出现引用常量
Member *o = &s;
*对于 Member o = &s; 的理解
本质上是对已经实例化对象s的变量的内存访问范围截取。
编辑
这里并没有创造临时变量,而是仅仅对student类型变量的内存访问范围进行了“截断”,也就是说Member*类型的o变量存的地址就是student类型的s变量的地址,所以o变量和&s都是相同的起始地址,但是它们的类型不同,导致了解引用的内存访问范围不一样。
基类向派生类转换
基本不行,在一些特殊情况下可以
继承中的作用域及隐藏(重定义)的概念
在继承体系中基类和派生类都有独立的作用域,就是它们类名形成的作用域。
派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏,也叫重定义
在子类成员函数中,可以使用 基类::基类成员 显式访问。
例子
class Member
{
protected:
string _name = "XiaoMing";//姓名
int _age = 18;//年龄
int _id = 123;
};
class Student : public Member
{
public:
void Print()
{
cout << "_id:" << _id << endl;
cout << "Member::_id:" << Member::_id << endl;//通过指定类域访问基类变量
}
protected:
int _id = 1111; // 学号
};
int main()
{
Student s;
s.Print();
return 0;
}
结果:
注意:如果是成员函数在不同作用域,只需要函数名相同就构成隐藏(返回值和参数可以不相同) 。 在实际中在继承体系里面最好不要定义同名的成员。
派生类的默认成员函数
构造函数
默认的构造函数
派生类的成员:1.内置类型不做处理 2.自定义类型去调他的构造函数 (和普通的类一样!)。
派生类中的基类成员:去调基类的构造函数(与普通类的区别!)。
例子:
class Member
{
protected:
string _name;//姓名
int _age;//年龄
};
class Student : public Member
{
public:
private:
int _id;
};
int main()
{
Student s;
return 0;
}
构造Student对象,对于他自己(派生类)的成员,调用其默认的构造函数,即1.内置类型不做处理(int类型的_id初始化为随机值) 2.自定义类型去调他的构造函数(此处没有)
对于基类的成员(string类型的_name和int类型的_age),去调其基类的构造函数(此处父类仍是默认构造函数)。
显示的构造函数
在初始化基类成员时不能在派生类中单独初始化,需要在父类构造函数中初始化。
显示调用初始化函数
class Member
{
public:
Member(const char* name = "XiaoMing", int age = 10)
:_name(name)
,_age(age)
{
cout << "Member()" << endl;
}
protected:
string _name;//姓名
int _age;//年龄
};
class Student : public Member
{
public:
Student(const char* name, int age ,int id = 10)
:Member(name, age)
,_id(id)
{
cout << "Student()" << endl;
}
private:
int _id;
};
int main()
{
Student s("XiaoFang",10,100);
return 0;
}
进行对象实例化,结果为
无论初始化列表顺序如何改变,构造函数构造顺序都是先父后子。
理解: 如果子类初始化时需要父类继承的成员变量时,此时若父类成员还未初始化,则子类初始化出现错误,所以构造顺序一定是先父类后子类。
构造函数构造的顺序:先父后子。
析构函数
class Member
{
public:
Member(const char* name = "XiaoMing", int age = 10)
:_name(name)
,_age(age)
{
cout << "Member()" << endl;
}
~Member()
{
cout << "~Member()" << endl;
};
protected:
string _name;//姓名
int _age;//年龄
};
class Student : public Member
{
public:
Student(const char* name, int age ,int id = 10)
:Member(name, age)
,_id(id)
{
cout << "Student()" << endl;
}
~Student()
{
//~Member();错误写法,原因如下
//子类的析构函数和父类的析构函数构成隐藏关系
//由于多态原因,析构函数被特殊处理,函数名都被处理成destructor(),同名函数形成隐藏
Member::~Member();//要指定类域;
cout << "~Student()" << endl;
};
private:
int _id;
};
int main()
{
Student s("XiaoFang",10,100);
return 0;
}
我们创建一个对象,观察一下
此处我们发现Member被析构两次,原因是父类析构函数会在子类析构后自动调用。
我们在~Student()中显示调用~Member(),在子类自己析构后又自动调用一次父类的析构函数,从而导致析构两次,所以这种析构函数的写法是错误的。
总结: 对于父类的析构函数不用再子类的析构函数中显示调用,子类析构函数结束后父类析构函数自动调用!
继承体系中正确析构函数写法:
class Member
{
public:
Member(const char* name = "XiaoMing", int age = 10)
:_name(name)
,_age(age)
{
cout << "Member()" << endl;
}
~Member()
{
cout << "~Member()" << endl;
};
protected:
string _name;//姓名
int _age;//年龄
};
class Student : public Member
{
public:
Student(const char* name, int age ,int id = 10)
:Member(name, age)
,_id(id)
{
cout << "Student()" << endl;
}
~Student()
{
cout << "~Student()" << endl;
};
private:
int _id;
};
int main()
{
Student s("XiaoFang",10,100);
return 0;
}
观察结果我们发现析构顺序与构造顺序相反,析构顺序是先子类后父类。
理解: 假设析构先父后子,会存在安全隐患,可能父类成员的资源已经清理,派生类再去访问可能会找不到,或者出现野指针等问题。
析构函数析构的顺序:先子后父
拷贝构造函数
利用切片原则,先将子类赋值给父类,然后再单独赋值子类剩下的函数。
例子:
class Member
{
public:
Member(const char* name = "XiaoMing", int age = 10)
:_name(name)
,_age(age)
{}
~Member()
{
cout << "~Member()" << endl;
};
protected:
string _name;
int _age;
};
class Student : public Member
{
public:
Student(const char* name, int age ,int id = 10)
:Member(name, age)
,_id(id)
{
}
//----------------------------------------------------------------------------
Student(const Student& s)
:Member(s)//利用赋值转换的原则(切片原则)将派生类成员切片给基类进行赋值
,_id(s._id)
{}
//----------------------------------------------------------------------------
~Student()
{
cout << "~Student()" << endl;
};
private:
int _id;
};
int main()
{
Student s("XiaoFang",10,100);
Student s1(s);
return 0;
}
赋值重载函数
class Member
{
public:
Member(const char* name = "XiaoMing", int age = 10)
:_name(name)
,_age(age)
{}
//-----------------------------------------------------------
Member& operator=(const Member& p)
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
return *this;
}
//-----------------------------------------------------------
~Member()
{
cout << "~Member()" << endl;
};
protected:
string _name;
int _age;
};
class Student : public Member
{
public:
Student(const char* name, int age ,int id)
:Member(name, age)
,_id(id)
{
}
Student(const Student& s)
:Member(s)//利用赋值转换的原则(切片原则)将派生类成员切片给基类进行赋值
,_id(s._id)
{}
//----------------------------------------------------------
Student& operator=(const Student& s)
{
if (this != &s)
{
Member::operator=(s);//这里构成了隐藏,所以要想调用父类的,需要指明类域
_id = s._id;
}
return *this;
}
//----------------------------------------------------------
~Student()
{
cout << "~Student()" << endl;
};
private:
int _id = 10;
};
int main()
{
Student s("XiaoFang",10,100);
Student s1 = s;
return 0;
}
总结
派生类的默认成员函数规则跟普通类的规则一致,唯一不同的是,不管是构造/析构/拷贝,多的是基类那一部分,基类部分调用基类那一部分对应函数去完成。
继承与友元
友元关系不能继承,基类友元不能访问子类私有和保护成员。 (爸爸的朋友不是孩子的朋友)
class Student;
class Member
{
public:
//friend void print(const Member& m, const Student& s);
//友元不能继承(不能继承下去,成为子类的友元),无法获取子类的成员
protected:
string _name;
int _age;
};
class Student:public Member
{
public:
friend void print(const Member& m, const Student& s);
private:
int _id;
};
void print(const Member& m,const Student& s)
{
cout << m._name << endl;
cout << s._id << endl;
}
int main()
{
print();
return 0;
}
继承与静态成员
基类定义了static静态成员,则整个继承体系中只有一个这样的成员,子类只能继承访问权。(子类中不会再产生一份拷贝,原因是static静态成员不是在对象中的,是在静态区的)
菱形继承
单继承:一个子类只有一个直接父类。
多继承:一个子类有两个或两个以上的直接父类。
菱形继承:在多继承的基础上,被同一个类继承的两个类,他们又继承自同一对象。
有了多继承就可能出现菱形继承,菱形继承会产生二义性,空间浪费。
产生的问题:Student类和Teacher类继承Member类的成员,具有相同的成员(作用域不同),Assistant类又继承Student类和Teacher类,这样Assistant类就有两个相同的成员(但是作用域不同,所以可以通过不同的作用域访问这两个相同的成员),造成了变量二义性,数据浪费。
解决菱形继承——虚继承
为了解决这个问题,引入关键字virtual,加在产生同一个成员变量的类中,这个时候B对象实例化时没有保存A类成员,而是通过一个机制指向A类成员
例子:
关于虚继承的底层原理
class A
{
public:
int _a;
};
class B :public A
{
public:
int _b;
};
class C :public A
{
public:
int _c;
};
class D :public B, public C
{
public:
int _d;
};
int main()
{
D dd;
dd.B::_a = 1;
dd.C::_a = 2;
dd._b = 3;
dd._c = 4;
dd._d = 5;
return 0;
}
该代码描述的是这样的场景:
我们观察其内存地址:
观察发现其确实多储存了两份_a(B::_a与C::_a)。
在B类与C类中加入virtual关键字。
class A
{
public:
int _a;
};
class B :virtual public A
{
public:
int _b;
};
class C :virtual public A
{
public:
int _c;
};
class D :public B, public C
{
public:
int _d;
};
int main()
{
D dd;
dd.B::_a = 1;
dd.C::_a = 2;
dd._b = 3;
dd._c = 4;
dd._d = 5;
return 0;
}
我们观察其内存地址:
在两个蓝色框中我们发现下一行分别存储B类_b和C类_c的值,而上一行储存的是地址,由于我的是小端机,则_b存储的地址是0x001c7bdc,_c存储的地址是0x001c7be4 ,找到这两个地址,我们发现它们下一个地址存储了一个十六进制的值,这个值是偏移量。
再将这个偏移量加回原来存储地址的地址,我们发现它们的结果都指向了A类的地址。
B类和C类都通过偏移量表去找到公共的A类。这个时候B或C对象实例化时没有保存A类成员,而是通过一个机制指向A类成员。
总结:D类继承自B和C,因此D对象的内存布局中,会包含属于B类的成员部分(即B切片)和属于C类的成员部分(即C切片),还包含D自身新增的成员,以及虚继承带来的公共A部分。
类D的对象的内存布局(虚继承下):
+---------------------+
| B切片(B类的成员) | <-- 类D对象的指针指向这里
+---------------------+
| C切片(C类的成员) |
+---------------------+
| D自身新增的成员 |
+---------------------+
| 公共A的成员(虚基类)| <-- B、C通过指针指向这里
+---------------------+
关于对类D进行切片后的类B对象
D dd;
dd.B::_a = 1;
dd.C::_a = 2;
dd._b = 3;
dd._c = 4;
dd._d = 5;
B* pb = d;//对类D对象进行切片
切片后的类B对象仍然可以访问到基类A的成员变量(通过偏移量找),逻辑自洽。
举例: C++ 标准库中IO 流类的继承结构(菱形继承)
ios:作为 IO 流的核心基类istream:通过虚继承从ios派生,是输入流的基类,提供字符 / 数据的输入操作(例如cin是istream的实例)ostream:通过虚继承从ios派生,是输出流的基类,提供字符 / 数据的输出操作(例如cout是ostream的实例)iostream:同时继承istream和ostream。
回顾:关于访问结构体成员的本质
对象中多个成员,存储的顺序是按照声明顺序(从上到下)确定的
- 第一个成员的内存偏移量为 0(直接从结构体的起始地址开始存储)
- 后续每个成员,默认会紧跟在前一个成员的 “逻辑末尾地址” 之后(注意:不是物理末尾,因为存在内存对齐)
#include <stdio.h>
// 声明顺序:id → name → score
struct Student {
int id; // 第1个成员
char name[6]; // 第2个成员
double score; // 第3个成员
};
内存中默认排布顺序:id(先存)→ name(中间存)→ score(后存),绝不会出现name排在id前面的情况。
例子
p_stu→score 找到对应内存范围的过程,本质是地址定位 + 类型确定范围。
step1:获取结构体的起始地址
p_stu是一个struct Student*类型的指针变量,它的核心作用就是存储结构体stu的起始内存地址(即&stu的值)。
step2:计算score成员的偏移量
编译器在编译代码时,会根据结构体成员的声明顺序和内存对齐规则,提前计算好每个成员相对于结构体起始地址的偏移量(字节数),这个偏移量是固定的,不会在运行时改变。
对于score成员:
- 前面的
id占 4 字节,name占 6 字节; - 为了满足
double类型的内存对齐(偏移量需是 8 的整数倍),name后面会填充 6 字节; - 因此
score的偏移量 = 4(id) + 6(name) + 6(填充) = 16 字节(可通过offsetof(struct Student, score)获取)。
简单说:score在结构体中,距离结构体起始地址往后偏移了 16 个字节。
step3: 计算score成员的起始内存地址
有了结构体起始地址(来自p_stu)和score的偏移量,就能通过加法得到score的起始地址:
score起始地址 = p_stu存储的结构体起始地址 + score的偏移量
step4: 根据score的数据类型,确定内存范围
当找到score的起始地址后,它的内存范围由自身的数据类型(double)决定
double类型在系统中固定占 8 字节;- 因此**
score的内存范围 = score起始地址 ~ score起始地址+7**