面向对象特性
封装
把客观事物封装成抽象的类,类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
继承
它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。其继承的过程,就是从一般到特殊的过程。
继承的实现方式
- 实现继承:是指使用基类的属性和方法而无需额外编码的能力;
- 接口继承:是指仅使用属性和方法的名称、但是子类必须提供实现的能力;
- 可视继承:是指子窗体(类)使用基窗体(类)的外观和实现代码的能力。
多态:
编译时多态
函数模板
其实编译器对函数模板进行了两次编译,第一次编译时,首先去检查函数模板本身有没有语法错误,第二次编译时,若编译器发现有调用,又会在调用的地方编译一次,生成相应类型的调用代码,然后通过代码的真正参数,来生成真正的函数。所以函数模板,其实只是一个模具,当我们调用它时,编译器就会给我们生成真正的函数.
在编译期间,编译器推断出模板参数,这类似于重载函数在编译器进行推导,以确定哪一个函数被调用。对模板参数而言,多态是通过模板特例化和函数重载解析实现的。以不同的模板参数特例化导致调用不同的函数,这就是所谓的编译期多态。
函数重载
程序从编译到运行出结果几个阶段。其中一个阶段提到生成符号表。
编译器在编译.c文件时,只会给函数进行简单的重命名;具体的方法是给函数名之前加上”_”;所以加入两个函数名相同的函数在编译之后的函数名也照样相同;调用者会因为不知道到底调用那个而出错;
后边的3个字符分别表示返回值类型,两个参数类型。“@Z”表示名称结束。由于在.cpp文件中,两个函数生成的符号表中的名称不一样,所以是可以编译通过的。
重载、覆盖、遮蔽、
- 函数重载发生在同一个类或顶层函数中,同名的函数具有不同的参数列表
- 函数覆盖发生在继承层次中,该函数在父类中必须是virtual,而子类的该函数必须与父类具有相同的参数列表
- 函数遮蔽(隐藏)发生在继承层次中,父类和子类同名的函数中,不属于函数覆盖的都属于函数遮蔽
运行时多态
运行期多态的实现依赖于虚函数机制。当某个类声明了虚函数时,编译器将为该类对象安插一个虚函数表指针,并为该类设置一张唯一的虚函数表,虚函数表中存放的是该类虚函数地址。运行期间通过虚函数表指针与虚函数表去确定该类虚函数的真正实现。运行期多态通过虚函数发生于运行期
编译与链接
C程序编译过程
- 预处理:处理以 # 开头的指令;将.c 文件转化成 .i文件
- 编译和优化:将源码 .i 文件翻译成 .s 汇编代码;
- 汇编:将汇编代码 .s 翻译成机器指令 .o 文件;
- 链接:链接程序的主要工作就是将有关的目标文件连接起来
.o文件无法立即执行,原因:1.某个源文件调用了另一个源文件中的函数或常量2.在程序中调用了某个库文件中的函数
gdb的工作原理
通过一个系统调用:ptrace。ptrace系统调用的原型如下:1
2
3
4
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
说明:ptrace系统调用提供了一种方法来让父进程可以观察和控制其它进程的执行,检查和改变其核心映像以及寄存器。 主要用来实现断点调试和系统调用跟踪。(man手册)
下面我们来看ptrace函数中request参数的一些主要选项:
- PTRACE_TRACEME: 表示本进程将被其父进程跟踪,交付给这个进程的所有信号,即使信号是忽略处理的(除SIGKILL之外),都将使其停止,父进程将通过wait()获知这一情况。
- 这是什么意思呢?我们可以结合到gdb上来看。如果在gdb中run一个程序,首先gdb会fork一个子进程,然后该子进程调用ptrace系统调用,参数就是PTRACE_TRACEME,然后调用一个exec执行程序。基本过程是这样,细节上可能会有出入。需要注意的是,这个选项PTRACE_TRACEME是由子进程调用的而不是父进程!
以下选项都是由父进程调用:
- PTRACE_ATTACH:attach到一个指定的进程,使其成为当前进程跟踪的子进程,而子进程的行为等同于它进行了一次PTRACE_TRACEME操作。但是,需要注意的是,虽然当前进程成为被跟踪进程的父进程,但是子进程使用getppid()的到的仍将是其原始父进程的pid。
- 这下子gdb的attach功能也就明朗了。当你在gdb中使用attach命令来跟踪一个指定进程/线程的时候,gdb就自动成为改进程的父进程,而被跟踪的进程则使用了一次PTRACE_TRACEME,gdb也就顺理成章的接管了这个进程。
PTRACE_CONT:继续运行之前停止的子进程。可同时向子进程交付指定的信号。
- 这个选项呢,其实就相当于gdb中的continue命令。当你使用continue命令之后,一个被gdb停止的进程就能继续执行下去,如果有信号,信号也会被交付给子进程。
库和链接
静态库和动态库
静态库(.a、.lib)特点总结
静态库,在链接阶段,会将汇编生成的目标文件.o与引用到的库一起链接打包到可执行文件中。因此对应的链接方式称为静态链接。
- 静态库对函数库的链接是放在编译时期完成的。
- 程序在运行时与函数库再无瓜葛,移植方便。
- 浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件。
Linux创建静态库过程如下:
- 首先,将代码文件编译成目标文件.o(StaticMath.o)
1
g++ -c StaticMath.cpp //注意带参数-c,否则直接编译为可执行文件
- 然后,通过ar工具将目标文件打包成.a静态库文件
1
ar -crv libstaticmath.a StaticMath.o
- 生成静态库libstaticmath.a。
动态库(.so、.dll)特点总结
- 动态库把对一些库函数的链接载入推迟到程序运行的时期。
- 可以实现进程之间的资源共享。(因此动态库也称为共享库)
- 将一些程序升级变得简单。
- 甚至可以真正做到链接载入完全由程序员在程序代码中控制(显示调用)。
- 动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。动态库在程序运行是才被载入,也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可,增量更新。
Linux创建动态库过程如下:
- 首先,生成目标文件,此时要加编译器选项-fpic
1
g++ -fPIC -c DynamicMath.cpp //-fPIC 创建与地址无关的编译程序(pic,position independent code),是为了能够在多个应用程序间共享。
- 然后,生成动态库,此时要加链接器选项-shared
1
g++ -shared -o libdynmath.so DynamicMath.o //-shared指定生成动态链接库。
静态链接和动态链接
静态链接:代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的虚拟地址空间中。
动态链接:代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进行的虚拟地址的空间。
二者的优缺点
静态链接:浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行编译链接生成可执行程序(更新困难);优点就是执行的时候运行速度快,因为可执行程序具备了程序运行的所有内容。
动态链接:节省内存、更新方便,但是动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失。
gcc和g++
GCC:GNU Compiler Collection(GUN 编译器集合),它可以编译C、C++、JAV、Fortran、Pascal、Object-C、Ada等语言。
- gcc是GCC中的GUN C Compiler(C 编译器)
- g++是GCC中的GUN C++ Compiler(C++编译器)
gcc和g++的主要区别
- 对于 .c和 .cpp文件,gcc分别当做c和cpp文件编译(c和cpp的语法强度是不一样的)
- 对于 .c和 .cpp文件,g++则统一当做cpp文件编译
- 使用g++编译文件时,g++会自动链接标准库STL,而gcc不会自动链接STL
- gcc在编译C文件时,可使用的预定义宏是比较少的
- gcc在编译cpp文件时/g++在编译c文件和cpp文件时(这时候gcc和g++调用的都是cpp文件的编译器),会加入一些额外的宏,这些宏如下:
1
2
3
4
5
6 - 在用gcc编译c++文件时,为了能够使用STL,需要加参数 –lstdc++ ,但这并不代表 gcc –lstdc++ 和 g++等价。
误区:编译只能用gcc,链接只能用g++
编译可以用gcc/g++,而链接可以用g++或者gcc -lstdc++。因为gcc命令不能自动和C++程序使用的库联接,所以通常使用g++来完成联接。但在编译阶段,g++会自动调用gcc,二者等价。
深入C++
C++的内存
可以结合操作系统—内存篇学习
C/C++内存分布
- BSS段(bss):通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS段属于静态内存分配。
- 数据段(data):通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。
全局数据区(静态存储区):全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束释放 - 代码段(code /text):通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。
- 堆(heap):堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)
- 栈(stack):存放函数的局部变量、函数参数、返回地址等,由编译器自动分配和释放,是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
从操作系统的本身来讲,以上存储区在内存中的分布是如下形式(从低地址到高地址):.text 段 —> .data 段 —> .bss 段 —> 堆 —> unused —> 栈 —> env
栈和堆的区别
申请方式:栈是系统自动分配,堆是程序员主动申请。
申请后系统响应:
- 分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出;
- 申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上。
栈在内存中是连续的一块空间(向低地址扩展)最大容量是系统预定好的,堆在内存中的空间(向高地址扩展)是不连续的。
申请效率:栈是有系统自动分配,申请效率高,但程序员无法控制;堆是由程序员主动申请,效率低,使用起来方便但是容易产生碎片。
存放内容:栈中存放的是局部变量,函数的参数;堆中存放的内容由程序员控制。
new与malloc区别
- malloc与free是c++/c语言的标准函数,new/delete是C++的运算符, C++允许重载new/delete操作符
- new/delete比malloc/free更加智能,其实底层也是执行的malloc/free。因为new和delete在对象创建的时候自动执行构造函数,对象消亡之前会自动执行析构函数。
- new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。
- new返回指定类型的指针,并且可以自动计算出所需要的大小。malloc必须用户指定大小,并且默认返回类型为void*,必须强行转换为实际类型的指针。
- 申请的内存所在位置
new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。
那么自由存储区是否能够是堆,这取决于operator new 的实现细节。自由存储区不仅可以是堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。 - 对数组的处理
C++提供了new[]与delete[]来专门处理数组类型:使用new[]分配的内存必须使用delete[]进行释放:1
A * ptr = new A[10];//分配10个A对象
new对数组的支持体现在它会分别调用构造函数函数初始化每一个数组元素,释放对象时为每个对象调用析构函数。注意delete[]要与new[]配套使用,不然会找出数组对象部分释放的现象,造成内存泄漏。1
delete [] ptr;
至于malloc,它并知道你在这块内存上要放的数组还是啥别的东西,反正它就给你一块原始的内存,在给你个内存的地址就完事。所以如果要动态分配一个数组的内存,还需要我们手动自定数组的大小:1
int * ptr = (int *) malloc( sizeof(int)* 10 );//分配一个10个int元素的数组
C++中内存泄漏的几种情况
在类的构造函数和析构函数中没有匹配的调用new和delete函数
一是在堆里创建了对象占用了内存,但是没有显示地释放对象占用的内存;
二是在类的构造函数中动态的分配了内存,但是在析构函数中没有释放内存或者没有正确的释放内存没有正确地清除嵌套的对象指针(share_ptr)
在释放对象数组时在delete中没有使用方括号
方括号是告诉编译器这个指针指向的是一个对象数组,同时也告诉编译器正确的对象地址值并调用对象的析构函数,如果没有方括号,那么这个指针就被默认为只指向一个对象,对象数组中的其他对象的析构函数就不会被调用,结果造成了内存泄露。如果在方括号中间放了一个比对象数组大小还大的数字,那么编译器就会调用无效对象(内存溢出)的析构函数,会造成堆的奔溃。如果方括号中间的数字值比对象数组的大小小的话,编译器就不能调用足够多个析构函数,结果会造成内存泄露。
释放单个对象、单个基本数据类型的变量或者是基本数据类型的数组不需要大小参数,释放定义了析构函数的对象数组才需要大小参数。指向对象的指针数组不等同于对象数组
对象数组是指:数组中存放的是对象,只需要delete []p,即可调用对象数组中的每个对象的析构函数释放空间
指向对象的指针数组是指:数组中存放的是指向对象的指针,不仅要释放每个对象的空间,还要释放每个指针的空间,delete []p只是释放了每个指针,但是并没有释放对象的空间,正确的做法,是通过一个循环,将每个对象释放了,然后再把指针释放了。缺少拷贝构造函数
两次释放相同的内存是一种错误的做法,同时可能会造成堆的奔溃。
按值传递会调用(拷贝)构造函数,引用传递不会调用。
在C++中,如果没有定义拷贝构造函数,那么编译器就会调用默认的拷贝构造函数,会逐个成员拷贝的方式来复制数据成员,如果是以逐个成员拷贝的方式来复制指针被定义为将一个变量的地址赋给另一个变量。这种隐式的指针复制结果就是两个对象拥有指向同一个动态分配的内存空间的指针。当释放第一个对象的时候,它的析构函数就会释放与该对象有关的动态分配的内存空间。而释放第二个对象的时候,它的析构函数会释放相同的内存,这样是错误的。
所以,如果一个类里面有指针成员变量,要么必须显示的写拷贝构造函数和重载赋值运算符,要么禁用拷贝构造函数和重载赋值运算符缺少重载赋值运算符
这种问题跟上述问题类似,也是逐个成员拷贝的方式复制对象,如果这个类的大小是可变的,那么结果就是造成内存泄露,如下图:
关于nonmodifying运算符重载的常见迷思
a. 返回栈上对象的引用或者指针(也即返回局部对象的引用或者指针)。导致最后返回的是一个空引用或者空指针,因此变成野指针
b. 返回内部静态对象的引用。
c. 返回一个泄露内存的动态分配的对象。导致内存泄露,并且无法回收
解决这一类问题的办法是重载运算符函数的返回值不是类型的引用,二应该是类型的返回值,即不是 int&而是int没有将基类的析构函数定义为虚函数
当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄露野指针:指向被释放的或者访问受限内存的指针。
造成野指针的原因:
指针变量没有被初始化(如果值不定,可以初始化为NULL)
指针被free或者delete后,没有置为NULL, free和delete只是把指针所指向的内存给释放掉,并没有把指针本身干掉,此时指针指向的是“垃圾”内存。释放后的指针应该被置为NULL.
指针操作超越了变量的作用范围,比如返回指向栈内存的指针就是野指针。
struct与class
C中的struct与C++中struct的区别
成员
C中的struct将一系列数据放在一个结构体中统一管理,只有数据,没有函数。
C++中的struct允许同时又函数和数据出现,允许通过构造函数进行初始化等。访问权限
C中的struct没有访问权限的设定,也就是说对外并没有隐藏数据的做法。
C++中的struct有访问权限的设定,private、protected、public三种。默认的访问权限是public。是否可以继承
C中struct不存在继承的关系。
C++中struct可以继承class和struct,也可以被class和struct继承。
C++中struct与class的区别
在C++中,为了保持对于C的兼容,保留了struct,并在此基础上对struct进行了扩充。
C++中的struct可以包含成员函数,可以继承,可以实现多态,但是在C++中的struct还是与class有一些区别的:
首先明确,struct是一个数据结构的实现体;而class更像是一个对象的实现体。默认的访问权限不同
struct默认的访问权限是public的,class默认的访问权限是private的。
但是我们在平时应该习惯在程序里明确指出某个数据/成员的访问权限,而不是靠默认,这是个比较好的编程习惯。默认的继承访问权限不同
对于默认的继承访问权限:
struct是public的,而class是private的。
并且要注意的是,C++中,struct可以继承struct也可以继承class,class也是一样,可以继承struct也可以继承class。因此默认的继承访问权限是取决于子类,而不是取决于基类。
也就是说:struct做子类的时候,默认是public的;class做子类的时候,默认是private的。与基类无关。定义模板参数
class关键字可以用于定义模板参数,而struct不能。
类相关
this指针
成员函数默认第一个参数为T* const this。1
class A { public: int func(int p) { } };
func的原型在编译器看来应该是:1
int func(A* const this,int p);
this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候,编译器会自动将对象本身的地址作为一个隐含参数传递给函数。
this指针不会影响sizeof(对象)的结果。任何对类成员的直接访问都被看成this的隐式使用。
每个对象都拥有一个this指针,通过this指针来访问自己的地址,所以this是一个常量指针,我们不允许改变this中保存的地址
this只能在成员函数中使用。全局函数,静态函数不能使用this。(原因:静态函数不属于具体的对象)
this指针是什么时候创建的?
this在成员函数的开始执行前构造,在成员的执行结束后清除。this指针存放在何处?堆、栈、全局变量,还是其他?
this指针会因编译器不同而又不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。我们只有获得一个对象后,才能通过对象使用this指针。如果我们知道一个对象this指针的位置,可以直接使用吗?
this指针只有在成员函数中才有定义。因此,你获得一个对象后,也不能通过对象使用this指针。所以,我们无法知道一个对象的this指针的位置(只有成员函数里才有this指针的位置)。当然,在成员函数里,你是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。
extern “C”的作用
“提示”编译器,下面的文件和函数,要用C语言的命名规则进行。
所以加入要在A.cpp中使用C语言的B.lib库文件中的在xx.h中定义的导出函数CFun(),需要这么做。
- 首先将这个库文件链接到我们的工程中。
- 声明但是我发现,有时候不使用extern “C”的方式(而是直接#include “”….)也可以使用这类函数,原因我分析如下:
1
2
3
4
5
6extern "C"
{
extern void CFun();
} - 那个库文件并不是纯正的C语言下环境下编译而成的(具体是环境是在.c文件中使用了C++中的关键字)。
- extern “C”只是一种“提示”,而不是强制性命令编译器,具体是要不要用C语言规则,让编译器自己去判断。
在C语言的库文件中,”abc.h”包含如下定义:1
2
3
4
5
extern "C"
{
}
也就是说,在”abc.c”中 #include “abc.h”进行编译的时候,#ifdefcplusplus并不会生效,因为当前是C语言的编译环境。
而在外部”abc.cpp”中 #include “abc.h” #ifdef cplusplus就会生效了,所以可以用C语言的方式进行编译。
回调函数
回调函数机制:
- 定义一个函数(普通函数即可);
- 将此函数的地址注册给调用者;
- 特定的事件或条件发生时,调用者使用函数指针调用回调函数。
1 |
|
在这个例子中,可以看到,我们定义了一个callbak的函数指针,参数为两个int,返回值为int,通过调用函数地址来进行简单的相加运算。
define和inline
define
宏定义在形式及使用上像一个函数,但它使用预处理器实现,没有了参数压栈,代码生成等一系列的操作,因此,效率很高
缺点:
- 宏定义在形式上类似于一个函数,但在使用它时,仅仅只是做预处理器符号表中的简单替换,因此它不能进行参数有效性的检测,也就不能享受C++编译器严格类型检查的好处,另外它的返回值也不能被强制转换为可转换的合适的类型,和局限性。
- 宏不能访问对象私有成员。
define用参数时,是严格的替换策略。无论你得参数时何种形式,在展开代码中都是用形参替换实参。这样,宏的定义很容易产生二意性,它的使用就存在着一系列的隐患
inline
内联函数和普通函数的区别在于:当编译器处理调用内联函数的语句时,不会将该语句编译成函数调用的指令,而是直接将整个函数体的代码插人调用语句处,就像整个函数体在调用处被重写了一遍一样。
1 | inline int max(int a, int b) |
区别
1.内联函数在运行时可调试,而宏定义不可以;
2.编译器会对内联函数的参数类型做安全检查或自动类型转换(同普通函数),而宏定义则不会;
3.内联函数可以访问类的成员变量,宏定义则不能;
include重复引入问题
1 | //student.h |
编译器报“Student 类型重定义”错误
使用宏定义避免重复引入
在实际多文件开发中,我们往往使用如下的宏定义来避免发生重复引入:1
2
3
4
5
6
7
//头文件内容
其中,_NAME_H 是宏的名称。这里设置的宏名必须是独一无二的
当程序中第一次 #include 该文件时,由于_NAME_H 尚未定义,所以会定义_NAME_H并执行“头文件内容”部分的代码;当发生多次 #include 时,因为前面已经定义了 _NAME_H,所以不会再重复执行“头文件内容”部分的代码。
student.h 文件做如下修改:1
2
3
4
5
6
7
8
9
10
class Student {
//......
};
虽然该项目 main.cpp 文件中仍 #include 了 2 次 “student.h”,但鉴于 _STUDENT_H 宏只能定义一次,所以 Student 类也仅会定义一次。再次执行该项目会发现,其可以正常执行。
使用#pragma once避免重复引入
使用 #pragma once指令,将其附加到指定文件的最开头位置,则该文件就只会被 #include 一次。
我们知道,#ifndef 是通过定义独一无二的宏来避免重复引入的,这意味着每次引入头文件都要进行识别,所以效率不高。但考虑到 C 和 C++ 都支持宏定义,所以项目中使用 #ifndef 规避可能出现的“头文件重复引入”问题,不会影响项目的可移植性。
和 ifndef 相比,#pragma once 不涉及宏定义,当编译器遇到它时就会立刻知道当前文件只引入一次,所以效率很高。
除此之外,#pragma once 只能作用于某个具体的文件,而无法向 #ifndef 那样仅作用于指定的一段代码。
将其内容修改为:1
2
3
4
5
6
7
class Student {
//......
};
使用_Pragma操作符
C99 标准中新增加了一个和 #pragma 指令类似的 _Pragma 操作符,其可以看做是 #pragma 的增强版,不仅可以实现 #pragma 所有的功能,更重要的是,_Pragma 还能和宏搭配使用。
当处理头文件重复引入问题时,可以将如下语句添加到相应文件的开头:1
_Pragma("once")
比如,将该语句添加到前面项目中 student.h 文件中的开头位置,再次执行项目,其可以正常执行。
事实上,无论是 C 语言还是 C++,为防止用户重复引入系统库文件,几乎所有库文件中都采用了以上 3 种结构中的一种,这也是为什么重复引入系统库文件编译器也不会报错的原因。
指针和引用
指针传递的实质
形参为指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进行的操作
引用传递的实质
被调函数的形参也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参的地址。被调函数对形参的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量。正因为如此,被调函数对形参做的任何操作都影响了主调函数中的实参变量。
区别
- 指针是一个实体,而引用仅是个别名;
- 引用只能在定义时被初始化一次,之后不可变;指针可变;
- 引用不能为空,指针可以为空;
- “sizeof引用”得到的是所指向的变量(对象)的大小,而“sizeof 指针”得到的是指针本身的大小;32位操作系统返回值4,64位操作系统返回值8
- 引用是类型安全的,而指针不是 (引用比指针多了类型检查)
static关键字
C 语言的 static 关键字有三种(具体来说是两种)用途:
静态局部变量:用于函数体内部修饰变量,这种变量的生存期长于该函数。
对于一个完整的程序,在内存中的分布情况如下图:
- 栈区: 由编译器自动分配释放,像局部变量,函数参数,都是在栈区。会随着作用于退出而释放空间。
- 全局数据区(静态区):全局变量和静态便令的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束释放。
- 堆区:程序员分配并释放的区域,像malloc(c),new(c++)
- 代码区
1
2
3
4
5
6int foo(){
static int i = 1; // note:1
//int i = 1; // note:2
i += 1;
return i;
}
- 它存在的意义就是随着第一次函数的调用而初始化,却不随着函数的调用结束而销毁(如果把以上的note:1换成note:2,那么i就是在栈区分配了,会随着foo的调用结束而释放)。
- 它是在第一次调用进入note:1的时候初始化(当初面试被坑过,我居然说是一开始就初始化了)。且只初始化一次,也就是你第二次调用foo(),不会继续初始化,而会直接跳过。
那么它跟定义一个全局变量有什么区别呢,同样是初始化一次,连续调用foo()的结果是一样的,但是,使用全局变量的话,变量就不属于函数本身了,不再仅受函数的控制,给程序的维护带来不便。静态局部变量正好可以解决这个问题。静态局部变量保存在全局数据区,而不是保存在栈中,每次的值保持到下一次调用,直到下次赋新值。
那么我们总结一下,静态局部变量的特点(括号内为note:2,也就是局部变量的对比):
- 该变量在全局数据区分配内存(局部变量在栈区分配内存);
- 静态局部变量在程序执行到该对象的声明处时被首次初始化,即以后的函数调用不再进行初始化(局部变量每次函数调用都会被初始化);
- 静态局部变量一般在声明处初始化,如果没有显式初始化,会被程序自动初始化为0(局部变量不会被初始化);
- 它始终驻留在全局数据区,直到程序运行结束。但其作用域为局部作用域,也就是不能在函数体外面使用它(局部变量在栈区,在函数结束后立即释放内存);
静态全局变量:定义在函数体外,用于修饰全局变量,表示该变量只在本文件可见
1 | static int i = 1; //note:3 |
假设我有一个文件a.c,我们再新建一个b.c,内容如下。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23//file a.c
//static int n = 15; //note:5
int n = 15; //note:6
//file b.c
extern int n;
void fn()
{
n++;
printf("after: %d\n",n);
}
void main()
{
printf("before: %d\n",n);
fn();
}
我们先使用note:6,也就是非静态全局变量,发现输出为:
before: 15
after: 16
也就是我们的b.c通过extern使用了a.c定义的全局变量。
那么我们改成使用note:5,也就是使用静态全局变量呢?会出现类似undeference to “n”的报错,它是找不到n的,因为static进行了文件隔离,你是没办法访问a.c定义的静态全局变量的,当然你用 #include “a.c”,那就不一样了。
以上我们就可以得出静态全局变量的特点:
- 静态全局变量不能被其它文件所用(全局变量可以);
- 其它文件中可以定义相同名字的变量,不会发生冲突(自然了,因为static隔离了文件,其它文件使用相同的名字的变量,也跟它没关系了);
静态函数: 准确的说,静态函数跟静态全局变量的作用类似:
1 | //file a.c |
可以正常输出:this is non-static func in a。当给void fn()加上static的关键字之后呢? undefined reference to “fn”.
所以,静态函数的好处跟静态全局变量的好处就类似了:
- 静态函数不能被其它文件所用;
- 其它文件中可以定义相同名字的函数,不会发生冲突;
上面一共说了三种用法,为什么说准确来说是两种呢?
- 一种是修饰变量,一种是修饰函数,所以说是两种(这种解释不多)。
- 静态全局变量和修饰静态函数的作用是一样的,一般合并为一种。(这是比较多的分法)。
C++ 语言的 static 关键字有额外二种用途:
静态数据成员:
用于修饰 class 的数据成员,即所谓“静态成员”。这种数据成员的生存期大于 class 的对象(实体 instance)。静态数据成员是每个 class 有一份,普通数据成员是每个 instance 有一份,因此静态数据成员也叫做类变量,而普通数据成员也叫做实例变量。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using namespace std;
class Rectangle
{
private:
int m_w,m_h;
static int s_sum;
public:
Rectangle(int w,int h)
{
this->m_w = w;
this->m_h = h;
s_sum += (this->m_w * this->m_h);
}
void GetSum()
{
cout<<"sum = "<<s_sum<<endl;
}
};
int Rectangle::s_sum = 0; //初始化
static 并不占用Rectangle的内存空间,static在全局数据区(静态区)分配内存的。static只会被初始化一次,于实例无关。
对于非静态数据成员,每个类对象(实例)都有自己的拷贝。而静态数据成员被当作是类的成员,由该类型的所有对象共享访问,对该类的多个对象来说,静态数据成员只分配一次内存。
静态数据成员存储在全局数据区。静态数据成员定义时要分配空间,所以不能在类声明中定义。
也就是说,你每new一个Rectangle,并不会为static int s_sum的构建一份内存拷贝,它是不管你new了多少Rectangle的实例,因为它只与类Rectangle挂钩,而跟你每一个Rectangle的对象没关系。
静态成员函数:用于修饰 class 的成员函数。
1 |
|
上面注释可见:对GetSum()加上static,使它变成一个静态成员函数,可以用类名::函数名进行访问。
- 静态成员之间可以相互访问,包括静态成员函数访问静态数据成员和访问静态成员函数;
- 非静态成员函数可以任意地访问静态成员函数和静态数据成员;
- 静态成员函数不能访问非静态成员函数和非静态数据成员;
- 调用静态成员函数,可以用成员访问操作符(.)和(->)为一个类的对象或指向类对象的指针调用静态成员函数,也可以用类名::函数名调用(因为他本来就是属于类的,用类名调用很正常)
为什么静态成员不能在类内初始化
在C++中,类的静态成员(static member)必须在类内声明,在类外初始化,像下面这样。1
2
3
4
5
6
7class A
{
private:
static int count ; // 类内声明
};
int A::count = 0 ; // 类外初始化,不必再加static关键字
为什么?因为静态成员属于整个类,而不属于某个对象,如果在类内初始化,会导致每个对象都包含该静态成员,这是矛盾的。1
2
3
4
5
6class A
{
private:
static int count = 0; // 静态成员不能在类内初始化
};
错误1
2
3
4
5
6class A
{
private:
const int count = 0; // 常量成员也不能在类内初始化
};
错误
能在类中初始化的成员只有一种,那就是静态常量成员。1
2
3
4
5class A
{
private:
static const int count = 0; // 静态常量成员可以在类内初始化
};
数据成员初始化位置:
- 静态常量数据成员可以在类内初始化(即类内声明的同时初始化),也可以在类外,即类的实现文件中初始化,不能在构造函数中初始化,也不能在构造函数的初始化列表中初始化;
- 静态非常量数据成员只能在类外,即类的实现文件中初始化,也不能在构造函数中初始化,不能在构造函数的初始化列表中初始化;
- 非静态的常量数据成员不能在类内初始化,也不能在构造函数中初始化,而只能且必须在构造函数的初始化列表中初始化;
- 非静态的非常量数据成员不能在类内初始化,可以在构造函数中初始化,也可以在构造函数的初始化列表中初始化;
为什么静态成员函数只能访问静态成员变量
- 静态成员函数只属于类本身,随着类的加载而存在,不属于任何对象,是独立存在的。
- 静态成员函数产生在前,非静态成员函数产生在后。类的静态成员在类加载的时候就已经分配内存,而此时类的非静态成员尚未分配内存,当且仅当实例化对象之后才存在,访问内存中不存在的东西自然会出错。
- 内部访问静态成员用self::,而访问非静态成员要用this指针,静态成员函数没有this指针,故不能访问。
为什么静态成员函数只能访问静态成员变量
- 静态成员函数只属于类本身,随着类的加载而存在,不属于任何对象,是独立存在的。
- 静态成员函数产生在前,非静态成员函数产生在后。类的静态成员在类加载的时候就已经分配内存,而此时类的非静态成员尚未分配内存,当且仅当实例化对象之后才存在,访问内存中不存在的东西自然会出错。
- 内部访问静态成员用self::,而访问非静态成员要用this指针,静态成员函数没有this指针,故不能访问。
虚函数
普通函数
- 普通函数是静态编译的,没有运行时多态,只会根据指针或引用的“字面值”类对象,调用自己的普通函数。
- 普通函数是父类为子类提供的“强制实现”。
- 因此,在继承关系中,子类不应该重写父类的普通函数,因为函数的调用至于类对象的字面值有关。
虚函数
- 编译器检测到虚函数(virtual)时,会将虚函数的地址放到虚表(vftable)中,并且在类中增加一个指针:vfpter。
- 在对象初始化调用构造函数时,编译器会在编写的每一个构造函数中,增加vfpter初始化的代码,使它指向本对象的虚函数表。在多态调用的时候,根据vfpter指针,找到虚函数表来实现动态绑定
- 子类继承基类的vfpter指针,这个指针指向基类虚函数表,当子类调用构造函数时,子类的vfpter指针便会指向子类的虚函数表。
- 当实例化子类时,检测到有虚函数的重写,编译器会用子类重写的虚函数地址覆盖掉之前父类的虚函数地址,
- 当调用虚函数时,检测到是虚函数就会从虚表中找对应的位置调用,若子类没有重写,虚表中的虚函数地址就还是父类的,若子类中有重写,虚表记录的就是子类重写的虚函数地址,即实现了父类的指针调用子类的函数
- 虚表中先记录父类中的虚函数地址,接着记录子类中虚函数地址(若子类重写父类的虚函数则是覆盖)
- 最后虚表还有一个尾值是 0
普通函数与虚函数的区别
- 调用流程不同:虚函数通过虚函数指针间接引用,普通函数可以使用函数名直接调用
- 调用效率不同:虚函数调用流程复杂,效率低,普通函数效率高
- 使用场景不同:虚函数的目的是为了实现多态
- 使用了虚函数,会增加访问内存开销,降低效率。
虚函数与纯虚函数区别
- 虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类,而只含有虚函数的类不能被称为抽象类。
- 虚函数可以被直接使用,也可以被子类重载以后以多态的形式调用,而纯虚函数必须在子类中实现该函数才可以使用,因为纯虚函数在基类只有声明而没有定义。
- 在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时候要求前期bind,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样。
- 虚函数必须实现,如果不实现,编译器将报错
- 对于虚函数来说,父类和子类都有各自的版本。由多态方式调用的时候动态绑定。
虚析构
- 如果释放父类指针(指向子类的父类指针),只会调用父类的析构函数,将父类的析构函数声明为虚函数,就会先调用子类的析构函数再调用父类的析构函数(子类中存放的是自己的析构函数)
- 父类的析构函数加了 virtual 修饰,delete 会调用子类和父类的析构函数,子类可以显式的加 virtual ,也可以不加, 默认是有的 virtual,
为什么使用虚析构
用对象指针来调用一个函数,有以下两种情况:
- 如果是虚函数,会调用子类中的版本。(在有子类的情况下)
- 如果是非虚函数,会调用指针所指类型的实现版本。
- 当子类对象出了作用域,子类的析构函数会先调用,然后再调用它父类的析构函数, 这样能保证分配给对象的内存得到正确释放。
- 但是,如果我们删除一个指向派生类对象的基类指针,而基类析构函数又是非虚的话, 那么就会先调用基类的析构函数(上面第2种情况),子类的析构函数得不到调用。
为什么不默认使用虚析构
因为它会为类增加一个虚函数表,使得对象的体积翻倍,还有可能降低其可移植性。
为什么构造函数不可以被声明为虚函数:
- 因为创建一个对象时要确定对象的类型,而虚函数是在运行时确定其类型的,而在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型。
- 如果构造函数是虚的,就需要通过虚表来调用,可是对象还没有构造,也就是内存空间还没有,无法找到虚表。所以构造函数不能是虚函数。
总结
非虚函数属于静态绑定:编译器在编译期间,根据指针(或对象)的类型完成了绑定。而对于虚函数,知道指针的类型也无济于事。假设 func() 为虚函数,p 的类型为 A,那么 p->func() 可能调用 A 类的函数,也可能调用 B、C 类的函数,不能根据指针 p 的类型对函数重命名。也就是说,虚函数在编译期间无法绑定。
继承关系
无继承
类A定义如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14class A
{
private:
int A_1;
public:
static int A_2;
int A_3;
virtual void Vfun1() {};
virtual void Vfun2() {};
virtual void Vfun3() {};
};
int A::A_2 = 5;
这里给A定义了一个私有变量,一个静态变量、一个公有变量以及3个虚函数,现在来看看A的内存布局:
A的内存布局如下:
从A的内存布局中可以知道:在无继承的情况下,对象内存布局中会先放置虚表指针vfptr,然后再按声明顺序存放数据成员。并且可以知道:虚表vftable中按声明先后顺序存放了A中定义的3个虚函数的地址,此外,类中定义的静态变量A_2并不存在于类中,并且类成员变量的访问权限也不会影响到成员的内存布局(比如这里的私有变量A_1和公有变量A_3是放在一起的)。
一般继承
单继承
现在定义一个A的继承类B,类B定义如下:1
2
3
4
5
6
7
8class B :public A
{
public:
int B_1;
int B_2;
virtual void Vfun1() {}; //重写了A中定义的一个虚函数
virtual void Vfun4() {}; //定义了一个新的虚函数
};
在类B中,重写了父类A的虚函数Vfun1,并且定义了一个新的虚函数Vfun4,现在来看看B的内存布局:
B的内存布局如下:
从这里可以看到,在类B中的前面,存放了类A的整个布局,即类A中的所有非静态数据成员都原封不动地放在了类B的前面,然后才是类B自己的数据成员B_1和B_2。不过值得注意的是,在类B的最前面,存放的依然是一个虚表指针vfptr,再来看vfptr所指的虚表中,由于类B重写了虚函数Vfun1,因此B中定义的虚函数的地址&B::Vfun1覆盖了之前的&A::Vfun1,并且在虚表最后还加上了类B中新定义的虚函数地址&B::Vfun4。
因此我们可以得出以下结论:在子类的内存布局的最前面存放的是父类的内存布局,一开始依然是存放一个虚表指针,只不过这个虚表指针与父类的虚表指针是不一样的,这个虚表指针所指向的虚函数表中,会包含所有父类中的虚函数(不一定是父类所定义的,也可能是父类从爷爷类继承下来的虚函数)的地址以及子类中新定义的虚函数的地址,如果子类对虚函数进行了重写,那么重写后的虚函数地址将会覆盖相应的父类虚函数地址。这也说明了如果父类和子类中有不同的虚函数,最终在子类中也只有一个虚表指针,对应一个虚函数表。
那么要是多层继承也是这样吗?
多层继承
现在来定义一个类C,用于继承类B,类C的定义如下:1
2
3
4
5
6
7
8
9class C :public B
{
int C_1;
int C_2;
virtual void Vfun1() {}; //重写了B中定义的虚函数(这个函数在B中也是重写的A定义的虚函数)
virtual void Vfun2() {}; //重写了A中定义的虚函数(这个函数在B中未被重写)
virtual void Vfun4() {}; //重写了B中新定义的虚函数(这个函数未在A中定义)
virtual void Vfun5() {}; //定义了一个新的虚函数
};
现在先不看C的内存布局结果,试着来推测一下:
首先,如前所述:子类的内存布局最前面存放的是父类的内存布局,因此在类C的内存布局中,最开始应当存放一个类B的布局,相当于直接把上面B的内存布局搬到C中,最前面依然是一个虚表指针vfptr,只不过这个虚表指针与原先B中的虚表指针完全不一样。接着在B的布局之后,则应当是按照声明顺序存放的C中的成员变量,如下所示:
再来看虚表中的内容:根据前面所分析的,如果C中没有进行任何的虚函数的声明定义,那么C中虚表指针所指虚表中应当与B中虚表指针所指虚表的内容一样了。而现在类C重写了Vfun1,Vfun2和Vfun4,并且新定义了一个虚函数Vfun5,那么虚表中的内容应当如下所示:(黑色表示未发生重写的父类虚函数,蓝色表示重写的虚函数,紫色表示新定义的虚函数)
有了上述的分析,再来看下类C实际的内存布局:
由此可见,完全符合前面的分析结果。
为了考虑更多的情况,下面再来分析一下多重继承的情况。
多重继承
多重继承的是指一个子类含多个不同的父类。为了验证这种情况,将B与A的继承关系取消,让C同时继承自A和B,如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31class A
{
private:
int A_1;
public:
static int A_2;
int A_3;
virtual void Vfun1() {};
virtual void Vfun2() {};
virtual void Vfun3() {};
};
int A::A_2 = 5;
class B
{
public:
int B_1;
int B_2;
virtual void Vfun1() {};
virtual void Vfun4() {};
};
class C :public A,public B
{
public:
int C_1;
int C_2;
virtual void Vfun1() {}; //A中和B中均含有Vfun1虚函数
virtual void Vfun2() {}; //重写A中的虚函数
virtual void Vfun4() {}; //重写B中的虚函数
virtual void Vfun5() {}; //新定义虚函数
};
这时候的C同时继承A和B,并且重写了一个A和B中均定义了的虚函数,为了考虑所有因素,现在来分别看看类C先继承A再继承B和先继承B再继承A的各自内存布局是怎样的?
左图为类C先继承A再继承B,由图为先继承B再继承A,分析两种情况可以发现,C的内存布局中,先按照继承顺序包含父类的布局,然后才是C中定义的数据成员。不过尤其需要注意的是,C的内存布局中所包含的每一个父类的布局最开始都各自含有一个虚表指针vfptr,也就是说,有几个父类就会有虚表指针,自然也就有几个虚表了。这里的C类继承自两个类,因此就有两个虚表指针,也就对应两个虚表,这里设定为vftA和vftB,现在来看看两个虚表中的内容。
当先继承A再继承B的时候,虚函数的覆盖情况如下:
这里有个需要注意的地方,C中重写的Vfun1在A和B中都有定义,此时在vftA中有Vfun1的地址,而在vftB中显示的是this-=12,goto C::Vfun1,这是什么意思呢?注意到vftB的偏移量本身就是12,而vftA的偏移量为0,因此在C的内存布局中vftB虚表指针的地址 = vftA虚表指针的地址+12,而这里有一个this-=12,相当于访问vftB的时候又回到了vftA的位置,此时访问的函数还是C::Vfun1。可以写一段程序测试一下:
这也就表示vftA所指虚表中第一个虚函数的地址与vftB所指虚表中第一个虚函数的地址是完全相同的,都是指向Vfun1函数。
另外一个需要注意的地方是:C中新定义了一个Vfun5,而该虚函数的地址,是存放在vftA中的。
当先继承B然后继承A时,情况和上述类似,这里就不多说了。
由此可以得出结论:当一个子类C继承于多个相互独立的父类的时候,会先按照继承的先后顺序存放每个父类的内存布局,然后才是子类定义的数据成员。而在子类下的每个父类对应的布局中都有一个虚表指针各自对应一个虚函数表,各个虚函数表的构成只需关注该父类中的虚函数是否在子类中重写,如2.1中单继承所示。如果子类中新定义了一个虚函数,那么这个虚函数的地址会放在子类布局下先声明继承的那个父类对应的虚表中;如果子类重写了多个父类定义的同名虚函数(如这里的Vfun1),那么在子类布局下每个父类对应的虚表中都会有被重写的虚函数的地址。
那么,要是子类所继承的多个父类之间也有关系呢?下面就拿菱形继承为例进行分析。
菱形继承
菱形继承的含义是在设定的多层继承基础上,类A和类B又同时继承于同一个父类。因此这里先定义一个类D,然后让类A和类B都继承它,这里给类D添加了如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42class D
{
public:
int D_1;
virtual void Vfun1() {};
virtual void Vfun2() {};
virtual void Vfun4() {};
virtual void Vfun5() {};
virtual void Vfun6() {};
};
class A:public D
{
private:
int A_1;
public:
static int A_2;
int A_3;
virtual void Vfun1() {}; //重写D中的Vfun1
virtual void Vfun2() {}; //重写D中的Vfun2
virtual void Vfun3() {}; //新定义的Vfun3
};
int A::A_2 = 5;
class B :public D
{
public:
int B_1;
int B_2;
virtual void Vfun1() {}; //重写D中的Vfun1
virtual void Vfun4() {}; //重写D中的Vfun4
};
class C :public A,public B
{
public:
int C_1;
int C_2;
virtual void Vfun1() {}; //重写Vfun1,该虚函数在父类A和父类B以及类D中都有定义
virtual void Vfun2() {}; //重写类D及类A中均有定义的虚函数Vfun2
virtual void Vfun4() {}; //重写类D及类B中均有定义的虚函数Vfun2
virtual void Vfun5() {}; //重写类D中定义,但类B和类C中未定义的Vfun5
virtual void Vfun7() {}; //定义的新虚函数Vfun7
};
查看C的内存布局如下所示:
整个过程的虚函数地址覆盖如图所示:(蓝色线表示有重写,黑色线表示无重写,红色字体表示为新定义虚函数)
虽然举的例子复杂了点,但是从整个过程中也清楚了虚表内容的覆盖过程的:当子类继承自多个类时,每一个父类在子类中都有一个虚表指针,举个例子,类C继承自类A和类B,那么在类C中,会有一个虚表指针vfptrA和虚表指针vfptrB,vfptrA所指向的虚表内容,实际上就只用关注类C和类A之间的虚函数重写关系,这与类B没有任何关系,可以参考2.1;同样的,vfptrB所指向的虚表内容,就只用关注类C和类B之间的虚函数重写关系,和类A也没有任何关系。
简单来说,多重继承下的子类,每个父类在该子类中都对应一个虚函数表,而每个虚函数表的内容只用关注该子类与对应父类之间虚函数关系,与其他父类无关,如果子类重写的虚函数在多个父类中存在定义,那么每个父类与子类对应的虚表中都会存在这个虚函数的地址(实际上只有第一个声明继承的父类与子类对应的虚表中的才是直接存储的该虚函数的地址,其他虚表都是间接寻址第一个虚表相应位置来得到的该虚函数地址)。即使是菱形继承,子类中的虚表也只关心父类中的虚函数与自身虚函数的关系,而并不关心父类的父类。
除此之外,虚表中虚函数地址的顺序,是按照每个虚函数第一次定义和声明的先后顺序。如果子类中含有新定义的虚函数,那么该虚函数的地址只会存在于第一个虚表中的末尾。
虚继承
虚继承的布局
有了前面对一般继承的分析,实际上虚继承也很容易了。不过最需要注意的,还是虚继承与一般继承相比,最大的不同点。我们知道,虚继承最常用的场景,就是在菱形继承下防止子类访问父类成员出现二义性的情况,比如按照2.4所用例子,现在我要在C的实例中访问D_1成员,此时就会出现二义性。如下所示:
其实从2.4中C的内存布局就可以知道原因:C的内存布局中由于A和B都继承D,因此存在两个D_1,此时C的实例去访问D_1,到底访问哪一个呢?这样就出现了二义性。虚继承就可以解决这个问题。
那么是如何解决的呢?我们先从虚继承下子类的内存布局说起。
用前面2.1单继承的例子,将B继承A改为虚继承,如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22class A
{
private:
int A_1;
public:
static int A_2;
int A_3;
virtual void Vfun1() {};
virtual void Vfun2() {};
virtual void Vfun3() {};
};
int A::A_2 = 5;
class B :virtual public A
{
public:
int B_1;
int B_2;
virtual void Vfun1() {}; //重写了A中定义的一个虚函数
virtual void Vfun4() {}; //定义了一个新的虚函数
};
B的内存布局如下所示:
可以看到,和一般继承不同的是,虚继承并不是将父类的布局放在子类布局的开头了,而是将其放在了子类数据成员的后面。在一般继承中,只有一个虚表指针放在布局最前面,而虚继承下出现了两个虚表指针vfptr,分别位于子类布局最前面以及子类中父类布局的最前面。这里还涉及到一个vbptr,现在先不管它。
因此虚继承中子类和父类的布局关系与一般继承中的区别如下所示:(先无视vbptr)
再来看各自虚函数表的内容。类A中定义了3个虚函数Vfun1、Vfun2和Vfun3,类B中重写了Vfun1并且定义了一个新的虚函数Vfun4。而在类B所对应的虚函数表中,只有一个&B::Vfun4,在类A对应的虚表中,含有被类B重写的&B::Vfun1,以及类A定义的&A::Vfun2和&A::Vfun3。这里尤其需要注意的是,B重写的虚函数Vfun1的地址是放在父类A对应的虚表中的。
也就是说,在虚继承的情况下,每一个虚函数表中只会包含在对应类下第一次定义的虚函数的地址,即使该虚函数在子类中被重写,它的地址仍然是放在第一次定义该虚函数的父类中,并且被重写后的虚函数地址所覆盖。如这里的Vfun1,在类A中被第一次定义,在类B中被重写,因此&A::Vfun1被覆盖为&B::Vfun1,并且存在于类A布局中的虚表指针所对应的虚函数表中,而不是类B对应的虚函数表中。而相应的,在子类对应的虚函数表中,只包含在子类中第一次定义的虚函数的地址,如果子类中没有定义新的虚函数,那么子类中的虚表就为空,自然也就不存在虚表指针了,如把上面B类下定义的Vfun4删去,得到的B内存布局如下所示:
此时由于子类中没有新定义的虚函数,因此子类所属的内存布局中没有虚表指针。
实际上,虚继承的类内存布局与一般继承的类内存布局还是大体类似的,每一个父类在子类中依然都各自对应一个虚函数表,不同的是,虚继承相当于在子类的内存布局中把虚基类的布局独立了出去,在一般继承中,子类新定义的虚函数是放在第一个声明继承的父类所对应的虚函数表的末尾,而在虚继承中,则是单独用一个属于子类的虚函数表去存放所有子类新定义的虚函数的地址。
现在再来回头看,这里还多了一个“虚基类表指针”vbptr,它指向一个虚基类表,并且可以看到虚基类表vbtable中有两个数据,一个是-4,一个是12,-4描述的是虚基类表指针vbptr自身相对于整个类首地址的偏移地址,12描述的是虚基类A的首地址相对于vbptr的偏移地址。
而这两个数据刚好就是两个虚表指针vfptr相对于虚基类表指针vbptr的偏移,也可以说,虚基类表中存放的数据是子类中子类的首地址和各个虚基类的首地址相对于虚基类表指针的地址偏移量。下面举个例子来看一看:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class A
{
int a;
int b;
virtual void Vfun1() {};
};
class B
{
int c;
int d;
virtual void Vfun2() {};
};
class C
{
int e;
int f;
virtual void Vfun3() {};
};
class D:virtual public B,virtual public A,public C
{
int g;
int h;
virtual void Vfun1() {};
};
这里先不去管虚函数了,只关注虚基类在子类中的布局,D的内存布局如下:
可以看到, 子类D先后虚继承类B和类A,再一般继承类C,在子类D的内存布局中,由于B和A属于虚继承,因此存在虚基类表指针,D中虚基类表指针地址偏移量为12,虚基类B和虚基类A各自布局下的虚函数表指针偏移量分别为24和36,由于D是一般继承继承类C,因此类C布局下的虚函数表指针放在最开头,偏移量为0。
再来看虚基类表vftable中,有三个元素,第一个元素为-12,刚好是C布局下的虚函数表指针(图中红色指向的vfptr)相对于虚基类表指针vbptr的偏移量(0-12 = -12);第二个元素为12,刚好也是B布局下的虚函数表指针(图中黄色指向的vfptr)相对于虚基类表指针vbptr的偏移量(24-12 = 12);第三个元素为24,刚好也是A布局下的虚函数表指针(图中蓝色指向的vfptr)相对于虚基类表指针vbptr的偏移量(36-12 = 24);
由此可以看出,不管是否为虚继承还是一般继承,如果发生多层继承,子类继承自多少个父类,子类内存布局中就会存在多少个虚表指针,每个虚表指针对应一个虚表;如果多层继承中某一个继承为虚继承,那么在子类的内存布局就还会存在一个虚基类表指针,对应一个虚基类表,在虚基类表中,第一项放的是整个类首地址相对于虚基类表指针的偏移地址,从第二项开始存放的是所有虚基类首地址相对于虚基类表指针的偏移地址,虚基类表中不会存放非虚基类的偏移地址。而虚基类指针的位置,则是放在属于子类的那一段布局的最前面(比如说这里由于一般继承C,因为会先放C的布局,C的布局之后,才是属于子类D的布局,当然如果没有一般继承全部为虚继承,那么虚基类指针就直接放在第一个虚表指针的后面)。
虚继承的多层继承
通过分析虚继承的单继承以及虚继承的多重继承知道了虚继承的内存布局,现在再来看看虚继承的多层继承。
现有如下定义:类B虚继承类A,类C继承类B,如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class A
{
int a;
int b;
virtual void Vfun1() {};
virtual void Vfun2() {};
};
class B:virtual public A
{
int c;
int d;
virtual void Vfun1() {};
virtual void Vfun3() {};
};
class C:public B
{
int e;
int f;
virtual void Vfun1() {}; //重写B中重写的Vfun1
virtual void Vfun3() {}; //重写B中定义的Vfun3
virtual void Vfun2() {}; //重写A中定义的但是B中未重写的Vfun2
virtual void Vfun4() {}; //定义一个新的虚函数Vfun4
};
再看类C的布局:
从该布局中可以看到,类A一旦被类B虚继承,那么即使类B被类C一般继承,在类C中类A的布局也是独立的,类A的布局中也拥有自己的虚表指针对应一个虚函数表。与前面的分析相同,在类A的虚函数表中,即使类C中重写了Vfun1,重写后的Vfun1函数地址依然存在与类A对应的虚函数表中。而由于C是一般继承B,因此C中新定义的虚函数Vfun4的地址则放在了类B所对应的虚函数表中。
那么如果这里类C也是虚继承类B呢?来看看结果:
可以看到,在类C的内存布局中,类A和类B都成虚基类,并且各自都有一个虚基类表指针,其中类B对应的虚基类表描述的是类B自己的的虚函数表指针和类A的虚函数表指针偏移地址(B也是虚继承自A),而类C对应的虚基类表描述的则是类C自己的虚函数表指针和类B的虚函数表指针偏移地址(C虚继承自B)。因此可以知道,每发生一次虚继承,那么虚继承的子类中就会存在一个虚基类表指针,所对应的虚基类表中存放的则是该子类和其所继承的虚基类的虚函数表指针的偏移量。
那么如果两个类同样虚继承自同一个父类呢?在子类中会如何存放这一个父类呢?这就来分析一下虚继承下的菱形继承:
虚继承的菱形继承
在最开始就说了,虚继承最常用于解决菱形继承下的“二义性”,那么现在定义如下:B虚继承自A,C也虚继承自A,D同时继承于B和C。定义如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28class A
{
int a;
int b;
virtual void Vfun1() {};
virtual void Vfun2() {};
};
class B:virtual public A
{
int c;
int d;
virtual void Vfun1() {};
virtual void Vfun3() {};
};
class C:virtual public A
{
int e;
int f;
virtual void Vfun1() {};
virtual void Vfun4() {};
};
class D:public B,public C
{
int g;
int h;
virtual void Vfun1() {};
virtual void Vfun5() {};
};
再来看类D的内存布局:
可以看到,虽然D的父类B和C都虚继承自类A,但是在类B中,只存在一个类A的布局,这样,当类D的对象访问成员a时,由于在D的内存布局中只存在一个a,这样也就不会出现二义性了。
再来看虚函数表,由于这里继承了两个父类,并且还有一个虚基类,因此在类D中会有3个虚函数表,分别对应于类B,类C和类A,由于类B虚继承自类A,因此类B的虚函数表中只含有类B中新定义的虚函数Vfun3的地址,又因为类B是第一个声明继承的,因此类B的虚函数表中还有类D新定义的虚函数Vfun5的地址;同样的,对于类C来说,由于类C虚继承自类A,因此类C对应的虚函数表中只包含新定义的虚函数Vfun4的地址;再看类A,由于类A是虚基类,因此它对应的虚函数表中就包含所有在类A中定义的虚函数的地址,也就是虚函数Vfun1和Vfun2的地址。
总结
对于单继承来说,
如果是一般继承,那么子类内存布局的最前面会先放父类的内存布局,然后才是子类自身的数据成员。并且在该父类的内存布局的最前面会有一个虚函数表指针,其所对应的虚函数表中,会先继承父类自身虚函数表的所有内容,如果子类对父类的虚函数进行了重写,那么就会对相应的虚函数地址进行覆盖,如果子类有新定义的虚函数,那么还会将新定义的虚函数地址追加在虚函数表的后面;
而如果是虚继承,那么子类内存布局中则会把父类的内存布局单独放在最后,在该父类的内存布局中,最前面会有一个虚函数表指针,其所对应的虚函数表会继承父类的虚函数表所有内容,如果子类对父类中的虚函数进行了重写,那么也会将继承下来的虚函数表中的对应函数进行覆盖。如果子类中新定义了虚函数,那么在子类布局的最前面,就会有一个虚函数表指针,其对应一个新的虚函数表,表中存放的是所有子类中新定义的虚函数地址,如果子类中没有定义虚函数,那么就不存在子类的虚函数表指针了。然后会放一个虚基类表指针,该指针所指向的虚基类表,反映的是该子类以及父类(虚基类)的虚函数表指针相对于虚基类表指针的偏移位置,由此可以知道,通过虚基类表指针,可以找到虚继承中子类和父类各自的虚函数表。
对于多层继承来说,实际上也可以理解为子类和父类的单继承,如果是一般继承,那么实际上就是先看父类到父类的父类之间的单继承,然后再看子类到父类之间的单继承;但是一旦在多层继承中出现了虚继承,那么被虚继承的父类,都会在其子类、子类的子类、子类的子类的子类…的内存布局最后存在一份该父类的布局,并且该父类的布局中也会有一个虚函数表指针(如果该父类有继承或定义虚函数)和一个虚基类表指针,虚基类表的第一项是虚基类表指针相对于整个类的首地址的偏移量,从第二项开始是该类所有虚基类首地址相对于该虚基类表指针的偏移地址。
对于多重继承来说,不管是虚继承还是一般继承,每继承一个类就都会有一个虚函数表,该虚函数表会先继承对应父类的虚函数表的所有内容,如果子类中对虚函数有重写,那么就会覆盖相应的虚函数表中的虚函数地址。如果子类中有重新定义虚函数,那么该虚函数地址就会放在最先声明的一般继承的父类对应的虚函数表的最后,如果每一个继承都是虚继承,那么在子类的最前面就会有一个虚函数表指针,对应一个属于子类自己的虚函数表,其中是所有子类新定义的虚函数的地址。而每个父类在子类内存中的布局,则是虚继承的父类布局放在最后,一般继承的父类布局放在前面,均按照继承声明的先后顺序,并且在子类的开头还会存放一个虚基类指针(在子类的虚函数表指针后面),对应的虚基类表中存放所有虚继承的父类的虚函数表指针的相对于该虚基类指针的偏移地址。
STL
STL组成
容器、迭代器、仿函数、算法、分配器、配接器
他们之间的关系:分配器给容器分配存储空间,算法通过迭代器获取容器中的内容,仿函数可以协助算法完成各种操作,配接器用来套接适配仿函数
STL的组成 | 含义 |
---|---|
容器 | 一些封装数据结构的模板类,例如 vector 向量容器、list 列表容器等。 |
算法 | STL 提供了非常多的算法,它们都被设计成一个个的模板函数,这些算法在 std 命名空间中定义,其中大部分算法都包含在头文件algorithm中,少部分位于头文件numeric中。 |
迭代器 | 在 C++ STL 中,对容器中数据的读和写,是通过迭代器完成的,扮演着容器和算法之间的胶合剂。 |
仿函数 | 如果一个类将 () 运算符重载为成员函数,这个类就称为函数对象类,这个类的对象就是函数对象(又称仿函数)。 |
适配器 | 可以使一个类的接口(模板的参数)适配成用户指定的形式,从而让原本不能在一起工作的两个类工作在一起。值得一提的是,容器、迭代器和函数都有适配器 |
内存分配器 | 为容器类模板提供自定义的内存申请和释放功能,由于往往只有高级用户才有改变内存分配策略的需求,因此内存分配器对于一般用户来说,并不常用。 |
仿函数
1 | //仿函数1,比较大小 |
适配器
C++中定义了3种容器适配器,它们让容器提供的接口变成了我们常用的的3种数据结构:栈stack,队列queue和优先队列priority_queue。
默认情况下,栈和队列都是基于deque实现的,而优先级队列则是基于vector实现的。
内存分配器(allocaotr)
- new运算分两个阶段:(1)调用::operator new配置内存;(2)调用对象构造函数构造对象内容
- delete运算分两个阶段:(1)调用对象析构函数;(2)调用::operator delete释放内存
- 为了精密分工,STL allocator将两个阶段操作区分开来:内存配置有alloc::allocate()负责,内存释放由alloc::deallocate()负责;对象构造由::construct()负责,对象析构由::destroy()负责。
上述工作分别由stl_construct.h, stl_alloc.h, stl_uninitialized.h
对象的构造和析构工具(stl_construct.h)
提供了两种对象的构造方法,默认构造和赋值构造:
提供了两种析构方法:
- 析构函数接受一个指针,将该指针所指的对象析构掉;
- 析构函数接受first和last两个迭代器,将这两个迭代器范围内的对象析构掉。
内存空间管理工具(stl_alloc.h)
- __malloc_alloc_template内存分配器(malloc)
- __default_alloc_template分配器(内存池)
- allocator是一个由两级分配器构成的内存管理器
- 当申请的内存大小大于128byte时,就启动第一级分配器通过malloc直接向系统的堆空间分配,
- 如果申请的内存大小小于128byte时,就启动第二级分配器,从一个预先分配好的内存池中取一块内存交付给用户,这个内存池由16个不同大小(8的倍数,8~128byte)的空闲列表组成,allocator会根据申请内存的大小(将这个大小round up成8的倍数)从对应的空闲块列表取表头块给用户。
这种做法有两个优点:
- 小对象的快速分配。小对象是从内存池分配的,这个内存池是系统调用一次malloc分配一块足够大的区域给程序备用,当内存池耗尽时再向系统申请一块新的区域,整个过程类似于批发和零售,起先是由allocator向总经商批发一定量的货物,然后零售给用户,与每次都总经商要一个货物再零售给用户的过程相比,显然是快捷了。当然,这里的一个问题时,内存池会带来一些内存的浪费,比如当只需分配一个小对象时,为了这个小对象可能要申请一大块的内存池,但这个浪费还是值得的,况且这种情况在实际应用中也并不多见。
- 避免了内存碎片的生成。程序中的小对象的分配极易造成内存碎片,给操作系统的内存管理带来了很大压力,系统中碎片的增多不但会影响内存分配的速度,而且会极大地降低内存的利用率。以内存池组织小对象的内存,从系统的角度看,只是一大块内存池,看不到小对象内存的分配和释放。
基本内存处理工具(stl_uninitialized.h)
STL提供了三类内存处理工具:uninitialized_copy(), uninitialized_fill()和uninitialized_fill_n()
- uninitialized_copy()会将迭代器_first和_last之间的对象拷贝到迭代器_result开始的地方
- uninitialized_fill()会将迭代器_first和_last范围内的所有元素初始化为x
- uninitialized_fill_n()会将迭代器_first开始处的n个元素初始化为x
类型转换
隐式类型转换
- 算术转换(Arithmetic conversion) : 在混合类型的算术表达式中, 最宽的数据类型成为目标转换类型。
1
2
3int ival = 3;
double dval = 3.14159;
ival + dval;//ival被提升为double类型 - 一种类型表达式赋值给另一种类型的对象:目标类型是被赋值对象的类型例外:void指针赋值给其他指定类型指针时,不存在标准转换,编译出错
1
2int *pi = 0; // 0被转化为int *类型
ival = dval; // double->int - 将一个表达式作为实参传递给函数调用,此时形参和实参类型不一致:目标转换类型为形参的类型4。 从一个函数返回一个表达式,表达式类型与返回类型不一致:目标转换类型为函数的返回类型
1
2
3extern double sqrt(double);
cout << "The square root of 2 is " << sqrt(2) << endl;
//2被提升为double类型:2.01
2
3
4
5double difference(int ival1, int ival2)
{
return ival1 - ival2;
//返回值被提升为double类型
}四种显示类型转换
- 向上转换:子类向基类的转换
向下转换:基类向子类的转换
static_cast
进行无条件转换,静态类型转换:
基类和子类之间的转换:其中子类指针转换为父类指针是安全的,但父类指针转换为子类指针是不安全的(基类和子类之间的动态类型转换建议用dynamic_cast)。
- 基本数据类型转换,enum,struct,int,char,float等。static_cast不能进行无关类型(如非基类和子类)指针之间的转换。
- 把任何类型的表达式转换成void类型。
1
int c=static_cast<int>(7.987);
const_cast:
去掉类型的const或volatile属性
volatile
volatile关键字是一种限定符用来声明一个对象在程序中可以被语句外的东西修改,比如操作系统、硬件或并发执行线程。
遇到该关键字,编译器不再对该变量的代码进行优化,不再从寄存器中读取变量的值,而是直接从它所在的内存中读取值,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。
一般说来,volatile用在如下的几个地方:
- 中断服务程序中修改的供其它程序检测的变量需要加volatile;
- 多任务环境下各任务间共享的标志应该加volatile;
- 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能有不同意义;
当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值,
1 | int main() { |
dynamic_cast
有条件转换,动态类型转换,运行时检查类型安全(转换失败返回NULL):
- 安全的基类和子类之间的转换。
- 必须有虚函数。原因:类中存在虚函数,就说明它有想要让基类指针或引用指向派生类对象的情况,此时转换才有意义。
- 相同基类不同子类之间的交叉转换,但结果返回NULL。
- 对指针进行dynamic_cast,失败返回null,成功返回正常cast后的对象指针;
- 对引用进行dynamic_cast,失败抛出一个异常bad_cast,成功返回正常cast后的对象引用。
- 对于“向上转换”(即派生类指针或引用转换为其基类类型)都是安全的。
- 对于“向下转型”有两种情况:
第一,基类指针所指对象是派生类类型,这种转换是安全的;
第二,基类指针所指对象为基类类型,dynamic_cast在运行时做检查,转换失败,返回结果为0。
1 |
|
reinterpret_cast
它可以转化任何内置的数据类型为其他任何的数据类型,也可以转化任何指针类型为其他的类型。它甚至可以转化内置的数据类型为指针,无须考虑类型安全或者常量的情形。不到万不得已绝对不用。
智能指针
为什么要使用智能指针:
申请的空间在函数结束时忘记释放,造成内存泄漏。使用智能指针可以很大程度上的避免这个问题,因为智能指针就是一个类,当超出了类的作用域是,类会自动调用析构函数,析构函数会自动释放资源。所以智能指针的作用原理就是在函数结束时自动释放内存空间,不需要手动释放内存空间。
智能指针是一个类对象,这样在被调函数执行完,程序过期时,对象将会被删除(对象的名字保存在栈变量中),这样不仅对象会被删除,它指向的内存也会被删除的。
auto _ptr(c98 的方案,c11已经抛弃)采用所有权模式
1 | auto_ptr< string> p1 (new string ("I reigned lonely as a cloud.”)); |
此时不会报错,p2 剥夺了 p1 的所有权,但是当程序运行时访问 p1 将会报错。所以 auto_ptr的缺点是:存在潜在的内存崩溃问题!
unique_ptr(替换 auto_ptr)
unique_ptr 实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露(例如“以 new 创建对象后因为发生异常而忘记调用 delete”)特别有用。采用所有权模式1
2
3unique_ptr<string> p3 (new string ("auto")); //#4
unique_ptr<string> p4; //#5
p4 = p3;//此时会报错!!
编译器认为 p4=p3 非法,避免了 p3 不再指向有效数据的问题。因此,unique_ptr 比 auto_ptr更安全。
另外 unique_ptr 还有更聪明的地方:当程序试图将一个 unique_ptr 赋值给另一个时,如果源 unique_ptr 是个临时右值,编译器允许这么做;如果源 unique_ptr 将存在一段时间,编译器将禁止这么做,比如:1
2
3
4
5unique_ptr<string> pu1(new string ("hello world"));
unique_ptr<string> pu2;
pu2 = pu1; // #1 not allowed
unique_ptr<string> pu3;
pu3 = unique_ptr<string>(new string ("You")); // #2 allowed
其中#1 留下悬挂的 unique_ptr(pu1),这可能导致危害。而#2 不会留下悬挂的 unique_ptr,因为它调用 unique_ptr 的构造函数,该构造函数创建的临时对象在其所有权让给 pu3 后就会被销毁。这种随情况而已的行为表明,unique_ptr 优于允许两种赋值的 auto_ptr 。
注:如果确实想执行类似与#1 的操作,要安全的重用这种指针,可给它赋新值。C++有一个标准库函数 std::move(),让你能够将一个 unique_ptr 赋给另一个。例如:1
2
3
4
5unique_ptr<string> ps1, ps2;
ps1 = demo("hello");
ps2 = move(ps1);
ps1 = demo("alexia");
cout << *ps2 << *ps1 << endl;
shared_ptr实现共享式拥有概念
多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。从名字 share 就可以看出了资源可以被多个指针共享,它使用计数机制来表明资源被几个指针共享。可以通过成员函数 use_count()来查看资源的所有者个数。除了可以通过 new 来构造,还可以通过传入 auto _ptr, unique_ptr,weak_ptr 来构造。当我们调用 release()时,当前指针会释放资源所有权,计数减一。当计数等于 0 时,资源会被释放。
shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性(auto_ptr 是独占的), 在使用引用计数的机制上提供了可以共享所有权的智能指针。
成员函数:
- use_count 返回引用计数的个数
- unique 返回是否是独占所有权( use_count 为 1)
- swap 交换两个 shared_ptr 对象(即交换所拥有的对象)
- reset 放弃内部对象的所有权或拥有对象的变更, 会引起原有对象的引用计数的减少
- get 返回内部对象(指针), 由于已经重载了()方法, 因此和直接使用对象是一样的.如shared_ptr
sp(new int(1)); sp 与 sp.get()是等价的
weak_ptr
weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象. 进行该对象的内存管理的是那个强引用的 shared _ptr. weak_ptr 只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少。weak_ptr 是用来解决 shared_ptr 相互引用时的死锁问题, 如果说两个 shared _ptr 相互引用,那么这两个指针的引用计数永远不可能下降为 0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和 shared_ptr 之间可以相互转化,shared_ptr 可以直接赋值给它,它可以通过调用 lock 函数来获得 shared_ptr。
1 | class B; |
可以看到 fun 函数中 pa ,pb 之间互相引用,两个资源的引用计数为 2,当要跳出函数时,智能指针 pa,pb 析构时两个资源引用计数会减一,但是两者引用计数还是为 1,导致跳出函数时资源没有被释放(A B 的析构函数没有被调用)
把其中一个改为 weak_ptr,我们把类 A 里面的 shared_ptr pb; 改为 weak_ptr pb; 运行结果如下,这样的话,资源 B 的引用开始就只有 1,当 pb 析构时,B 的计数变为 0,B 得到释放,B 释放的同时也会使 A 的计数减 1,同时 pa 析构时使 A 的计数减 1,那么 A 的计数为 0,A 得到释放。
注意的是我们不能通过 weak_ptr 直接访问对象的方法,比如 B 对象中有一个方法 print(), 我们不能这样访问,pa->pb->print(); 英文 pb是一个 weak_ptr,应该先把它转化为shared_ptr,如:shared_ptr p = pa->pb.lock(); p->print();
emplace_back()和push_back()的区别
push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素)
emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。
string是如何存储数据的,具体过程?为什么会扩容2倍或1.5倍?
- 以前的编译器是用写时复制(COW)技术,每当字符串发生复制构造或赋值时进行浅拷贝,只复制指针并增加一个引用计数,只有对其中一个字符串进行修改时才会执行真正的复制。既然用到了引用计数,那么就要考虑在多线程环境下的线程安全问题,而且string会把operator[]和at()都认定为修改“语义”,即使我们只是访问字符串也会触发COW,这就导致string的COW实现存在诸多弊端;
- 现在编译器大多采用SSO短字符串优化,当字符串长度小于15字节时直接存放在栈中,大于15字节时,栈中存放指针,指针指向堆中的完整字符串。这样的好处是,当字符串较短时,直接将其数据存在栈中,而不用去堆中动态申请空间,避免了申请堆空间的开销;
- 在g++环境下,string的扩容机制跟vector一样是两倍扩容,最初分配的capacity是15字节;
- 如果扩容倍率太低,继续插入字符的话会出现频繁扩容的现象,效率降低;如果倍率太高,又会造成空间浪费,所以我想2倍或1.5倍是一个折中的考虑,兼顾了效率与空间利用率;
unorder_map底层分布
- unordered_map 内部采用 hashtable 的数据结构存储,每个特定的 key 会通过特定的哈希运算映射到一个特定的位置。
- 一般来说,hashtable 是可能存在冲突的,即不同的key值经过哈希运算之后得到相同的结果。解决方法是:在每个位置放一个桶,用于存放映射到此位置的元素, 当桶内数据量在8以内使用链表来实现桶,当数据量大于8 则自动转换为红黑树结构 也就是有序map的实现结构。
C++11新特性
volatile 关键字
在 C/C++ 中,volatile 关键字用于告诉编译器一个变量的值可能会在程序的任何时刻被外部因素改变,因此编译器不应该优化对这个变量的访问。具体来说,volatile 的主要作用有以下几方面:
- 防止优化:编译器在优化代码时,通常会假设变量的值在其生命周期内不会改变。但对于 volatile 声明的变量,编译器会强制每次访问该变量时都进行实际的内存读写,而不是使用寄存器中的缓存值。
- 多线程环境:在多线程程序中,多个线程可能会访问同一个变量。使用 volatile 可以确保一个线程对该变量的修改能被其他线程立即看到,尽管它并不能替代适当的线程同步机制。
- 硬件寄存器:在嵌入式编程中,通常会用 volatile 来描述对硬件寄存器的访问,因为这些寄存器的值可能会被硬件事件改变。
- 信号处理:在处理信号时,使用 volatile 可以保证信号处理程序对变量的修改能够被主程序看到。
1 | volatile int counter = 0; |
在这个例子中,counter 被声明为 volatile,这样编译器不会对其读写进行优化,确保每次对 counter 的访问都是直接从内存中获取值。
nullptr
在某种意义上来说,传统 C++ 会把 NULL、0 视为同一种东西,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定义为 (void 0),有些则会直接将其定义为 0。
C++ 不允许直接将 void 隐式转换到其他类型,但如果 NULL 被定义为 (void 0),那么当编译char ch = NULL;时,NULL 只好被定义为 0。
而这依然会产生问题,将导致了 C++ 中重载特性会发生混乱,考虑:1
2void foo(char *);
void foo(int);
对于这两个函数来说,如果 NULL 又被定义为了 0 那么 foo(NULL); 这个语句将会去调用 foo(int),从而导致代码违反直观。
为了解决这个问题,C++11 引入了 nullptr 关键字,专门用来区分空指针、0。
nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较。
类型推导
auto
C++11 引入了 auto 和 decltype 这两个关键字实现了类型推导,让编译器来操心变量的类型。
注意:auto 不能用于函数传参,因此下面的做法是无法通过编译的(考虑重载的问题,我们应该使用模板):1
int add(auto x, auto y);//错误
此外,auto 还不能用于推导数组类型:1
2
3
4
5
6
7
8
9
10
11
int main() {
auto i = 5;
int arr[10] = {0};
auto auto_arr = arr;
auto auto_arr2[10] = arr;//该行错误
return 0;
}
decltype
decltype 关键字是为了解决 auto 关键字只能对变量进行类型推导的缺陷而出现的。它的用法和 sizeof 很相似:1
2
3auto x = 1;
auto y = 2;
decltype(x+y) z;
拖尾返回类型、auto 与 decltype 配合
C++11 还引入了一个叫做拖尾返回类型(trailing return type),利用 auto 关键字将返回类型后置:1
2
3
4template<typename T, typename U>
auto add(T x, U y) -> decltype(x+y) {
return x+y;
}
从 C++14 开始是可以直接让普通函数具备返回值推导,因此下面的写法变得合法:1
2
3
4
5template<typename T, typename U>
auto add(T x, U y) {
return x+y;
}
区间迭代
基于范围的 for 循环
C++11 引入了基于范围的迭代写法,我们拥有了能够写出像 Python 一样简洁的循环语句。1
2
3
4// & 启用了引用
for(auto &i : arr) {
std::cout << i << std::endl;
}
初始化列表
C++11 提供了统一的语法来初始化任意的对象,例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14struct A {
int a;
float b;
};
struct B {
B(int _a, float _b): a(_a), b(_b) {}
private:
int a;
float b;
};
A a {1, 1.1}; // 统一的初始化语法
B b {2, 2.2};
lambda表达式
1 | []:表示不捕获任何外部变量 |
例子:1
2auto func1 = [](){cout << "hello world!" << endl; };
func1();
其对应的类:1
2
3
4
5
6
7
8
9
10template<typename T=void>
class TestLambda01
{
public:
TestLambda01() {}
void operator()()const
{
cout << "hello world!" << endl;
}
};
右值引用
左值持久,右值短暂,右值只能绑定到临时对象,所引用的对象将要销毁或该对象没有其他用户,使用右值引用的代码可以自由的接管所引用对象的内容1
2
3
4
5
6// 移动构造函数
MyString(MyString&& str) noexcept
:m_data(str.m_data) {
MCtor ++;
str.m_data = nullptr; //不再指向之前的资源了
}
右值优先进入移动构造函数而不是拷贝构造函数。而移动构造函数与拷贝构造不同,它并不是重新分配一块新的空间,将要拷贝的对象复制过来,而是”偷”了过来,将自己的指针指向别人的资源,然后将别人的指针修改为nullptr,这一步很重要,如果不将别人的指针修改为空,那么临时对象析构的时候就会释放掉这个资源,”偷”也白偷了。
C++11提供了std::move()方法来将左值转换为右值,从而方便应用移动语义。我觉得它其实就是告诉编译器,虽然我是一个左值,但是不要对我用拷贝构造函数,而是用移动构造函数吧。。。
如果我们没有提供移动构造函数,只提供了拷贝构造函数,std::move()会失效但是不会发生错误,因为编译器找不到移动构造函数就去寻找拷贝构造函数,也这是拷贝构造函数的参数是const T&常量左值引用的原因!