Cpp面经
旧人飘无岸,往事随风散
C++98
0. C++和C的区别?
- 面向对象编程:C++支持面向对象编程(OOP),包括类、对象、继承、封装、多态等特性。这使得C++更适合大型软件项目,因为OOP可以提高代码的重用性和可读性。C语言是一种过程性语言,没有这些特性。
- STL(Standard Template Library):C++提供了STL,这是一套强大的模板类和函数的库,包括列表、向量、队列、栈、关联数组等。这些可以大大提高开发效率。C语言没有内置的数据结构库。
- 异常处理:C++提供了异常处理机制,可以更优雅地处理错误情况。C语言处理错误通常依赖于函数返回值。
- 构造函数和析构函数:C++支持构造函数和析构函数,这些特殊的函数允许对象在创建和销毁时执行特定的代码。C语言没有这个概念。
- 运算符重载:C++允许运算符重载,这意味着开发者可以更改已有运算符的行为,或者为用户自定义类型添加新的运算符。C语言不支持运算符重载。
https://www.iamshuaidi.com/22647.html
1. 在main执行的前后需要处理什么工作?
Main函数之前
(1) 设置栈指针
(2) 全局对象初始化,在main之前调用构造函数
(3) 将main函数的参数argc,argv等传递给main函数
Main函数之后
全局对象的析构函数
详细版本:
main函数执行之前:
- 配置堆栈
- 初始化静态和全局变量
- 为未初始化部分的全局变量赋值
- 运行全局构造器
- 将main函数的参数(argv,argc等)传递给main函数
main函数执行之后:
- 全局对象的析构函数会在main函数之后执行;
- 可以用
atexit
注册一个函数,它会在main 之后执行;- atexit() 函数是C/C++标准库中的一个函数,用于注册一个函数,在程序退出时自动执行。
- _ attribute_((destructor))
- _ _ attribute _ _((destructor)) 是 C/C++ 编译器属性,用于定义一个对象的析构函数。
在 C++ 中,当使用动态内存分配(如 new 或 malloc)创建对象时,需要在程序退出时手动释放这些对象所占用的内存。如果不手动释放内存,就会出现内存泄漏的问题。为了避免这种情况,可以使用 _ _attribute _ _((destructor)) 来定义一个对象的销毁函数。
补充:int main(int argc,char*argv[])
-
argc
:表示命令行参数的数量,是一个整型变量。./myprogram arg1 arg2 arg3
-
那么
argc
的值为4,argv
数组中的元素分别为:1
2
3
4argv[0] = "./myprogram"
argv[1] = "arg1"
argv[2] = "arg2"
argv[3] = "arg3"argv
:是一个字符指针数组,其中每个元素都是一个字符串,表示命令行参数的值。
2.什么是内存对齐?
【什么是内存对齐,内存对齐的好处,设置内存对齐】
将数据存储在内存中时,按照一定的规则将数据的首地址对齐到某个数的倍数上
学习自:C/C++内存对齐详解
还是得看例子:【从例子解释概念】
还是用一个例子带出这个问题,看下面的小程序,理论上,32位系统下,int占4byte,char占一个byte,那么将它们放到一个结构体中应该占4+1=5byte;但是实际上,通过运行程序得到的结果是8 byte,这就是内存对齐所导致的。
1 | // 32位系统 |
现代计算机中内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8)的倍数,这就是所谓的内存对齐。
精简:
(1) 在计算机中,内存是按字节划分的,而CPU在读取数据时,并不是一个字节一个字节的读取,实际上是按块的大小读取,块大小可以是2,4,8,16等等,称为内存访问粒度。
(2) 内存对齐则是将特定的数据类型按照一定的规则摆放在内存上,具体规则是按照变量的声明顺序,依次安排内存,其偏移量为变量大小的整数倍。
补充说法:
(1)硬件平台限制
● 内存以字节为单位,但不同硬件平台不一定支持任何内存地址的存取。例如,某些硬件平台可能以双字节、4字节等为单位存取内存。因此,为了保证处理器正确读取数据,需要进行内存对齐。
(2)提高cpu内存访问速度
● 处理器访问内存时,对齐的数据可以在较少的内存访问周期内被读取或写入。
● 如果数据没有对齐,处理器可能需要进行额外的处理来组合不同内存地址中的数据片段,这会增加处理器的工作负担,降低效率。
3. 为什么要做内存对齐?
这个的话展开说就是:
尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的.它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度.
现在考虑4字节存取粒度的处理器取int类型变量(32位系统),该处理器只能从地址为4的倍数的内存开始读取数据。
假如没有内存对齐机制,数据可以任意存放,现在一个int变量存放在从地址1开始的联系四个字节地址中,该处理器去取数据时,要先从0地址开始读取第一个4字节块,剔除不想要的字节(0地址),然后从地址4开始读取下一个4字节块,同样剔除不要的数据(5,6,7地址),最后留下的两块数据合并放入寄存器.这需要做很多工作.
现在有了内存对齐的,int类型数据只能存放在按照对齐规则的内存中,比如说0地址开始的内存。那么现在该处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,提高了效率。
精简:
(1) **平台原因(移植原因):**不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
(2) **性能原因:**数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
4. 说说内存对齐规则?
每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。gcc中默认**#pragma pack(4)**,可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数。
有效对其值:是给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位。
了解了上面的概念后,我们现在可以来看看内存对齐需要遵循的规则:
(1) 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。
(3) 结构体的总大小为 有效对齐值 的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。
下面给出几个例子以便于理解:
1 | //32位系统 |
以上测试都是在Linux环境下进行的,linux下默认#pragma pack(4),且结构体中最长的数据类型为4个字节,所以有效对齐单位为4字节,下面根据上面所说的规则以s2来分析其内存布局:
首先使用规则1,对成员变量进行对齐:
sizeof(c1) = 1 <= 4(有效对齐位),按照1字节对齐,占用第0单元;
sizeof(i) = 4 <= 4(有效对齐位),相对于结构体首地址的偏移要为4的倍数,占用第4,5,6,7单元;
sizeof(c2) = 1 <= 4(有效对齐位),相对于结构体首地址的偏移要为1的倍数,占用第8单元;
然后使用规则2,对结构体整体进行对齐:
s2中变量i占用内存最大占4字节,而有效对齐单位也为4字节,两者较小值就是4字节。因此整体也是按照4字节对齐。由规则1得到s2占9个字节,此处再按照规则2进行整体的4字节对齐,所以整个结构体占用12个字节。
根据上面的分析,不难得出上面例子三个结构体的内存布局如下:
#pragma pack(n)
不同平台上编译器的 pragma pack 默认值不同。而我们可以通过预编译命令#pragma pack(n), n= 1,2,4,8,16来改变对齐系数。
例如,对于上个例子的三个结构体,如果前面加上#pragma pack(1),那么此时有效对齐值为1字节,此时根据对齐规则,不难看出成员是连续存放的,三个结构体的大小都是6字节。
如果前面加上#pragma pack(2),有效对齐值为2字节,此时根据对齐规则,三个结构体的大小应为6,8,6。内存分布图如下:
经过上面的实例分析,大家应该对内存对齐有了全面的认识和了解,在以后的编码中定义结构体时需要考虑成员变量定义的先后顺序了。
精简:
(1) 每个特定平台上的编译器都有自己的默认**“对齐系数”(也叫对齐模数)**。32位系统,gcc中默认#pragma pack(4),即对齐系数默认为4。可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数。
(2) 有效对齐值:是给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位。
(3) 结构体第一个成员的偏移量为0,以后每个成员相对于结构体首地址的偏移量都是该成员大小与有效对齐值中较小数的整数倍,如有需要编译器会在成员之间加上填充字节。
(4) 结构体的总大小为有效对齐值的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。
补充:对齐实战下
1.默认对齐
1 |
|
2.#pragma pack(1)
1 |
|
3.alignas
1 |
|
5. 指针和引用的区别?
(1) 指针是一个变量,存储的是一个地址。引用跟原来的变量实质上是同一个东西,是原变量的别名
(2) 指针可以有多级,引用只有一级
(3) 指针可以为空,引用不能为NULL且在定义时必须初始化
(4) 指针在初始化后可以改变指向,而引用在初始化之后不可再改变
(5) sizeof指针得到的是本指针的大小,sizeof引用得到的是引用所指向变量的大小
6. 堆和栈的区别?
(1) 栈由系统自动分配,堆是自己申请和释放的。
(2) 堆向上,向高地址方向增长。栈向下,向低地址方向增长。
(3) 在空间连续性上,栈区的空间是连续的;但堆的空间是不连续的。
(4) 堆只能动态分配。栈有静态分配和动态分配,静态分配由编译器完成,动态分配由alloca函数分配,但栈的动态分配的资源由编译器进行释放,无需程序员实现。
(5) 因为操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行,所以栈的效率比较高也比较快。
(6) 堆的操作是由C/C++函数库提供的,在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。
7. new / delete 与 malloc / free的异同?
【相同点和不同点】
(1) 都可用于内存的动态申请和释放
(2) 前者是C++运算符,后者是C/C++语言标准库函数
(3) new自动计算要分配的空间大小,malloc需要手工计算
(4) new是类型安全的,malloc不是。
(5) new的实现过程是:首先调用名为operator new的标准库函数,分配足够大的原始类型的内存,接下来在该内存上调用构造函数初始化对象;最后返回该内存指针。
(6) delete的实现过程:对指针指向的对象运行适当的析构函数;然后通过调用名为operator delete的标准库函数释放该对象所用内存
(7) malloc/free可重载,new/delete不可重载,operator new/operator delete可重载
8. 被free回收的内存是立即返还给操作系统吗?
不会,new或者malloc申请内存是向内存管理器申请,内存管理器再向操作系统申请,这里面涉及到系统调用,如果频繁的申请释放,效率会很低,所以一般进程申请了内存后,释放资源后并不会立即将内存还给操作系统,而是放到一个类似于内存缓存池的地方,下次申请的时候首先会在内存缓存池中查找合适的内存,减少了大量的系统调用,提高速度。
【mmap会,sbrk不会】
9. 宏函数和普通函数有何区别?
【1.预处理2.不占用代码段3.类型检测】
(1) 宏函数作用在预编译期,进行文本替换,相当于直接插入了代码,运行时不存在函数调用,执行起来更快;普通函数调用在运行时需要跳转到具体调用函数。
(2) 宏函数属于在结构中插入代码,没有返回值;普通函数调用具有返回值。
(3) 宏函数参数没有类型,不进行类型检查;普通函数参数具有类型,需要检查类型。
1 | // 宏定义,没有类型检查 |
在这个例子中,SQUARE(x)
是一个宏,它接受任何类型的参数x
,并返回x
的平方。因为宏没有类型检查,所以你可以传递任何类型的参数给宏,编译器不会报错。
但是,square(x)
是一个函数,它期望一个整数类型的参数x
。如果你尝试将一个非整数类型的值(如double
类型的值)传递给这个函数,编译器会报错,因为函数参数的类型检查失败。
请注意他们的写法:
1 | // 定义宏 MIN |
10. 宏定义和typedef有何区别?
(1) 宏主要用于定义常量及书写复杂的内容;typedef主要用于定义类型别名。
(2) 宏替换发生在预编译期,属于文本插入替换;typedef是编译的一部分。
(3) 宏不检查类型;typedef会检查数据类型。
11. 宏定义和const的区别?
(1) 宏定义发生在预编译期。const是在编译、运行的时候起作用
(2) 宏定义只做替换,不做类型检查和计算。const常量有数据类型,编译器做类型检查。
(3) define只是将宏名称进行文本替换,占用代码段内存。const在程序运行中只有一份备份,占用数据段内存。
作用:用于定义只读的变量,即如果一个变量被const修饰,那么它的值将无法再改变。值得注意的是,const定义的是变量而不是常量。在C99标准中,const定义的变量是全局变量,存放在全局数据区。此外,用const修饰变量时,一定要给变量初始值,否则编译器会报错。
12. 内联函数和宏函数的区别?
(1) 宏函数在预处理阶段进行文本替换,inline函数在编译阶段进行替换
(2) inline函数有类型检查,相比宏函数比较安全
(3) Inline函数具有返回值,宏函数没有
C/C++预处理过程详细梳理(预处理步骤+宏定义#define/#include+inline函数+宏展开顺序+条件预处理+其它预处理定义)
13. 变量声明和定义的区别?
声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间。只有在定义处才为其分配存储空间。相同变量可以在多处声明,但只能在一处定义。
1 | // 声明一个变量 |
isOK!
1 |
|
区别
1.声明/定义次数
变量/函数可以声明多次,变量/函数的定义只能一次。
2.分配内存
声明不会分配内存,定义会分配内存。
3.做了什么
声明是告诉编译器变量或函数的类型和名称等,定义是告诉编译器变量的值,函数具体干什么。
14. strlen和sizeof的区别?
(1) sizeof是C/C++操作符,并不是函数,结果在编译时得到。strlen是字符处理的库函数。
(2) sizeof参数可以是任何数据的类型或者数据。strlen的参数只能是字符指针,它指向了一个结尾是’\0’的字符串。
区别:
- sizeof 是一个运算符,而strlen()是一个函数。
- sizeof计算的是变量或类型所占用的内存字节数,而strlen()计算的是字符串中字符的个数。
- sizeof的语法sizeof(data type),即sizeof可用于计算任何类型的数据;而strlen()的语法strlen(const char* str),即只能用于计算字符串。
- sizeof计算字符串的长度,包含末尾的‘\0’;stlen()计算字符串的长度,不包含字符串末尾的‘\0’。
sizeof指针和数组的区别
-
数组:
sizeof
返回整个数组在内存中占用的字节数。 -
指针:
sizeof
返回指针本身在内存中占用的字节数,而不是指针所指向的内存区域的大小。
15. 常量指针和指针常量的区别?
(1) 指针常量(底层const)是一个指针,它指向了一个只读值。它实际上只是限制了指针的拥有者不能通过指针修改它所指向的值,对这个值的属性没有限制,这个值是可以是常量也可以变量,均不影响。
(2) 常量指针(顶层const)是一个不能给改变指向的指针,即我们可以通过指针修改它所指向的值,但不能修改指针的指向。
详细说明:
指针常量
指针常量:顾名思义它就是一个常量,但是是指针修饰的。
格式为:
1 | int * const p //指针常量 |
在这个例子下定义以下代码:
1 | int a,b; |
常量指针
常量指针:如果在定义指针变量的时候,数据类型前用const修饰,被定义的指针变量就是指向常量的指针变量,指向常量的指针变量称为常量指针,格式如下
1 | const int *p = &a; //常量指针 |
在这个例子下定义以下代码:
1 | int a,b; |
因为常量指针本质是指针,并且这个指针是一个指向常量的指针,指针指向的变量的值不可通过该指针修改,但是指针指向的值可以改变。
因为常量指针本质是指针,并且这个指针是一个指向常量的指针,指针指向的变量的值不可通过该指针修改,但是指针指向的值可以改变。
16. 指针常量能不能赋值给非指针常量?
不能,指针常量限制了指针的拥有者不能通过指针修改它所指向的值,如果指针常量可以赋值给非指针常量,那就意味着拥有者可以使用这种方法获取写权限,这在语义上是冲突的。
17. C++和Python的区别?
(1) Python是一种脚本语言,是解释执行的,而C++是编译语言,是需要编译后在特定平台运行的。
(2) python可以很方便的跨平台,但是效率没有C++高。
(3) C++中需要事先定义变量的类型,而Python不需要
(4) Python的库函数比C++的多,调用起来很方便
18. C++中struct和class的区别?
【1.访问权限,2.继承问题】
(1) 两者都拥有成员函数、公有和私有部分
(2) 任何可以使用class完成的工作,同样可以使用struct完成
(3) 两者中如果不对成员不指定公私有,struct默认是公有的,class则默认是私有的
(4) class默认是private继承,而struct模式是public继承
补充:Union概念。
共用体是一种特殊的数据类型,允许您在相同的内存位置存储不同的数据类型。您可以定义一个带有多成员的共用体,但是任何时候只能有一个成员带有值。共用体提供了一种使用相同的内存位置的有效方式。
共用体占用的内存应足够存储共用体中最大的成员。
1 |
|
当上面的代码被编译和执行时,它会产生下列结果:
1 | Memory size occupied by data : 20 |
访问共用体成员
为了访问共用体的成员,我们使用成员访问运算符(.)。成员访问运算符是共用体变量名称和我们要访问的共用体成员之间的一个句号。您可以使用 union 关键字来定义共用体类型的变量。下面的实例演示了共用体的用法:
1 |
|
当上面的代码被编译和执行时,它会产生下列结果:
1 | data.i : 1917853763 |
在这里,我们可以看到共用体的 i 和 f 成员的值有损坏,因为最后赋给变量的值占用了内存位置,这也是 str 成员能够完好输出的原因。现在让我们再来看一个相同的实例,这次我们在同一时间只使用一个变量,这也演示了使用共用体的主要目的:
1 |
|
当上面的代码被编译和执行时,它会产生下列结果:
1 | data.i : 10 |
在这里,所有的成员都能完好输出,因为同一时间只用到一个成员。
19. C++和C的struct的区别?
C语言:struct是用户自定义数据类型(UDT),不能设置权限,成员不可以是函数,不能被继承。
C++:struct是抽象数据类型(ADT)支持成员函数的定义,能设置权限,能被继承与实现多态。
20. C++中const和static的作用?
(1) Static(类外):
① 隐藏。所有不加static的全局变量和函数具有全局可见性,可以在其他模块中使用,加了之后只能在该文件所在的编译模块中使用
② 静态变量没有定义初始值时,会初始化为0。
③ 静态变量在函数内定义,生命周期跟随程序(同全局变量),且只进行一次初始化,具有记忆性,其作用范围与局部变量相同,函数退出后仍然存在,但不能使用
(2) Static(类内):
① static成员变量:只与类关联,不与类的对象关联。定义时要分配空间,不能在类声明中初始化, 必须在类定义体外部初始化,初始化时不需要标示为static;可以被非static成员函数任意访问。
② static成员函数:不具有this指针,无法访问类对象的非static成员变量和非static成员函数;不能被声明为const、虚函数和volatile;可以被非static成员函数任意访问
(3) Const(类外):
① const常量在定义时必须初始化,之后无法更改
② const形参可以接收const和非const类型的实参
(4) Const(类内):
① const成员变量:只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其const数据成员的值可以不同,所以不能在类中声明时初始化
② const成员函数:const类对象不可以调用类中的非const成员函数;非const类对象则均可调用;const成员函数不可以改变类中非mutable数据成员的值。
举例说明
在C++中,const
和static
都是修饰符,它们可以用来修改变量、函数或对象的行为。
const
:const
关键字可以用来修饰变量,表示这个变量的值是常量,不能被修改。在类中,如果一个成员函数被声明为const
,那么这个函数不能修改类的任何成员变量(除非这个变量是mutable
的)。
1 | class MyClass { |
static
:static
关键字在类中有两种用法。一种是修饰成员变量,这样这个变量就变成了一个静态成员变量,它不属于类的任何对象,而是属于类本身,所有的对象都共享这一个变量。另一种是修饰成员函数,这样这个函数就变成了一个静态成员函数,它可以在不创建对象的情况下被调用。
1 | class MyClass { |
从类外部来看,const
和static
修饰的成员变量或函数可以通过类名直接访问(对于static
),或者通过对象访问但不能修改(对于const
)。
-
符号隐藏:在链接过程中,
static
函数的符号不会被导出到程序的全局符号表中。这有助于防止命名冲突,并保证函数的封装性。 -
static
变量的内存分配通常在数据段(data segment)中。数据段是程序的一个内存区域,用于存放程序的全局变量和静态变量。 -
static
变量在编译时就分配了内存,并在程序加载时初始化。
21.数组名和指针(这里为指向数组首元素的指针)的区别?
(1) 编译器为了简化对数组的支持,实际上是利用指针实现了对数组的支持。具体来说,就是将表达式中的数组元素引用转换为指针加偏移量的引用。
(2) 二者均可通过增减偏移量来访问数组中的元素。
(3) 数组名不是真正意义上的指针,实际上是一个常量指针,所以不能进行自增自减操作。
(4) 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作, 但sizeof运算符不能再得到原数组的大小了。
22. C代码使用C语言编译和C++编译有什么不同?
(1) C++虽然语法上支持和兼容C语言,但为了支持多种特性,在编译上却做了很多C语言所没有的其他处理。
(2) 比如说现在有一个C语言函数库,而我们用C++去调用该函数库,在编译链接时链接器会报错。因为C++为了支持函数重载,会在编译时给每个函数进行“改名”,但是C语言在编译时则不会改名,C函数库中的函数名都保持原样,这就会使链接器在函数库中找不到改名后的对应函数地址,然后报错。extern “C”可以很好地解决这个问题。
(3) 因为C++的函数改名规则,C++代码在使用其他模块的函数前必须包含其头文件或显式声明函数,不然它无法识别该函数是C函数还是C++函数,是否需要进行改名。C语言编译器即使不提前声明函数也可以调用函数,因为C编译器没有改名规则。
补充:extern重载问题讨论!
c++函数与c函数命名是不冲突的,即便二者同名,返回类型和参数类型都一样。估计是编译器内部禁止了这种用法,防止在决议过程中无法判断正确的函数。
23. extern”C”的用法?
在程序中加上extern “C”后,相当于告诉编译器这部分代码是C语言写的,因此要按照C语言进行编译,而不是C++,在以下情况时会用到该语法:
(1) C++代码中调用C语言函数库;
(2) 在多个人协同开发时,可能有人擅长C语言,而有人擅长C++
24. 说说野指针和悬空指针?
(1) **野指针:**指的是没有被初始化过的指针。解决方法:定义指针变量要及时初始化或者置空。(保持良好的编码规范)
(2) **悬空指针:**指针最初指向的内存已经被释放了的一种指针。解决方法:释放操作后立即置空,或者使用智能指针
野指针和空悬指针的区别是野指针是指向未知地址的指针,而空悬指针是指向已经被释放的地址的指针
1 | void *p; |
1 | void *p = malloc(size); |
25. C++中的重载、重写(覆盖)和隐藏的区别?
(1) 重载是指在同一范围定义中的同名函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。
(2) 重写指的是在派生类中重写父类的函数体,要求基类函数必须是虚函数,且重写的函数签名**(返回值、函数名、参数列表)**必须完全一致。
(3) 隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数。
(4) 重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
(5) 隐藏和重写的区别在于基类函数是否是虚函数。
26. 浅拷贝和深拷贝的区别?
(1) 浅拷贝就是将对象的指针进行简单的复制,原对象和副本指向的是相同的资源。如果原来的指针所指向的资源释放了,那么使用新指针就会出现错误。
(2) 深拷贝是新开辟一块空间,将原对象的资源复制到新的空间中,并返回该空间的地址。即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。
来,手写个简单的深拷贝
1 |
|
27. public,protected和private权限的区别?
(1) 访问权限:
① public的变量和函数在类的内部外部都可以访问。
② protected的变量和函数只能在类的内部和其派生类中访问。
③ private修饰的元素只能在类内访问。
(2) 继承权限:
① Public:基类的公有成员和保护成员作为派生类的成员时,都保持原有的访问权限,基类的私有成员任然是私有的
② Protected:基类的公有成员和保护成员都成为派生类的保护成员,基类的私有成员仍然是私有的
③ private:基类的公有成员和保护成员都成为派生类的私有成员,基类的私有成员任然是私有的
对于继承权限而言,它所针对的是类外的访问权限。对于类内来说,不管是何种继承,派生类均可访问基类的公用和保护成员,但不可访问基类的私有成员。
28. 如何用代码判断大小端存储?
(1) **大端存储:**字数据的高字节存储在低地址中
(2) **小端存储:**字数据的高字节存储在高地址中
(3) 方式一:使用强制类型转换
1 |
|
(4) 方式二:使用union联合体
1 | union MyUnion { |
29. volatile、mutable和explicit关键字的用法?
(1) volatile 关键字是一种类型修饰符,用它声明的类型变量表示它可能被某些编译器未知的因素更改,所以系统总是重新从它所在的内存读取数据而不会去使用内存读取优化(使用寄存器),即使它前面的指令刚刚从该处读取过数据。
【防止编译的时候被优化!每次都得从内存中读取!】
(2) mutable关键字是一种类型修饰符,被mutable修饰的数据成员表示他可以被const成员函数所修改(const成员函数无法修改类中的普通数据成员),它的修改不会影响整个类对象的状态。
(3) **explicit关键字用来修饰类的构造函数,**加上该关键字,表示该类不能发生相应的隐式类型转换(函数调用传参时的类型转换),只能以显式地进行类型转换(调用构造函数) 。
(4)register关键字:这个关键字请求编译器尽可能的将变量存在CPU内部寄存器中,而不是通过内存寻址访问,以提高效率
30. C++中有几种类型的new?
【未看】
在C++中,new有三种典型的使用方法:plain new,nothrow new和placement new
(1) plain new:最普通的new,它在分配内存失败时会抛出std::bad_alloc异常而不是返回NULL
-
Plain
new
是最常用的new
操作符。它在堆上分配内存,并调用构造函数来初始化对象。如果内存分配失败,它会抛出一个std::bad_alloc
异常。 -
#include <iostream> int main() { try { int* p = new int(5); // 分配一个整数并初始化为5 std::cout << "Value: " << *p << std::endl; delete p; // 释放内存 } catch (std::bad_alloc& e) { std::cerr << "Memory allocation failed: " << e.what() << std::endl; } return 0; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(2) **nothrow new** :不会抛出异常的new,分配内存失败时返回NULL
- ```c++
int main() {
int* p = new (std::nothrow) int(5); // 分配一个整数并初始化为5
if (p == nullptr) {
std::cerr << "Memory allocation failed" << std::endl;
} else {
std::cout << "Value: " << *p << std::endl;
delete p; // 释放内存
}
return 0;
}
(3) placement new[定位new]:不分配内存,只在一块分配好了的内存上调用类的构造函数.
placement new 是 new 关键字的一种进阶用法,既可以在栈(stack)上生成对象,也可以在堆(heap)上生成对象。
-
#include <iostream> #include <new> // 需要包含这个头文件来使用placement new class MyClass { public: MyClass(int x) : value(x) { std::cout << "Constructor called, value: " << value << std::endl; } ~MyClass() { std::cout << "Destructor called, value: " << value << std::endl; } private: int value; }; int main() { // 分配一块原始内存 char buffer[sizeof(MyClass)]; // 在预先分配的内存上构造对象 MyClass* p = new (buffer) MyClass(42); // 手动调用析构函数 p->~MyClass(); return 0; }
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
补充:
- [[c++]placement new(定位new运算符)用法及用途](https://blog.csdn.net/qq_63884623/article/details/139635613#:~:text=Placement%20New%EF%BC%9A%E7%B2%BE%E5%87%86%E6%8E%A7%E5%88%B6%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80%20%E5%AE%9A%E4%BD%8D%20new%20%E8%BF%90%E7%AE%97%E7%AC%A6%20%EF%BC%88Placement%20new%EF%BC%89%E6%98%AF%20C%2B%2B,%E8%BF%99%E5%9C%A8%E9%9C%80%E8%A6%81%E7%B2%BE%E7%BB%86%E6%8E%A7%E5%88%B6%E5%AF%B9%E8%B1%A1%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80%E7%9A%84%E9%AB%98%E7%BA%A7%E5%BA%94%E7%94%A8%E4%B8%AD%E9%9D%9E%E5%B8%B8%E6%9C%89%E7%94%A8%EF%BC%8C%E4%BE%8B%E5%A6%82%E5%86%85%E5%AD%98%E6%B1%A0%E3%80%81%E5%B5%8C%E5%85%A5%E5%BC%8F%E7%B3%BB%E7%BB%9F%E5%92%8C%E5%AE%9E%E6%97%B6%E7%B3%BB%E7%BB%9F%E3%80%82%20%E4%BD%BF%E7%94%A8%E5%9C%BA%E6%99%AF%EF%BC%9A%20%E5%86%85%E5%AD%98%E6%B1%A0%E7%AE%A1%E7%90%86%20%EF%BC%9A%E5%9C%A8%E5%86%85%E5%AD%98%E6%B1%A0%E4%B8%AD%E9%A2%84%E5%85%88%E5%88%86%E9%85%8D%E4%B8%80%E5%A4%A7%E5%9D%97%E5%86%85%E5%AD%98%EF%BC%8C%E7%84%B6%E5%90%8E%E5%9C%A8%E5%85%B6%E4%B8%AD%E6%9E%84%E9%80%A0%E5%AF%B9%E8%B1%A1%E3%80%82%20%E8%87%AA%E5%AE%9A%E4%B9%89%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E7%AD%96%E7%95%A5%20%EF%BC%9A%E5%9C%A8%E7%89%B9%E5%AE%9A%E7%9A%84%E5%86%85%E5%AD%98%E5%8C%BA%E5%9F%9F%EF%BC%88%E5%A6%82%E5%85%B1%E4%BA%AB%E5%86%85%E5%AD%98%E3%80%81%E5%86%85%E5%AD%98%E6%98%A0%E5%B0%84%E6%96%87%E4%BB%B6%E7%AD%89%EF%BC%89%E4%B8%AD%E6%9E%84%E9%80%A0%E5%AF%B9%E8%B1%A1%E3%80%82%20%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%20%EF%BC%9A%E9%80%9A%E8%BF%87%E5%87%8F%E5%B0%91%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E5%92%8C%E9%87%8A%E6%94%BE%E7%9A%84%E5%BC%80%E9%94%80%E6%9D%A5%E4%BC%98%E5%8C%96%E6%80%A7%E8%83%BD%E3%80%82)
### 31. 形参与实参的区别?
(1) **形参在定义时不分配内存,只有在被调用时才分配内存**
(2) 实参可以任意形式的表达式,但在进行函数调用时,它们都必须具有确定的值
(3) 实参和形参在数量上,类型上,顺序上应严格一致,否则会发生**“类型不匹配”**的错误
(4) **函数调用中发生的数据传送是单向的。** 即只能把实参的值传送给形参。
### 32. 值传递、指针传递、引用传递的区别和效率?
(1) 值传递:实参的值向形参进行值拷贝,如果值传递的对象是类对象或是大的结构体对象,将耗费一定的时间和空间。
(2) 指针传递:同样有实参的值向形参进行值拷贝,但拷贝的数据是一个固定大小的地址。
(3) 引用传递:同样有上述的数据拷贝过程,但其是针对地址的,相当于为该数据所在的地址起了一个别名。
(4) **效率上讲,指针传递和引用传递比值传递效率高。一般主张使用引用传递,代码逻辑上更加紧凑、清晰**。
### 33. C++有哪几种的构造函数?
(1) 默认构造函数(无参数)
(2) 初始化构造函数(有参数)
(3) 拷贝构造函数
```c++
#include<iostream>
using namespace std;
class CExample
{
private:
int a;
public:
//构造函数
CExample(int b)
{
a=b;
printf("constructor is called\n");
}
//拷贝构造函数
CExample(const CExample & c)
{
a=c.a;
printf("copy constructor is called\n");
}
//析构函数
~CExample()
{
cout<<"destructor is called\n";
}
void Show()
{
cout<<a<<endl;
}
};
int main()
{
CExample A(100);
CExample B=A;
B.Show();
return 0;
}
34. 什么情况下会调用拷贝构造函数?
(1) 用类的一个实例对象去初始化构造一个类的新对象的时候
(2) 函数的参数是类的对象时(值传递)
(3) 函数的返回值是函数体内局部对象的类的对象时
35. 说说C++中的初始化?
C++中变量的初始化有很多种方式,如:默认初始化,值初始化,直接初始化,拷贝初始化。下面一一介绍。
(1) 默认初始化
① 默认初始化是指定义变量时 没有指定初值时进行的初始化操作。
1 | int a; |
② 对于内置类型变量(如int,double,bool等),如果是全局变量或静态变量,则初始化为0,如果是栈或者堆变量,则将拥有未定义的值。
③ 对于类类型的变量(如string或其他自定义类型),不管定义于何处,都会执行默认构造函数。如果该类没有默认构造函数,则会引发错误。因此,建议为每个类都定义一个默认构造函数(=default)。
④ 注意:默认初始化的值并不是绝对的,在一些情况下会产生未知的错误,一般定义变量时最好都要为它设定初始值,这是一种良好的编程习惯。
(2) 值初始化
① 值初始化是指使用了初始化器(即使用了圆括号或花括号)但却没有提供初始值的情况。
② 特别的,采用动态分配内存的方式(即采用new关键字)创建的变量,不加括号时,如int *p=new int,是默认初始化,加了括号,如int *p=new int(),为值初始化。
③ 若不采用动态分配内存的方式(即不采用new运算符),写成int a();是错误的值初始化方式,因为这种方式是声明了一个函数而不是进行值初始化。如果一定要进行值初始化,必须结合拷贝初始化使用,即写成int a=int()。
④ 值初始化和默认初始化一样,对于内置类型初始化为0,对于类类型则调用其默认构造函数,如果没有默认构造函数,则不能进行初始化。
(3) 直接初始化
① 直接初始化是指采用小括号的方式进行变量初始化(小括号里一定要有初始值,如果没提供初始值,那就是值初始化了!)
② 对于类类型来说,直接初始化会直接调用与实参匹配的构造函数。
(4) 拷贝初始化
① 拷贝初始化是指采用等号(=)进行初始化的方式,编译器把等号右侧的初始值拷贝到新创建的对象中去。
② 拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。
36. 直接初始化、拷贝初始化、赋值的差别?
(1) 这三种操作非常类似,语法上也很相像,区分这三者主要看他们的最终调用的函数。
(2) 直接初始化:要创建的对象不存在,主要利用**初始化器(圆括号)**使用其他的初始值进行初始化操作,一般这个初始值是别的对象或数据类型,直接初始化调用类的构造函数(调用参数类型最佳匹配的那个)。
(3) 拷贝初始化:要创建的对象不存在,使用已有的对象(与创建的对象类型一致)来进行初始化,这个已有的对象既可以是临时对象也可以是其他对象。既可以使用初始化器(圆括号),也可以使用赋值号(“=”)来进行初始化操作,但他们的背后都会调用类的拷贝构造函数。
(4) 赋值:要创建的对象已存在,用已存在的对象给它赋值,这属于重载“=”号运算符的范畴,他并不是一种初始化操作,背后调用类的重载“=”运算符函数。
(5) 对于内置类型变量(如int,double,bool等),直接初始化与拷贝初始化差别可以忽略不计。
37. 静态变量什么时候初始化?
视编译器而定,有些可能在代码执行之前就初始化,有些则可能直到代码执行时才初始化。
38. delete、delete []的区别?
delete只会调用一次析构函数,delete [] 会根据数组元素的数量,对数组中的每个元素调用析构函数。
这个可以衍生个有意思的问题?
delete[]
如何知道要调用多少次析构函数
解答:
delete[]
需要知道数组中有多少个元素,以便对每个元素调用析构函数。这是通过在分配数组时存储数组大小来实现的。具体步骤如下:
- 内存分配: 当使用
new[]
分配内存时,编译器会在分配的内存块前面存储一个额外的区域,用来记录数组的大小。这个额外的区域通常位于用户数据的前面。 - 数组大小存储: 这个额外的区域(通常称为“头部”)存储数组的大小(即元素个数)。例如,如果分配了一个包含
N
个元素的数组,则这个头部会存储N
。 - 析构函数调用: 当使用
delete[]
释放数组时,delete[]
会先访问这个头部,以获取数组的大小N
。然后,delete[]
会依次对数组中的每个元素调用析构函数。
39. malloc、calloc、realloc的区别?
(1) malloc函数:void* malloc(unsigned int num_size); 需要手动计算分配大小,申请的空间的值是随机初始化的
(2) calloc函数:void* calloc(size_t n,size_t size);无需手动计算分配大小,申请的空间的值是初始化为0的
(3) realloc函数 :给动态分配的空间分配额外的空间,用于扩充容量。
详细解释:
malloc
, calloc
和 realloc
都是 C 和 C++ 语言中用来动态分配内存的函数,但它们各有不同的用途和行为。
malloc
: 这是最常用的动态内存分配函数。malloc
接受一个参数,即要分配的字节数,然后返回一个指向新分配内存的指针,或者在无法分配内存时返回 NULL。需要注意的是malloc
不会清零新分配的内存。
1 | int *arr = (int*)malloc(10 * sizeof(int)); // 分配足够存储10个整数的内存 |
calloc
: 这个函数与malloc
类似,但它接受两个参数:要分配的元素数量和每个元素的大小。calloc
会返回一个指向新分配内存的指针,或者在无法分配内存时返回 NULL。与malloc
不同,calloc
会清零新分配的内存。
1 | int *arr = (int*)calloc(10, sizeof(int)); // 分配足够存储10个整数的内存,并将内存清零 |
realloc
: 这个函数用于改变已分配的内存大小。realloc
接受两个参数:一个指向已分配内存的指针和新的内存大小(以字节为单位)。realloc
会返回一个指向新内存的指针,或者在无法分配内存时返回 NULL。如果新的内存大小大于原来的大小,realloc
会保留原来内存的内容,并可能将新的内存部分初始化为零。
1 | int *arr = (int*)malloc(10 * sizeof(int)); // 分配足够存储10个整数的内存 |
在使用这些函数时,一定要记住在不再需要内存时使用 free
函数释放它,以防止内存泄漏。
40. 说说类成员的初始化方式?
(1) **构造函数初始化:**在构造函数体中初始化,在所有的数据成员被分配内存空间后,才进行赋值操作。背后会调用一次数据成员的构造函数和赋值函数。
(2) **初始化列表:**给数据成员分配内存空间时就进行初始化,相比起构造函数初始化,少了一次调用赋值函数的操作,因此效率会更高一些。
41. 构造函数的执行顺序?
(1) 基类的构造函数
(2) 派生类的数据成员的构造函数
(3) 派生类自己的构造函数
42. 析构函数的执行顺序?
(1) 调用派生类的析构函数;
(2) 调用派生类的数据成员的析构函数;
(3) 调用基类的析构函数。
43. 有哪些情况必须使用初始化列表?
要不然进入到括号里面就对象就构造完成了。
(1) 当初始化一个引用成员时
(2) 当初始化一个常量成员时
(3) 当调用一个基类的构造函数,而它拥有一组参数时
(4) 当调用一个成员类的构造函数,而它拥有一组参数时
44. 初始化列表的初始化顺序?
这和对象内存有关。
初始化列表中出现的顺序并不是真正的初始化顺序,初始化顺序只取决于成员变量在类中的声明顺序。我们应尽可能保证成员变量的声明顺序与初始化列表顺序一致,才能真正保证其效率。
45. C++中新增的string与C中的 char *有什么区别?
(1) string对char*进行了封装,包含了字符串的属性以及对外提供了通用方法。
(2) string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2^n),然后将原字符串拷贝过去,并加上新增的内容。
46. 什么是内存泄露,如何检测与避免?
(1) 内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的内存块,使用完后必须显式释放的内存,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。
(2) 检测方法:有专门的内存泄漏检测工具,Linux下可以使用Valgrind工具 ,Windows下可以使用CRT库。
(3) 避免方法:使用智能指针,良好的编程习惯。
47. 什么是内存溢出和内存越界?
(1) 内存溢出指的是程序在申请内存时,没有足够的内存空间供其使用。
(2) 内存越界指的是申请了一块内存,使用的时候超出了这块内存区域。
48. 介绍C++面向对象的三大特性?
(1) 继承
让某个类获得另一个类的属性和方法。它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展,是代码复用的一种机制。
常见的继承有两种方式:
-
**实现继承:**指使用基类的属性和方法而无需额外编码的能力
-
**接口继承:**指仅使用属性和方法的名称、但是子类必须提供实现的能力
※:构造函数、析构函数、友元函数、静态数据成员、静态成员函数都不能被继承。
(2) 封装
数据和代码捆绑在一起,避免外界干扰和不确定性访问。在C++中借以权限机制实现这一特性,利用三种访问权限控制外界对对象数据的访问。
(3) 多态
多态是同一个行为具有多个不同表现形式或形态的能力。
常见的多态有两种方式:
**编译时多态(重载):**是指允许存在多个同名函数,而这些函数的参数表不同【运算符重载,函数重载,模板】
**运行时多态(重写):**是指子类重新定义父类的虚函数的做法
49. 说说C++的四种强制转换?
(1) **reinterpret_cast:**reinterpret_cast 用以处理互不相关的类型之间的转换,reinterpret_cast 操作执行的是比特位拷贝,即编译器不会做任何检查,截断,补齐的操作,只是把比特位拷贝过去。这种转换提供了很强的灵活性,但转换的安全性只能由程序员的细心来保证了。
(2) **const_cast:**该运算符用来修改类型的const属性,可以使指向常量的指针被转化成指向非常量的指针,并且仍然指向原来的对象,使拥有者可以通过指针修改对象。
(3) static_cast:static_cast 用于进行比较“自然”和低风险的转换,如整型和浮点型、字符型与整型之间的互相转换。static_cast **不能用于在不同类型的指针之间互相转换,也不能用于整型和指针之间的互相转换,当然也不能用于不同类型的引用之间的转换。**因为这些属于风险比较高的转换。他的安全性比起reinterpret_cast 更高,但是由于该操作符没有运行时类型检查机制,在进行下行转换(把基类指针或引用转换成派生类指针或引用)时仍然是不安全的。
(4) **dynamic_cast:**dynamic_cast主要用于类层次间的上行转换和下行转换,因为有动态类型检测,在进行下行转换时比static_cast更安全。
举例
C++中有四种类型的强制类型转换,分别是 static_cast
,dynamic_cast
,const_cast
和 reinterpret_cast
。
static_cast
:这是最常用的类型转换符。它可以在任何的数据类型之间进行转换,但是转换的类型需要是相关的,否则可能会产生意外的结果。
例如,将整数转换为浮点数:
1 | int a = 10; |
dynamic_cast
:这个类型转换符主要用在类的继承体系中,用于进行上行转换和安全的下行转换。当我们试图将父类指针转换为子类指针时,可以使用dynamic_cast
。
1 | class Base {}; |
const_cast
:这个类型转换符用于移除常量性。如果我们有一个const
变量,但我们需要修改它,可以使用const_cast
。
1 | const int a = 10; |
reinterpret_cast
:这个类型转换符用于进行低级别的类型转换,它可以在任何指针或整数类型之间进行转换。但是使用这个转换符需要非常小心,因为它可能会产生意外的结果。
1 | int a = 10; |
注意:尽管 C++ 提供了这些强制类型转换,但应尽量避免使用它们,除非你非常确定你知道自己在做什么。在很多情况下,使用强制类型转换可能会导致未定义的行为。
50. 如何获得结构成员相对于结构开头的字节偏移量?
使用<stddef.h>头文件中的,offsetof宏
offsetof用法:offsetof(S, x),S为结构体对象,x为结构体数据成员之一
1 |
|
解释
此处,通过 static_cast<s*>(nullptr)
,编译器相信在 nullptr
处(0x0
)有一个真实存在的 s
类型的对象。
此处使用 static_cast
而非 reinterpret_cast
是因为 C++ 标准不允许将 nullptr
通过 reinterpret_cast
转换成其他类型的指针;
此类转换应用 static_cast
。由于 static_cast<s*>(nullptr)
返回指向 s
类型对象的指针,因此 static_cast<s*>(nullptr)->m
就是一个虚拟但在编译器看来可用的成员变量 m
。
为了求得以字节为单位的 ptrdiff_t
,实现中通过 &reinterpret_cast<const volatile char&>(static_cast<s*>(nullptr)->m)
获得一个 const volatile char*
类型的变量。
由于在该实现中,虚拟的变量位于 0x0
位置,故而 &reinterpret_cast<const volatile char&>(static_cast<s*>(nullptr)->m)
即是 m
在 s
类型对象当中相对对象起始地址的偏移量。
最后,只需将它转换为 size_t
类型的值即可。
51. 静态类型和动态类型,静态绑定和动态绑定的介绍?
(1) 静态类型:对象在声明时采用的类型,在编译期既已确定;
(2) 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
(3) 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
(4) 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;
52. 全局变量和局部变量有什么区别?
(1) 生命周期:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;
(2) 作用域:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用
53. 指针加减计算要注意什么?
指针加减本质是对其所指地址的移动,移动的步长跟指针的类型是有关系的,因此在涉及到指针加减运算需要十分小心,加多或者减多都会导致指针指向一块未知的内存地址,即内存越界。
54. 怎样判断两个浮点数是否相等?
对两个浮点数判断是否相等不能直接用==来判断,会出错!明明相等的两个数比较反而是不相等!对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!
55. 类如何实现只能静态分配和只能动态分配?
(1) 实现只能静态分配:把operator new运算符重载为private属性。
(2) 实现只能动态分配:把构造函数设为private属性
只能动态分配类的对象的例子
将构造函数设置成private.
1 |
|
只能静态分配对象的例子
直接删除new这个重载运算符
1 | class StaticOnly { |
56. 知道C++中的组合吗?它与继承相比有什么优缺点吗?
继承有以下几个缺点:
(1) 父类的内部细节对子类是可见的,破坏了封装性。
(2) 子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。
(3) 子类与父类是一种高耦合,违背了面向对象思想。
使用组合的目的即为了克服这几个缺点,但也因此产生了另一些缺点,如:容易产生过多的对象、为了能组合多个对象,必须仔细对接口进行定义。
组合是通过包含另一个类的对象来实现功能复用,而继承是通过派生出新的类型来实现功能复用。组合的耦合度更高,而继承的耦合度较低。
1.组合示例:
1 | // 引擎类 |
2.继承示例:
1 | // 基类 - 交通工具 |
57. 为什么模板类一般都是放在一个头文件中?
模板定义很特殊,编译器在遇到任何的模板定义时不会为它分配内存空间,它一直处于等待状态直到被一个模板实例告知才会分配内存空间。如果在分离式编译环境下,编译器编译某一个源文件时并不知道另一个源文件的存在,也不会去查找(当遇到未决符号时它会寄希望于连接器),同时由于模板定义没有被分配空间,链接器也无法查找到函数的入口地址。
58. 你了解重载运算符吗?
C++预定义中的运算符的操作对象只局限于基本的内置数据类型,但是对于我们自定义的类型(类)是没有办法操作的。但是大多时候我们需要对我们定义的类型进行类似的运算,这个时候就需要我们对这么运算符进行重新定义,赋予其新的功能,以满足自身的需求。这就是运算符重载,它的实质就是函数重载。
运算符重载规则:
(1) 为了防止用户对标准类型进行运算符重载,C++规定重载后的运算符的操作对象必须至少有一个是用户定义的类型。
(2) 使用运算符不能违法运算符原来的句法规则。如不能将‘+’重载为一个操作数。
(3) 不能修改运算符原先的优先级。
(4) 不能创建一个新的运算符
(5) 不能进行重载的运算符:成员运算符,作用域运算符,条件运算符,sizeof运算符,typeid运算符,const_cast、dynamic_cast、reinterpret_cast、static_cast强制类型转换运算符。
(6) 大多数运算符可以通过成员函数和非成员函数进行重载,但是下面这四种运算符只能通过成员函数进行重载:= 赋值运算符,()函数调用运算符,[ ]下标运算符,->通过指针访问类成员的运算符。
(7) 一般来说,单目运算符重载为类的成员函数,双目运算符重载为类的友元函数
59. 前++和后++重载的区别?
(1) 前++重载函数参数列表不需要带参数,后++参数列表需要带参数,这个参数仅仅只是区分前++和后++用,没有实际意义。
(2) 前++返回一个引用,后++返回一个临时对象,后++效率比较高。
60. 当程序中有函数重载时,函数的匹配原则和顺序是什么?
(1) 名字查找
(2) 确定候选函数
(3) 寻找最佳匹配
61. 条件编译的作用?
(1) 一般情况下,源程序中所有的行都参加编译。但是有时希望对其中一部分内容只在满足一定条件才进行编译,也就是对一部分内容指定编译的条件,这就是“条件编译”。
(2) 在一个大的软件工程里面,可能会有多个文件同时包含一个头文件,当这些文件编译链接成一个可执行文件上时,就会出现大量“重定义”错误。在头文件中使用条件编译即可避免该错误。
62. 隐式转换是什么,如何消除类的隐式转换?
(1) C++的基本类型中并非完全的对立,部分数据类型之间是可以进行隐式转换的。所谓隐式转换,是指不需要用户干预,编译器私下进行的类型转换行为。很多时候用户可能都不知道进行了哪些转换。最常见的隐式转换为函数传参。
(2) C++中提供了explicit关键字,在构造函数声明的时候加上explicit关键字,能够禁止隐式转换。
63. 如何在不使用额外空间的情况下,交换两个数?
1 | (1) |
64. 你知道strcpy和memcpy的区别是什么?
(1) **复制的内容不同。**strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类对象等。
(2) **复制的方法不同。**strcpy不需要指定长度,它遇到被复制字符串的结束符”\0”才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度,更加安全。
(3) **用途不同。**通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy。
65. 如果有一个空类,它会默认添加哪些函数?
(1) 默认构造函数
(2) 拷贝构造函数
(3) 析构函数
(4) 赋值运算符函数
66. static_cast比C语言中的转换强在哪里?
(1) 更加安全
(2) 更直接明显,能够一眼看出是什么类型转换为什么类型,容易找出程序中的错误
67. 成员函数里memset(this,0,sizeof(*this))会发生什么?
有时候类里面定义了很多int,char,struct等c语言里的那些类型的变量,我习惯在构造函数中将它们初始化为0,但是一句句的写太麻烦,所以直接就memset(this, 0, sizeof *this);将整个对象的内存全部置为0。
对于这种情形可以很好的工作,但是下面几种情形是不可以这么使用的;
(1) 类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;
(2) 类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么一做就破坏了list对象的内存。
68. 你知道回调函数吗?它的作用?
(1) 回调函数就是一个通过函数指针调用的函数。回调的函数的定义由程序员实现,但无需程序员调用,可将函数指针作为参数传递给某个函数库中的函数,由函数库去调用。
(2) 回调函数是在**“你想让别人的代码执行你的代码,而别人的代码你又不能动”**这种需求下产生的。
(3) 可以做回调函数的函数有两种,一种是普通函数,一种是静态成员函数。普通成员函数不能做回调函数,因为普通成员函数自带this指针参数,会导致函数声明与调用不匹配的情况发生。
(4) 回调函数是一种设计系统的思想,能够解决系统架构中的部分问题,但是系统中不能过多使用回调函数,因为回调函数会改变整个系统的运行轨迹和执行顺序,耗费资源,而且会使得代码晦涩难懂。
回调函数的本质确实是函数指针。通过函数指针,我们可以将函数作为参数传递给另一个函数,这就是所谓的“回调”。
1 |
|
补充:函数指针的使用例子
1 |
|
69. C++从代码到可执行程序经历了什么?
(1) 预编译
主要处理源代码文件中的以“#”开头的预编译指令。
(2) 编译
把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。
(3) 汇编
将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来。
(4) 链接
将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链接。
70. 类的对象存储空间?
(1) 非静态成员的数据类型大小之和。
(2) 编译器加入的额外成员变量(如指向虚函数表的指针)。
(3) 为了内存对齐而补入的额外空间。
(4) 空类大小为1,但若是作为基类,则大小为0。
1 |
|
71. 静态链接和动态链接的区别?
静态链接和动态链接是针对函数库来说的,现在的函数库分为两种:静态库和动态库。他们分别对应静态链接和动态链接。
(1) **静态链接:**所有的函数和数据都被编译进一个文件中。在使用静态库的情况下,在编译链接可执行文件时,链接器从函数库中复制函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。静态链接有以下特点:
① 空间浪费:因为每个可执行程序中都拥有函数库数据的一份副本,所以如果多个程序对同一个目标文件都有依赖,当他们同时运行在计算机上,会出现同一个函数库在内存中有多个副本,浪费内存空间;
② 更新困难:每当库函数的代码修改了,所有依赖该库的程序都需要重新进行编译链接。
③ 运行速度快:因为在可执行程序中已经具备了所有执行程序所需要的任何东西,所以在执行的时候运行速度快。
(2) **动态链接:**动态链接会在程序运行时才将函数库与源程序执行链接,而不是像静态链接一样把所有模块都链接成一个单独的可执行文件。动态链接有以下特点:
① 共享库:如果多个程序都依赖同一个库,该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本;
② 更新方便:更新时只需要替换原来的库文件,依赖它的程序无需重新编译。当程序下一次运行时,新库会被自动加载到内存并且与其他程序执行链接,程序就完成了升级迭代。
性能损耗:因为把链接推迟到了程序运行时,每次执行程序都需要进行链接,所以性能会有一定损失。
72. 为什么不能把所有的函数写成内联函数?
(1) 首先,不管是什么函数声明为内联函数,在语法上没有错误。因为inline同register一样,只是个建议,编译器并不一定真正的内联。
(2) 内联函数以代码复杂为代价,省去了函数调用的开销来提高执行效率。如果内联函数体内代码执行时间相比起函数调用开销更大,则没有太大的意义。一般来说若是函数体代码比较长或者内部带有循环,则不推荐使用内联函数。
(3) **将构造函数和析构函数声明为inline是没有什么意义的,**即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。
(4) **将虚函数声明为inline,要分情况讨论。**当指向派生类的指针(多态性)调用声明为inline的虚函数时,由于inline是编译期决定的,而虚函数是运行期决定的,在不知道将要调用哪个函数的情况下,编译器不会内联展开;当对象本身调用虚函数时,编译器能决议出会调用哪个函数时,就会内联展开,当然前提依然是函数并不复杂的情况下。
73. 为什么C++没有垃圾回收机制?
(1) 实现一个垃圾回收器会带来额外的空间和时间开销。
(2) 垃圾回收会使得C++不适合进行很多底层的操作。
74. 说说C++的内存分区?
在C++中,内存分成5个区,他们分别是堆、栈、全局/静态存储区和常量存储区和代码区。
(1) **栈:**在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈工作效率很高,但是分配的内存容量有限。
(2) **堆:**就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
(3) **全局/静态存储区:**这块内存在编译时已经分配好,且在程序运行期间都存在。它主要存放静态数据(局部static变量,全局static变量)、全局变量。
(4) **常量存储区:**这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。
(5) **代码区:**存放程序的二进制代码。
75. 说说友元函数和友元类的特性?
友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。通过友元,外部的普通函数或者另一个类中的成员函数可以访问本类中的私有成员和保护成员。友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。友元具有以下特性:
(1) 友元关系不能被继承。
(2) **友元关系是单向的,不具有交换性。**若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
**友元关系不具有传递性。**若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的声明。
76. 关于this指针你知道什么?
(1) this指针是类的指针,指向对象的首地址。
(2) this指针只能在普通成员函数中使用,在全局函数、静态成员函数中都不能用this。
(3) this在成员函数的开始执行前构造,在成员的执行结束后清除。
(4) this指针只有在普通成员函数中才有定义,且存储位置会因编译器不同有不同存储位置。
(5) this指针主要可用于返回类对象本身的时候,直接使用 return *this。或者当形参数与成员变量名相同时用于区分,如this->n = n。
77. 在成员函数中调用delete this会出现什么问题?
当调用delete this时,类对象的内存空间被释放。因为类成员函数并没有存放在类对象的内存空间中,所以在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,因为内存被释放,所以就会出现不可预期的问题。
78. 如果在类的析构函数中调用delete this,会发生什么?
会导致堆栈溢出。因为delete会调用析构函数,导致递归。
79. C++中类的数据成员和成员函数内存分布情况?
(1) 普通数据成员存放在对象内存空间中
(2) 静态数据成员存放在全局/静态存储区中
(3) 普通成员函数和静态成员函数都存放在代码区中
80. C++的多态怎么实现?
C++的多态机制有两种,分为编译时多态和运行时多态,下面分别介绍这两种多态。
编译时多态
(1) 主要通过模板和函数重载实现,在编译期发生,由编译器进行推断决议。
(2) 优点:
① 它带来了泛型编程的概念,使得C++拥有泛型编程与STL这样的强大武器。
② 在编译器完成多态,提高运行期效率。
③ 具有很强的适配性与松耦合性,对于特殊类型可由模板偏特化、全特化来处理。
(3) 缺点:
① 程序可读性降低,代码调试带来困难。
② 无法实现模板的分离编译,当工程很大时,编译时间不可小觑。
运行时多态
(1) 运行期多态的设计思想要归结到类继承体系的设计上去。对于有相关功能的对象集合,我们总希望能够抽象出它们共有的功能集合,在基类中将这些功能声明为虚接口(虚函数),然后由子类继承基类去重写这些虚接口,以实现子类特有的具体功能。
(2) 在C++中,主要由虚表和虚表指针实现运行时多态。运行时多态实现细节如下:
① 编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址。
② 编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数。
③ 在派生类定义对象时,会先调用父类的构造函数,此时,编译器只“看到了父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表。
④ 当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面。
(3) 优点:
面向对象设计中重要的特性,对客观世界直觉认识。
(4) 缺点:
① 运行期间进行虚函数绑定,提高了程序运行开销。
② 庞大的类继承层次,对接口的修改易影响类继承层次。
③ 由于虚函数在运行期在确定,所以编译器无法对虚函数进行优化。
④ 虚表指针增大了对象体积,类也多了一张虚函数表。
81. 为什么要把析构函数写成虚函数?
由于类的多态性,基类指针可以指向派生类的对象。如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。
82. 虚函数表存放在内存的什么区域?
C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;
虚函数则位于代码段(.text),也就是C++内存模型中的代码区。
83. 模板偏特化了解吗?
(1) 通过编写模板,能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化(模板偏特化)。
(2) 所谓的模板偏特化对单一模板提供的一个特殊实例,它将一个或多个模板参数绑定到特定的类型或值上。
(3) 特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。
(4) 可以特例化类中的部分成员函数而不是整个类。
举例:
模板偏特化是C++模板编程中的一个重要概念。简单来说,模板偏特化就是对模板进行部分特化,也就是将模板的部分参数具体化。模板偏特化可以让我们为特定的类型或条件提供特化的实现。
举例来说,假设我们有一个模板类用于存储数组:
1 | template <typename T, int N> |
现在,我们想要对bool
类型的数组进行特化,因为bool
类型的数组可以通过位操作进行压缩存储,从而节省空间。我们可以通过模板偏特化来实现:
1 | template <int N> |
在这个例子中,Array<bool, N>
是Array<T, N>
的一个偏特化版本。当我们创建一个Array<bool, 100>
对象时,编译器会使用偏特化版本的模板,从而实现对bool
类型数组的优化存储。
结论:
- 类模板:可以进行全特化和偏特化。
- 类模板的成员函数:可以进行全特化和偏特化。
- 普通函数模板:只能进行全特化,不能进行偏特化。
例子1:
类模板的全特化
类模板的全特化是指为所有模板参数提供一个专门的实现。下面是一个简单的示例,展示了如何使用类模板的全特化。
1 |
|
类模板的偏特化
类模板的偏特化是指为部分模板参数提供一个专门的实现。下面是一个简单的示例,展示了如何使用类模板的偏特化。
1 |
|
84. 哪些函数不能被定义为虚函数?
(1) **构造函数。**每一个声明了虚函数的类对象都有一个指向虚表(vtable)的指针,但是这个指向vtable的指针事实上是存储在对象的内存空间的,假设构造函数是虚的,就须要通过 vtable来调用,但是对象还没有实例化,也就是内存空间还没有,怎么找vtable呢?再者,虚函数主要是在调用对象不确定的情况下使用的,然而构造函数本身就是要初始化实例,那使用虚函数也没有实际意义。
(2) **静态函数。**静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
(3) **友元函数。**友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
(4) **普通函数。**普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。
85. 构造函数和析构函数可以调用虚函数吗,为什么?
(1) 构造函数调用虚函数没有意义,因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编。
(2) **析构函数调用虚函数没有意义,**析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。
86. 构造函数和析构函数可否抛出异常?
(1) **构造函数不可抛出异常:**C++只会析构已经完成的对象,对象只有在其构造函数执行完毕才算是完全构造妥当。在构造函数中发生异常,控制权转出构造函数之外。因此,如果某个对象的构造函数中发生异常,则该对象的析构函数不会被调用。因此会造成内存泄漏。
(2) **析构函数不可抛出异常:**如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。再者,通常异常发生时,C++ 的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。
87. 模板类和模板函数的区别是什么?
函数模板的实例化是由编译器在处理函数调用时自动完成的,而类模板的实例化必须由程序员在代码中显式地指定。即函数模板允许隐式调用和显式调用,而类模板只能显式调用。
88. 什么是虚继承?
(1) 由于C++支持多继承,因此除了public、protected和private三种继承方式外,还支持虚拟(virtual)继承。
(2) 多继承有可能引发一直特别情况:B和C公有继承A,D又公有继承B和C,这种方式是一种菱形继承或者钻石继承,如下图所示。
(3) 如果D调用了A的方法,则会引发数据的二义性和冗余,编译器不知道是要调用B所继承的A,还是C所继承的A。
(4) 为了解决这个问题,C++引入了虚拟继承,在虚拟继承的情况下,无论基类被继承多少次,只会存在一个实体。
89. 抽象基类为什么不能创建对象?纯虚函数又是什么?
(1) 带有纯虚函数的类为抽象类。抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的。抽象类会将所有派生类中有关的操作抽象成一个通用接口且不做具体实现(纯虚函数),具体实现由派生类实现,因此抽象基类不可以创建对象。
(2) 纯虚函数是一种特殊的虚函数,该类函数没有函数体。因为在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。
(3) 抽象类可以有构造函数,子类继承时构造函数调用。
1 |
|
90. 说说RTTI?
运行时类型识别(Run-time type identification , RTTI),是指在只有一个指向基类的指针或引用时,确定所指对象的准确类型的操作。其常被说成是C++的四大扩展之一(其他三个为异常、模板和名字空间)。使用RTTI有两种方法:
1、typeid()
第一种就像sizeof(),它看上像一个函数,但实际上它是由编译器实现的。typeid()带有一个参数,它可以是一个对象引用或指针,返回全局typeinfo类的常量对象的一个引用。可以用运算符“= =”和“!=”来互相比较这些对象,也可以用name()来获得类型的名称。同时,我们也可以用typeid 检查基本类型和非多态类型。如果想知道一个指针所指对象的精确类型,我们必须逆向引用这个指针。比如:
2、dynamic_cast (expression)
该运算符为强制类型转换符,上文第49条有提及
91. 多继承的优缺点,作为一个开发者怎么看待多继承?
多重继承的优点很明显,就是一个对象可以调用多个基类中的接口。多继承容易导致菱形继承问题,虽然可以用虚继承解决该问题,但也会造成内存结构复杂,效率降低,继承体系过于复杂化的缺点。
92. 为什么拷贝构造函数必须传引用不能传值?
拷贝构造函数的作用就是用来拷贝对象的,使用一个已存在的对象来初始化一个新的对象。如果使用传值方式,那么在拷贝构造函数被调用时,在进行参数传递的时候就会调用拷贝构造函数,这样会导致递归溢出。
93. 为什么在C/C++中要将代码分为头文件和源文件,不能写到一起吗?
(1) 将所有代码写入一个文件中当然可以,同样可以通过编译且正常运行。
(2) **分开写的目的是方便未来。**有时候我们写的代码会给别人去用,如果是非开源代码,可以将源文件封装起来并生成库文件(库文件是二进制文件,无法查阅代码)。只对外开放头文件和库文件,那么别人就无法看到代码的具体实现了。
94.数组指针和指针数组的区别
数组指针和指针数组的区别 - jack_Meng - 博客园 (cnblogs.com)
数组指针(行指针):数组指针只是一个指针变量,C语言里专门用来指向二维数组的,它占有内存中一个指针的存储空间。
指针数组:指针数组是一个数组,多个指针变量,以数组形式存在内存当中,占有多个指针的存储空间。
数组指针:定义 int ( p)[n];*
*指针数组:定义 int p[n];
优先级为 **() > [] > ***
比如int (* p)[n],因为()的优先级最高,p先于*结合,所以p是一个指针,后面是数组,于是这个就是数组指针。
在如int * p[n],[]优先级比* 高,p先与[]结合,所以p是一个数组,前边又有*,所以是指针数组。
数组指针:首先说明p是一个指针,指向一个整型的一维数组,这个一维数组的长度是n,也可以说是p的步长。也就是说执行p+1时,p要跨过n个整型数据的长度。
指针数组:[]先与p结合成为一个数组,再由int * 说明这是一个整型指针数组,它有n个指针类型的数组元素。这样赋值是错误的:p=a;因为p是个不可知的表示,只存在p[0]、p[1]、p[2]…p[n-1],而且它们分别是指针变量可以用来存放变量地址。可以这样 p=a; 这里p表示指针数组第一个元素的值,a的首地址的值。
p1为指针数组,p2为数组指针。
看例子更加的通透
数组指针
1 |
|
指针数组
1 |
|
95.shared_ptr在多线程下的安全性问题
【因为指针的指向和引用计数不能保证原子性,所以不安全】
具体看这个案例:https://blog.csdn.net/www_dong/article/details/114418454
96.结构体可以直接赋值吗
两个同类型结构体变量可以直接赋值,不同类型结构体不能直接赋值。
1 |
|
97.一个参数既可以是const还可以是volatile吗
可以
98.为什么 const 变量必须初始化?
- 语义约束:
const
关键字的语义是“常量”,即其值是固定的,不会改变。如果在声明时不初始化,那么该变量将没有确定的值,这与const
的语义相矛盾。 - 编译器优化:编译器会对
const
变量进行优化,将其值直接嵌入到代码中,而不是从内存中读取。如果const
变量没有初始化,编译器无法进行这种优化。 - 防止未定义行为:如果
const
变量没有初始化,它将包含一个未定义的值(可能是随机值),这可能导致程序出现未定义行为。
99.句柄(Handle)和指针(Pointer)
在编程中用于引用和操作对象或资源,但它们在实现和用途上有显著的区别。以下是一些具体的例子来说明它们的区别:
指针示例
指针是一个变量,其值为另一个变量的地址。指针直接存储内存地址,可以用来直接访问和操作内存中的数据。
C语言中的指针示例:
1 |
|
在这个例子中,ptr
是一个指向 x
的指针。通过 ptr
,我们可以直接访问和修改 x
的值。
句柄示例
句柄是一个抽象的引用,通常是一个整数或一个不透明的数据结构,用于标识和访问操作系统或应用程序中的资源。句柄并不直接指向资源的内存地址,而是通过操作系统提供的API来间接访问资源。
Windows API中的句柄示例:
1 |
|
在这个例子中,fileHandle
是一个文件句柄。我们通过 CreateFile
函数获取文件句柄,然后通过 ReadFile
函数使用这个句柄读取文件内容。句柄并不直接指向文件的内存地址,而是通过操作系统的API来间接访问文件。
总结
- 指针:直接存储内存地址,可以用来直接访问和操作内存中的数据。
- 句柄:是一个抽象的引用,通过操作系统提供的API来间接访问资源。
指针提供了直接的内存访问,而句柄提供了间接的资源访问和一定程度的保护。指针在C和C++等语言中非常常见,用于动态内存管理、数据结构(如链表、树)的实现等。句柄通常用于保护系统资源,防止用户程序直接修改或破坏资源。
100.引用限定符
引用限定符可以应用于成员函数,以指定该函数只能被左值对象或右值对象调用。
引用限定符有两种形式:
&
:表示该成员函数可以被左/右值对象调用。&&
:表示该成员函数只能被右值对象调用。
1 |
|
101.强类型枚举
enum常量存储在内存数据段的静态存储区
在C++11中,引入了强类型枚举(strongly-typed enumerations),也称为枚举类(enumeration classes),使用关键字 enum class
定义。强类型枚举提供了比传统枚举(enum
)更强的类型安全性和作用域控制。
基本语法:
1 | enum class EnumName { |
优势:
-
强类型: 强类型枚举不会隐式地转换为整数类型,这可以防止类型错误和意外的类型转换。
1
2
3enum class Color { Red, Green, Blue };
Color color = Color::Red;
// int num = color; // 错误:不能隐式转换为整数 -
作用域控制: 强类型枚举的值被限制在枚举的作用域内,这解决了传统枚举可能出现的命名冲突问题。
1
2
3enum class Color { Red, Green, Blue };
enum class TrafficLight { Red, Yellow, Green };
// Color和TrafficLight中的Red不会冲突 -
指定底层类型: 在定义强类型枚举时,可以显式指定底层存储类型,这有助于控制枚举的大小和性能。
1
2
3
4
5enum class StatusCode : uint32_t {
Ok = 0,
Error = 1,
// ...
};
示例代码:
1 |
|
总结:
强类型枚举是C++11中的一个重要特性,它提供了比传统枚举更好的类型安全性和作用域控制。通过使用强类型枚举,可以避免命名冲突和类型错误,使代码更加可靠和易于维护。在需要枚举类型的场景中,强烈推荐使用强类型枚举。
102.C++中的锁
-
std::lock_guard(C++11)
-
std::unique_lock(C++11)
-
std::share_lock(C++14)
-
std::scoped_lock(C++17)
std::lock_guard
(C++11)
std::lock_guard
是一个简单的互斥锁管理类,它实现了RAII(Resource Acquisition Is Initialization)机制,确保在构造时锁定互斥锁,在析构时解锁互斥锁。这样可以避免忘记解锁导致的死锁问题。
特点:
- 构造时锁定互斥锁,析构时解锁互斥锁。
- 不支持手动解锁或延迟锁定。
- 适用于简单的锁定需求,不需要额外控制权的场景。
示例:
1 |
|
2. std::unique_lock
(C++11)
std::unique_lock
是一个更灵活的互斥锁管理类,它提供了比 std::lock_guard
更多的功能,例如可以延迟锁定、尝试锁定、手动解锁等。
特点:
- 支持延迟锁定(
std::defer_lock
)、尝试锁定(std::try_to_lock
)、手动锁定和解锁。 - 可以在构造时指定锁定策略。
- 适用于需要更多控制权的场景,例如条件变量配合使用。
示例:
1 |
|
std::shared_lock
(C++14)
std::shared_lock
用于管理共享互斥锁(例如 std::shared_mutex
),它允许多个线程同时以共享模式访问资源,但阻止任何线程以独占模式访问资源。
特点:
- 支持共享锁定和独占锁定。
- 适用于读写分离的场景,允许多个读操作同时进行,但写操作独占。
1 |
|
4. std::scoped_lock
(C++17)
std::scoped_lock
是一个多互斥锁管理类,它可以同时锁定多个互斥锁,避免死锁问题。它类似于 std::lock_guard
,但可以处理多个互斥锁。
特点:
- 支持同时锁定多个互斥锁。
- 使用
std::lock
算法来避免死锁。 - 适用于需要同时锁定多个互斥锁的场景。
std::scoped_lock
是 C++17 引入的一个多互斥锁管理类,它主要用于同时锁定多个互斥锁,避免死锁问题。std::scoped_lock
使用 std::lock
算法来确保锁定的顺序,从而避免死锁。
用途
在多线程编程中,当需要同时锁定多个互斥锁时,很容易出现死锁问题。死锁通常发生在两个或多个线程各自持有一些锁并尝试获取对方持有的锁时,导致所有线程都无法继续执行。std::scoped_lock
通过确保所有互斥锁以一种不会导致死锁的顺序被锁定,从而解决了这个问题。
示例
假设有两个互斥锁 mtx1
和 mtx2
,两个线程分别需要同时锁定这两个互斥锁。如果没有适当的锁定顺序,可能会导致死锁。
不使用 std::scoped_lock
的示例:
1 |
|
在这个示例中,如果 thread1
和 thread2
同时运行,并且 thread1
锁定了 mtx1
而 thread2
锁定了 mtx2
,它们都会尝试获取对方持有的锁,从而导致死锁。
使用 std::scoped_lock
的示例:
1 |
|
在这个示例中,std::scoped_lock
确保 mtx1
和 mtx2
以一种不会导致死锁的顺序被锁定。无论 thread1
和 thread2
以何种顺序执行,都不会发生死锁。
总结
std::scoped_lock
的主要用途是同时锁定多个互斥锁,避免死锁问题。它通过使用 std::lock
算法来确保锁定的顺序,从而简化了多互斥锁管理的复杂性。在需要同时锁定多个互斥锁的场景中,使用 std::scoped_lock
可以提高代码的安全性和可读性。
103.自由存储区是否等价于堆?
堆是操作系统维护的一块内存,而自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。堆与自由存储区并不等价。
104.c++的this指针存在什么寄存器里
可以通过调试器(如 GDB)在调试时查看 this
指针被放置在哪个寄存器中。通过在函数调用时设置断点并检查寄存器内容,可以确认 this
指针的确切存储位置。
1 | class MyClass { |
使用 GDB 调试时,设置断点并运行:
1 | bashCopy codegdb ./my_program |
当程序停在 myFunction
处时,查看寄存器内容:
1 | info registers |
此时,this
指针通常会存储在 ecx
(x86)或 rdi
(x86-64)寄存器中。
105.Cpp性能优化唠唠
《C++性能优化指南》
106.相互引用与循环依赖问题
头文件
1 | // A.cpp |
这是由于预处理阶段,A.h与B.h,相互嵌套,导致头文件展开无限循环。
一跑下,就变成这样了
1 | In file included from B.h:1, |
为了避免同一个头文件被包含(include)多次,C/C++中有两种宏实现方式:一种是#ifndef方式;另一种是#pragma once方式。
- 解决方法1:#ifndef
main.cpp
1 |
|
A.h
1 |
B.h
1 |
- 解决方法2:#pragma once
A.h
1 |
B.h
1 |
类循环依赖问题
1 | // A.cpp |
上述程序出现下面错误
1 | A.h:2:7: error: redefinition of ‘class A’ |
错误原因:
在A.h:2,处理语句#include “B.h”,进行头文件展开
在B.h:5进行的是在class B中声明一个A类型的成员变量,而此时class A还没有被声明
因此编译报错:‘A’ does not name a type
a.使用前向声明
b.重新设计程序结构,避免循环依赖
前向声明
main.cpp
1 |
|
A.h
1 |
|
B.h
1 |
|
注意:
-
前向声明:仅告诉编译器类存在,但不提供类的详细信息。
-
指针或引用:前向声明足以让编译器处理指针或引用,因为指针和引用的大小是固定的,编译器不需要知道类的内部细节。
如果使用下面这种写法就要问题!
1
2
3
4
5
6class A;
class B
{
public:
A a;
};
107.程序中如何判断计算机是32位还是64位
使用预定义宏
在C/C++中,可以使用预定义宏来判断目标平台是32位还是64位。这些宏通常由编译器定义,可以在编译时进行条件编译。
1 |
|
检查指针大小
在C/C++中,可以通过检查指针的大小来判断系统是32位还是64位。32位系统的指针大小通常是4字节,而64位系统的指针大小通常是8字节。
1 |
|
使用系统调用
在Unix/Linux系统上,可以使用系统调用来获取系统信息。
1 |
|
108.str1+str2和str1.append(str2)的区别
- 返回值
str1 + str2
:返回一个新的std::string
对象,包含str1
和str2
连接后的结果。原字符串str1
和str2
不会被修改。str1.append(str2)
:直接修改str1
,将str2
的内容追加到str1
的末尾,并返回修改后的str1
。原字符串str1
会被修改。
- 性能
str1 + str2
:由于会创建一个新的字符串对象,可能会涉及内存分配和拷贝操作,因此在性能上可能不如str1.append(str2)
。str1.append(str2)
:直接在原字符串上进行操作,避免了创建新对象的开销,因此在性能上通常更优。
109.C++ static保存哪个区
-
一般来说,
static
变量会被保存到静态存储区(也称为数据段)。这个区域的具体位置可以进一步细分为以下几个部分:1. 全局作用域的
static
变量- 存储位置:数据段(Data Segment)
- 解释
static
变量在全局作用域(即在函数体外定义的static
变量)会被存储在数据段中。- 数据段通常分为已初始化数据段和未初始化数据段(又称为.bss段)。
- 已初始化的
static
变量会被存储在已初始化数据段(也称为.data
段)。 - 未初始化的
static
变量(或者初始化为零的静态变量)会被存储在未初始化数据段(.bss
段)。
- 已初始化的
2. 函数内部的
static
局部变量- 存储位置:数据段(Data Segment)
- 解释
- 在一个函数内部定义的
static
局部变量,也会被存储在数据段中。 - 这种
static
变量的特殊之处在于,它们的生命周期是整个程序的运行期间,即使是在函数多次调用之间,static
局部变量也会保持其值不变。 - 和全局作用域的
static
变量一样,初始化的static
局部变量存储在已初始化数据段,而未初始化的static
局部变量存储在.bss
段。
- 在一个函数内部定义的
3. 类成员的
static
变量- 存储位置:数据段(Data Segment)
- 解释
- 类的
static
成员变量是与类整体相关的,而不是与类的某个对象实例相关,因此它们只存在一份拷贝。 - 这些变量也存储在数据段中,并且可以在类的外部进行初始化(通常在类的实现文件
.cpp
中)。
- 类的
4.
static
常量- 存储位置:如果是编译期常量,可能会被优化到只读段(
.rodata
段)。 - 解释
- 如果
static
常量是编译期常量(如static const int kValue = 42;
),编译器可能会将它们放到只读数据段(.rodata
段),这也是数据段的一部分,但是是只读的。
- 如果
总结
static
变量,无论是全局的、局部的,还是类的成员变量,通常都被保存在数据段(即静态存储区)中。- 数据段在程序的整个生命周期内存在,并且在程序加载时由操作系统初始化。
- 细分
- 已初始化的
static
变量存储在已初始化数据段(.data
段)。 - 未初始化的
static
变量存储在未初始化数据段(.bss
段)。 - 静态常量可能存储在只读数据段(
.rodata
段)。
- 已初始化的
110.C++ const保存哪个区
- 编译期常量的
const
:- 存储位置:只读数据段(
.rodata
段) - 说明:如果
const
变量是编译期常量(如const int kValue = 42;
),它通常会被存储在只读数据段,或者在某些情况下直接被编译器内联。
- 存储位置:只读数据段(
- 非编译期常量的
const
:- 存储位置:数据段(
.data
段) - 说明:如果
const
变量的值不能在编译时确定,它会被存储在数据段(通常是已初始化数据段)。
- 存储位置:数据段(
- 局部作用域的
const
:- 存储位置:栈(如果是编译期常量,可能被优化到
.rodata
段) - 说明:在函数内部定义的
const
变量通常保存在栈上,但如果是编译期常量,可能被优化到只读数据段。
- 存储位置:栈(如果是编译期常量,可能被优化到
- 字符串字面值:
- 存储位置:只读数据段(
.rodata
段) - 说明:
const char*
指向的字符串字面值通常保存在只读数据段。
- 存储位置:只读数据段(
重点概括:
- 编译期常量:
.rodata
段(只读数据段) - 非编译期常量:
.data
段(数据段) - 局部
const
:栈(或.rodata
段) - 字符串字面值:
.rodata
段
111.父类构造函数设置为private后,会发生什么
- 子类无法实例化:
- 由于父类的构造函数是
private
的,子类无法访问它,因此子类无法实例化。 - 如果你尝试实例化子类对象,编译器会报错,提示无法访问父类的私有构造函数。
- 由于父类的构造函数是
- 无法继承:
- 如果父类的构造函数是
private
的,子类无法继承父类。 - 编译器会报错,提示无法访问父类的私有构造函数。
- 如果父类的构造函数是
1 | error: 'Base::Base()' is private within this context |
112.哪些类不能被继承
1. 使用 final
关键字
C++11 引入了 final
关键字,可以直接将一个类标记为不可继承的。
1 | class FinalClass final { |
2. 私有构造函数和析构函数
通过将类的构造函数和析构函数设置为 private
,可以阻止类的实例化和继承。
1 | class NonInheritable { |
3. 友元类
通过将类的构造函数和析构函数设置为 private
,并提供一个友元类来实例化该类,可以阻止类的继承。
1 | class NonInheritable { |
4. 使用 delete
关键字
C++11 引入了 delete
关键字,可以显式删除类的构造函数和析构函数,从而阻止类的实例化和继承。
1 | class NonInheritable { |
总结
通过使用 final
关键字、私有构造函数和析构函数、友元类以及 delete
关键字,可以创建不能被继承的类。这些技术在某些情况下非常有用,例如当你希望确保某个类的实现不被扩展或修改时
113.模板如何解析参数包
逗号表达式展开参数包
1 |
|
114.虚函数(virtual)可以是内联函数(inline)吗?
虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
inline virtual
唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()
),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。
1 |
|
- 虚函数可以被声明为内联函数,但它只有在编译器能够确定对象的类型时才能被内联。
- 当虚函数通过指针或引用调用时,表现出多态性,编译器无法确定具体调用哪个类的函数,因此无法内联。
- 当虚函数通过对象直接调用或按值传递时,编译器能够确定对象的类型,可以进行内联优化。
115.虚函数和默认参数
默认参数是静态绑定的,而虚函数是动态绑定的。
默认参数的使用需要看指针或引用本身的类型,而不是对象的类型。
1 | /** |
输出
1 | Derived::fun(), x = 10 |
116.静态函数可以声明为虚函数吗?
静态函数不可以声明为虚函数,同时也不能被const 和 volatile关键字修饰
static成员函数不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义
虚函数依靠vptr指针来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,静态成员函数没有this指针,所以无法访问vptr。
117.空指针可以访问成员函数?
空指针可以访问成员函数,但是不能使用成员变量。
如果使用成员变量会报错,因为是在用nullptr->m.
解决方法
在成员函数中加入if (this == nullptr) {return;}
如此可以保证代码的健壮性。
原理
在 C++ 中,成员函数的调用实际上是通过一个隐式传递的 this
指针完成的。this
指针指向当前调用成员函数的对象实例。每当你调用一个成员函数时,编译器会将对象的地址(即 this
指针)传递给函数。
1 |
|
118.常对象的理解?
声明对象前加const称该对象为常对象
常对象只能调用常函数
1 |
|
119.构造函数体内初始化与列表初始化的区别?
效率
- 列表初始化(构造函数初始化列表)是在对象创建时,直接使用提供的值进行成员变量的构造。对于类类型的成员变量,这意味着它们只会被构造一次。
- 构造函数体内初始化则是在对象创建后,成员变量会先使用默认构造函数进行初始化,然后再在构造函数体内进行赋值操作。这意味着类类型的成员变量会被构造两次(先默认构造,再赋值)。
120.istringstream 流的底层?
先来看看怎么用
1 |
|
简易实现
1 |
|
121.动态绑定汇编角度理解
动态绑定的例子
1 |
|
在汇编层面,调用虚函数的过程可以描述为以下几个步骤:
- 通过对象的虚指针(
vptr
)找到虚函数表(vtable
)。 - 从虚函数表中读取相应虚函数的地址。
- 调用虚函数的地址。
当调用 b->foo()
时,汇编代码的伪代码可能如下
1 | mov eax, [b] ; eax = b,加载指向对象的指针 |
详细汇编解释
mov eax, [b]
:将指针b
的值放入寄存器eax
。b
是指向Derived
对象的指针。mov eax, [eax]
:根据eax
的值(即b
的值),取出Derived
对象的虚指针(vptr)。记住,vptr
存储在对象的内存布局中,通常是对象的第一个成员。mov edx, [eax]
:eax
当前指向vtable
(虚函数表),从虚函数表的第一个条目中取出foo()
的地址,并将它存入寄存器edx
。call edx
:调用edx
中存储的地址,实际上就是Derived::foo()
的函数地址。
类似地,b->bar()
的调用过程也是通过虚表查找 bar()
的地址来实现的,伪汇编代码如下:
1 | mov eax, [b] ; eax = b,加载指向对象的指针 |
在这个例子中,[eax + 4]
表示从虚函数表中读取第二个虚函数的地址(因为每个虚函数的地址通常占用 4 或 8 个字节,具体取决于系统架构)。
静态态绑定的例子
1 |
|
在静态绑定的情况下,函数调用是直接的,即编译器在编译时已经知道要调用哪个函数,并直接生成跳转指令到该函数的地址。没有虚指针和虚函数表的间接查找。
1 | b->foo(); // 静态绑定,调用 Base::foo |
静态绑定的汇编代码可能类似如下:
1 | mov eax, [b] ; eax = b,加载指向对象的指针 |
详细汇编解释:
mov eax, [b]
:将指针b
的地址加载到寄存器eax
中。b
是指向Base
对象的指针。call Base::foo
:直接调用Base::foo
的函数地址。由于这是静态绑定,编译器在编译时已经确定了函数的具体地址,因此可以直接使用call
指令调用该地址。
122.吸氧一下
-o1 -o2 -o3的区别
在 C++ 编译器中,-O1
、-O2
和 -O3
是用于优化代码的编译选项。这些选项控制编译器在编译过程中应用的优化级别。优化级别越高,编译器会执行更多的优化,但编译时间也会相应增加。下面是这些优化选项的详细解释:
-O1
(一级优化)
-O1
是最基本的优化级别,编译器会执行一些简单的优化,以减少代码的大小和提高执行速度。这些优化通常包括:
- 常量折叠:将常量表达式在编译时计算出来,而不是在运行时计算。
- 死代码消除:删除不会被执行的代码。
- 内联简单函数:将一些简单的函数内联到调用处,减少函数调用的开销。
- 基本块重排:重新排列基本块(basic blocks)以提高指令缓存的利用率。
-O2
(二级优化)
-O2
是比 -O1
更高一级的优化级别,编译器会执行更多的优化,以进一步提高代码的性能。这些优化通常包括:
- 所有
-O1
的优化:包括常量折叠、死代码消除、内联简单函数等。 - 循环优化:对循环进行优化,如循环展开、循环不变量外提等。
- 函数内联:更多地内联函数,减少函数调用的开销。
- 寄存器分配:优化寄存器的使用,减少内存访问的开销。
- 指令调度:重新排列指令以提高处理器的并行执行能力。
-O3
(三级优化)
-O3
是最高级别的优化,编译器会执行所有 -O2
的优化,并进一步应用更多的优化。这些优化通常包括:
- 所有
-O2
的优化:包括常量折叠、死代码消除、循环优化、函数内联等。 - 更激进的内联:更多地内联函数,包括一些较大的函数。
- 自动向量化:尝试将循环代码自动转换为向量化代码,以利用 SIMD 指令集(如 SSE、AVX)提高性能。
- 更激进的循环优化:进一步优化循环,如循环展开、循环不变量外提等。
- 更激进的指令调度:进一步优化指令的排列,以提高处理器的并行执行能力。
注意事项
- 编译时间:优化级别越高,编译时间越长。
- 代码大小:优化级别越高,生成的可执行文件可能越大。
- 性能提升:优化级别越高,程序的执行速度可能越快,但并不是所有情况下都能显著提升性能。
- 可读性:优化级别越高,生成的代码可能越难以阅读和调试。
123.sizeof和string的len的区别
sizeof(s)
返回的是std::string
对象本身在内存中占用的字节数,通常是8个字节(在64位系统上)。s.size()
返回的是字符串中字符的数量,即字符串的长度。
1 |
|
输出
1 | 8 3 |
C++11
1. auto、decltype的用法?
(1) auto:C++11引入了auto类型说明符,它可以让编译器通过初始值来进行类型推演,使得程序员无需知道类型名称就可以定义变量,所以auto 定义的变量必须有初始值。
(2) decltype: auto定义的变量必须初始化,如果我们不想要初始化就可以使用decltype,它会返回参数的数据类型,并定义新的变量,而且新变量的值不会被初始化。
2. C++中NULL和nullptr的区别?
(1) NULL是一个宏定义,C中NULL为(void*)0,C++中NULL为整数0。
(2) 将NULL定义为0带来的一个问题是无法与整数的0区分,因为C++中允许有函数重载,若是有个a、b两个重载函数,参数分别为整数和指针,那么在传入NULL参数时,会把NULL当做整数0来看,导致错误调用了参数为整数的函数。
(3) nullptr可以解决这一问题,nullptr可以明确区分整型和指针类型,能够根据环境自动转换成相应的指针类型,但不会被转换为任何整型,所以不会造成参数传递错误。
3. 说说final和override关键字?
(1) Override指定了子类的这个虚函数是对父类虚函数的重写,如果函数名不小心打错了的话,编译器会进行报错。
(2) 当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后如果被继承或重写,编译器会报错。
举例:
在C++中,final
和 override
是两个非常有用的关键字,它们主要用于类和成员函数的继承。
final
关键字可以用于阻止类的进一步继承,或者阻止虚函数的进一步覆盖。例如:
1 | class Base final { // 这个类不能被继承 |
override
关键字用于明确表示一个虚函数覆盖了基类中的虚函数。这有助于编译器检查我们的代码,如果基类中没有对应的虚函数,使用override
将导致编译错误。例如:
1 | class Base { |
以上就是 final
和 override
在C++中的基本用法。这两个关键字都可以帮助我们编写出更清晰、更安全的代码。
4. C++中的智能指针?
(1) 智能指针会管理程序员申请的内存,在使用结束后会自动释放,防止堆内存泄漏。
(2) auto_ptr:最原始的智能指针。auto_ptr采用的是独享所有权语义,一个非空的auto_ptr总是拥有它所指向的资源,转移一个auto_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空。由于支持拷贝语义,拷贝后源对象变得无效,如果程序员忽视了这点,这可能引发很严重的问题。在C++11中该指针已被弃用。
(3) unique_ptr:与auto_ptr类似,采用独享所有权语义。unique_ptr提供移动语义,这在很大程度上避免了auto_ptr的错误,因为很明显必须使用std::move()进行转移,提醒程序员在这个地方发生了移动。
(4) shared_ptr:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1,每当减少一个智能指针指向对象时,引用计数会减1,当计数为0的时候会自动的释放动态分配的资源。引用计数器的变化依据如下所示。
① 每次创建类的新对象时,初始化指针并将引用计数置为1
② 当对象作为另一对象的副本而创建时,拷贝构造函数会拷贝指针并增加与之相应的引用计数
③ 对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数
④ 调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除基础对象)
(5) weak_ptr: 由于shared_ptr引用计数存在的问题,即互相引用形成环(环形引用),使得两个指针指向的内存都无法释放,如下所示。
① 为了解决这个问题,C++引入了weak_ptr(弱引用),它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是说,它只引用,不计数。
② 如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有weak_ptr引用该内存,内存也会被释放。
③ weak_ptr不保证它指向的内存一定是有效的,在使用之前应先检查weak_ptr是否为空指针,避免访问非法内存,也因此weak_ptr并不能直接访问对象,他只能通过转化为shared_ptr来使用对象。
share_ptr手写
share_ptr-double free问题:double free 问题就是一块内存空间或者资源被释放两次。
double free 可能是下面这些原因造成的:
- 直接使用原始指针创建多个 shared_ptr,而没有使用 shared_ptr 的 make_shared 工厂函数,从而导致多个独立的引用计数。
- 循环引用,即两个或多个 shared_ptr 互相引用,导致引用计数永远无法降为零,从而无法释放内存。
case1:举例
在C++中,std::shared_ptr 是一个智能指针,它可以自动管理对象的生命周期。它通过引用计数来确保当没有任何 shared_ptr 指向一个对象时,该对象会被自动删除。
然而,直接使用原始指针创建多个 shared_ptr,而没有使用 shared_ptr 的 make_shared 工厂函数,可能会导致多个独立的引用计数,进而导致double-free的问题。
下面是一个例子:
1 | int* ptr = new int(10); // 创建一个原始指针指向一个新的int对象 |
在这个例子中,我们创建了两个独立的 shared_ptr,它们都指向同一个对象。当它们离开其作用域时,它们都会试图删除同一个对象,导致double-free的问题。
正确的做法是使用 make_shared 工厂函数来创建 shared_ptr,或者只使用一个原始指针来初始化一个 shared_ptr,然后使用这个 shared_ptr 来初始化其他的 shared_ptr。这样,所有的 shared_ptr 都会共享同一个引用计数。
1 | std::shared_ptr<int> sp1(new int(10)); // 使用原始指针初始化一个shared_ptr |
在上述正确的做法中,所有的 shared_ptr 都会共享同一个引用计数,因此不会出现double-free的问题。
case2:
循环引用问题
1 |
|
使用weak_ptr
1 |
|
enable_shared_from_this
从名字可以看出几个关键词:enable: 允许 shared 指 shared_ptr, from_this 则是指从类自身this 构造 shared_ptr。
想象这样一个场景:
1 | struct SomeData; |
上面这段代码需要在NeedCallSomeAPI函数中调用SomeAPI,而SomeAPI需要的是一个std::shared_ptr的实参。这个时候应该怎么做? 这样吗?
1 | struct SomeData { |
上面的做法是错误的,因为SomeAPI调用结束后std::shared_ptr对象的引用计数会降为0,导致 this 被意外释放。
这种情况下,我们需要使用std::enable_shared_from_this ,使用方法很简单,只需要让SomeData继承std::enable_shared_from_this,然后调用shared_from_this,例如:
1 |
|
总结一下,当下面👇这些场景用到 shared_ptr 时,需要搭配上 enable_shared_from_this:
- 当你需要将this指针传递给其他函数或方法,而这些函数或方法需要一个std::shared_ptr,而不是裸指针。
- 当你需要在类的成员函数内部创建指向当前对象的std::shared_ptr,例如在回调函数或事件处理中。
手敲智能指针是面试常见的题目。
1 |
|
weaker_ptr原理
1 |
|
5. 说说STL容器中的智能指针?
具备独占所有权语义的智能指针不能在STL的容器中使用,如auto_ptr和unique_ptr,因为STL容器中的元素经常要支持拷贝、赋值操作,在这过程中auto_ptr会传递所有权,容易导致错误,而unique_ptr又不支持普通的拷贝和赋值操作,也不能用在STL标准容器中。
6. 说说lambda函数?
利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象。每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了()运算符),我们称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,该实例是一个右值。lambda的优点有以下几点:
(1) 距离:很多人认为,让定义位于使用的地方附近很有用。这样,就无需翻阅很多页的源代码,以了解函数。另外,如果需要修改代码,设计的内容就在附近,就很好修改。
(2) 简洁:函数符代码要比lambda代码更加繁琐,函数和lambda的简洁程度相当。
(3) 功能:lambda可以访问作用域内的任何动态变量,可以采用取值、引用的形式进行捕获。
Lambda 函数的基本语法如下:
1 | [capture list](parameter list) -> return type { function body } |
- capture list:捕获列表,用于捕获外部变量。
- parameter list:参数列表,类似于普通函数的参数列表。
- return type:返回类型,可以省略,编译器会自动推导。
- function body:函数体,包含 Lambda 函数的具体实现。
捕获列表
捕获列表用于捕获外部变量,有以下几种方式:
[]
:不捕获任何外部变量。[x]
:按值捕获变量x
。[&x]
:按引用捕获变量x
。[=]
:按值捕获所有外部变量。[&]
:按引用捕获所有外部变量。[x, &y]
:按值捕获变量x
,按引用捕获变量y
。
7. 什么是声明时初始化?
C++11新增了类成员初始化新机制——声明时初始化,可以直接在类中声明数据成员时就进行初始化操作,而不用借助构造函数或者初始化列表。
8. C++11添加哪几种构造函数关键字?
(1) default关键字可以显式要求编译器生成默认构造函数,防止在调用时相关构造函数没有定义而报错。
(2) delete关键字可以删除构造函数、赋值运算符函数等,在使用时编译器会报错
9. 说说C++的左值和右值?
(1) 在C++11中所有的值必属于左值、右值两者之一,右值又可以细分为纯右值、将亡值。
(2) 在C++11中可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值(将亡值或纯右值)。
(3) 纯右值的概念等同于我们在C++98标准中右值的概念,指的是临时变量和字面量值;将亡值则是C++11新增的新右值,它表示该对象的内存空间接下来会被其他对象接管。通过这种接管空间的方式可以避免内存空间的释放和分配,延长变量值的生命期。
(4) C++11对于引用了也有了新的解释。传统的C++引用被称为左值引用,符号为&,他关联的是左值。C++11中增加了右值引用,符号为&&。右值引用会关联到右值,右值被存储到特定位置,右值引用会指向该特定位置,也就是说,右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置。使用std::move可以使一个左值转换为右值引用。
10. 说说移动构造函数?
(1) 移动构造是C++11标准中提供的一种新的构造方法,用来给予程序员新的构造选择,用以替换拷贝构造。
(2) 拷贝构造函数是先将传入的参数对象进行一次深拷贝,再传给新对象。这就会有一次拷贝对象的开销,拷贝的内存越大越耗费时间,并且进行了深拷贝,就需要给对象分配地址空间。
(3) 移动构造函数会直接接管源对象空间,既不会产生额外的拷贝开销,也不会给新对象分配内存空间。提高程序的执行效率,节省内存消耗。
(4) 移动构造函数的参数必须是自身类型的右值引用,也就是说能调用移动构造函数的参数必然是个右值(纯右值和将亡值)。
Q1:调用移动构造时传入一个左值会发生什么?
答案是会报错。
1 |
|
11. 什么是列表初始化?
列表初始化是C++ 11新引进的初始化方式,它采用一对花括号(即**{}**)进行初始化操作。能用直接初始化和拷贝初始化的地方都能用列表初始化,而且列表初始化能对容器进行方便的初始化,因此在新的C++标准中,推荐使用列表初始化的方式进行初始化。
12.初始化列表和列表初始化的区别?
(1) 初始化列表是在创建类对象时,对类对象内部的数据成员进行的一种初始化方式,具体用在类的构造函数中。
13.什么是完美转发?
完美转发是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另一个函数,即传入转发函数的是左值对象,目标函数就能获得左值对象,转发函数是右值对象,目标函数就能获得右值对象,而不产生额外的开销。
1.为什么要有完美转发?
在模板函数中,传递参数时可能会丢失参数的值类别。具体来说,函数模板无法区分传入的参数是左值还是右值,这会导致一些效率问题,特别是在处理右值引用时。
假设我们有一个模板函数 forwardCall
,它简单地把参数转发给另一个函数:
1 | template<typename T> |
在上面的例子中,无论传入的 arg
是左值还是右值,当我们转发 arg
时,它总是被当作左值传递给 someFunction
。这会导致效率问题,因为原本的右值可能被错误地当作左值处理,丧失了右值的优势(比如移动语义)。
2.std::forward
的作用
std::forward
就是为了解决这个问题而设计的,它通过模板的类型推导和引用折叠规则,保留了参数的原始值类别(左值或右值)。换句话说,它能够完美地转发参数,使得右值保持右值的身份,左值保持左值的身份。
1 | template<typename T> |
通过 std::forward<T>(arg)
,可以确保:
- 如果
arg
是右值,std::forward
会将其完美地转发为右值。 - 如果
arg
是左值,std::forward
会将其完美地转发为左值。
forward实现
1 | template<typename T> |
对比例子:
1 |
|
有个有意思的疑问?
forward函数typename std::remove_reference<T>::type& arg
为什么需要这么写来接受参数呢?
因为你传入进来的是一个变量,一定是左值!
一道非常经典的题目!加深对于forward的理解!
1 |
|
14.左值和右值引用
左值和右值的概念:
左值:能对表达式取地址、或具名对象/变量。一般指表达式结束后依然存在的持久对象。
右值:不能对表达式取地址,或匿名对象。一般指表达式结束就不再存在的临时对象。
右值和左值的区别:
- 左值可以寻址,而右值不可以。
- 左值可以被赋值,右值不可以被赋值,可以用来给左值赋值。
- 左值可变,右值不可变(仅对基础类型适用,用户自定义类型右值引用可以通过成员函数改变)。
而在指C++11中,右值是由两个概念构成,==将亡值==和==纯右值==。纯右值是用于识别临时变量和一些不跟对象关联的值,比如1+3产生的临时变量值,2、true等,而将亡值通常是指具有转移语义的对象,比如返回右值引用T&&的函数返回值等。
本质都是引用,就是取别名,只不过对象不一样罢了!
15.move原理
std::move() 函数原型:
1 | template <typename T> |
std::move() 实现原理:
- 利用引用折叠原理将右值经过 T&& 传递类型保持不变还是右值,而左值经过 T&& 变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变;
- 然后通过 remove_refrence 移除引用,得到具体的类型 T;
- 最后通过 static_cast<> 进行强制类型转换,返回 T&& 右值引用。
16.C++11 constexpr和const的区别
区别总结
- 编译时确定性:
const
变量的值可以在编译时确定,也可以在运行时确定。constexpr
变量的值必须在编译时确定。
- 用途:
const
主要用于声明不可修改的变量或对象。constexpr
主要用于声明编译时常量,适用于需要在编译时计算的值,例如数组大小、模板参数等。
- 函数:
const
可以用于成员函数,表示该函数不会修改对象的状态。constexpr
可以用于函数,表示该函数可以在编译时计算其结果。
1 |
|
C++14
函数返回值类型推导
1 |
|
[[deprecated
]]标记
C++14中增加了deprecated标记,修饰类、变、函数等,当程序中使用到了被其修饰的代码时,编译时被产生警告,用户提示开发者该标记修饰的内容将来可能会被丢弃,尽量不要使用。
1 | struct [[deprecated]] A { }; |
std::make_unique
我们都知道C++11中有std::make_shared,却没有std::make_unique,在C++14已经改善。
1 | struct A {}; |
C++17
构造函数模板推导
在C++17前构造一个模板类对象需要指明类型:
1 | pair<int, double> p(1, 2.2); // before c++17 |
C++17就不需要特殊指定,直接可以推导出类型,代码如下:
1 | pair p(1, 2.2); // c++17 自动推导 |
结构化绑定
通过结构化绑定,对于tuple、map等类型,获取相应值会方便很多,看代码:
1 | std::tuple<int, double> func() { |
结构化绑定还可以改变对象的值,使用引用即可:
1 | // 进化,可以通过结构化绑定改变对象的值 |
if-switch语句初始化
C++17前if语句需要这样写代码:
1 | int a = GetValue(); |
C++17之后可以这样:
1 | // if (init; condition) |
std::variant
C++17增加std::variant实现类似union的功能,但却比union更高级,举个例子union里面不能有string这种类型,但std::variant却可以,还可以支持更多复杂类型,如map等,看代码:
1 | int main() |
对比union
1 |
|
optional
std::optional
是 C++17 引入的一个标准库模板类,用于表示一个可能存在也可能不存在的值。它提供了一种优雅的方式来处理那些可能没有值的情况,避免了使用空指针或特殊值(如 -1
或 nullptr
)来表示“无值”的情况。
1 |
|
让我们自己手写一份
1 |
|
any
std::any
是 C++17 引入的一个标准库类**,用于存储任意类型的单个值**。它类似于 std::variant
,但与 std::variant
不同,std::any
不限制存储的类型,可以存储任何类型的值。std::any
提供了一种灵活的方式来处理需要在运行时确定类型的值。
1 |
|
知其然知其所以然
1 |
|
stringview
原理就是视图,内部没有申请和拷贝任何内存,只有一个初始指针和长度
1. 所有权和生命周期
std::string
: 拥有字符串数据的所有权,负责管理字符串的内存分配和释放。当你需要拥有字符串数据并可能对其进行修改时,应该使用std::string
。std::string_view
****: 不拥有字符串数据的所有权,只是提供一个对现有字符串数据的只读视图。它适用于那些只需要读取字符串内容而不需要修改或拥有字符串的场景。
2. 内存管理
std::string
****: 在创建、复制和修改字符串时,可能会涉及动态内存分配和释放,这可能导致额外的性能开销。std::string_view
****: 不进行任何内存分配,因为它不拥有字符串数据。这使得std::string_view
在性能上通常比std::string
更高效,尤其是在只需要读取字符串内容的情况下。
3. 构造和赋值
std::string
: 可以从字符串字面量、其他std::string
对象、std::string_view
等构造,并且可以进行复制和赋值操作。std::string_view
: 可以从字符串字面量、std::string
对象、其他std::string_view
等构造,但它不能拥有或修改字符串数据。
4. 功能和接口
std::string
: 提供了丰富的接口,包括字符串的修改、拼接、查找、替换等操作。std::string_view
: 提供了只读的字符串操作接口,如子字符串查找、获取子字符串视图等,但不支持修改字符串内容。
5. 使用场景
std::string
: 适用于需要拥有字符串数据并可能对其进行修改的场景。std::string_view
: 适用于只需要读取字符串内容而不需要修改或拥有字符串的场景,特别是在函数参数中传递字符串时,可以避免不必要的字符串拷贝。
1 |
|
C++20
C++20 引入了 Ranges 库,这是一个强大的工具集,用于处理和操作序列(范围)。Ranges 库提供了一种更高层次的抽象,使得对容器、数组和其他序列的操作更加直观和灵活。Ranges 库的核心概念包括范围(Range)、视图(View)和操作(Adaptor)。
基本概念
- Range:一个 Range 是一个可以迭代的对象,例如标准容器(如
std::vector
、std::list
)、数组、字符串等。 - View:一个 View 是一个轻量级的、延迟计算的 Range,它不拥有数据,而是提供对数据的视图。View 可以链式组合,形成复杂的操作管道。
- Adaptor:一个 Adaptor 是一个用于创建 View 的工具,例如过滤器(filter)、变换器(transform)等。
1 |
|
STL
1.什么是STL?
Standard Template Library(标准模板库),是C++的标准库之一,它是一套基于模板的容器类库,还包括许多常用的算法,提高了程序开发效率和复用性。STL包含6大部件:容器、迭代器、算法、仿函数、适配器和空间配置器。
2.SGI的二级空间配置器了解吗?
(1) 对象构造前的空间配置和对象析构后的空间释放,由<stl_alloc.h>负责,SGI设计了双层级配置器:
① 第一级空间配置器直接使用malloc和free,如果在申请动态内存时找不到足够大的内存块,将返回NULL 指针,宣告内存申请失败。
② 第二级空间配置器视情况使用不同的策略,当申请内存大于128字节时,调用第一级配置器。当申请内存小于128b字节时,采用内存池方式,维护16个(128/8)自由链表,每个链表维护8字节大小的内存块,从中进行内存分配,如果内存不足转第一级配置器处理。
(2) 二级空间配置器存在的问题:
① 自由链表所挂区块都是8的整数倍,因此当我们需要非8倍数的区块,往往会导致浪费。
② 由于配置器的链表都是静态变量,他们存放在全局/静态区,其释放时机就是程序结束,这样子会导致自由链表一直占用内存。
3.traits技法?
入门案例:
在C++中,traits(特性)是一种常用的技术,主要用于在编译期间获取类型的信息。通过traits,我们可以编写出更加通用、灵活的代码。
下面是一个简单的traits的例子:
1 | // 定义一个traits模板 |
这个例子中,我们定义了一个名为 MyTraits
的模板,并对指针类型进行了特化。通过 MyTraits
,我们可以在编译期间判断一个类型是否是指针类型。
除了这个简单的例子,traits还有很多其他的应用,例如获取一个类型的大小、判断一个类型是否是const类型、获取一个容器的迭代器类型等等。通过traits,我们可以更好地抽象和封装代码,提高代码的复用性和灵活性。
4.说说STL中的容器?
(1) vector
① vector底层是一个动态数组,包含三个迭代器:start、finish、end_of_storage。start和finish之间是已经被使用的空间范围,表示当前vector中有多少个元素,即有效空间size。start和end_of_storage是整块连续空间包括备用空间的大小,表示它分配的内存中可以容纳多少元素,即容量capacity。
② 当空间不够装下数据(vec.push_back(val))时,会自动申请另一片更大的空间(1.5倍或者2倍),然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间。之所以是1.5倍或者2倍,是因为考虑到可能产生的堆空间浪费,增长倍数不能太大,使用1.5或者2是比较合理的倍数。
③ 当释放或者删除(vec.clear())里面的数据时,其存储空间不释放,仅仅是清空了里面的数据。
④ 对vector的任何操作一旦引起了空间的重新配置,指向原vector的所有迭代器都会失效。
⑤ reserve函数的作用:将vector直接扩充到确定的大小,可以减少多次开辟和释放空间的效率问题(优化push_back),还可以减少拷贝数据的次数,它直接更改capacity。
⑥ resize()函数的作用:可以改变vector有效空间的大小,即size的大小。如果size大于capacity,capacity的大小也会随着改变。
⑦ vector的底层实现要求连续的对象排列,引用并非对象,没有实际地址,因此vector的元素类型不能是引用。
⑧ 当删除容器中一个元素后,该迭代器所指向的元素已经被删除,那么也造成迭代器失效。erase方法会返回下一个有效的迭代器。
⑨ 释放vector内存的方法:
**vec.clear():**清空内容,但是不释放内存。
**vector().swap(vec):**清空内容,且释放内存,想得到一个全新的vector。
**vec.shrink_to_fit():**请求容器降低其capacity和size匹配。
**vec.clear();vec.shrink_to_fit();:**清空内容,且释放内存。
- reserve只修改capacity大小,不修改size大小,resize既修改capacity大小,也修改size大小
(2) List
① list的底层是一个双向链表,以结点为单位存放数据,结点的地址在内存中不一定连续,每次插入或删除一个元素,就配置或释放一个元素空间。
② list不支持随机存取,如果需要大量的插入和删除,而不关心随机存取,则可以使用list。
(3) Deque
【精辟】:
动态开辟的⼆维数组空间,第⼆维固定⻓度的数组空间,扩容的时候(第⼀维的数组进⾏2倍扩容)。
deque内部实现的是⼀个双向队列。元素在内存连续存放。随机存取任何元素都在常数时间完成(仅次于vector)。所有适⽤于vector的操作都适⽤于deque。在两端增删元素具有较佳的性能(⼤部分情况下是常数时间)。
① deque的是一个双向开口的连续线性空间(双端队列),在头尾两端进行元素的插入跟删除操作都有理想的时间复杂度。
② deque的底层并不是真正连续的空间,而是由一段段连续的小空间拼接而成,实际deque类似于一个动态的二维数组,由一个map(中控指针数组)和多个连续的缓冲区组成。
③ 当deque不断增加元素时,一旦map(中控指针数组)满了,那么会增容,不过map增容的代价非常低,因为只需要拷贝存储数据的buffer数组的指针,不需要拷贝buffer中的内容。
④ 双端队列底层是一段假象的连续空间,实际是分段连续的,为了维护其“整体连续”以及随机访问的假象,deque的迭代器设计就比较复杂。由cur、first、last指向当前遍历buffer数组,node指向map中的元素,遍历deque的操作由这几个指针进行维护。
⑤ deque并不是从map的第一个位置就开始存放元素,而是从中间开始存放,这样在头部和尾部插入元素就会变得容易。
⑥ 与vector比较:头部插入和删除时,不需要搬移元素,效率特别高,而且在扩容时,也不需要搬移大量的元素,因此其效率是必vector高的。
⑦ 与list比较:其底层是连续空间,空间利用率比较高,不需要存储额外字段。
⑧ 不适合遍历,因为在遍历时,deque的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低下。不适合大量的中间插入删除,也不适合大量的随机访问。
(4) map 、set、multiset、multimap
① 底层数据结构都是红黑树,是一种自平衡的二叉搜索树
② set和multiset会根据特定的排序准则自动将元素排序,set中元素不允许重复,multiset可以重复。
③ map和multimap将key和value组成的键值对作为元素,根据key的排序准则自动将元素排序,map中元素的key不允许重复,multimap可以重复。
④ map和set的增删改查速度为都是logn,是比较高效的。
⑤ map和set的插入删除效率比序列容器高,而且每次insert之后,以前保存的iterator不会失效。因为存储的是结点,不需要内存拷贝和内存移动。
(5) unordered_map、unordered_set
① 底层数据结构是一个防冗余的哈希表(采用除留余数法)。其数据的存储和查找的效率很高,时间复杂度为O(1);而代价仅仅是消耗比较多的内存。
② 使用开链法解决哈希冲突。
③ 数据的存放是无序的
5. 什么是STL的顺序容器和关联式容器?
(1) 关联容器(Associative Container)与顺序容器(Sequential Container)的本质区别在于:关联容器是通过键(key)存储和读取元素的,而顺序容器则通过元素在容器中的位置顺序存储和访问元素。
(2) 在STL中,这里的“顺序”和“关联”指的是上层接口表现出来的访问方式,并非底层存储方式。为什么这样划分呢?因为对STL的用户来说,他们并不需要知道容器的底层实现机制,只要知道如何通过上层接口访问容器元素就可以了,否则违背了泛型容器设计的初衷。
(3) 顺序容器主要采用向量和链表及其组合作为基本存储结构,如堆栈和各种队列。而关联式容器采用平衡二叉搜索树作为底层存储结构。
6. 说说STL的容器适配器?
容器适配器,其就是将不适用的序列式容器(包括 vector、deque 和 list)变得适用。容器适配器的底层实现都是通过封装某个序列式容器,并重新组合该容器中包含的成员函数,使其满足某些特定场景的需要。
(1) stack
① stack(栈)是一种**先进后出(First In Last Out)**的数据结构,只有一个出入口,那就是栈顶,除了对栈顶元素进行操作外,没有其他方法可以操作内部的其他元素。
② C++的栈是一种容器适配器,其底层数据结构一般用list或deque实现,只开放一部分的接口和方法即可完成对栈的支持。
(2) queue
① queue(队列)是一种**先进先出(First In First Out)**的数据结构,只有一个入口和一个出口,分别位于队头与队尾,只能在队尾插入元素,在队头取出元素,没有其他方法可以操作内部的其他元素。
② C++的队列是一种容器适配器,其底层数据结构一般用list或deque实现,只开放一部分的接口和方法即可完成对队列的支持。
(3) priority_queue
① priority_queue,优先级队列,是一个拥有权值观念的queue,它跟queue一样只能在队尾插入元素,在队头取出元素。在插入元素时,元素并非按照插入次序排列,它会自动根据权值(通常是元素的实值)排列,权值最高,排在最前面。
② priority queue(优先队列)的底层实现机制实际上是堆,因为大根堆总是最大值位于堆的根部,优先级最高。
③ C++中的堆是容器适配器,一般是vector为底层容器,以堆的处理规则来进行管理。
7. 说说STL的迭代器?
(1) 迭代器是连接容器和算法的一种重要桥梁,通过迭代器可以在不了解容器内部原理的情况下遍历容器。
(2) 在遍历容器的时候,不可避免的要对遍历的容器内部有所了解,所以,干脆把迭代器的开发工作交给容器的设计者好了,如此以来,所有实现细节反而得以封装起来不被使用者看到,这正是为什么每一种 STL 容器都提供有专属迭代器的缘故。
(3) 迭代器种类分为5类:
① **输入迭代器:**是只读迭代器,在每个被遍历的位置上只能读取一次。
② **输出迭代器:**是只写迭代器,在每个被遍历的位置上只能被写一次。
③ **前向迭代器:**兼具输入和输出迭代器的能力,但是它可以对同一个位置重复进行读和写。但它不支持operator–,所以只能向前移动。
④ **双向迭代器:**很像前向迭代器,只是它向后移动和向前移动同样容易。
⑤ **随机访问迭代器:**有双向迭代器的所有功能。而且,它还提供了“迭代器算术”,即在一步内可以向前或向后跳跃任意位置, 包含指针的所有操作,可进行随机访问,随意移动指定的步数。
(5) 通过traits技法,我们可以获取到迭代器一些特性,STL规定,每一个迭代器至少包含以下几种特性供外界获取,方便算法使用迭代器。
① **value_type:**迭代器所指对象的类型
② **difference_type:**两个迭代器之间的距离
③ **pointer:**迭代器所指对象的指针类型
④ **reference:**迭代器所指对象的引用类型
⑤ **iterator_category:**迭代器种类
(6) 迭代器失效问题
① **数组型数据结构(vector):**该数据结构的元素是分配在连续的内存中,insert和erase操作,都会使得删除点和插入点之后的元素挪位置,所以,插入点和删除掉之后的迭代器全部失效,也就是说insert( * iter)(或erase( * iter)),然后再iter++,是没有意义的。解决方法:erase( * iter)的返回值是下一个有效迭代器的值。 iter =cont.erase(iter)。
② **链表型数据结构:**对于list型的数据结构,使用了不连续分配的内存,删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器.解决办法两种,erase( * iter)会返回下一个有效迭代器的值,或者erase(iter++)。
③ 树形数据结构: 使用红黑树来存储数据,插入不会使得任何迭代器失效;删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器。erase迭代器只是被删元素的迭代器失效,但是返回值为void,所以要采用erase(iter++)的方式删除迭代器。
④ Deque:插入头尾会使迭代器全部失效但是引用不失效。其原因在于插入头尾可能会进行扩容,由于map的重新分配,迭代器的node失效,但是原map指向的连续数组并没有重新分配。因此,对整个迭代器来说是失效的,但对于元素的指针和引用仍然是有效的。删除头尾会使被删除的元素迭代器和引用失效,插入和删除中间会使迭代器和引用全部失效。
⑤ unodered_map/unordered_set:由于底层是哈希表,迭代器是否失效主要看哈希表的实现策略,对于使用除留余数法和开链法的哈希表来说,删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器。
8. 说说STL容器的线程安全性?
**STL只保证最低限度的线程安全性,即:**多个读者是安全的,多线程可以同时读取一个容器的内容,但如果有多个写者,则必须使用同步互斥机制保证线程安全。
9. STL容器的使用场景?
(1) vector的使用场景:只查看,而不频繁插入删除的
(2) deque的使用场景:头尾需要频繁插入删除
(3) list的使用场景:频繁的插入删除的场景,且位置不固定
(4) Set:针对单一值的增删查改操作都要有,且要求数据有序
(5) Map:针对键值对的增删查改操作都要有,且要求数据有序
(6) unordered_set:针对单一值的增删查改操作都要有,数据排列无要求。
(7) unordered_map:针对键值对的增删查改操作都要有,数据排列无要求。
10.哈希碰撞的处理方法
开放定址法:当遇到哈希冲突时,去寻找一个新的空闲的哈希地址。
再哈希法:同时构造多个哈希函数,等发生哈希冲突时就使用其他哈希函数知道不发生冲突为止,虽然不易发生聚集,但是增加了计算时间
链地址法:将所有的哈希地址相同的记录都链接在同一链表中
建立公共溢出区:将哈希表分为基本表和溢出表,将发生冲突的都存放在溢出表中
11.unordered_map的扩容过程
当unordered_map中的元素数量达到桶的负载因子(0.75)时,会重新分配桶的数量(通常会按照原有桶的数量*2的方式进行扩容,但是具体的增长策略也可以通过修改容器中的max_load_factor成员变量来进行调整),并将所有的元素重新哈希到新的桶中。
STL 之 unordered_map
unordered_map 内部实现是散列表,是一个无序的容器。内部实现的散列表采用了链地址法,意思是使用链表来解决散列冲突。当往容器中加入一个元素的时候,会计算散列值,然后取余之后放到一个桶 (bucket) 里。如果不断往容器加元素,那么所有的桶都会变成一个很长的链表,这样效率就很低了,这种情况应该如何避免呢?unordered_map 采用了扩容的机制,当负载因子 (load factor) 超过了阈值,就会进行一次扩容。负载因子的计算方法是总键值对数除以桶数。扩容的时候,会重新计算散列,重新放入到桶里。
12.vector的元素类型可以是引用吗
vector的底层实现要求连续的对象排列,引用并非对象,没有实际地址,因此vector的元素类型不能是引用。
1 |
|
补充1:vector和数组的区别
- 大小:数组大小固定,向量大小可变。
- 功能:数组功能简单,向量功能丰富。
- 灵活性:数组灵活性低,向量灵活性高。
- 内存管理:数组需要手动管理内存,向量自动管理内存。
13.C++新特性总结
C++11、C++14、C++17、C++20新特性总结(5万字详解)
配享太庙🐕
14.迭代器失效问题
强推!迭代器失效问题
例子:
1.std::vector
删除元素导致迭代器失效
1 |
|
说明:上述代码并未出现未定义行为,我在gcc4.8.5下执行。
- std::map 删除元素后继续使用失效的迭代器
1 |
|
说明:我在win是执行会产生未定义行为,但是linux上执行是正常的。
正确用法
1 |
|
3.排序
1 |
|
说明:上述代码并未出现未定义行为,我在gcc4.8.5下执行。
史上最全C/C++面试、C++面经八股文,一文带你彻底搞懂C/C++面试、C++面经!
15.Sort函数
STL中,大数据排序时候,首选了快排;
递归深度到达一定程度的时候,选择了堆排;(允许1.5 log2(N) 的递归深度)
数据量小到一定程度的时候,选择插入排序;(小于32个数据时候)
解释:
当快速排序遇到极端不平衡的情况(例如数组已经接近有序),递归深度可能会过大,导致性能退化到 O(N²)。为了解决这个问题,std::sort
在递归深度达到一定阈值时(约为 1.5 * log₂(N)),会切换到堆排序。
堆排序的时间复杂度为 O(N log N),而且无论输入数据如何分布,它的最坏情况性能是稳定的。因此,当快速排序递归过深时,使用堆排序可以避免最坏情况的性能退化,确保排序过程的时间复杂度不会超过 O(N log N)。
对象模型
知识点记载
通过虚函数指针执行虚函数
关键代码:
1 | // 先找到虚函数指针,然后转成指针的指针,即虚函数表的地址,然后解引用 |
完整代码
1 |
|
C++继承
-
单继承:子类单一继承自父类,分析了子类重写父类虚函数、子类定义了新的虚函数情况下子类对象内存布局。
-
多继承:子类继承于多个父类,分析了子类重写父类虚函数、子类定义了新的虚函数情况下子类对象内存布局,同时分析了非虚继承下的菱形继承。
-
虚继承(virtual)就是子类中只有一份间接父类的数据用于解决多继承中的父类为非虚基类时出现的数据冗余问题
存在虚函数的对象内存布局
单继承
若子类重写(overwrite)了父类的虚函数,则子类虚函数将覆盖虚表中对应的父类虚函数(注意子类与父类拥有各自的一个虚函数表);
若子类并无overwrite父类虚函数,而是声明了自己新的虚函数,则该虚函数地址将扩充到虚函数表最后(在vs中无法通过监视看到扩充的结果,不过我们通过取地址的方法可以做到,子类新的虚函数确实在父类子物体的虚函数表末端)
Note:子类不会直接拷贝父类的虚函数表,而是会根据编译器根据继承和重写情况动态构建。
多继承
重要特点:
- 子类的虚函数被放在声明的第一个基类的虚函数表中。
- overwrite时,所有基类的print()函数都被子类的print()函数覆盖。
- 内存布局中,父类按照其声明顺序排列。
其中第二点保证了父类指针指向子类对象时,总是能够调用到真正的函数。
虚继承
虚继承解决了菱形继承中派生类拥有多个间接父类实例的情况。
- 虚继承而来的子类会生成一个隐藏的虚基类指针(vbptr),在Microsoft Visual C++中,虚基类表指针总是在虚函数表指针之后。
- 如果本身定义了新的虚函数,则编译器为其生成一个虚函数指针(vptr)以及一张虚函数表。
- 虚继承的子类对象中,含有四字节的虚表指针偏移值。
看个例子:
当通过派生类对象访问虚基类成员时,编译器使用 vbptr
访问 vbtbl
中的偏移量,并通过该偏移量计算虚基类成员的实际内存地址。
在上面的例子中,当 base_ptr->show()
被调用时,编译器通过 vbptr
和 vbtbl
找到 Base
的实际位置,并访问其虚函数表,从而正确调用了 Derived::show()
。
1 |
|
vbtbl
表的主要内容是指向虚基类的偏移量信息,具体包括:
- 虚基指针所指向的虚基表的内容:
-
虚基指针的第一条内容表示的是该虚基指针距离所在的子对象的首地址的偏移
-
虚基指针的第二条内容表示的是该虚基指针距离虚基类子对象的首地址的偏移
-
vbtbl
表中主要包含虚基类在派生类对象中的内存偏移量信息,用于定位虚基类成员在多重继承或菱形继承结构中的位置。
什么是虚基类? 虚基类是C++中解决多重继承中的菱形问题的一种机制
简单虚继承
1 | //类的内容与前面相同 |
虚拟菱形继承
1 | class B{...} |
学习自:
c++虚表(vftable)、虚函数指针(vfptr)、虚基指针(vbptr)的测试结果
以上学习可以通过GDB调试查看也可以通过直接打印地址查看!