C++ 硬核知识点: 数据段/代码段/内存分配/虚函数/继承/多态
1. 结构体struct和类class占用内存大小解析
今天面试遇到一个比较有意思的问题, 这里安排一下
空结构体和空类占内存大小是多少?
答案:
1. C++指定空结构体和空类所占内存大小为1,
2. C 的空类和空结构体大小为0
为何c++会有这样的规定呢?
no object shall have the same address in memory as any other variable
如果允许C++对象大小为0, 那么这里的运算将产生两个问题:
不能通过指针区分不同的数组对象,
sizeof(S1)为0, 导致非法除零操作
这样一来就需要更复杂的代码处理异常
示例代码
#include<iostream>
#include<string>
using namespace std;
struct S1{
}; // 内存大小是1字节
S1 s1,s2; //对象大小s1=1, s2=1, 对象地址 s1=1556095296, s2=1556095297
class C1{
}; // 1字节
class C2{
C2(){};
~C2(){};
}; // 1字节 // 证明析构函数和构造函数不占空间
int main(){
printf("空结构体大小=%d, \n对象大小s1=%d, s2=%d, \n对象地址 s1=%d, s2=%d\n",sizeof(S1), sizeof(s1), sizeof(s2), &s1, &s2);
cout<<"空类大小" <<sizeof(C1) <<endl;
cout<<"C1类大小" <<sizeof(C2) <<endl;
return 0;
}
/*
空结构体大小=1,
对象大小s1=1, s2=1,
对象地址 s1=-2084564687, s2=-2084564686
空类大小1
C1类大小1
*/
2. C++中各类数据所占内存字节数
1字节=8位 [ 00000001 ] 2 [00000001]_2 [00000001]2
sizeof(void*) 32位操作系统中4字节, 64位操作系统8字节
int 4字节
char 1字节
float 4字节
double 8字节
空类class, 空结构体struct 1字节
3. C++字节对齐规则
3.1 什么是字节对齐
现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
3.2 字节对齐的原因和作用
各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些架构的CPU在访问 一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐.其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对 数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那 么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数 据。显然在读取效率上下降很多。
3.3 字节对齐三个准则 这太磨叽了, 直接看下面的几个示例就明白了
结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
结构体每个成员相对于结构体首地址的偏移量都是当前成员大小的整数倍,如有需要编译器会在成员之间加上填充字节;
结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。// 比如结构体最大的成员double8字节, 那么整个内存大小应该是8的整数倍, 不够的补
3.4 字节对齐的示例, 看不懂你来打我
#include <iostream>
#include <iostream>
using namespace std;
struct S1{
}; // 内存大小是1字节
S1 s1,s2; //对象大小s1=1, s2=1, 对象地址 s1=1556095296, s2=1556095297
// 准则1, 首地址必须能被double 8整除,
// 准则2, 首地址偏移量都是当前大小的整数倍, 即char=1需要补全到4才能是int=4的整数倍, int4就不用补全了, 因为当前偏移量是4+4是double的整数倍
struct S2{
char a; // 1 偏移1, 不是int的整数倍, 偏移量+3
int b; // 4 偏移4+4=8, 是double的整数倍, 偏移量+0
double c; // 8 偏移8+8=16字节
}; // 16字节
S2 s3,s4; //对象大小s3=16, s4=16, 对象地址 s3=1556095312, s4=1556095328
struct S3{ // 首地址8的整数倍
char a; // 1 a偏移1=1, 是e的整数倍, 偏移量+0
char e; // 1 1偏移1=2 不是c的整数倍, 偏移量+6=8
double c; // 8 8偏移8 = 16, 是int b的整数倍, 偏移量+0
int b; // 4 16偏移4=20, 不是成员最大字节整数倍, 偏移量补全+4, 最后20+4=24字节
}; // 24字节
struct S4{ // 首地址8的整数倍
char a; // a偏移1=1, 是e的整数倍, 偏移量+0 = 1
char e; // e偏移1=2, 不是c的整数倍, 偏移量+6 = 8
double c; // c偏移8=16, 是b的整数倍, 偏移量+0 = 16
int b; // b偏移4=20, 是f的整数倍, 偏移量+0 = 20
char f; // f偏移1=21, 最后不是8的整数倍, 补全, 偏移量+3, 最后24字节
};
int a; // 4字节
char b; // 1字节
double c; // 8字节
void struct_test(){
cout<<"指针大小" << sizeof(void *)<<endl; // 32位机指针大小是4字节, 64位是8字节
cout<<"int型大小" << sizeof(a)<<endl; // 4
cout<<"char型大小" << sizeof(b)<<endl; // 1
cout<<"double型大小" << sizeof(c)<<endl; // 8
printf("空结构体大小=%d, \n对象大小s1=%d, s2=%d, \n对象地址 s1=%d, s2=%d\n",sizeof(S1), sizeof(s1), sizeof(s2), &s1, &s2);
printf("S2结构体大小=%d, \n对象大小s3=%d, s4=%d, \n对象地址 s3=%d, s4=%d\n",sizeof(S2), sizeof(s3), sizeof(s4), &s3, &s4);
cout<< "S3内存大小" << sizeof(S3)<<endl; //24
cout<< "S4内存大小" << sizeof(S4)<<endl; //24
}
class C1{
}; // 1字节
class C2{
C2(){};
~C2(){};
}; // 1字节 // 证明析构函数和构造函数不占空间
class C3{
public:
C3(int a){a=a;};
int a; // 4
char b; // 1 + 4是2的倍数, =5
char e; // 5+1不是8的倍数, 偏移2=8
double c; // 8+8 = 16字节
}; // 16字节
class C4{
public:
C4(int a){a=a;};
int a; // 4
char b; // 1 + 4是2的倍数, =5
char e; // 5+1不是8的倍数, 偏移2=8
double c; // 8+8 = 16字节
int sum3(int f, int m, int n){
return f+m+n;
}
}; // 16字节
void class_test(){
cout<<"空类大小" <<sizeof(C1) <<endl;
cout<<"C1类大小" <<sizeof(C2) <<endl;
cout<<"C2类大小" <<sizeof(C3) <<endl;
cout<<"C3类大小" <<sizeof(C3) <<endl;
cout<<"C4类大小" <<sizeof(C3) <<endl;
// 综上所述, class和struct异曲同工
}
int main()
{
struct_test();
class_test();
return 0;
}
到此, 这段代码也算是解释和证明了了上面所有的问题
4. 内存存储
4.1 为何使用数据/代码分存储
一个类去定义对象, 系统会为每个对象分配存储空间
一个类包含数据, 函数
理论上讲: 一个类定义10个对象, 需要分配10个数据和代码存储单元, 如图1
图1
能否只用一段空间来存放共同的函数代码段, 在调用个对象的函数时, 直接去调用公共函数代码, 如图2
图2
显然后者能够大大解决存储空间; 因此C++编译系统就是这样做的
每个对象所占用的存储空间只是该对象的数据部分(虚函数指针和抽象类指针也属于数据部分) 所占用的存储空间都不包含函数代码所占的存储空间(这句话在上面的类C4中既已证明)
4.2 分类存储实际操作
C++程序的内存格局有四个区: 全局数据区, 代码区, 栈区, 堆区
在这里插入图片描述
加粗样式
在这里插入图片描述
在这里插入图片描述
总的来讲
分区 分配 存放的东西 管理
全局数据区static 全局变量, 常量, 静态数据 操作系统管理
代码区 function 类的成员函数和非成员函数 操作系统管理
堆区 heap 程序员指定, malloc/new 动态分配的对象 操作系统管理
栈区 stack 局部变量、函数参数、返回数据、返回地址
静态区,代码区,堆区,栈区 存储地址依次下降
//main.cpp
int a = 0; //全局初始化区
char *p1; //全局未初始化区
main()
{
int b;// 栈
char s[] = "abc"; //栈
char *p2; //栈
char *p3 = "123456"; 123456/0";//在常量区,p3在栈上。
static int c =0; //全局(静态)初始化区
p1 = (char *)malloc(10);
p2 = (char *)malloc(20); //分配得来得10和20字节的区域就在堆区。
strcpy(p1, "123456"); //123456/0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
注意:
类成员函数和非成员函数代码存放在代码区;
静态成员函数和非静态成员函数都是在类的定义时放在内存的代码区
类的非静态类成员函数其实都内含了一个指向类对象的指针型参数(即this指针),因而只有类对象才能调用(此时this指针有实值)
成员函数的代码段都不占用对象的存储空间
class C5
{
public:
void printA() {
cout<<"printA"<<endl;
}
virtual void printB() { // 基类里定义的虚函数
cout<<"printB"<<endl;
}
};
int main(){
C5 *d=NULL;
d->printA(); // printA
d->printB(); // Segmentation fault (core dumped)
// 这是因为printB包含指向类对象的指针, 而d不是个对象, 因此程序出错
5. C++中哪些函数不能定义为虚函数*
1. 不能被继承的函数
2. 不能被重写的函数
普通函数:
不属于成员函数,是不能被继承的。普通函数只能被重载,不能被重写,因此声明为虚函数没有意义。因为编译器会在编译时绑定函数。
友元函数: 不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
构造函数:
首先说下什么是构造函数,构造函数是用来初始化对象的。假如子类可以继承基类构造函数,那么子类对象的构造将使用基类的构造函数,而基类构造函数并不知道子类的有什么成员,显然是不符合语义的。从另外一个角度来讲,多态是通过基类指针指向子类对象来实现多态的,在对象构造之前并没有对象产生,因此无法使用多态特性,这是矛盾的。因此构造函数不允许继承。
内联成员函数
我们需要知道内联函数就是为了在代码中直接展开,减少函数调用花费的代价。也就是说内联函数是在编译时展开的。而虚函数是为了实现多态,是在运行时绑定的。因此显然内联函数和多态的特性相违背。
静态成员函数
首先静态成员函数理论是可继承的。但是静态成员函数是编译时确定的,无法动态绑定,不支持多态,因此不能被重写,也就不能被声明为虚函数。
// 友元函数示例
#include <iostream>
#include <string>
using namespace std;
class Box{
double width; // C++默认为private
public:
// 成员函数声明
friend void printWidth(Box box); // 定义友元函数, 可以访问这里面的所有成员, 但不属于该类的成员
void setWidth(double wid);
};
// 成员函数定义
void Box::setWidth(double wid){
width = wid;
}
// 请注意:printWidth() 不是任何类的成员函数
void printWidth(Box box){
/* 因为 printWidth() 是 Box 的友元,它可以直接访问该类的任何成员 */
cout << "Width of box : " << box.width << endl;
}
// 程序的主函数
int main(){
Box box;
// 使用成员函数设置宽度
box.setWidth(10.0);
// 使用友元函数输出宽度
printWidth(box); // Width of box : 10
return 0;
}
6. 虚函数与纯虚函数
6.1 虚函数和纯虚函数
虚函数: C++中用于实现多态的机制, 核心理念是通过基类访问派生类定义的函数, 是C++中多态的一个重要体现; 利用基类指针访问派生类中的虚函数, 这种情况采用的是动态绑定技术;
纯虚函数: 基类声明的虚函数, 基类无定义, 要求任何派生类都需要定义自己的实现方法, 在基类中实现纯虚函数的方法是在函数原型后面加 =0 纯虚函数不能实例化对象; 带有纯虚函数的类也叫做抽象类
下面几个重要的概念关于虚函数和纯虚函数
定义一个函数为虚函数,不代表函数为不被实现的函数。
定义他为虚函数是为了允许用基类的指针来调用子类的这个函数。
定义一个函数为纯虚函数,才代表函数没有被实现。
定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。
6.2 虚函数的使用
#include <iostream>
using namespace std;
class A{
public:
virtual void foo(){ // 定义虚函数并实现了
cout<<"A::foo() is called"<<endl;
}
};
class B:public A{
public:
void foo() { // 重现了虚函数
cout<<"B::foo() is called"<<endl;
}
};
int main(void)
{
A *a = new B();
// 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的
// 所以返回的结果是 B::foo() is called
// 同时说明, 一个类函数的调用并不是编译时刻被确定的, 而是运行时刻
a->foo();
return 0;
}
6.3 纯虚函数的使用
定义: 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加 =0: 如 virtual void funtion1()=0
引入原因:
为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数,则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。
抽象类的作用: 是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
7. 多态底层实现
7.1 虚函数表
class B {
virtual int f1 (void); // 0
virtual void f2 (int); // 1
virtual int f3 (int); // 2
};
// 虚函数表
vptr -> [B::f1, B::f2, B::f3]
0 1 2
首先对于包含虚函数的类, 编译器会为每个包含虚函数的类生成一张虚函数表,即存放每个虚函数地址的函数指针的数组,简称虚表(vtbl),每个虚函数对应一个虚函数表中的下标。
除了为包含虚函数的类生成虚函数表以外,编译器还会为该类增加一个隐式成员变量,通常在该类实例化对象的起始位置,用于存放虚函数表的首地址,该变量被称为虚函数表指针,简称虚指针(vptr)。例如:
B* pb = new B;
pb->f3 (12);
// 被编译为
pb->vptr[2] (pb, 12); // B::f3 参数pb是this指针
// 注意:虚表是一个类一张,而不是一个对象一张,同一个类的多个对象,通过各自的虚指针,共享同一张虚表。
vptr-> | vptr1 | vptr2 | vptr3 |
7.2 多态的工作原理
// 继承自B的子类
class D : public B {
int f1 (void);
int f3 (int);
virtual void f4 (void);
};
// 虚函数表
// 子类覆盖了基类的f1和f3,继承了基类的f2,增加了自己的f4,编译器同样会为子类生成一张专属于它的虚表。
vptr(子类)-> D::f1, B::f2, D::f3, D::f4
0 1 2 3
// 指向子类虚表的虚指针就存放在子类对象的基类子对象中。例如:
B* pb2 = new D; // 父类指向子类, 调用子类的方法
pb2->f3 (12);
// 被编译为
pb2->vptr(子类)[2] (pb2, 12); // D::f3
派生类定义对象时, 程序运行会自动调用构造函数, 在构造函数中创建虚函数表并对虚表初始化; 在构造子类对象时, 先调用父类构造函数, 此时, 编译器只"看到了"父类, 并为父类对象初始化虚表指针, 令他指向父类虚表, 当调用子类的构造函数时, 为子类对象初始化虚表指针, 令他指向子类虚表;
请添加图片描述
动态绑定- 多态的灵魂
当编译器“看到”通过指针或者引用调用基类中的虚函数时,并不急于生成有关函数调用的指令,相反它会用一段代码替代该调用语句,这段代码在运行时被执行,
完成如下操作:
根据调用指针或引用的目标对象找到其内部的虚表指针;
根据虚表指针找到其所指向的虚函数表;
根据虚函数名和函数指针在虚函数表中的索引,找到所调用虚函数的入口地址;
在完成函数调用的准备工作以后,直接跳转到虚函数入口地址处顺序执行函数体指令序列,直到从函数中返回。
动态绑定对性能的影响
虚函数表和虚指针的存在势必要增加内存空间的开销。
和普通函数调用相比,虚函数调用要多出一个步骤,增加运行时间的开销。
动态绑定会妨碍编译器通过内联优化代码,虚函数不能内联。
今天面试遇到一个比较有意思的问题, 这里安排一下
空结构体和空类占内存大小是多少?
答案:
1. C++指定空结构体和空类所占内存大小为1,
2. C 的空类和空结构体大小为0
为何c++会有这样的规定呢?
no object shall have the same address in memory as any other variable
如果允许C++对象大小为0, 那么这里的运算将产生两个问题:
不能通过指针区分不同的数组对象,
sizeof(S1)为0, 导致非法除零操作
这样一来就需要更复杂的代码处理异常
示例代码
#include<iostream>
#include<string>
using namespace std;
struct S1{
}; // 内存大小是1字节
S1 s1,s2; //对象大小s1=1, s2=1, 对象地址 s1=1556095296, s2=1556095297
class C1{
}; // 1字节
class C2{
C2(){};
~C2(){};
}; // 1字节 // 证明析构函数和构造函数不占空间
int main(){
printf("空结构体大小=%d, \n对象大小s1=%d, s2=%d, \n对象地址 s1=%d, s2=%d\n",sizeof(S1), sizeof(s1), sizeof(s2), &s1, &s2);
cout<<"空类大小" <<sizeof(C1) <<endl;
cout<<"C1类大小" <<sizeof(C2) <<endl;
return 0;
}
/*
空结构体大小=1,
对象大小s1=1, s2=1,
对象地址 s1=-2084564687, s2=-2084564686
空类大小1
C1类大小1
*/
2. C++中各类数据所占内存字节数
1字节=8位 [ 00000001 ] 2 [00000001]_2 [00000001]2
sizeof(void*) 32位操作系统中4字节, 64位操作系统8字节
int 4字节
char 1字节
float 4字节
double 8字节
空类class, 空结构体struct 1字节
3. C++字节对齐规则
3.1 什么是字节对齐
现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
3.2 字节对齐的原因和作用
各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些架构的CPU在访问 一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐.其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对 数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那 么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数 据。显然在读取效率上下降很多。
3.3 字节对齐三个准则 这太磨叽了, 直接看下面的几个示例就明白了
结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
结构体每个成员相对于结构体首地址的偏移量都是当前成员大小的整数倍,如有需要编译器会在成员之间加上填充字节;
结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。// 比如结构体最大的成员double8字节, 那么整个内存大小应该是8的整数倍, 不够的补
3.4 字节对齐的示例, 看不懂你来打我
#include <iostream>
#include <iostream>
using namespace std;
struct S1{
}; // 内存大小是1字节
S1 s1,s2; //对象大小s1=1, s2=1, 对象地址 s1=1556095296, s2=1556095297
// 准则1, 首地址必须能被double 8整除,
// 准则2, 首地址偏移量都是当前大小的整数倍, 即char=1需要补全到4才能是int=4的整数倍, int4就不用补全了, 因为当前偏移量是4+4是double的整数倍
struct S2{
char a; // 1 偏移1, 不是int的整数倍, 偏移量+3
int b; // 4 偏移4+4=8, 是double的整数倍, 偏移量+0
double c; // 8 偏移8+8=16字节
}; // 16字节
S2 s3,s4; //对象大小s3=16, s4=16, 对象地址 s3=1556095312, s4=1556095328
struct S3{ // 首地址8的整数倍
char a; // 1 a偏移1=1, 是e的整数倍, 偏移量+0
char e; // 1 1偏移1=2 不是c的整数倍, 偏移量+6=8
double c; // 8 8偏移8 = 16, 是int b的整数倍, 偏移量+0
int b; // 4 16偏移4=20, 不是成员最大字节整数倍, 偏移量补全+4, 最后20+4=24字节
}; // 24字节
struct S4{ // 首地址8的整数倍
char a; // a偏移1=1, 是e的整数倍, 偏移量+0 = 1
char e; // e偏移1=2, 不是c的整数倍, 偏移量+6 = 8
double c; // c偏移8=16, 是b的整数倍, 偏移量+0 = 16
int b; // b偏移4=20, 是f的整数倍, 偏移量+0 = 20
char f; // f偏移1=21, 最后不是8的整数倍, 补全, 偏移量+3, 最后24字节
};
int a; // 4字节
char b; // 1字节
double c; // 8字节
void struct_test(){
cout<<"指针大小" << sizeof(void *)<<endl; // 32位机指针大小是4字节, 64位是8字节
cout<<"int型大小" << sizeof(a)<<endl; // 4
cout<<"char型大小" << sizeof(b)<<endl; // 1
cout<<"double型大小" << sizeof(c)<<endl; // 8
printf("空结构体大小=%d, \n对象大小s1=%d, s2=%d, \n对象地址 s1=%d, s2=%d\n",sizeof(S1), sizeof(s1), sizeof(s2), &s1, &s2);
printf("S2结构体大小=%d, \n对象大小s3=%d, s4=%d, \n对象地址 s3=%d, s4=%d\n",sizeof(S2), sizeof(s3), sizeof(s4), &s3, &s4);
cout<< "S3内存大小" << sizeof(S3)<<endl; //24
cout<< "S4内存大小" << sizeof(S4)<<endl; //24
}
class C1{
}; // 1字节
class C2{
C2(){};
~C2(){};
}; // 1字节 // 证明析构函数和构造函数不占空间
class C3{
public:
C3(int a){a=a;};
int a; // 4
char b; // 1 + 4是2的倍数, =5
char e; // 5+1不是8的倍数, 偏移2=8
double c; // 8+8 = 16字节
}; // 16字节
class C4{
public:
C4(int a){a=a;};
int a; // 4
char b; // 1 + 4是2的倍数, =5
char e; // 5+1不是8的倍数, 偏移2=8
double c; // 8+8 = 16字节
int sum3(int f, int m, int n){
return f+m+n;
}
}; // 16字节
void class_test(){
cout<<"空类大小" <<sizeof(C1) <<endl;
cout<<"C1类大小" <<sizeof(C2) <<endl;
cout<<"C2类大小" <<sizeof(C3) <<endl;
cout<<"C3类大小" <<sizeof(C3) <<endl;
cout<<"C4类大小" <<sizeof(C3) <<endl;
// 综上所述, class和struct异曲同工
}
int main()
{
struct_test();
class_test();
return 0;
}
到此, 这段代码也算是解释和证明了了上面所有的问题
4. 内存存储
4.1 为何使用数据/代码分存储
一个类去定义对象, 系统会为每个对象分配存储空间
一个类包含数据, 函数
理论上讲: 一个类定义10个对象, 需要分配10个数据和代码存储单元, 如图1
图1
能否只用一段空间来存放共同的函数代码段, 在调用个对象的函数时, 直接去调用公共函数代码, 如图2
图2
显然后者能够大大解决存储空间; 因此C++编译系统就是这样做的
每个对象所占用的存储空间只是该对象的数据部分(虚函数指针和抽象类指针也属于数据部分) 所占用的存储空间都不包含函数代码所占的存储空间(这句话在上面的类C4中既已证明)
4.2 分类存储实际操作
C++程序的内存格局有四个区: 全局数据区, 代码区, 栈区, 堆区
在这里插入图片描述
加粗样式
在这里插入图片描述
在这里插入图片描述
总的来讲
分区 分配 存放的东西 管理
全局数据区static 全局变量, 常量, 静态数据 操作系统管理
代码区 function 类的成员函数和非成员函数 操作系统管理
堆区 heap 程序员指定, malloc/new 动态分配的对象 操作系统管理
栈区 stack 局部变量、函数参数、返回数据、返回地址
静态区,代码区,堆区,栈区 存储地址依次下降
//main.cpp
int a = 0; //全局初始化区
char *p1; //全局未初始化区
main()
{
int b;// 栈
char s[] = "abc"; //栈
char *p2; //栈
char *p3 = "123456"; 123456/0";//在常量区,p3在栈上。
static int c =0; //全局(静态)初始化区
p1 = (char *)malloc(10);
p2 = (char *)malloc(20); //分配得来得10和20字节的区域就在堆区。
strcpy(p1, "123456"); //123456/0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
注意:
类成员函数和非成员函数代码存放在代码区;
静态成员函数和非静态成员函数都是在类的定义时放在内存的代码区
类的非静态类成员函数其实都内含了一个指向类对象的指针型参数(即this指针),因而只有类对象才能调用(此时this指针有实值)
成员函数的代码段都不占用对象的存储空间
class C5
{
public:
void printA() {
cout<<"printA"<<endl;
}
virtual void printB() { // 基类里定义的虚函数
cout<<"printB"<<endl;
}
};
int main(){
C5 *d=NULL;
d->printA(); // printA
d->printB(); // Segmentation fault (core dumped)
// 这是因为printB包含指向类对象的指针, 而d不是个对象, 因此程序出错
5. C++中哪些函数不能定义为虚函数*
1. 不能被继承的函数
2. 不能被重写的函数
普通函数:
不属于成员函数,是不能被继承的。普通函数只能被重载,不能被重写,因此声明为虚函数没有意义。因为编译器会在编译时绑定函数。
友元函数: 不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
构造函数:
首先说下什么是构造函数,构造函数是用来初始化对象的。假如子类可以继承基类构造函数,那么子类对象的构造将使用基类的构造函数,而基类构造函数并不知道子类的有什么成员,显然是不符合语义的。从另外一个角度来讲,多态是通过基类指针指向子类对象来实现多态的,在对象构造之前并没有对象产生,因此无法使用多态特性,这是矛盾的。因此构造函数不允许继承。
内联成员函数
我们需要知道内联函数就是为了在代码中直接展开,减少函数调用花费的代价。也就是说内联函数是在编译时展开的。而虚函数是为了实现多态,是在运行时绑定的。因此显然内联函数和多态的特性相违背。
静态成员函数
首先静态成员函数理论是可继承的。但是静态成员函数是编译时确定的,无法动态绑定,不支持多态,因此不能被重写,也就不能被声明为虚函数。
// 友元函数示例
#include <iostream>
#include <string>
using namespace std;
class Box{
double width; // C++默认为private
public:
// 成员函数声明
friend void printWidth(Box box); // 定义友元函数, 可以访问这里面的所有成员, 但不属于该类的成员
void setWidth(double wid);
};
// 成员函数定义
void Box::setWidth(double wid){
width = wid;
}
// 请注意:printWidth() 不是任何类的成员函数
void printWidth(Box box){
/* 因为 printWidth() 是 Box 的友元,它可以直接访问该类的任何成员 */
cout << "Width of box : " << box.width << endl;
}
// 程序的主函数
int main(){
Box box;
// 使用成员函数设置宽度
box.setWidth(10.0);
// 使用友元函数输出宽度
printWidth(box); // Width of box : 10
return 0;
}
6. 虚函数与纯虚函数
6.1 虚函数和纯虚函数
虚函数: C++中用于实现多态的机制, 核心理念是通过基类访问派生类定义的函数, 是C++中多态的一个重要体现; 利用基类指针访问派生类中的虚函数, 这种情况采用的是动态绑定技术;
纯虚函数: 基类声明的虚函数, 基类无定义, 要求任何派生类都需要定义自己的实现方法, 在基类中实现纯虚函数的方法是在函数原型后面加 =0 纯虚函数不能实例化对象; 带有纯虚函数的类也叫做抽象类
下面几个重要的概念关于虚函数和纯虚函数
定义一个函数为虚函数,不代表函数为不被实现的函数。
定义他为虚函数是为了允许用基类的指针来调用子类的这个函数。
定义一个函数为纯虚函数,才代表函数没有被实现。
定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。
6.2 虚函数的使用
#include <iostream>
using namespace std;
class A{
public:
virtual void foo(){ // 定义虚函数并实现了
cout<<"A::foo() is called"<<endl;
}
};
class B:public A{
public:
void foo() { // 重现了虚函数
cout<<"B::foo() is called"<<endl;
}
};
int main(void)
{
A *a = new B();
// 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的
// 所以返回的结果是 B::foo() is called
// 同时说明, 一个类函数的调用并不是编译时刻被确定的, 而是运行时刻
a->foo();
return 0;
}
6.3 纯虚函数的使用
定义: 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加 =0: 如 virtual void funtion1()=0
引入原因:
为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数,则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。
抽象类的作用: 是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
7. 多态底层实现
7.1 虚函数表
class B {
virtual int f1 (void); // 0
virtual void f2 (int); // 1
virtual int f3 (int); // 2
};
// 虚函数表
vptr -> [B::f1, B::f2, B::f3]
0 1 2
首先对于包含虚函数的类, 编译器会为每个包含虚函数的类生成一张虚函数表,即存放每个虚函数地址的函数指针的数组,简称虚表(vtbl),每个虚函数对应一个虚函数表中的下标。
除了为包含虚函数的类生成虚函数表以外,编译器还会为该类增加一个隐式成员变量,通常在该类实例化对象的起始位置,用于存放虚函数表的首地址,该变量被称为虚函数表指针,简称虚指针(vptr)。例如:
B* pb = new B;
pb->f3 (12);
// 被编译为
pb->vptr[2] (pb, 12); // B::f3 参数pb是this指针
// 注意:虚表是一个类一张,而不是一个对象一张,同一个类的多个对象,通过各自的虚指针,共享同一张虚表。
vptr-> | vptr1 | vptr2 | vptr3 |
7.2 多态的工作原理
// 继承自B的子类
class D : public B {
int f1 (void);
int f3 (int);
virtual void f4 (void);
};
// 虚函数表
// 子类覆盖了基类的f1和f3,继承了基类的f2,增加了自己的f4,编译器同样会为子类生成一张专属于它的虚表。
vptr(子类)-> D::f1, B::f2, D::f3, D::f4
0 1 2 3
// 指向子类虚表的虚指针就存放在子类对象的基类子对象中。例如:
B* pb2 = new D; // 父类指向子类, 调用子类的方法
pb2->f3 (12);
// 被编译为
pb2->vptr(子类)[2] (pb2, 12); // D::f3
派生类定义对象时, 程序运行会自动调用构造函数, 在构造函数中创建虚函数表并对虚表初始化; 在构造子类对象时, 先调用父类构造函数, 此时, 编译器只"看到了"父类, 并为父类对象初始化虚表指针, 令他指向父类虚表, 当调用子类的构造函数时, 为子类对象初始化虚表指针, 令他指向子类虚表;
请添加图片描述
动态绑定- 多态的灵魂
当编译器“看到”通过指针或者引用调用基类中的虚函数时,并不急于生成有关函数调用的指令,相反它会用一段代码替代该调用语句,这段代码在运行时被执行,
完成如下操作:
根据调用指针或引用的目标对象找到其内部的虚表指针;
根据虚表指针找到其所指向的虚函数表;
根据虚函数名和函数指针在虚函数表中的索引,找到所调用虚函数的入口地址;
在完成函数调用的准备工作以后,直接跳转到虚函数入口地址处顺序执行函数体指令序列,直到从函数中返回。
动态绑定对性能的影响
虚函数表和虚指针的存在势必要增加内存空间的开销。
和普通函数调用相比,虚函数调用要多出一个步骤,增加运行时间的开销。
动态绑定会妨碍编译器通过内联优化代码,虚函数不能内联。