语言基础
C和C++区别
-
C++是面向对象的语言,而C是面向过程的结构化编程语言; -
C++具有封装、继承和多态三种特性; -
C++相比C,增加了许多类型安全的功能,比如强制类型转换; -
C++支持范式编程,比如模板类、函数模板等; -
函数方面 C++ 中有重载和虚函数的概念,用以实现多态; -
C++ 中增加了模板还重用代码,提供了更加强大的 STL 标准库。
内存模型
C/C++内存有哪几种类型?
C++ 内存分区:栈、堆、全局/静态存储区、常量存储区、代码区。
栈:存放函数的局部变量、函数参数、返回地址等,由编译器自动分配和释放。
堆:动态申请的内存空间,就是由 malloc 分配的内存块,由程序员控制它的分配和释放,如果程序执行结束还没有释放,操作系统会自动回收。
全局区/静态存储区(.bss 段和 .data 段):存放全局变量和静态变量,程序运行结束操作系统自动释放,在 C 语言中,未初始化的放在.bss 段中,初始化的放在 .data 段中,C++ 中不再区分了。
常量存储区(.data 段):存放的是常量,不允许修改,程序运行结束自动释放。
代码区(.text 段):存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。
堆和栈的区别
申请方式:栈是系统自动分配,堆是程序员主动申请。
存放的内容:栈中存放的是局部变量,函数的参数;堆中存放的内容由程序员控制;
空间大小不同:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的;
碎片问题不同:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出;
分配方式不同:堆都是动态分配的,没有静态分配的堆。栈有两种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由malloc函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现;
分配效率不同:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的奇存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的。
申请后系统响应:分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出;申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上;
栈在内存中是连续的一块空间(向低地址扩展)最大容量是系统预定好的,堆在内存中的空间(向高地址扩展)是不连续的。
默认的栈空间大小为1M(64位和32位系统),可通过CreatThread参数列表改变线程的StackSize,最大支持线程数 = 内存/StackSize。
堆:与64位/32位有关,与编译器有关,受限于计算机系统中有效的虚拟内存。理论上,32位系统,堆内存可以达到4G的空间,但是堆最大也没有4G,因为整个进程的映像空间有一部分被映射给操作系统,另外栈也占据了一部,全局、静态变量再占据一部分,还有其他代码数据占据一部分。操作系统中有记录空闲内存地址的链表,申请时,寻找第一个空间大于申请空间的堆。
如何定义一个只能在堆(栈)上生成对象的类?
-
只能在堆上生成对象:将析构函数设置为私有。
原因:C++是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性。若析构函数不可访问,则不能在栈上创建对象。
-
只能在栈上生成对象:将new 和 delete 重载为私有。
原因:在堆上生成对象,使用new关键词操作,其过程分为两阶段:第一阶段,使用new在堆上寻找可用内存,分配给对象;第二阶段,调用构造函数生成对象。 将new操作设置为私有,那么第一阶段就无法完成,就不能够再堆上生成对象。
程序编译的过程
编译过程分为四个过程:编译预处理,编译,汇编,链接。
编译预处理:处理以 # 开头的指令;
编译、优化:将源码 .cpp 文件翻译成 .s 汇编代码;
汇编:将汇编代码 .s 翻译成机器指令 .o 文件;
链接:汇编程序生成的目标文件,即 .o 文件,并不会立即执行,因为可能会出现:.cpp 文件中的函数引用了另一个 .cpp文件中定义的符号或者调用了某个库文件中的函数。那链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序 .exe文件。
链接分为两种:
静态链接:代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的虚拟地址空间中。
动态链接:代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进行的虚拟地址的空间。
二者的优缺点:
静态链接:浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行编译链接生成可执行程序(更新困难);优点就是执行的时候运行速度快,因为可执行程序具备了程序运行的所有内容。
动态链接:节省内存、更新方便,但是动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失。
内存泄漏
-
什么是内存泄露
内存泄漏:由于疏忽或错误导致的程序未能释放已经不再使用的内存。 进一步解释:
-
并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。 -
常指堆内存泄漏,因为堆是动态分配的,而且是用户来控制的,如果使用不当,会产生内存泄漏。 -
使用 malloc、calloc、realloc、new 等分配内存时,使用完后要调用相应的 free 或 delete释放内存,否则这块内存就会造成内存泄漏。 -
指针重新赋值
char *p = (char *)malloc(10);
char *p1 = (char *)malloc(10);
p = np;
开始时,指针 p 和 p1 分别指向一块内存空间,但指针 p 被重新赋值,导致 p 初始时指向的那块内存空间无法找到,从而发生了内存泄漏。
-
怎么防止内存泄漏?
防止内存泄漏的方法:
-
内部封装:将内存的分配和释放封装到类中,在构造的时候申请内存,析构的时候释放内存。(说明:但这样做并不是最佳的做法,在类的对象复制时,程序会出现同一块内存空间释放两次的情况);
-
智能指针:智能指针是 C++ 中已经对内存泄漏封装好了一个工具,可以直接拿来使用。
指针和引用
区别
指针和引用都是一种内存地址的概念,区别:指针是一个实体,引用只是一个别名。
在程序编译的时候,将指针和引用添加到符号表中。
指针指向一块内存,指针的内容是所指向的内存的地址,在编译的时候,则是将“指针变量名-指针变量的地址”添加到符号表中,所以说,指针包含的内容是可以改变的,允许拷贝和赋值,有 const 和非 const 区别,甚至可以为空,sizeof 指针得到的是指针类型的大小。
而对于引用来说,它只是一块内存的别名,在添加到符号表的时候,是将"引用变量名-引用对象的地址"添加到符号表中,符号表一经完成不能改变,所以引用必须而且只能在定义时被绑定到一块内存上,后续不能更改,也不能为空,也没有 const 和非 const 区别。
sizeof 引用得到代表对象的大小,而 sizeof 指针得到的是指针本身的大小。另外在参数传递中,指针需要被解引用后才可以对对象进行操作,而直接对引用进行的修改会直接作用到引用对象上。
作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引用的实质是传地址,传递的是变量的地址。
使用场景
-
如果使用一个变量并让它指向一个对象,但是该变量在某些时候也可能不指向任何对象,这时应该把变量声明为指针,因为这样可以赋空值给该变量;
-
如果变量肯定指向一个对象,例如设计中不允许变量为空,这时就可以把变量声明为引用;
-
重载操作符时应当返回引用(主要是为了减少不必要开销,引用效率高);
-
引用主要是作为函数的参数和返回值来使用的。
static、const、#define宏定义
static关键字
static修饰局部变量:变量存放在静态数据区,其生命周期会一直延续到整个程序执行结束;
static修饰全局变量:会改变变量的作用域范围,变量只在本文件内部有效,对其他文件不可见;
static修饰函数:会改变函数的作用域,函数只在本文件内部有效,对其他文件不可见;
static修饰类:如果对类中的某个函数用 static 修饰,则表示该函数属于一个类而不是属于此类的任何特定对象;如果对类中的某个变量进行 static 修饰,则表示该变量是类中所有对象所共有的,存储空间中只存在一个副本,可以通过类和对象去调用;
(类外定义和初始化,在类内仅是声明而已)
const关键字
const修饰常量:定义时就初始化,以后不能更改;
const修饰指针变量和引用变量:如果 const 位于星号的左侧,则 const 就是用来修饰指针所指向的变量,即指针指向为常量;如果 const 位于星号的右侧,则 const 就是修饰指针本身,即指针本身是常量;
const修饰函数的形参:func(const int a),该形参在函数里不能改变;
const修饰类成员变量:const 成员变量,只在某个对象生命周期内是常量,而对于整个类而言是可以改变的。因为类可以创建多个对象,不同的对象其 const 数据成员值可以不同,所以不能在类的声明中初始化 const 成员变量,因为类的对象在没有创建时候,编译器不知道 const 成员变量的值是什么,const 成员变量的初始化只能在类的构造函数的初始化列表中进行。
const修饰类成员函数:防止成员函数修改对象的内容。要注意,const 关键字和 static 关键字对于成员函数来说是不能同时使用的,因为 static 关键字修饰静态成员函数不含有 this 指针,即不能实例化,const 成员函数又必须具体到某一个函数。
const 修饰类对象,定义常量对象:常量对象只能调用常量函数,不能调用非常量函数。而非常量对象可以调用类中的常量函数,也可以调用非常量函数。
原因:对象调用成员函数时,在形参列表的最前面加一个形参 this,但这是隐式的。this 指针是默认指向调用函数的当前对象的,所以,this 是一个常量指针,因为不可以修改 this 指针代表的地址。但当成员函数的参数列表(即小括号)后加了 const 关键字(void print() const;),此成员函数为常量函数,此时它的隐式this形参为 const test * const,即不可以通过 this 指针来改变指向对象的值。
const和宏定义的区别
对于 define
来说, 宏定义实际上是在预编译阶段进行处理,没有类型,也就没有类型检查,仅仅做的是遇到宏定义进行字符替换,遇到多少次就字符替换,而且这个简单的字符替换过程中,很容易出现边界效应,达不到预期的效果。因为 define 宏定义仅仅是字符替换,因此运行时系统并不为宏定义分配内存,但是从汇编的角度来讲,define 以立即数的方式保留了多份数据的拷贝。
对于 const
来说, const 是在编译期间进行处理的,const 有类型,也有类型检查,程序运行时系统会为 const 常量分配内存,而且从汇编的角度讲,const 常量在出现的地方保留的是真正数据的内存地址,只保留了一份数据的拷贝,省去了不必要的内存空间。而且,有时编译器不会为普通的 const 常量分配内存,而是直接将 const 常量添加到符号表中,省去了读取和写入内存的操作,效率更高。
volatile 和 extern 关键字
volatile 的作用:当对象的值可能在程序的控制或检测之外被改变时,应该将该对象声明为 volatile,告知编译器不应对这样的对象进行优化。volatile不具有原子性。
volatile 对编译器的影响:使用该关键字后,编译器不会对相应的对象进行优化,即不会将变量从内存缓存到寄存器中,每次使用该变量必须从内存地址中读取,而不是保存在寄存器中的备份。防止多个线程有可能使用内存中的变量,有可能使用寄存器中的变量,从而导致程序错误。
用到volatile的几种情况:
-
并行设备的硬件寄存器(如状态寄存器) -
中断服务子程序会访问到的非自动变量 -
多线程应用中被几个任务共享的变量
volatile 三个特性
易变性:在汇编层面反映出来,就是两条语句,下一条语句不会直接使用上一条语句对应的 volatile 变量的寄存器内容,而是重新从内存中读取。
不可优化性:volatile 告诉编译器,不要对我这个变量进行各种激进的优化,甚至将变量直接消除,保证程序员写在代码中的指令,一定会被执行。
顺序性:能够保证 volatile 变量之间的顺序性,编译器不会进行乱序优化。
volatile可理解为“编译器警告指示字”,告诉编译器必须每次去内存中取变量值。主要修饰可能被多个线程访问的变量,也可以修饰可能被未知因素更改的变量。
extern
在 C 语言中,修饰符 extern 用在变量或者函数的声明前,用来说明 “此变量/函数是在别处定义的,要在此处引用”。
注意 extern 声明的位置对其作用域也有关系,如果是在 main 函数中进行声明的,则只能在 main 函数中调用,在其它函数中不能调用。其实要调用其它文件中的函数和变量,只需把该文件用 #include 包含进来即可,为啥要用 extern?因为用 extern 会加速程序的编译过程,这样能节省时间。
在 C++ 中 extern 还有另外一种作用,用于指示 C 或者 C++函数的调用规范。比如在 C++中调用 C 库函数,就需要在 C++程序中用 extern “C” 声明要引用的函数。这是给链接器用的,告诉链接器在链接的时候用C 函数规范来链接。主要原因是 C++和 C 程序编译完成后在目标代码中命名规则不同,用此来解决名字匹配的问题。
#define和inline
-
inline 函数工作原理
inline 内联函数不是在调用时发生控制转移关系,而是在编译阶段将函数体嵌入到每一个调用该函数的语句块中,编译器会将程序中出现内联函数的调用表达式用内联函数的函数体来替换。
普通函数是将程序执行转移到被调用函数所存放的内存地址,当函数执行完后,返回到执行此函数前的地方。转移操作需要保护现场,被调函数执行完后,再恢复现场,该过程需要较大的资源开销。
-
宏定义(define)和内联函数(inline)的区别
-
内联函数是在编译时展开,而宏在编译预处理时展开;在编译的时候,内联函数直接被嵌入到目标代码中去,而宏只是一个简单的文本替换。
-
内联函数是真正的函数,和普通函数调用的方法一样,在调用点处直接展开,避免了函数的参数压栈操作,减少了调用的开销。而宏定义编写较为复杂,常需要增加一些括号来避免歧义。
-
宏定义只进行文本替换,不会对参数的类型、语句能否正常编译等进行检查。而内联函数是真正的函数,会对参数的类型、函数体内的语句编写是否正确等进行检查。
new/delete malloc/ free
malloc/free底层
在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk、mmap、munmap这些系统调用实现的;
malloc是从堆里面申请内存,也就是说函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。
malloc的底层实现 :
-
开辟空间小于128K时,通过brk()函数 -
将数据段.data的最高地址指针**_edata向高地址移动,即增加堆**的有效区域来申请内存空间 -
brk分配的内存需要等到高地址内存释放以后才能释放,这也是内存碎片产生的原因 -
开辟空间大于128K时,通过mmap()函数 -
利用mmap系统调用,在堆和栈之间文件映射区域申请一块虚拟内存 -
128K限制可由M_MMAP_THRESHOLD选项进行修改 -
mmap分配的内存可以单独释放 -
以上只涉及虚拟内存的分配,直到进程第一次访问其地址时,才会通过缺页中断机制分配到物理页中
malloc 申请的内存,free 释放内存会归还给操作系统吗?
malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
为什么不全部使用 mmap 来分配内存?
频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗。
为什么不全部使用 brk 来分配?
如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。
随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致内存泄露。所以,malloc 实现中,充分考虑了 sbrk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。
new/delete底层
new和 delete是用户进行动态内存申请和释放的操作符,operator new 和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过operator delete全局函数来释放空间。
-
内置类型: 如果申请的是内置类型的空间,new和malloc,delete和free基本类似。不同之处:new在申请空间失败时会抛异常,malloc在申请空间失败时会返回NULL。 -
自定义类型: -
new的原理: -
delete的原理: -
new[N]的原理: -
delete[N]的原理: -
在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理; -
调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间; -
调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请; -
在申请的空间上执行N次构造函数; -
在空间上执行析构函数,完成对象中资源的清理工作; -
调用operator delete函数释放对象的空间; -
调用operator new函数申请空间; -
在申请的空间上执行构造函数,完成对象的构造;
区别
new、delete是C++中的操作符需要编译器支持,而malloc和free是标准库函数,需要加入头文件 stdlib.h。
malloc需要自行指定动态分配内存的大小,而new在指定指针类型之后可以自动分配内存,无需指定大小。
malloc返回的指针需要进行强制类型转换,而new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换;
new内存分配失败时,会抛出bad_alloc异常。malloc分配内存失败时返回NULL。
new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现),然后调用类型的构造函数,初始化成员变量,最后返回自定义类型的指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。
malloc / free是库函数而不是运算符,只能动态的申请和释放内存,不在编译器控制范围之内,不能够自动调用构造函数和析构函数,无法强制要求其做自定义类型对象构造和析构工作(对于C++来说)。
智能指针
-
智能指针的作用
智能指针的作用是管理一个指针,避免程序员申请的空间在函数结束时忘记释放,造成内存泄漏。使用智能指针可以很大程度上的避免这个问题,因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源。所以智能指针的作用原理就是在函数结束时自动释放内存空间,不需要手动释放内存空间。
如果在程序中使用 new 从堆(自由存储区)分配内存,等到不再需要时,应使用 delete 将其释放,如果忘记释放,则会产生内存泄露。C++ 引入了智能指针 auto_ptr(C++98), 以帮助自动完成这个过程。智能指针是行为类似于指针的类对象。
-
有哪些智能指针
C++ 中有 4 种智能指针:auto_ptr、unique_ptr、shared_ptr、weak_ptr。其中 auto_ptr 在 C++11 中被弃用,weak_ptr 需要配合 shared_ptr 使用,并不能算是真正的智能指针。
-
智能指针实现原理
智能指针解决问题的思想:将常规指针进行封装,当智能指针对象过期时,让它的析构函数对常规指针进行内存释放。
auto_ptr(C++98的方案,C++11已经废弃):采用所有权模式,对于特定的对象,只能有一个智能指针可拥有它,这样只有拥有对象的智能指针的析构函数会删除该对象。然后,让赋值操作转让所有权。
unique_ptr(替代 auto_ptr):也是采用所有权模式,实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。
shared_ptr:采用引用计数实现共享式拥有概念。多个智能指针可以指向相同对象,该对象和其相关资源会在最后一个引用被销毁时候释放。它使用引用计数来表明资源被几个指针共享。例如,赋值时,计数将加 1,而指针过期时,计数将减 1。仅当最后一个指针过期时,才调用 delete。当两个对象相互使用一个shared_ptr成员变量指向对方,会造成循环引用,使引用计数失效,从而导致内存泄漏。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。
weak_ptr:该类型指针通常不单独使用,只能和 shared_ptr 类型指针搭配使用。weak_ptr 类型指针并不会影响所指堆内存空间的引用计数,可以用来解决循环引用问题。
为了解决循环引用导致的内存泄漏,引入了weak_ptr弱指针,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但不指向引用计数的共享内存,可以检测到所管理的对象是否已经被释放,从而避免非法访问。
weak_ptr 是用来解决 shared_ptr 相互引用时的死锁问题,如果说两个 shared_ptr 相互引用,那么这两个指针的引用计数永远不可能下降为0,也就是资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和 shared_ptr 之间可以相互转化,shared_ptr 可以直接赋值给它,它可以通过调用
lock
函数来获得shared_ptr。当两个智能指针都是 shared_ptr 类型的时候,析构时两个资源引用计数会减一,但是两者引用计数还是为 1,导致跳出函数时资源没有被释放(析构函数没有被调用),解决办法:把其中一个改为weak_ptr就可以。
-
weak_ptr -
shared_ptr -
unique_ptr -
auto_ptr -
如何选择智能指针
如果程序要使用多个指向同一个对象的指针,应该选择 shared_ptr;
如果程序不需要多个指向同一个对象的指针,则可以使用 unique_ptr;
如果使用 new [] 分配内存,应该选择 unique_ptr;
如果函数使用 new 分配内存,并返回指向该内存的指针,将其返回类型声明为 unique_ptr 是不错的选择。
-
shared_ptr底层
shared_ptr的实现机制是在拷贝构造时使⽤同⼀份引⽤计数:
(1)⼀个模板指针T* ptr,指向实际的对象;
(2)⼀个引⽤次数,必须new出来的,不然会多个shared_ptr⾥⾯会有不同的引⽤次数⽽导致多次delete;
(3)重载拷贝构造函数,使其引⽤次数加⼀;
(4)重载析构函数,使引⽤次数减⼀并判断引⽤是否为零(是否调⽤delete);
(5)重载operator=(赋值运算符),如果原来的shared_ptr已经有对象,则让其引⽤次数减⼀并判断引⽤是否为零(是否调⽤delete),然后将新的对象引⽤次数加⼀;
(6)重载operator*(解引用运算符)和operator->(获取指针运算符),使得能像指针⼀样使⽤shared_ptr;
代码实现:
template<typename T>
class SharedPtr{
public:
SharedPtr(T* ptr = NULL):_ptr(ptr), _pcount(new int(1)){}
// 拷贝构造函数
SharedPtr(const SharedPtr& s):_ptr(s._ptr), _pcount(s._pcount){
(*_pcount)++;
}
// 析构函数
~SharedPtr(){
if (--(*(this->_pcount)) == 0){
delete _ptr;
delete _pcount;
_ptr = NULL;
_pcount = NULL;
}
}
// 重载operator=(赋值运算符)
SharedPtr<T>& operator=(const SharedPtr& s){
if (this != &s){
if (--(*(this->_pcount)) == 0){
delete this->_ptr;
delete this->_pcount;
}
_ptr = s._ptr;
_pcount = s._pcount;
*(_pcount)++;
}
return *this;
}
// 重载operator*
T& operator*(){
return *(this->_ptr);
}
// 重载operator->
T* operator->(){
return this->_ptr;
}
private:
T* _ptr;
int* _pcount; //指向引用计数的指针
};
线程安全问题
为什么多线程读写 shared_ptr 要加锁? - 陈硕的Blog - C++博客 (cppblog.com)
shared_ptr的引用计数本身是线程安全(引用计数是原子操作)。 多个线程同时读同一个shared_ptr对象是线程安全的,如果是多个线程对同一个shared_ptr进行读和写,则需要加锁。 多线程读写shared_ptr所指向的同一个对象,不管是相同的shared_ptr对象,还是不同的,都需要加锁保护,因为shared_ptr有两个数据成员,读写操作不能原子化,使得多线程读写同一个shared_ptr对象需要加锁。
防止出现指针空悬。
-
weak_ptr底层
weak_ptr是为了配合shared_ptr⽽引⼊的⼀种智能指针,它的最⼤作⽤在于协助shared_ptr⼯作,像旁观者那样观测资源的使⽤情况,但weak_ptr没有共享资源,它的构造不会引起指针引⽤计数的增加。weak_ptr和shared_ptr指向相同内存,shared_ptr析构之后内存释放,在使⽤之前使⽤函数lock()检查weak_ptr是否为空指针。
shared_ptr和weak_ptr主要区别如下:
-
shared_ptr对象能够初始化实际指向一个地址内容,而weak_ptr对象没办法直接初始化,需要用一个shared_ptr实例来初始化weak_ptr;
-
weak_ptr不会影响shared_ptr的引用计数,因为它是一个弱引用,只是一个临时引用指向shared_ptr。即使用shared_ptr对象初始化weak_ptry也不会导致shared_ptr引用计数增加,依此特性可以解决shared_ptr的循环引用问题;
-
weak_ptr没有解引用*和获取指针->运算符,它只能通过lock成员函数去获取对应的shared_ptr智能指针对象,从而获取对应的地址和内容。
-
unique_ptr底层
unique_ptr”唯⼀”拥有其所指对象,同⼀时刻只能有⼀个unique_ptr指向给定对象,离开作⽤域时,若其指向对象,则将其所指对象销毁(默认delete)。定义unique_ptr时需要将其绑定到⼀个new返回的指针上。
unique_ptr不⽀持普通的拷⻉和赋值,因为拥有指向的唯一对象,但是可以拷⻉和赋值⼀个将要被销毁的unique_ptr。可以通过release或者reset将指针所有权从⼀个(⾮const)unique_ptr 转移到另⼀个unique_ptr 。
一个unique_ptr怎么赋值给另一个unique_ptr对象?(std::move)
借助 std::move() 可以实现将一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,其目的是实现所有权的转移。
// A 作为一个类
std::unique_ptr<A> ptr1(new A());
std::unique_ptr<A> ptr2 = std::move(ptr1);
-
初始化一个智能指针的三种方法:
// 利用构造函数来初始化
std::shared_ptr<int> sp1(new int(123));
// 利用智能指针的reset方法来初始化
std::shared_ptr<int> sp2;
sp2.reset(new int(123));
// 利用make_shared函数来初始化
std::shared_ptr<int> sp3;
sp3 = std::make_shared<int>(123);
C++11的新特性
nullptr替代 NULL;引入了 auto 和 decltype 这两个关键字实现了类型推导;基于范围的 for 循环for(auto& i : res){};类和结构体的中初始化列表;Lambda 表达式(匿名函数);std::forward_list(单向链表);右值引用和move语义。
1. auto 类型推导auto 关键字:自动类型推导,编译器会在 编译期间 通过初始值推导出变量的类型,通过 auto 定义的变量必须有初始值。
2. decltype 类型推导decltype 是“declare type”的缩写,译为“声明类型”。和 auto 的功能一样,都用来在编译时期进行自动类型推导。如果希望从表达式中推断出要定义的变量的类型,但是不想用该表达式的值初始化变量,这时就不能再用 auto。decltype 作用是选择并返回操作数的数据类型。
auto和decltype的区别:
auto var = val1 + val2;
decltype(val1 + val2) var1 = 0;
-
auto 根据 = 右边的初始值 val1 + val2 推导出变量的类型,并将该初始值赋值给变量 var;decltype 根据 val1 + val2 表达式推导出变量的类型,变量的初始值和与表达式的值无关。 -
auto 要求变量必须初始化,因为它是根据初始化的值推导出变量的类型,而 decltype 不要求,定义变量的时候可初始化也可以不初始化。
3. lambda 表达式lambda 表达式,又被称为 lambda 函数或者 lambda 匿名函数。
lambda匿名函数的定义:
[capture list] (parameter list) -> return type
{
function body;
};
其中:
-
capture list:捕获列表,指 lambda 所在函数中定义的局部变量的列表,通常为空。 -
return type、parameter list、function body:分别表示返回值类型、参数列表、函数体,和普通函数一样。
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
int arr[4] = {4, 2, 3, 1};
//对 a 数组中的元素进行升序排序
sort(arr, arr+4, [=](int x, int y) -> bool{ return x < y; } );
for(int n : arr){
cout << n << " ";
}
return 0;
}
4. 范围 for 语句
for (declaration : expression){
statement
}
参数的含义:
-
expression:必须是一个序列,例如用花括号括起来的初始值列表、数组、vector ,string等,这些类型的共同特点是拥有能返回迭代器的 beign、end 成员。 -
declaration:此处定义一个变量,序列中的每一个元素都能转化成该变量的类型,常用 auto 类型说明符。
for循环后的括号被冒号”:”分为两部分,第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。它与普通循环类似,可以用 continue来结束本次循环,也可以用break来跳出整个循环。
-
for循环迭代的范围是确定的。对于数组而言就是数组第一个元素和最后一个元素的范围;对于类来说,应该提供begin和end方法,begin和end就是for循环迭代的范围;
-
迭代的对象要实现++和==操作符。注意:基于范围的for循环使用于标准库的容器时,如果使用auto来声明迭代的对象,那么这个对象不会是迭代器对象,如:
std::vector v{ 1, 2, 3, 4, 5 };
for (auto e : v)
cout << e << " "; // e为解引用后的对象,不是迭代器
5. 右值引用右值引用:绑定到右值的引用,用 && 来获得右值引用,右值引用只能绑定到要销毁的对象。为了和右值引用区分开,常规的引用称为左值引用。
#include <iostream>
#include <vector>
using namespace std;
int main()
{
int var = 42;
int &l_var = var;
int &&r_var = var; // 错误:不能将右值引用绑定到左值上
int &&r_var2 = var + 40; // 正确:将 r_var2 绑定到求和结果上
return 0;
}
左值:指表达式结束后依然存在的持久对象,右值:表达式结束就不再存在的临时对象,左值和右值的区别:左值持久,右值短暂。
右值引用和左值引用的区别:
-
左值引用不能绑定到要转换的表达式、字面常量或返回右值的表达式。右值引用恰好相反,可以绑定到这类表达式,但不能绑定到一个左值上。 -
右值引用必须绑定到右值的引用,通过 && 获得。右值引用只能绑定到一个将要销毁的对象上,因此可以自由地移动其资源。
std::move 可以将一个左值强制转化为右值,继而可以通过右值引用使用该值,以用于移动语义。
#include <iostream>
using namespace std;
void fun1(int& tmp) {
cout << "fun1(int& tmp):" << tmp << endl;
}
void fun2(int&& tmp) {
cout << "fun2(int&& tmp)" << tmp << endl;
}
int main() {
int var = 11;
fun1(12); // 错误:不能将左值引用绑定到右值上
fun1(var);// 正确
fun2(1); // 正确
}
6. 标准库 move() 函数move() 函数:通过该函数可获得绑定到左值上的右值引用,该函数包括在 utility 头文件中。
std::move() 函数原型:
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type &&>(t);
}
说明:引用折叠原理
-
右值传递给上述函数的形参 T&& 依然是右值,即 T&& && 相当于 T&&。 -
左值传递给上述函数的形参 T&& 依然是左值,即 T&& & 相当于 T&。
小结:通过引用折叠原理可以知道,move() 函数的形参既可以是左值也可以是右值。
remove_reference 具体实现:
//原始的,最通用的版本
template <typename T> struct remove_reference{
typedef T type; //定义 T 的类型别名为 type
};
//部分版本特例化,将用于左值引用和右值引用
template <class T> struct remove_reference<T&> //左值引用
{ typedef T type; }
template <class T> struct remove_reference<T&&> //右值引用
{ typedef T type; }
//举例如下,下列定义的a、b、c三个变量都是int类型
int i;
remove_refrence<decltype(42)>::type a; //使用原版本,
remove_refrence<decltype(i)>::type b; //左值引用特例版本
remove_refrence<decltype(std::move(i))>::type b; //右值引用特例版本
举例:
int var = 10;
转化过程:
1. std::move(var) => std::move(int&& &) => 折叠后 std::move(int&)
2. 此时:T 的类型为 int&,typename remove_reference<T>::type 为 int,这里使用 remove_reference 的左值引用的特例化版本
3. 通过 static_cast 将 int& 强制转换为 int&&
整个std::move被实例化如下
string&& move(int& t)
{
return static_cast<int&&>(t);
}
总结std::move() 实现原理:
-
利用引用折叠原理将右值经过 T&& 传递类型保持不变还是右值,而左值经过 T&&变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变; -
然后通过 remove_refrence 移除引用,得到具体的类型 T; -
最后通过 static_cast<> 进行强制类型转换,返回 T&& 右值引用。
7. 智能指针相关知识已在前面中进行了详细的说明,这里不再重复。
8. delete 函数和 default 函数
-
delete 函数: = delete
表示该函数不能被调用。 -
default 函数: = default
表示编译器生成默认的函数,例如:生成默认的构造函数。
#include <iostream>
using namespace std;
class A
{
public:
A() = default; // 表示使用默认的构造函数
~A() = default; // 表示使用默认的析构函数
A(const A &) = delete; // 表示类的对象禁止拷贝构造
A &operator=(const A &) = delete; // 表示类的对象禁止拷贝赋值
};
int main()
{
A ex1;
A ex2 = ex1; // error: use of deleted function 'A::A(const A&)'
A ex3;
ex3 = ex1; // error: use of deleted function 'A& A::operator=(const A&)'
return 0;
}
四种强制转换
C++ 的四种强制转换包括:static_cast, dynamic_cast, const_cast, reinterpret_cast
基本用法:static_cast<type-id> expression
,其他类似xxx_cast<newType>(data)
。
static_cast
-
使用场景:
a、用于类层次结构中基类和派生类之间指针或引用的转换
上行转换(派生类---->基类)是安全的;
下行转换(基类---->派生类)由于没有动态类型检查,所以是不安全的。
b、用于基本数据类型之间的转换,如把int转换为char,这种带来安全性问题由程序员来保证
c、把空指针转换成目标类型的空指针
d、把任何类型的表达式转为void类型
-
使用特点
a、主要执行非多态的转换操作,用于代替C中通常的转换操作;
b、隐式转换都建议使用static_cast进行标明和替换。
dynamic_cast
专门用于派生类之间的转换,type-id 必须是类指针,类引用或 void*,对于下行转换是安全的,当类型不一致时,转换过来的是空指针,而static_cast,当类型不一致时,转换过来的事错误意义的指针,可能造成非法访问等问题。
-
使用场景:只有在派生类之间转换时才使用dynamic_cast,type-id必须是类指针,类引用或者void*。
-
使用特点:
a、基类必须要有虚函数,因为dynamic_cast是运行时类型检查,需要运行时类型信息,而这个信息是存储在类的虚函数表中,只有一个类定义了虚函数,才会有虚函数表(如果一个类没有虚函数,那么一般意义上,这个类的设计者也不想它成为一个基类)。
b、对于下行转换,dynamic_cast是安全的(当类型不一致时,转换过来的是空指针),而static_cast是不安全的(当类型不一致时,转换过来的是错误意义的指针,可能造成踩内存,非法访问等各种问题)
c、dynamic_cast还可以进行交叉转换
const_cast
专门用于 const 属性的转换,去除 const 性质,或增加 const 性质, 是四个转换符中唯一一个可以操作常量的转换符。
-
使用场景:
a、常量指针转换为非常量指针,并且仍然指向原来的对象
b、常量引用被转换为非常量引用,并且仍然指向原来的对象
-
使用特点:
a、cosnt_cast是四种类型转换符中唯一可以对常量进行操作的转换符
b、去除常量性是一个危险的动作,尽量避免使用。一个特定的场景是:类通过const提供重载时,一般都是非常量函数调用const_cast<const T>
将参数转换为常量,然后调用常量函数,然后得到结果再调用const_cast <T>
去除常量性。
reinterpret_cast
不到万不得已,不要使用这个转换符,高危操作。使用特点:从底层对数据进行重新解释,依赖具体的平台,可移植性差;可以将整形转 换为指针,也可以把指针转换为数组;可以在指针和引用之间进行肆无忌惮的转换。
-
使用场景:不到万不得已,不用使用这个转换符,高危操作
-
使用特点:
a、reinterpret_cast是从底层对数据进行重新解释,依赖具体的平台,可移植性差
b、reinterpret_cast可以将整型转换为指针,也可以把指针转换为数组
c、reinterpret_cast可以在指针和引用里进行肆无忌惮的转换
自动类型转换(隐式):利用编译器内置的转换规则,或者用户自定义的转换构造函数以及类型转换函数(这些都可以认为是已知的转换规则)。例如从 int 到 double、从派生类到基类、从type *到void *、从 double 到 Complex 等。type *是一个具体类型的指针,例如int *、double *、Student *等,它们都可以直接赋值给void *指针。例如,malloc() 分配内存后返回的就是一个void *指针,我们必须进行强制类型转换后才能赋值给指针变量。
强制类型转换(显式):隐式不能完成的类型转换工作,就必须使用强制类型转换:(new_type) expression。
数据类型转换的本质:对数据所占用的二进制位做出重新解释。
隐式类型转换:编译器可以根据已知的转换规则来决定是否需要修改数据的二进制位。
强制类型转换:由于没有对应的转换规则,所以能做的事情仅仅是重新解释数据的二进制位,但无法对数据的二进制位做出修正。
class 和 struct 的异同
struct 和 class 都可以自定义数据类型,也支持继承操作。
struct 中默认的访问级别是 public,默认的继承级别也是 public;class 中默认的访问级别是 private,默认的继承级别也是 private。
当 class 继承 struct 或者 struct 继承 class 时,默认的继承级别取决于 class 或 struct 本身,class(private 继承),struct(public 继承),即取决于派生类的默认继承级别。
class 可以用于定义模板参数,struct 不能用于定义模板参数。
memset, memcpy, strcpy函数
strcpy函数的原型:char* strcpy(char* dest, const char* src)
, strcpy的作用是拷贝字符串,当它遇到'