C++
C++
内存分区
4个区
- 代码区:存放函数体的==二进制代码==,由操作系统进行管理的
- 全局区:存放全局变量、静态变量、==常量==
- 栈区:由编译器自动分配释放, 存放函数的参数值,局部变量等
- 堆区:由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收
5个区
- 代码区:存放函数体的二进制代码,由操作系统进行管理的
- 全局区:存放全局变量和静态变量以及常量
- 栈区:由编译器自动分配释放, 存放函数的参数值,局部变量等
- 自由存储区:由存储区是 C++ 的一个概念,特指通过
new
和delete
操作符在 C++ 中分配和释放的内存区域。 - 堆区:由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收
堆和栈在效率上有啥区别
栈(Stack)
- 内存分配和释放速度快:
- 栈是一个连续的内存区域,内存分配和释放的过程是基于栈指针的简单移动,通常在O(1)时间内完成。
- 内存分配通过递增栈指针来实现,释放则通过递减栈指针来实现,不需要复杂的内存管理操作。
- 高效的缓存局部性:
- 由于栈是连续分配的,栈上分配的内存具有较好的缓存局部性,能更有效地利用CPU缓存,从而提高程序的执行效率。
- 生命周期自动管理:
- 栈上的变量在函数调用结束时自动释放,无需程序员手动管理,有效减少内存泄漏的风险。
- 空间限制:
- 栈的大小通常是有限的,受限于操作系统或编译器设置。如果栈空间耗尽,会导致栈溢出(Stack Overflow)错误。
堆(Heap)
- 灵活的内存管理:
- 堆允许动态内存分配,程序可以在运行时根据需要分配和释放内存,灵活性更高。
- 适用于需要动态调整内存大小的数据结构,如链表、树、图等。
- 内存分配和释放速度慢:
- 堆的内存分配和释放需要更复杂的管理操作,如查找空闲块、合并碎片等,通常比栈的操作要慢。
- 分配和释放的时间复杂度一般不是固定的,具体取决于内存管理算法。
- 较差的缓存局部性:
- 堆上的内存分配是离散的,可能会导致内存碎片化,从而影响缓存的局部性和程序的执行效率。
- 生命周期由程序员管理:
- 堆上的内存需要程序员显式分配和释放,容易导致内存泄漏或重复释放的问题。
- 现代编程语言和环境提供了垃圾回收机制来帮助管理堆内存,但这也可能带来额外的性能开销。
总结
- 栈适用于生命周期较短、大小固定的局部变量和函数调用,具有高效的内存分配和释放速度,以及较好的缓存局部性,但受限于有限的栈空间。
- 堆适用于需要动态内存分配的复杂数据结构,提供更大的灵活性,但内存管理操作较复杂,可能影响性能,且需要程序员小心管理内存的分配和释放。
什么样的数据在栈区,什么样的在堆区
栈区主要用于存储局部变量、函数参数、返回地址、以及函数调用的上下文信息。
堆区用于动态分配内存,也就是通过new
或malloc
等函数显式分配的内存。
如何用代码判断栈的增长方向
可以通过参数的入栈顺序,一般从右往左,但是由于编译器的不同,可能会不符合预期。但是函数的调用栈是肯定的,因此我们可以打印函数调用栈中变量的地址去查看
|
|
C++怎么从源码到可执行程序的过程
C++从源码到可执行程序的过程主要包括以下四个步骤:
- 预处理(Preprocessing):预处理器会处理C++源代码文件中的预处理指令,如#include和#define。它会替换#include指令为相应文件的内容(通常只是声明),替换宏定义(#define),并根据#if、#ifdef和#ifndef指令选择不同的文本部分。预处理器对一个C++源文件进行操作,生成一个单一的输出,这是一个由上述转换产生的标记流。变成.i文件
gcc -E source.c -o source.i
- 编译(Compilation):编译步骤在每个预处理器的输出上执行。编译器解析纯C++源代码(现在没有任何预处理指令),并将其转换为汇编代码。 变成汇编代码.s文件
gcc -S source.i -o source.s
- 汇编(Assembling):将汇编代码翻译成机器代码,生成目标文件,是二进制格式的(.o或.obj)
gcc -c source.s -o source.o
- 链接(Linking):链接器接收由编译器产生的目标文件,并生成库或可执行文件。链接过程中,链接器会解析目标文件中的符号引用,并将这些引用与其他目标文件或库(标准库,动态链接库)中定义的符号进行链接。
交叉编译
交叉编译(Cross-Compilation)是指在一种平台上编译代码,以生成能够在另一种不同平台上运行的可执行文件的过程。这种编译方式通常用于目标平台和开发平台之间存在硬件架构或操作系统的差异。
静态库和动态库的区别
1. 静态库(Static Library)
- 文件扩展名:在Windows上通常是
.lib
,在Linux上通常是.a
。 - 编译链接时的行为:当程序被编译链接时,静态库中的代码会被完整地复制到最终的可执行文件中。这个过程是在编译时完成的,因此最终的可执行文件包含了所有必需的库代码。
- 运行时的行为:静态库的代码成为了可执行文件的一部分,这意味着在程序运行时,不需要额外的库加载。
- 优点:不需要在运行时加载额外的库文件,启动速度快,不会出现库版本不兼容的问题。
- 缺点:增加了可执行文件的大小,更新库时需要重新编译链接整个程序。
2. 动态库(Dynamic Library)
- 文件扩展名:在Windows上通常是
.dll
,在Linux上通常是.so
。 - 编译链接时的行为:动态库在编译时不被复制到可执行文件中。相反,程序存储了对动态库中函数和变量的引用。
- 运行时的行为:当程序运行时,操作系统负责加载动态库文件。如果库文件在系统上可用,程序就可以使用库中的代码;否则,程序启动会失败。
- 优点:可执行文件大小较小,易于更新和维护库文件,多个程序可以共享同一动态库的单个实例,节约内存。
- 缺点:启动时需要加载库,可能会稍慢;依赖于正确版本的库文件在运行时的可用性,可能存在库文件缺失或版本冲突的问题。
程序运行起来以后静态库和动态库在内存中哪里
静态库:代码会复制到可执行文件中,存储在代码段
动态库:动态库(动态链接库中的代码段,数据段,BSS 段)会被加载到文件映射与匿名映射区(共享区)
如何查看程序依赖的库?
1. 使用 ldd
命令查看依赖库
ldd
命令可以列出可执行文件所依赖的共享库。
|
|
2. 使用 objdump
命令查看依赖库
objdump
是一个强大的工具,可以显示可执行文件的各种信息。使用 objdump
查看动态依赖库:
|
|
全局变量和局部变量的区别
全局变量和局部变量的主要区别在于它们的作用域、生命周期、初始化和位置。
全局变量:
- 全局变量在程序的整个生命周期内都是有效的,可以在程序的任何地方被访问。
- 全局变量在程序开始时被创建,在程序结束时被销毁。
- 如果全局变量没有被初始化,它们会被自动初始化为零(对于数字类型)或者空(对于某些其他类型)。
- 全局区
局部变量:
- 局部变量只在定义它们的函数或代码块内部有效。
- 局部变量在进入函数或代码块时被创建,在离开函数或代码块时被销毁。
- 局部变量必须在使用前被明确地初始化。
- 栈区
注意,过度使用全局变量可能会导致代码难以理解和维护,因为全局变量可以在程序的任何地方被修改。相反,局部变量的使用范围受到限制,这有助于理解和跟踪它们的使用情况。
函数调用的参数是怎么入栈的,为什么?
函数调用时,参数的入栈过程主要取决于调用约定(calling convention),这是编译器和系统共同遵循的一套规则。
在典型的调用约定下,函数的参数按照从右到左的顺序依次入栈。
原因:
支持可变参数函数:
-
可变参数函数(如
printf
)可以接受不确定数量的参数。通过从右到左入栈,编译器可以确保第一个固定参数位于栈顶附近,这样可以方便地访问后续的可变参数。在C语言中,处理变长参数的机制是通过va_list
、va_start
、va_arg
和va_end
宏来实现的。例如:
1
int printf(const char *format, ...);
format
参数在最上面,因此函数可以从已知的位置开始处理可变数量的参数。
函数调用的过程
栈帧
我们调用的函数执行完之后,需要返回。需要在栈帧中存一个返回的位置,也就是返回之后应该执行哪条指令。
此外,有两个专门的寄存器来标记当前函数栈帧的起始地址和结束地址,如果是 64 位的机器,那就是 rbp 和 rsp,而 32 位机器的是 ebp 和 esp。
调用函数时栈帧的变化
- 在调用一个函数时,我们先把函数的返回地址(也就是执行调用时 pc的值)压入栈中。
- 为了确保返回时能恢复当前帧指针的状态,还需要把帧指针压入栈中。
- 做完了准备工作,可以加入被调用函数的栈帧了。新栈帧当前还没有存放任何数据,所以起始地址和结束地址都是旧栈帧的结束地址。为了达到这一点,需要把帧指针的值(新栈帧起始值)设置成栈指针(老栈帧结束值)的值。
- 现在要把数据存入新栈帧,首先会需要更新栈指针的值,扩大栈的范围,因为栈帧是向低地址增长的,所以要根据局部变量及参数的大小,把栈指针的值减小一些。
- 栈帧已经有了足够的空间,可以放入局部变量并且执行这个函数了。至此,新栈帧的插入完全完成。
函数返回时栈帧的变化
- 函数返回首先要释放之前占用的所有内存,所以我们直接把栈指针设置成帧指针。也就是把栈帧的结束地址直接改成开始地址,相当于撤销调用函数时的第 4 步。
- 现在需要用到之前备份的原帧指针来恢复函数调用之前的状态。我们需要从栈中弹出这个原帧指针,然后复制到帧指针寄存器。
- 弹出返回地址,赋值到 pc。
- 根据 pc 的值,继续执行原函数。
char *和char []的区别
|
|
-
char *
指向一个字符串常量,在只读数据段中,因此不能修改,而char []
在栈上,可以修改 -
大小,
sizeof
后,s1是指针,32位是4B,s2为6B,因为包含\0 -
自增,由于s1是指针,可以通过自增移动指针,而s2数组名是地址常量,不能自增
-
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
char *fun() { char *s1 = "hello"; char s2[] = "hello" 可以return s1,不能return s2,因为s1在静态存储区,生命周期直到程序结束,而s2是一个局部变量 } #include <bits/stdc++.h> using namespace std; char* mymalloc1() { char p[] = "abcd"; return p; } char* mymalloc2() { char *p = "abcd"; return p; } int main(void) { char* a = nullptr; char* b = nullptr; a = mymalloc1(); b = mymalloc2(); printf("%s", a); cout << endl; printf("%s", b); return 0; } /* (null) abcd */
四种强制类型转换
const_cast
:用于移除或者添加const或volatile修饰符
static_cast
:1. 代替隐式转换 2. 基类与派生类的转换:派生类转基类的安全的,基类转派生类是不安全的,因为子类内容比父类多
dynamic_cast
:多用于有虚函数的基类和派生类之间的转换。用于类层次间的上下转换,在进行下转时,相比于static_cast,dynamic_cast具有类型检查,更安全
reinterpret_cast
:用于进行低级别的类型转换,通常用于将指针类型转换为其他指针类型,或将指针转换为整数类型及其反向转换。适用于需要对二进制表示进行重新解释的场景。【不会进行安全类型检查】
大小端的区别以及各自的优点,哪种时候用
大小端(Big-endian 和 Little-endian)是计算机存储数据时的一种字节序排列方式,决定了多字节数据在内存中存储的顺序。以下是详细解释:
大端(Big-endian)
定义:
- 在大端模式下,多字节数据的最高有效字节(Most Significant Byte,MSB)存储在内存的低地址处,而最低有效字节(Least Significant Byte,LSB)存储在内存的高地址处。
示例: 假设我们有一个 32 位的整数 0x12345678,它在内存中的存储方式如下:
|
|
小端(Little-endian)
定义:
- 在小端模式下,多字节数据的最低有效字节(LSB)存储在内存的低地址处,而最高有效字节(MSB)存储在内存的高地址处。
示例: 同样假设我们有一个 32 位的整数 0x12345678,它在内存中的存储方式如下:
|
|
优点与应用场景
大端(Big-endian)
优点:
- 大端存储符合人类的自然阅读习惯,即从高位到低位读取数据。
- 在网络协议中,使用大端存储方式更容易进行数据交换,因为网络字节序通常是大端(称为“网络字节序”)。
应用场景:
- 网络通信:大端被广泛应用于网络协议中,如IP地址、端口号等。这种一致性使得不同计算机架构之间的数据交换更为简单。
- 跨平台数据交换:在不同系统之间进行数据传输时,使用大端可以减少数据转换的复杂性。
小端(Little-endian)
优点:
- 小端存储在某些处理器上(如x86架构)进行整数计算时更高效,因为最低有效字节可以直接用于操作。
- 小端模式下,扩展数据类型(如将16位扩展为32位)时,可以保持低地址处的数据不变,提高了内存操作的效率。
应用场景:
- 通用计算:许多PC和服务器使用的小端架构(如x86、AMD64)在进行常规计算时效率更高。
- 嵌入式系统:一些嵌入式设备使用小端存储方式以优化性能。
选择使用哪种字节序
- 网络协议和跨平台通信:大端(网络字节序)通常被选择,以确保不同平台之间的数据一致性。
- 系统架构和性能优化:小端在许多计算平台上(如x86架构)被广泛使用,因为它在处理器操作中效率更高。
如何判断计算机使用大端还是小端
将1的地址强转为字符类型,如果第一个字节的值是1,那么就是小端,否则大端
|
|
大小端如何转换
- 手动交换:第1、4个字节交换,2、3字节交换
- 使用库函数:可以使用标准库函数如
htons
(Host to Network Short)和htonl
(Host to Network Long),来将主机字节序(一般为小端)转换为网络字节序(大端)。相应的函数ntohs
和ntohl
可以将网络字节序转换回主机字节序。
数据类型
int
- 类型:有符号整数类型。
- 大小:通常为4字节(32位),但在某些系统上可能不同。
- 范围:通常为
-2,147,483,648
到2,147,483,647
(即-2^31
到2^31 - 1
)。 - 用途:表示可以为正数、负数或零的整数。
unsigned int
- 类型:无符号整数类型。
- 大小:通常为4字节(32位),但在某些系统上可能不同。
- 范围:通常为
0
到4,294,967,295
(即0
到2^32 - 1
)。 - 用途:表示仅为非负整数的数值。在表示计数和索引等永远不会为负的情况下使用。
long long
- 类型:有符号长长整数类型。
- 大小:通常为8字节(64位),但在某些系统上可能不同。
- 范围:通常为
-9,223,372,036,854,775,808
到9,223,372,036,854,775,807
(即-2^63
到2^63 - 1
)。 - 用途:表示可以为正数、负数或零的较大整数,通常在需要大范围整数的计算中使用。
unsigned long long
- 类型:无符号长长整数类型。
- 大小:通常为8字节(64位),但在某些系统上可能不同。
- 范围:通常为
0
到18,446,744,073,709,551,615
(即0
到2^64 - 1
)。 - 用途:表示仅为非负的较大范围的整数。用于需要大范围的计数、索引或存储大数的场景。
有符号整数溢出
无符号整数(如 unsigned int
和 unsigned long long
)在溢出时会自动取模。无符号整数表示非负整数,其存储和操作是基于直接的二进制表示法。例如,unsigned int
类型在大多数系统上是32位宽,表示范围是 0 到 232−12^{32} - 1232−1。计算时,所有操作都是基于模 2N2^N2N 算术(其中 NNN 是位宽),这意味着任何超过最大值的结果都会被自动取模。
无符号整数溢出
有符号整数(如 int
和 long long
)在溢出时的行为是未定义的。这是因为有符号整数使用二进制补码(two’s complement)来表示,包括一个符号位。二进制补码允许使用简单的硬件进行加减运算,并且符号位影响结果。例如,32位 int
类型的表示范围是 −231-2^{31}−231 到 231−12^{31} - 1231−1。溢出发生时,结果不一定是合理的整数,可能会被解释为负数,甚至可能导致不可预测的行为,如程序崩溃。
原码,反码,补码的区别
1. 原码 (Sign-Magnitude)
原码是一种最直观的表示法,使用最高位表示符号位,其余位表示数值。
- 符号位:最高位(最左边的一位)表示符号,0表示正数,1表示负数。
- 数值位:剩余的位表示数值的绝对值。
2. 反码 (One’s Complement)
- 正数:与原码相同。
- 负数:反码通过将数值的每一位取反(即0变1,1变0)来表示负数。
反码有一个问题,即有两个表示零的方式:
- +0 的反码表示:
00000000
- -0 的反码表示:
11111111
3. 补码 (Two’s Complement)
补码是目前计算机系统中最常用的表示法,它通过对反码加1来表示负数。
- 正数:与原码相同。
- 负数:反码加1。
补码只有一个零的表示方式:
- +0 和 -0 的补码均表示为:
00000000
, 解决了双0问题
为什么补码是计算机最常用的?
1. 统一的零表示
在补码表示法中,零只有一种表示形式,即 00000000
。这消除了反码表示法中正零和负零两种表示的矛盾,简化了硬件和软件的实现。
2. 简化的加减运算
补码表示法使得加法和减法运算可以使用同一套电路进行:
想要把减法转化成加法运算,就需要在运算时带着符号一起运算,而反码和补码可以带符号位一起运算,也就方便了将减法转换为加法。
3. 自动处理溢出和溢出检测
自动处理溢出:补码表示法能够自动处理溢出问题。在加减法运算中,任何溢出都会被最高位的进位丢弃,而不会影响结果的正确性。比如无符号类型
溢出检测:两个正数相加得到负数(正溢出),或者两个负数相加得到正数(负溢出),有符号类型
4. 对称的范围
补码表示法提供了对称的数值范围,正负数的范围几乎相等。
对于n位补码表示法:
- 正数范围:$0$ 到 $2^{n - 1} - 1$
- 负数范围:$-1$ 到 $-2^{n - 1}$
例如,8位二进制数:
- 正数范围:0 到 127
- 负数范围:-1 到 -128
指针和引用
指针和引用的区别
指针:就是存放某个对象的地址,本身就是变量, 所以可以有指向指针的指针,可变。
引用:就是变量的别名,从一而终,不可变,==必须初始化==,引用的本质就是指针常量,代表这个指针是一个常量,即地址是不可改变的。
- sizeof运算结果:sizeof对指针操作得到的是指针本身的大小;对引用操作得到的是所引用对象的大小。
- 自增运算意义不同:对于指针,自增操作会使其指向下一个内存单元;对于引用,自增操作会改变其所引用对象的值。
指针和引用的内存占用
指针是一个变量,它存储另一个变量的内存地址。指针的大小通常取决于所运行的系统架构:
- 在32位系统上,指针通常占用4个字节(32位)。
- 在64位系统上,指针通常占用8个字节(64位)。
无论指针指向的数据类型是什么,指针本身的大小是固定的,只取决于系统架构。
例如:
|
|
在32位系统上,这些指针都占用4个字节。在64位系统上,它们都占用8个字节。
引用的内存占用
引用实际上是占空间大小的,本质就是一个指针常量,因此通常大小和指针一样。
但是通过sizeof来看话,对于一个普通变量而言,那么通常就是这个变量的大小。对于一个结构体而言,通常和指针的大小一样
|
|
但是通过sizeof去判断引用的大小是不对的。需要通过size命令去查看 size a.out
|
|
可以看出引用占了8个字节,就是指针的大小
指针和引用自增的区别
指针的自增
指针的自增操作(++
)会使指针指向下一个元素,这个下一个元素的地址由指针所指向的数据类型决定。指针增加的步长是它所指向的数据类型的大小。
例如:
|
|
在这段代码中,ptr
初始时指向数组的第一个元素,ptr++
会使 ptr
指向数组的下一个元素。如果 int
是4字节,那么 ptr
自增后会增加4个字节。
引用的自增
引用本质上是一个变量的别名,自增引用其实是在自增这个变量本身。也就是说,自增操作是对变量进行的,而不是对引用进行的。
例如:
|
|
在这段代码中,ref++
实际上是 a++
,即增加变量 a
的值。因此,引用的自增只是增加它所引用的变量的值,并不改变引用本身的指向(因为引用不能改变其引用的对象)。
什么是野指针,怎么产生的,如何避免?
野指针是指向"垃圾"内存的指针,也就是说,它的值是不确定的。野指针通常由以下几种情况产生:
-
未初始化的指针:如果你声明了一个指针变量但没有给它赋值,那么它就是一个野指针。例如:int *ptr;。
-
已删除的指针:如果你使用delete或free删除了一个指针,但没有将它设置为NULL,那么它就成了一个野指针(悬挂指针)。例如:
1 2
int *p = (int*)malloc(sizeof(int)); free(p); // p 现在是一个野指针
-
超出作用域的指针:如果你返回了一个函数内部的局部变量的地址,那么这个地址在函数返回后就不再有效,因此返回的指针就是一个野指针。或者超出了数组的范围
1 2 3
int arr[10]; int *p = arr; p = p + 20; // 超出数组范围,p 现在是一个野指针
为了避免野指针,你可以遵循以下几个原则:
- 初始化你的指针:当你声明一个新的指针时,总是给它一个初始值。这个值可以是一个已经分配的地址,或者是NULL。
- 删除后置空:当你删除一个指针后,立即将它设置为NULL。这样,即使你再次使用这个指针,程序也不会崩溃。
- 不要返回局部变量的地址:函数返回后,局部变量的内存会被回收,所以永远不要试图返回一个局部变量的地址。
- 使用智能指针(C++):C++11 引入了智能指针,如
std::unique_ptr
和std::shared_ptr
,可以自动管理内存,避免手动释放内存带来的错误。
const关键字
- 修饰变量:说明该变量不可以被改变,==必须在定义时初始化==
- 修饰指针:1.常量指针:指针指向的==对象不可改变== const int * | int const *,不可通过解引用来改变值,但可以通过其他方法 2.指针常量: 表示==指针不可改变==int * const
- 修饰引用:指向常量的引用(reference to const),用于形参类型,即避免了拷贝,又避免了函数对值的修改;
- 修饰成员函数:说明该成员函数内不能修改成员变量,除非成员属性声明时加上mutable,在const修饰的成员函数中仍可以修改
- 修饰对象:常对象只能调用const修饰的函数,即常成员函数
mutable的作用
mutable是C++中的一个关键字,用于修饰类的成员变量,表示该成员变量即使在一个const成员函数中也可以被修改。
mutable的中文意思是“可变的,易变的”,跟constant(既C++中的const)是反义词
因为在C++中,如果一个成员函数被声明为const,那么它不能修改类的任何成员变量,除非这个成员变量被声明为mutable。
关键用途:即允许在const成员函数中修改特定的成员变量,以支持内部实现所需的功能(比如缓存),同时仍然保持外部不变性。
#define和typedef
define | typredef |
---|---|
只是简单的字符串替换,没有类型检查 | 有对应的数据类型,要进行类型判断,实际上typedef就是对已经存在的数据类型起别名 |
在预处理阶段起作用 | 在编译和运行阶段起作用 |
不分配内存,给出的是立即数,有多少次使用就替换多少次,存储在代码段 | 在静态存储区(全局区)中分配空间,在程序运行过程中==内存中只有一个拷贝== |
宏可以递归吗
宏表达式中不能出现递归定义,这点区别于函数,因为宏只做简单的文本替换,且只替换一次,如果出现递归定义,就会无法被完全替换,导致后续编译时原宏名被当作未定义的函数而报错;
如何防止头文件重复定义?
|
|
第一行#ifndef
表示如果没有定义HEADER_FILE_NAME_H
,那么就执行下面的逻辑。
第二行定义一个宏,防止头文件重复包含。第一次包含头文件的时候,宏被定义。后面重复定义,就会跳过
#pragma once
通知编译器,即使这个头文件在其他地方被多次包含,也只会处理一次。
static
- 修饰普通变量,修改变量的存储区域和生命周期,使变量存储在静态区,在 main 函数运行前就分配了空间,如果有初始值就用初始值初始化它,如果没有初始值系统用默认值初始化它。
- 如果修饰的是函数内的局部变量,那么生命周期从它第一次被初始化的时刻延续到程序结束。这意味着该变量的值在函数调用之间会保 持不变。静态局部变量只在第一次函数调用时初始化一次。即使多次进入函数,初始化代码也只执行一次。这对于确保某些计算或设置只进行一次非常有用。
- 如果修饰的是全局变量,它的主要作用是修改该变量的链接属性(linkage):默认情况下,全局变量在整个程序中都是可见的,也就是说,其他文件中的代码可以通过外部声明(extern)来访问它。但如果一个全局变量被声明为
static
,它的链接属性变为内部的(internal linkage),意味着这个变量只在它被声明的文件中可见,不能被其他文件访问。
- 修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。在多人开发项目时,为了防止与他人命名空间里的函数重名,可以将函数定位为 static。
- 修饰成员变量,修饰成员变量使所有的对象只保存一个该变量,而且不需要生成对象就可以访问该成员。在编译阶段分配内存,==类内声明,类外初始化==
- 修饰成员函数,修饰成员函数使得不需要生成对象就可以访问该函数,但是在 static 函数内不能访问非静态成员,只能访问==静态成员变量==
全局变量和static的区别
全局变量和static变量的主要区别在于它们的作用域和生命周期。
全局变量:
- 全局变量在整个程序中都是可见的,可以在程序的任何地方被访问。
- 全局变量在程序开始时被创建,在程序结束时被销毁。
- 如果全局变量没有被初始化,它们会被自动初始化为零(对于数字类型)或者空(对于某些其他类型)。
static变量:
- static变量的生命周期是整个程序执行期间,但其作用域仅限于定义它的函数或代码块。
- static变量在程序开始时被创建,在程序结束时被销毁。
- 如果static变量没有被初始化,它们会被自动初始化为零(对于数字类型)或者空(对于某些其他类型)。
总的来说,全局变量和static变量的主要区别在于它们的作用域。全局变量可以在整个程序中使用,而==static变量只能在定义它的函数或代码块中使用==。然而,两者都有相同的生命周期,即在程序执行期间一直存在。
final关键字
final修饰类
当一个类被声明为 final
时,这个类不能被继承。这意味着该类的设计已经完成,任何试图继承它的行为都会导致编译错误。
|
|
final修饰虚函数
当一个虚函数被声明为 final
时,派生类不能重写这个虚函数。这样做的目的是防止派生类改变基类的特定行为。
|
|
final通常和override一起使用,可以显示的指出该函数是覆盖了基类中的某个函数,并且不允许在被派生类覆盖
new和malloc的区别
new和malloc都是C++中用于动态分配内存的方法,但它们之间存在一些重要的区别:
- 函数与操作符:new是一个操作符,而malloc是一个函数。
- **构造函数的调用:**new会调用构造函数,而malloc不会。实际上,原始数据类型(如char、int、float等)也可以使用new进行初始化。
- 返回类型安全性:new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型。
- 内存分配失败时的返回值:new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL。
- **内存分配位置:**new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。
在C++的术语中,我们通常说new操作符在自由存储区(free store)上为对象动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。
自由存储区的位置取决于operator new的实现。自由存储区不仅可以为堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。
所以,当我们说"new在自由存储区上分配内存"时,我们实际上是在说"new在堆上分配内存",因为在大多数情况下,自由存储区就是堆。
- 是否需要指定内存大小:使用new操作符申请内存分配时无需指定内存块的大小,编译器会根据类型信息自行计算,而malloc则需要显式地指出所需内存的尺寸。
- 对数组的处理:C++提供了new []与delete []来专门处理数组类型。使用new []分配的内存必须使用delete []进行释放。new对数组的支持体现在它会分别调用构造函数函数初始化每一个数组元素,释放对象时为每个对象调用析构函数。而malloc并不知道你在这块内存上要放的数组还是啥别的东西。
总的来说,你应该根据你的需求来选择使用哪个函数。如果你需要分配一块可以容纳特定类型对象的内存,并希望自动调用构造函数和析构函数,那么应该使用new。如果你只是需要分配一定数量的字节,并且不需要自动调用构造函数和析构函数,那么可以使用malloc。
C++中有几种类型的new
在C++中,new有三种典型的使用方法:plain new,nothrow new和placement new
- plain new:plain new在空间分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL
- nothrow new:nothrow new在空间分配失败的情况下是不抛出异常,而是返回NULL
- placement new:这种new允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。使用placement new需要注意两点:
- palcement new的主要用途就是反复使用一块较大的动态分配的内存来构造不同类型的对象或者他们的数组
- placement new构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存),千万不要使用delete,这是因为placement new构造起来的对象或数组大小并不一定在堆上,可能是栈或者静态存储区,不能通过delete删除
delete、delete[]、allocator区别
- delete:用于释放由
new
运算符分配的单个对象的内存。它会调用对象的析构函数,然后释放内存。 - delete[]:用于释放由
new[]
运算符分配的数组的内存。它会依次调用数组中每个元素的析构函数,然后释放整个数组的内存。数组中的元素按逆序的顺序进行销毁; - new在内存分配上面有一些局限性,new的机制是将内存分配和对象构造组合在一起,同样的,delete也是将对象析构和内存释放组合在一起的。allocator将这两部分分开进行,allocator申请一部分内存,不进行初始化对象,只有当需要的时候才进行初始化操作。
malloc、calloc、reallock区别
malloc(size_t size)
:分配的内存空间未初始化,其内容是随机的。需要手动计算所需内存大小。
calloc(size_t num, size_t size)
:在堆上分配 num 个大小为 size 的连续内存块,并初始化为全零。
realloc(void *ptr, size_t new_size)
:重新分配一块内存。可以扩大或缩小 ptr 指向的内存块的大小。
如果 new_size 大于原来的大小,则会尝试在原内存块之后分配额外的内存,并保证原有数据不变。如果原内存不够,那么找到一块新的size大小内存,拷贝旧数据,释放旧空间,返回新指针
如果 new_size 小于原来的大小,则会缩小内存块,但超出新大小的部分数据将会丢失。
如果 ptr 为 NULL,则相当于调用 malloc(new_size)
。
既然有了malloc/free,为什么还需要new/delete?
malloc/free和new/delete都是用来申请内存和回收内存的。
对于非内部数据对象(eg:类对象),只用malloc/free无法满足动态对象的要求。这是因为对象在创建的同时需要自动执行构造函数,对象在消亡之前要自动执行析构函数,而由于malloc/free是库函数而不是运算符,不在编译器的控制权限内,也就不能自动执行构造函数和析构函数。因此,不能将执行构造函数和析构函数的任务强加给malloc/free。所以,在c++中需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理和释放内存工作的运算符delete。
new建立的是一个对象,malloc分配的是一块内存区域,用指针来访问,并且可以在区域里面移动指针;
this指针
- 由于类内的成员变量和成员函数是分开存储的。每个对象都有自己的成员变量空间(不包括static变量),但是对象是不包括成员函数的,一==个类的所有对象共享同一组成员函数==,意味着成员函数在内存中只有一份拷贝,那么成员函数是如何知道哪个对象调用的他?
this
指针是一个隐含于每一个非静态成员函数中的特殊指针。它指向调用该成员函数的那个对象。 - 当对一个对象调用成员函数时,编译程序先将对象的地址赋给
this
指针,然后调用成员函数,每次成员函数存取数据成员时,都隐式使用this
指针。 - 当一个成员函数被调用时,自动向它==传递一个隐含的参数==,该参数是一个指向这个成员函数所在的对象的指针。
this
指针被隐含地声明为:ClassName *const this
,这意味着不能给this
指针赋值;在ClassName
类的const
成员函数中,this
指针的类型为:const ClassName* const
,这说明不能对this
指针所指向的这种对象是不可修改的(即不能对这种对象的数据成员进行赋值操作);this
并不是一个常规变量,而是个==右值==,所以不能取得this
的地址(不能&this
)。- this指针存储在寄存器中,ECX寄存器
- this指针可以为空,当函数内部不需要用到this指针的时候
- 当创建一个对象时,编译器会初始化一个this指针,指向创建的的对象。也就是new的时候分配了一段内存,此时this指针就已经被初始化了。
- 在以下场景中,经常需要==显式引用==this指针:
- 为实现对象的链式引用;
- 为避免对同一对象进行赋值操作(避免自赋值);(自己和自己赋值
if(this == &other)
) 移动赋值运算符例子
可以delete this吗
可以使用,但是必须非常谨慎。因为滥用可能导致未定义的行为。delete this主要是允许对象在类的成员函数中自行销毁,一般不用
析构函数调用delete this会怎么样?
会无限循环执行析构函数,直到stackoverflow, 因为delete本身也会执行析构函数,递归调用
空指针能否调用函数?虚函数?
|
|
问题一:能否调用函数?
对于类成员函数而言,并不是一个对象对应一个单独的成员函数体,而是此类的所有对象共用这个成员函数体。 当程序被编译之后,此成员函数地址即已确定。当调用a->func()
; 这句话时,其实就是调用A::func(this)
;而成员函数的地址在编译时就已经确定, 需要注意的是,你用空指针调用成员函数,只是让this指针指向了空,所以空指针也是可以调用普通成员函数,只不过此时的this指针指向空而已,但函数func函数体内并没有用到this指针,所以不会出现问题。
当成员函数体内用到this指针时,如果你的this指针是空,那么程序就会崩溃。比如,如果func1中a = 10
,程序就会出问题,原因就是this指针指向空,当进行赋值的时候,编译器不知道这个成员变量是哪一个对象的,所以他不知道给哪个对象的a赋值,因此就会出错。
问题二:能否调用虚函数?
我们知道,如果一个类中包含虚函数,那么他所实例化处的对象的前四个字节是一个虚表指针,这个虚表指针指向的是虚函数表。当然,虚函数的地址也是在编译时就已经确定了,这些虚函数地址存放在虚函数表里面,而虚函数表就在程序地址空间的数据段(静态区),也就是说虚表的建立是在编译阶段就完成的;当调用构造函数的时候才会初始化虚函数表指针,即把虚表指针存放在对象前四个字节(32位下)。试想一下,假如用空指针调用虚函数,这个指针根本就找不到对应的对象的地址,因此他也不知道虚表的地址,没有虚表的地址,怎么能调用虚函数呢。
构造函数可以memset(this, 0 ,sizeof(*this))吗
如果所有成员变量是简单的内置类型是没有问题
但是下面几种情形是不可以这么使用的:
1.类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常
- 类含有STL元素:对带有 string, map, vector 等 STL 元素的类或结构使用 memset,会产生崩溃、内存泄漏…等一系列未知的问题,问题的严重性与编译器有关。不对任何使用了 STL 容器的类或结构使用 memset 来做零初始化。
strcpy和memcpy的区别
|
|
1、复制的内容不同。strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。 2、复制的方法不同。strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度。 3、用途不同。通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy
inline内联函数
- 相当于把内联函数里面的内容写在调用内联函数处;
- 相当于不用执行进入函数的步骤,直接执行函数体;
- 相当于宏,却比宏多了类型检查,真正具有函数特性;
- 编译器一般不内联包含循环、递归、switch 等复杂操作的内联函数;
- 在==类声明中定义的函数==,除了虚函数的其他函数都会自动隐式地当成==内联函数==。
- 内联函数声明必须在调用语句之前
优缺点
优点
- 内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
缺点
- 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
- inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接。(因为inline直接将代码插入到每一个调用的地方,而non-inline函数的调用通过函数地址进行,地址在程序运行时由动态链接器填充)
- 是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。
inline和#define的区别
- 内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
- 内联函数可以访问类的成员变量,而宏定义则不能
- 内联函数在运行时可调试,而宏定义不可以。
虚函数是否可内联
- 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
- 内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
inline virtual
唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如Base::who()
),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。
什么是C++中的RAII?使用场景?
RAII全称是(Resource Acquisition Is Initialization)资源获取即初始化。
核心思想是将资源的获取和对象的生命周期绑定,通过构造函数获取资源(如内存,文件句柄,网络连接),通过析构函数释放资源。这样,即使程序在执行过程中抛出异常或多路径返回,也能确保资源最终得到正确释放,特别是可以避免内存泄露
使用场景
- 内存管理:标准库中的std::unique_ptr和std::shared_ptr是RAII的经典实现,用于智能管理动态内存
- 文件操作:std::fstream类在打开文件时获取资源,在析构函数中关闭文件
- 互斥锁:std::lock_guard和std::unique_lock用于在多线程编程中自动管理互斥锁的锁定和释放
RAII的好处
异常安全:使用RAII能够确保在异常发生的时候自动释放资源,避免资源泄露
简化资源管理:将资源的获取和释放逻辑封装在类内,使代码更加简洁且方便维护
lock_guard和unique_lock的区别?
两者都是RAII形式的锁管理类,用于管理互斥锁(mutex)。区别:
lock_guard
是一个简单且轻量级的锁管理类。在构造的时候锁定给定的互斥体,并在销毁的时候自动解锁。它不可以显示的解锁,也不支持锁的转移
unique_lock
提供了更多的灵活性。它允许显示的锁定和解锁操作,还支持锁的转移。unique_lock
可以选择在构造时不锁定互斥体,在稍后时手动锁定
lock_guard
|
|
unique_lock
|
|
如何设计一个线程安全的类?
- 使用互斥锁保护共享资源
- 部分逻辑可以使用无锁编程,原子变量控制
|
|
三个线程依次打印123
|
|
std::condition_variable::wait
是一个用于线程同步的函数,它让线程在等待某个条件满足之前阻塞自己。常用于多线程编程中,配合 std::mutex
使用,以避免竞争条件。
基本用法
|
|
cv
是std::condition_variable
类型的对象。lock
是一个std::unique_lock<std::mutex>
对象,它在cv.wait
被调用时必须已经锁住了互斥锁。pred
是一个返回布尔值的可调用对象(通常是 lambda 函数或函数对象),它表示线程应该继续执行的条件。如果pred
返回false
,线程将继续等待。
详细解释
- 等待与解锁: 当调用
cv.wait(lock)
时,当前线程会被阻塞,并且会临时解锁lock
持有的互斥锁,以允许其他线程在等待的同时操作共享资源。 - 条件满足后重新加锁: 当另一个线程调用
cv.notify_one()
或cv.notify_all()
唤醒等待线程时,wait
会尝试重新锁住lock
,并继续执行代码。如果pred
返回true
,线程就会继续执行,否则它将再次等待。
thread的join和detach的区别
join
:阻塞当前的调用线程,直到子线程完成。这意味着主线程等待子线程执行完毕后再继续执行,确保子线程完成。
detach
:将子线程从调用线程中分离开来,子线程在后台执行,不会阻塞调用线程。调用detach后,子线程的资源在它独立执行完后自动释放,但主线程将无法在与其通信或者得到执行结果
jthread和thread的区别
自动资源管理:std::jthread
是C++20引入的,它通过RAII管理线程生命周期,当std::jthread
对象被销毁时,它所管理的线程会join。而std::thread
需要手动的调用join
或者detach
方法,否则在std::thread
对象销毁时如果仍未join,会导致程序异常终止
中断支持:std::jthread
设计更优雅的支持线程中断机制,而在std::thread
中没有直接的中断支持,需要程序员手动实现中断逻辑
|
|
原子操作
在C++中,std::atomic
是一个模板类,用于实现原子操作,能够在线程之间安全地共享和修改变量,而无需使用显式的锁或其他同步机制。原子操作是指那些在执行过程中不可分割的操作,不会被其他线程中断,从而避免了数据竞争和竞态条件。
|
|
线程池
管理一个线程池,一个任务队列,每次从任务队列从取一个任务分配给一个线程去做,循环重复
其中,构造函数创建了指定数量的线程,并把它们的执行函数定义为一个无限循环,循环体中从队列中获取任务并执行。如果队列为空且线程池已经停止,线程将退出循环。其中,使用 std::unique_lock 和 std::condition_variable 实现了线程的挂起和唤醒(任务添加的时候会cv.notify_one()
),从而避免了空轮询的浪费。
shared_ptr
shared_ptr
内部包含两个指针,一个指向对象,另一个指向控制块(control block),控制块中包含一个引用计数和其它一些数据。由于这个控制块需要在多个shared_ptr
之间共享,所以它也是存在堆上的。shared_ptr
对象本身是在栈上的,利用RAII的思想,当栈对象超出生命周期后自动析构的特征,无需手动释放资源。
shared_ptr
对象本身是线程安全的,也就是说shared_ptr
的引用计数增加和减少的操作都是原子的。但是对于管理的对象而言不是线程安全的。
通过unique_ptr
来构造shared_ptr
是可行的
|
|
反之则不行,所有权和std::shared_ptr
指向的资源之间的关系是“除非死亡否则永不分离”
shared_prt内部如何实现
两个指针:
-
托管对象:即
shared_ptr
所指向的动态分配的对象。 -
==控制块==
(Control Block):这是一个单独的数据结构,用于存储与
shared_ptr
相关的元数据,主要包括:- 引用计数(Reference Count):跟踪有多少个
shared_ptr
实例指向同一个对象。 - 弱引用计数(Weak Count):跟踪有多少个
weak_ptr
实例与此shared_ptr
相关联,即使主对象已被删除,控制块仍然存在,直到弱引用计数也归零。 - 自定义销毁器,自定义分配器
- 引用计数(Reference Count):跟踪有多少个
以下是std::shared_ptr引用计数器增加和减少的情况:
- 增加:创建shared_prt对象的时候初始化为1,然后每次调用std::shared_ptr的拷贝构造函数或赋值运算时,引用计数器都会增加。也就是说,每当有新的std::shared_ptr开始共享同一个对象时,引用计数器就会增加。
- 减少:每次调用std::shared_ptr的析构函数时,引用计数器都会减少。当引用计数器减少到0时(即没有任何std::shared_ptr再共享该对象),该对象就会被自动删除。
计数器是放在堆上的还是栈上的
std::shared_ptr的引用计数器是存储在堆上的。这是因为std::shared_ptr需要动态分配一个控制块来存储引用计数器和其他信息。==这个控制块在堆上分配==,因为它的生命周期不受任何特定std::shared_ptr实例的影响。换句话说,即使最后一个指向对象的std::shared_ptr已经被销毁,控制块也可能仍然存在(例如,如果还有std::weak_ptr指向该对象)。只有当最后一个指向对象的std::shared_ptr或std::weak_ptr被销毁时,控制块才会被删除。
make_shared 和 shared_ptr的区别
|
|
- 内存分配方式:
std::make_shared
:std::make_shared
会一次性分配一块内存,用来存储控制块(引用计数等)和所管理的对象。这意味着控制块和对象是在同一块内存中分配的。std::shared_ptr
构造函数:如果直接使用std::shared_ptr
构造函数(例如std::shared_ptr<int> sp(new int(10));
),首先会分配一块内存来存储对象,然后分配另一块内存来存储控制块。这意味着控制块和对象可能不在同一块内存中。
- 性能和效率:
std::make_shared
:由于std::make_shared
只进行一次内存分配,它通常比直接使用std::shared_ptr
更高效。此外,由于控制块和对象在同一块内存中,缓存性能也会有所提高。std::shared_ptr
构造函数:直接使用std::shared_ptr
会导致两次内存分配,一次用于对象,一次用于控制块,因此效率可能略低。
- 异常安全性:
std::make_shared
:由于它只执行一次内存分配操作,所以在异常情况下(例如在分配内存或构造对象时抛出异常),不会产生内存泄漏。std::shared_ptr
构造函数:由于需要分别分配内存给对象和控制块,如果在对象分配完成后、控制块分配之前抛出异常,可能会导致内存泄漏。
shared_ptr的循环引用
使用shared_ptr
时,不可避免地会遇到循环引用的情况,这样容易导致内存泄露。循环引用就像下图所示,通过shared_ptr
创建的两个对象,同时它们的内部均包含shared_ptr
指向对方。
|
|
分析一下main
函数是如何退出的,一切就都明了:
main
函数退出之前,Father
和Son
对象的引用计数都是2
。son
指针销毁,这时Son
对象的引用计数是1
。father
指针销毁,这时Father
对象的引用计数是1
。- 由于
Father
对象和Son
对象的引用计数都是1
,这两个对象都不会被销毁,从而发生内存泄露。
线程安全
shared_ptr未对指向的对象内存区域有线程安全保护,因此并发读写对应内存区域是不安全的。
由于赋值操作涉及原内存释放、修改指针指向等多个修改操作,其过程不是原子操作,因此对shared_ptr进行并发赋值不是线程安全的。
对shared_ptr进行并发拷贝,对数据指针和控制块指针仅进行读取并复制,然后对引用计数进行递增,而引用计数增加是原子操作。因此是线程安全的。
避免从原始指针变量上创建std::shared_ptr
当从原始指针上构造出 std::shared_ptr
时会创建控制块。多个控制块意味会被释放多次,这样就会导致double free的问题。
第一,避免传递原始指针,通常使用std::make_unique
。第二,如果非要传递原始指针,直接传new出来的结果,不要传指针变量。
shared_from_this
shared_from_this
提供了一种方法,让一个类的成员函数能够安全的获得对象的std::shared_ptr
。如果直接通过this构造std::shared_ptr
,那么就会创建新的控制块,导致double free的问题【双重释放】。或者传入了this构造了std::shared_ptr
,跳出作用域后,this被意外释放了【指针悬挂】。
通过shared_from_this
,可以在类中创建指向对象的std::shared_ptr
,但不额外创建新的控制块。他需要继承std::enable_shared_from_this
不要memcpy shared_ptr
很显然,不是通过正常途径(拷贝构造,赋值运算),引用计数是不会正确增长的。
但是这并不会导致double free,因为不会去创建新的控制块,只是指针的赋值。那么在release()函数中,只有控制块的引用计数为0的时候,才会delete,所以当sp2去析构的时候,引用计数为-1,并不会去delete释放资源。但是会破坏shared_ptr内部状态
|
|
手写shared_ptr
在智能指针类中存储裸指针(raw pointer)和引用计数。 在构造函数中为裸指针和引用计数分配内存。 在拷贝构造函数和赋值操作符中正确地更新引用计数。 在析构函数中递减引用计数,并在引用计数为零时删除对象和引用计数。
|
|
weak_ptr
weak_ptr
是设计用来配合 shared_ptr
,解决因循环引用而导致的内存泄漏问题。weak_ptr
持有对对象的引用,但不增加其引用计数。这意味着 weak_ptr
不能阻止其指向的对象被销毁。当需要访问 weak_ptr
指向的对象时,可以将其临时提升为 shared_ptr
,使用lock()
|
|
应用场景
- 解决循环引用无法释放的问题
- 探查内存空间是否有效,避免指针悬挂。要是对象已被析构,那么
lock()
返回一个空的shared_ptr
。另⼀种形式是以std::weak_ptr
为实参构造std::shared_ptr
。这种情况中,如果std::weak_ptr
过期,会抛出⼀个异常
指针悬挂(Dangling Pointer)是指指针仍然指向先前分配的内存位置,但该内存位置已经被释放或重新分配。使用悬挂指针会导致未定义行为,包括可能的程序崩溃、数据损坏和安全漏洞。
unique_ptr
unique_ptr
是一种独占所有权的智能指针。它保证同一时间内只有一个 unique_ptr
可以指向一个给定的对象。当 unique_ptr
被销毁或者它被显式移动到另一个 unique_ptr
时,它所管理的对象也会被销毁。
|
|
通过将拷贝构造和复制运算符delete实现独占,这样每一个智能指针要指向一个对象的时候只能指向一个新的实例化对象,不能通过=或者拷贝去指向前面的对象
手写unique_ptr
|
|
std::exchange
|
|
- 以
new_value
替换obj
的值,并返回obj
的旧值。
自赋值问题
如果不判断是否是自赋值,会导致对象本该不被自赋值的动作改变,但是却发现自己持有了一个已经被删除了对象的指针
可以加一个if判断if(*this != p)
,但是这在拷贝赋值运算符中并不是异常安全的,因为如果在深拷贝的过程中,出现了异常,就会导致仍然持有了一个已经被删除了对象的指针。
可以使用copy and swap技术,先通过拷贝构造拷贝一份副本temp,然后通过swap交换指针指向的内存,等到超出作用域,那么临时对象temp就会自动释放,那么原来的内存就被释放了。
C++左值和右值
左值:常指的是具有持久状态的对象。左值可以出现在赋值语句的左边,也就是说,它可以被赋值。左值通常有一个明确的地址,意味着你可以获取它的内存地址。
右值:通常指的是临时的、没有持久状态的对象。右值不可以出现在赋值语句的左边(不能被赋值),但可以出现在右边。右值通常是在表达式计算过程中产生的临时结果。
++i, --i
是左值,因为返回的是对象的引用,i--,i++
是右值,因为返回的是一个临时对象
左值引用
左值引用是 C++ 中传统的引用类型,通常用来引用持久对象(左值)。
- 定义和使用: 使用单个
&
符号定义,例如int& ref = var;
其中var
必须是一个左值。 - 语义: 左值引用可以看作是被引用对象的一个别名,它必须引用一个具有明确存储位置的对象(即左值)。它不能初始化为临时对象或非持久对象。
- 用途: 左值引用主要用于函数参数传递,使得函数可以修改调用者的数据,或者用于返回函数内部的对象引用,使调用者可以访问或修改这些对象。
右值引用
右值引用是 C++11 引入的一种引用类型,用以支持移动语义和完美转发,特别是引用那些即将被销毁的临时对象(右值)。
- 定义和使用: 使用双
&&
符号定义,例如int&& rref = std::move(var);
其中var
可以是左值(经过std::move
转换)或右值。 - 语义: 右值引用允许引用到临时对象。与左值引用相比,右值引用的引入主要是为了允许资源的“窃取”,而不是复制,这极大提升了资源管理(如动态内存管理)的效率。
- 用途: 右值引用最常见的用途是实现移动构造函数和移动赋值运算符,这两个操作可以从即将销毁的对象中窃取资源,减少内存分配和数据复制的开销。此外,右值引用也用于实现完美转发,保证函数模板可以转发参数至其他函数,同时保留原有的值类别(左值或右值)。
移动语义
移动语义允许资源(如动态分配的内存)从一个对象转移至另一个对象,而不需要进行资源的复制。这是通过引入右值引用 (&&
) 实现的,允许开发者区分那些只能被赋值一次的临时对象(右值)。
基本原理
在传统的 C++ 编程中,对象间的赋值通常涉及深复制,即复制对象的所有内容,如动态内存。这种做法虽然简单,但在处理大量数据或复杂对象时会非常低效。移动语义通过允许资源“窃取”来优化这一过程。
移动构造函数和移动赋值运算符
一个类实现移动语义,通常要定义一个移动构造函数和一个移动赋值运算符:
|
|
在这个例子中,移动构造函数和移动赋值运算符“窃取”了源对象 other
的资源(这里是动态数组 data
),并将源对象的指针设置为 nullptr
。这样源对象在销毁时不会释放已经转移的资源。
完美转发
完美转发是模板编程中的一个技术,用于确保函数模板可以接受任何类型的实参并将其转发到另一个函数,同时保持所有实参的值类别(左值、右值性质)。这主要通过 std::forward
实现:
|
|
去掉std::forward
会怎么样?
如果去掉std::forward
,那么参数arg
将总是被视为左值,无论它最初是左值还是右值。这是因为在C++中,函数参数在传递时默认是左值引用
C++中新增了string,与C语言中的 char* 有什么区别吗?它是如何实现的?
C++中的string
类和C语言中的char*
在使用上有很大的区别。以下是一些主要的区别:
类型安全:string
是一个类,提供了许多方法(如append
,replace
,substr
等)来操作字符串。这使得字符串操作更加安全和方便。而char*
则需要使用字符串函数(如strcpy
,strcat
,strlen
等),这些函数在使用不当时可能会导致错误,如缓冲区溢出。
动态大小:string
对象可以动态地改变大小,你可以在运行时添加或删除字符,而不需要担心内存分配。而对于char*
,你需要手动管理内存,并确保有足够的空间来存储所有的字符。
易用性:使用string
可以更容易地进行一些操作,如字符串连接和比较。例如,你可以使用+运算符来连接两个字符串,或者使用==运算符来比较两个字符串是否相等。而对于char*
,你需要使用特定的函数(如strcat
和strcmp
)来进行这些操作。
至于C++中的 string
类是如何实现的,它通常是作为一个动态数组实现的,其中包含一个指向字符数组的指针、一个表示字符串长度的整数以及一个表示分配的内存大小的整数。当字符串增长并超出当前分配的内存大小时,会分配一块更大的内存区域,并将现有字符串复制到新内存中,然后释放旧内存。这种实现方式使得 string
类能够有效地处理动态大小变化的字符串。
string内部使用的是堆内存还是栈内存
string对象本身是在栈内存中,它所管理的字符串在堆内存中。但是,当字符串较短的时候,大多数实现可能会在对象内部使用一个短字符串优化(SSO),此时字符串会在栈内存
if和switch性能
switch…case会生成一个跳转表来指示实际的case分支的地址,而这个跳转表的索引号与switch变量的值是相等的。从而,switch…case不用像if…else那样遍历条件分支直到命中条件,而只需访问对应索引号的表项从而到达定位分支的目的。
当分支较多的时候,switch效率高,因为switch是随机访问的,if可能需要遍历所有可能的值。
volatile
- volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素(硬件、操作系统、其它线程等)更改。所以使用 volatile 告诉编译器不应对这样的对象进行优化,以确保每次访问时都==必须从内存中取出值==(没有被 volatile 修饰的变量,可能由于编译器的优化,从 CPU 寄存器中取值)
volatile
不能保证线程安全:volatile
仅确保变量的可见性,但不提供原子性操作。如果需要保证线程安全,还需要使用其他同步机制,如互斥锁(mutex)或原子操作(atomic)。
三个例子
1. 硬件寄存器
硬件寄存器通常通过内存映射访问,这些寄存器的值可能随时变化。使用 volatile
关键字确保每次访问都能反映寄存器的最新状态。
2. 中断处理程序中的变量
中断处理程序(ISR)中的变量可能在主程序和中断程序之间共享,使用 volatile
关键字确保变量的值在两个程序之间保持一致。
3. 多线程中的共享变量
在多线程程序中,共享变量可能被多个线程同时访问和修改。使用 volatile
确保每个线程都能看到变量的最新值。
assert()
断言,是宏,而非函数。assert 宏的原型定义在 <assert.h>
(C)、<cassert>
(C++)中,其作用是如果它的条件返回错误,则终止程序执行。可以通过定义 NDEBUG
来关闭 assert,但是需要在源代码的开头,include <assert.h>
之前。
assert() 使用
sizeof()
- sizeof 对数组,得到整个数组所占空间大小。
- sizeof 对指针,得到指针本身所占空间大小。
strlen和sizeof的区别
strlen
strlen
是一个函数,用于计算以 '\0'
结尾的字符串的长度,不包括终止字符 '\0'
。
特点:
- 运行时计算:
strlen
在运行时计算字符串的长度。 - 只适用于字符串:
strlen
只能用于以'\0'
结尾的字符串。 - 不包括终止字符:计算结果不包括终止字符
'\0'
。
sizeof
sizeof
是一个运算符,用于计算一个数据类型或变量所占用的内存大小(以字节为单位)。sizeof
操作符的结果是在编译期确定的。
特点:
- 指针的大小永远是固定的,取决于处理器位数,32位就是 4 字节,64位就是 8 字节
- 直接sizeof数组名,就是数组整个的大小。如果数组作为函数参数时会退化为指针,大小要按指针的计算
- struct 结构体要考虑字节对齐
- 字符串数组要算上末尾的 ‘\0’
数组退化为指针的例子
|
|
strlen("\0")
和sizeof("\0")
strlen
函数计算字符串的长度,不包括终止字符 '\0'
。
strlen("\0")
中,"\0"
是一个只包含终止字符'\0'
的字符串。- 因此,
strlen("\0")
计算的长度为0,因为在字符串中,'\0'
表示字符串的结束,前面没有任何字符。
sizeof
运算符计算对象或类型的大小,以字节为单位。
sizeof("\0")
中,"\0"
是一个字符串字面量。字符串字面量包含字符串中的所有字符以及一个额外的终止字符'\0'
。- 因此,
sizeof("\0")
的结果是2,因为"\0"
包含两个字符:一个是'\0'
,另一个是表示字符串结束的'\0'
。
如何不用sizeof求int的字节占用
利用指针的地址运算转换成char*
|
|
什么是内存对齐?为什么要内存对齐?
内存对齐指的是计算机访问内存的时候,会根据一些规则来为数据指定一个起始地址。这个起始地址通常是某个固定数字的整数倍。这样做,可以提高CPU的访问效率,尤其在读写的时候
原因
- 性能提升:对齐可以让CPU在一次内存周期内更高效的读写,减少内存访问次数
- 硬件限制:某些架构必须要求内存对齐,否则可能引发硬件异常或需要额外的处理时间
- 可移植性:代码在不同架构上运行,内存对齐可以减少潜在的问题
结构体对齐
成员偏移值是某个对齐数的整数倍,对齐数是该成员变量的大小
结构体大小是最大对齐数的整数倍
如果结构体有成员长度大于处理器的位数,那么就以处理器的位数作为对齐单位(比如结构体嵌套,导致成员大于处理器的最大位数)
#pragma pack(n)
设定结构体、联合以及类成员变量以 n 字节方式对齐
#pragma pack(n) 使用
|
|
如何获得结构成员相对于结构开头的字节偏移量
使用<stddef.h>头文件中的,offsetof宏。
|
|
位域
|
|
位域(bit field)是一种在结构体(struct)或联合体(union)中定义的特殊字段,用于以更精细的粒度管理数据。位域允许你在结构体中定义成员变量的位宽,从而更有效地使用内存,尤其在需要存储多个标志或小范围整数值的场景下非常有用。位域通常用于嵌入式系统、协议头解析等场景。
- 关键点
- 位域的类型:位域的类型通常为
unsigned int
或int
,但可以是其他整型类型,如signed int
、unsigned short
等。类型决定了位域的对齐方式。 - 位宽:位域的宽度是一个非负整数,表示该位域占用的位数。如果定义的位宽超出类型所能表示的范围,行为未定义。
- 位域对齐:位域的存储方式依赖于实现,编译器通常会将位域放在一个整型的存储单元中,并根据需要跨多个存储单元进行对齐。
- 跨存储单元:如果一个位域不能容纳在一个存储单元中,编译器会将其分成两部分,分别存储在两个相邻的存储单元中。
- 位域的类型:位域的类型通常为
extern “C”
- 被 extern 限定的函数或变量是 extern 类型的
- 被
extern "C"
修饰的变量和函数是按照 C 语言方式编译和链接的
extern "C"
的作用是让 C++ 编译器将 extern "C"
声明的代码当作 C 语言代码处理,可以避免 C++ 因==符号修饰==导致代码不能和C语言库中的符号进行链接的问题。
C 和 C++ 的编译器对符号(函数和变量)的命名规则不同。C++ 支持函数重载,因此编译器在生成符号时会对==函数名进行修饰==,称为名称修饰(name mangling)。C 语言不支持函数重载,其编译器不会对函数名进行修饰。如果不加 extern "C"
,C++ 编译器会对符号名进行修饰,导致 C 代码无法找到正确的符号。
extern “C” 使用
|
|
extern关键字
extern是c++引入的一个关键字,它可以应用于一个全局变量,函数或模板声明,说明该符号具有外部链接*(external linkage)*属性。也就是说,这个符号在别处定义。一般而言,C++全局变量的作用范围仅限于当前的文件,但同时C++也支持分离式编译,允许将程序分割为若干个文件被独立编译。于是就需要在文件间共享数据,这里extern就发挥了作用。
头文件声明时加extern,定义时不要加:因为extern可以多次声明,但只有一个定义
文件1:global.cpp
在这个文件中,我们定义一个全局变量。
|
|
文件2:main.cpp
在另一个文件中,我们使用 extern
声明这个全局变量,并使用它。
|
|
struct和typedef struct
C语言中
|
|
等价于
|
|
此时 S
等价于 struct Student
,但两个标识符名称空间不相同。
C++中
由于编译器定位符号的规则(搜索规则)改变,导致不同于C语言。
一、如果在类标识符空间定义了 struct Student {...};
,使用 Student me;
时,编译器将搜索全局标识符表,Student
未找到,则在类标识符内搜索。
即表现为可以使用 Student
也可以使用 struct Student
,如下:
|
|
二、若定义了与 Student
同名函数之后,则 Student
只代表函数,不代表结构体,如下:
|
|
C++中,Student
名称已被同名函数覆盖,使得编译器将 Student
识别为函数而非类型。
C++struct和class
总的来说,struct 更适合看成是一个==数据结构==的实现体,class 更适合看成是一个==对象==的实现体。
区别
- 最本质的一个区别就是默认的访问控制
- 默认的继承访问权限。struct 是 public 的,class 是 private 的。
- struct 作为数据结构的实现体,它默认的数据访问控制是 public 的,而 class 作为对象的实现体,它默认的成员变量访问控制是 private 的。
union联合
在C语言和C++中,union
(联合体)是一种数据结构,它允许在相同的内存位置存储不同类型的数据。union
中的所有成员共享同一个内存地址,因此一个union
变量的大小等于其最大的成员的大小。
|
|
在32位系统中,double占8字节,char占1字节,int数组占20字节,因此占用20字节,大小为20,由于double需要8字节对其,因此总共所占空间为24
函数指针、指针函数、回调函数、函数对象
函数指针是指向函数的指针变量。
通常我们说的指针变量是指向一个整型、字符型或数组等变量,而函数指针是指向函数。
函数指针可以像一般函数一样,用于调用函数、传递参数。
函数指针类型的声明:
|
|
指针函数就是返回值是指针的函数
回调函数:函数指针作为某个函数的参数
函数指针变量可以作为某个函数的参数来使用的,回调函数就是一个通过函数指针调用的函数。
简单讲:回调函数是由别人的函数执行时调用你实现的函数。
函数对象:
函数对象(仿函数)是一个类,不是一个函数
函数对象重载了()操作符使得他可以像函数一样调用,如果重载()只需要一个函数,那么就成为一元仿函数
C和C++区别
C 和 C++ 是两种不同的编程语言,尽管它们有许多相似之处,但也有显著的区别。
- 面向对象编程(OOP):
- C:C 是一种面向过程的编程语言,主要关注函数和过程。
- C++:C++ 是一种面向对象的编程语言,支持类和对象,提供封装、继承和多态等特性。
- 标准库:
- C:C 的标准库主要提供基本的输入输出、字符串处理、内存管理等功能。
- C++:C++ 标准库(包括 STL,标准模板库)提供了更丰富的数据结构和算法,如向量、列表、集合、映射等容器,以及迭代器和算法。
- 函数和操作符重载:
- C:C 不支持函数重载和操作符重载,每个函数必须有一个唯一的名称。
- C++:C++ 支持函数重载和操作符重载,可以根据参数类型和数量来定义同名函数或重载运算符,使代码更加简洁和直观。
用三个词概括 C 和 C++ 的区别
- 面向对象
- 标准库
- 重载
C实现C++类
C 实现 C++ 的面向对象特性(封装、继承、多态)
- 封装:我们可以使用结构体和函数指针来实现封装。
- 继承:结构体嵌套
- 多态:派生类中基类的函数指针指向派生类的函数
1. 封装(Encapsulation)
封装是面向对象编程中的一个核心概念,主要是为了隐藏对象的内部细节,只暴露必要的操作接口。在 C 语言中,你可以通过使用 struct
来定义数据结构,然后通过函数来操作这些数据结构,从而达到封装的效果。
2. 继承(Inheritance)
虽然 C 语言没有原生的继承支持,但可以通过结构体嵌套的方式模拟继承。基类的结构体被嵌入到派生类中作为第一个成员,从而实现类似继承的效果。
3. 多态(Polymorphism)
使用基类的函数指针调用函数,实现多态行为。
|
|
explicit关键字
- explicit 主要是防止构造函数或者转换函数进行隐式类型转换,尤其是构造函数的参数只有一种的时候,强烈建议加上expilicit
|
|
编译器行为
当编译器遇到一个构造函数时,它会检查该构造函数是否标记为 explicit
。如果构造函数是 explicit
的,那么编译器在进行以下操作时会进行检查并拒绝隐式转换:
- 隐式转换:编译器会拒绝将其他类型的对象隐式转换为目标类型的对象。
- 复制初始化:编译器会拒绝通过复制初始化的方式创建对象,例如
MyClass obj = value;
。
char和int的转换
在C和C++编程中,char
和int
之间的转换是常见操作,因为字符数据类型(char
)实际上是整数类型的一种,只不过它通常表示的是ASCII码表中的字符。以下是char
和int
之间的转换方法及注意事项。
char
转int
将char
类型转换为int
类型时,实际是获取字符对应的ASCII值。
示例:
|
|
输出结果:
|
|
注意char溢出后的数值,char的范围是-128~127
比如500
500的二进制1 11110100 舍弃超过8bit的字符,那么就是1111 0100整形提升,1111 1111 1111 1111 1111 1111 1111 0100,然后转换成原码(取反 + 1)就是 1000 0000 0000 0000 0000 0000 0000 1100 所以就是-12
int
转char
将int
类型转换为char
类型时,实际上是将整数解释为对应的ASCII字符。
示例:
|
|
输出结果:
|
|
friend友元类和友元函数
- 能访问私有成员
- 破坏封装性
- 友元关系不可传递
- 友元关系的单向性
- 友元声明的形式及数量不受限制
解决哈希冲突
拉链法 : 哈希表的每个槽位关联一个链表。所有映射到同一个槽位的元素都将存储在这个槽位的链表中。当发生冲突时,元素简单地被添加到相应槽位的链表尾部。
开放寻址法:在开放寻址法中,所有元素都直接存储在哈希表数组中。当发生冲突时,采用某种探测技术在表中寻找另一个空槽来存储冲突的元素。开放寻址法的几种常见探测技术包括:
- 线性探测(Linear Probing):顺序查找表中的下一个空槽。
- 平方探测(Quadratic Probing):使用平方来计算探测的位置。
- 双重散列(Double Hashing):使用第二个哈希函数来决定探测的步长
再哈希法(Rehashing):再哈希法是一种解决哈希冲突的技术,也可以作为表容量扩展时重新分配所有键的方法。它使用多个哈希函数。当第一个哈希函数导致冲突时,尝试第二个哈希函数,依此类推,直到找到空槽位。
建立公共溢出区:这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
常见的排序算法
快速排序
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;
|
|
非递归
|
|
归并排序
自顶向下,递归
|
|
冒泡排序
比较相邻的元素。如果第一个比第二个大,就交换他们两个。
对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
针对所有的元素重复以上的步骤,除了最后一个。
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
|
|
选择排序
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的后面一个位置。
重复第二步,直到所有元素均排序完毕。
算法流程:从第一个位置,找到最小的元素,交换位置。然后是第二个位置放谁,第三个位置放谁,以此类推
|
|
插入排序
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
|
|
堆排序
|
|
如何手写一个堆
如何手写一个堆,下标从1开始
- 插入一个数
heap[++size] = x; up(x);
- 求集合当中的最小值
heap[1];
- 删除最小值
heap[1] = heap[size]; size --; down(1);
- 删除任意位置元素
heap[k] = heap[size]; size --; down(k); up(k);
- 修改任意位置元素
heap[k] = x; down(k); up(k);
堆的存储,全新的存储方式,用一个数组来寸,1号点是根节点,$x$的左儿子是$2x$,右儿子是$2x+1$
第一个操作down(x)
第二个操作up(x)
|
|
std::sort 是怎么实现的,具体排序算法
std::sort
是 C++ 标准库中的一个函数模板,用于对指定范围内的元素进行排序。它在头文件 <algorithm>
中定义,通常实现为一种快速排序(QuickSort)算法与其他排序算法的混合,以确保在最坏情况下也能有较好的性能表现。下面是对 std::sort
具体实现和其使用的排序算法的详细介绍。
std::sort
的实现
std::sort
的具体实现可能因编译器的不同而有所不同,但通常采用了一种混合排序算法,例如 Introsort(IntroSort)。Introsort 结合了快速排序、堆排序(HeapSort)和插入排序(InsertionSort)三种排序算法。
Introsort(混合排序)
Introsort 的核心思想是先使用快速排序进行排序,当递归深度超过一定限制时,切换到堆排序,以避免快速排序在最坏情况下的 O(n^2) 时间复杂度。同时,当分区的子数组足够小时,切换到插入排序,因为插入排序在小数组上表现更好。
- 快速排序(QuickSort):
- 使用分治法将数组分成两部分,然后递归地对每一部分进行排序。
- 平均时间复杂度为 O(n log n),但最坏情况下为 O(n^2)(如数组已经有序时)。
- 堆排序(HeapSort):
- 当递归深度超过限制时使用,避免快速排序在最坏情况下的性能问题。
- 时间复杂度为 O(n log n),在最坏情况下也是如此。
- 插入排序(InsertionSort):
- 当分区的子数组足够小时使用,插入排序在小数组上效率较高。
- 时间复杂度为 O(n^2),但对小规模数据集效率高。
std::sort
的大致实现步骤
- 开始排序:
- 选择快速排序作为初始排序算法。
- 计算最大递归深度限制,通常是 2 * log(n),其中 n 是数组的大小。
- 执行快速排序:
- 使用快速排序对数组进行分区。
- 如果递归深度超过限制,切换到堆排序。
- 切换到堆排序:
- 当递归深度超过限制时,使用堆排序对当前分区进行排序。
- 切换到插入排序:
- 当分区的子数组大小小于某个阈值(例如 16)时,使用插入排序对小数组进行排序。
快速排序最好和最坏的情况
最好的情况
快速排序在最好的情况下的时间复杂度是 O(n log n)。最好的情况是每次选择的基准元素都能将数组均匀地分成两部分。即:
- 第一次划分将数组分成大小相等的两部分,每部分大小为
n/2
。 - 第二次划分将每部分再分成两部分,每部分大小为
n/4
,依此类推。
这种情况下,递归树的高度为 log(n),每一层的工作量为 O(n),因此总时间复杂度为 O(n log n)。
最坏的情况
快速排序在最坏的情况下的时间复杂度是 O(n^2)。最坏的情况是每次选择的基准元素总是数组中的最小或最大元素,导致划分非常不均匀。即:
- 每次划分时,一个子数组为空,另一个子数组包含剩下的所有元素。
例如,当数组已经有序或逆序,并且总是选择第一个或最后一个元素作为基准时:
- 第一次划分后,一个子数组为空,另一个子数组大小为
n-1
。 - 第二次划分后,一个子数组为空,另一个子数组大小为
n-2
,依此类推。
这种情况下,递归树的高度为 n,每一层的工作量为 O(n),因此总时间复杂度为 O(n^2)。
如果要对一个很大的数据集,进行排序,而没办法一次性在内存排序,这时候怎么办?(外部排序)
外部排序是一种将数据分块、分阶段处理,以适应内存限制的排序方法。最常用的外部排序算法是 归并排序(Merge Sort),它适合处理无法完全加载到内存的大型数据集。
外部排序的步骤
- 分块(Divide into Chunks):
- 将大数据集分成若干个可以放入内存的小块,每个小块都能够完全加载到内存中。
- 示例:假设有一个 100GB 的文件,但内存只能容纳 1GB 数据。我们可以将这个文件分成 100 个 1GB 的块。
- 内部排序(Sort Each Chunk):
- 对每个小块单独进行排序。由于每个小块可以完全放入内存,因此可以使用快速排序、归并排序或其他常见的内存排序算法。
- 示例:对每个 1GB 的块在内存中排序,然后将排序后的块写回磁盘。
- 外部归并(Merge Sorted Chunks):
- 将排序后的多个小块进行归并。归并时,每次从每个排序后的块中读取少量数据(可以放入内存),然后合并这些数据并输出到最终的排序结果中。
- 归并过程:通过多路归并(k-way merge),从多个有序块中选取最小的元素,依次合并,直到所有块都被完全归并。
哈夫曼(huffman)树和哈夫曼编码
首先哈夫曼树是什么?
哈夫曼树的定义:给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree),哈夫曼树是带权路径长度最短的树。权值较大的结点离根较近。
那这个树长啥样子呢?例如开始2,3,6,8,9权值节点构成的哈夫曼树是这样的:
从定义和图上你也可以发现下面的规律:
- 初始节点都在树的叶子节点上
- 权值大的节点离根更近
- 每个非叶子节点都有两个孩子(因为我们自下向上构造,两个孩子构成一个新树的根节点)
你可能会好奇这么一个哈夫曼树是怎么构造的,其实它是按照一个贪心思想和规则构造,而构造出来的这个树的权值最小。这个规则下面会具体讲解。
哈夫曼树非常重要的一点:WPL
(树的所有叶结点的带权路径长度之和)。至于为什么按照哈夫曼树方法构造得到的权重最小,这里不进行证明,但是你从局部来看(三个节点)也要权值大的在上一层WPL才更低。
WPL计算方法: WPL=求和(Wi * Li)其中Wi是第i个节点的权值(value)。Li是第i个节点的长(深)度.
例如上面 2,3,6,8,9权值节点构成的哈夫曼树的WPL计算为(设根为第0层):
比如上述哈夫曼树的WPL
为:2*3+3*3+6*2+8*2+9*2=(2+3)*3+(6+8+9)*2=61
.
既然了解了哈夫曼树的一些概念和WPL的计算方式,下面看看哈夫曼树的具体构造方式吧!
哈夫曼树构造
初始给一个森林有n个节点。我们主要使用贪心的思想来完成哈夫曼树的构造:
- 在n个节点找到两个最小权值节点(根),两个为叶子结构构建一棵新树(根节点权值为左右孩子权值和)
- 先删掉两个最小节点(n-2)个,然后加入构建的新节点(n-1)个
- 重复上面操作,一直到所有节点都被处理
在具体实现上,找到最小两个节点需要排序操作,我们来看看2,6,8,9,3权值节点构成哈夫曼树的过程。
初始时候各个节点独立,先将其排序(这里使用优先队列),然后选两个最小节点(抛出)生成一个新的节点,再将其加入优先队列中,此次操作完成后优先队列中有5,6,8,9节点
设计模式
-
单例模式:单例模式(Singleton Pattern)是一种常用的模式,有一些对象我们往往只需要一个,比如全局缓存、浏览器中的 window 对象等。单例模式用于保证一个类仅有一个实例,并提供一个访问它的全局访问点。// 定义私有的静态属性,来保存对象实例 // 提供一个静态的方法来获取对象实例【创建者模式】
单例模式的优点
全局访问:单例模式提供了对一个对象的全局访问点,类似于全局变量,但避免了全局变量的污染。
控制实例数量:单例模式确保一个类只有一个实例,避免了多个实例带来的资源浪费和不一致性问题。
延迟实例化:单例模式可以实现延迟实例化,即在第一次使用时才创建实例,这样可以避免不必要的资源消耗。
避免重复初始化:确保实例只被初始化一次,避免了多次初始化带来的问题和复杂性。
单例模式的缺点
违反单一职责原则:单例模式不仅管理类的实例化,还控制其生命周期,这违反了单一职责原则
单例模式一般可分为两种模式:饿汉模式和懒汉模式。顾名思义,饿汉模式是类加载时就创建该对象,懒汉模式 则是等需要使用该对象的时候才去创建。 饿汉模式的优点是线程安全,因为它一开始就将对象给创建完毕,但缺点也是由于创建后未必会立刻调用这个对象,这个实例对象保存在内存中会造成一定程度上的浪费。 懒汉模式则是在需要使用该对象时才会创建,这也就带来线程不够安全的问题。
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
懒汉 #include <iostream> #include <string.h> using namespace std; class Singleton{ public: static Singleton* getinstance(); private: Singleton(){ cout << "Singleton create" << endl; } ~Singleton() = default; Singleton (const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete; }; Singleton* Singleton::getinstance(){ static Singleton instance; return &instance; } int main() { Singleton* a = Singleton::getinstance(); Singleton* b = Singleton::getinstance(); if(a == b) cout << "one instance" << endl; else cout << "two instances" << endl; return 0; } 饿汉 #include <iostream> #include <string.h> using namespace std; class Singleton{ public: static Singleton* getinstance(); private: Singleton(){ cout << "Singleton create" << endl; } ~Singleton() = default; Singleton (const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete; static Singleton* m_instance; }; Singleton* Singleton::m_instance = new Singleton(); Singleton* Singleton::getinstance(){ return m_instance; } int main() { Singleton* a = Singleton::getinstance(); Singleton* b = Singleton::getinstance(); if(a == b) cout << "one instance" << endl; else cout << "two instances" << endl; return 0; }
-
工厂模式:简单工厂模式、工厂方法模式和抽象工厂模式。【创建型模式】
-
简单工厂:把对象的创建封装在一个接口函数里,通过传入不同的标志返回不同的对象。客户不需要自己new对象,不用了解对象创建的过程,因为封装在了Factory里
缺点:提供创建对象实例的接口函数不闭合,不能对修改关闭(开-闭原则)(如果有新的类,那么就需要修改工厂模式的代码)
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
#include <iostream> #include <string.h> using namespace std; enum Cartype{ baoma, aodi }; class Car{ public: Car(string name):_name(name){}; virtual void show() = 0; protected: string _name; }; class BMW: public Car{ public: BMW(string name):Car(name){}; void show(){cout << "this is BMW" << _name << endl;} }; class AUDI: public Car{ public: AUDI(string name):Car(name){}; void show(){cout << "this is AUDI" << _name << endl;} }; class SimpleFactory{ public: Car* create(Cartype name) { switch (name) { case baoma: return new BMW("baoma"); case aodi: return new AUDI("aodi"); default: break; } return nullptr; } }; int main() { SimpleFactory* factory = new SimpleFactory(); Car* car1 = factory->create(baoma); Car* car2 = factory->create(aodi); car1->show(); car2->show(); return 0; }
-
工厂方法模式(Factory Method Pattern)又称为工厂模式,也叫多态工厂(Polymorphic Factory)模式,它属于类创建型模式。一个类不知道它所需要的对象的类,客户端只需要知道创建具体产品的工厂类。Factory父类(创建不同产品)提供了一个纯虚函数,子类(具体工厂)负责创建对应工厂的产品。做到不同产品在不同工厂里创建,还能对现有工厂和产品进行修改
缺点: 很多产品都具有关联,属于一个产品簇不能简单的拆分,不应该分放到不同的工厂中创建 比如汽车工厂,里面需要实现车灯类,轮胎类等等。 一是不符合实际产品对象的创建逻辑,二是工厂类太多了,不好维护
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
#include <iostream> #include <string.h> using namespace std; class Car{ public: Car(string name):_name(name){}; virtual void show() = 0; protected: string _name; }; class BMW: public Car{ public: BMW(string name):Car(name){}; void show(){cout << "this is BMW" << _name << endl;} }; class AUDI: public Car{ public: AUDI(string name):Car(name){}; void show(){cout << "this is AUDI" << _name << endl;} }; class Factory{ public: virtual Car* create(string name) = 0; }; class BMWFactory: public Factory{ public: Car* create(string name) {return new BMW(name);} }; class AUDIFactory: public Factory{ public: Car* create(string name) {return new AUDI(name);} }; int main() { Factory* baoma = new BMWFactory(); Factory* aodi = new AUDIFactory(); Car* car1 = baoma->create("baoma"); Car* car2 = aodi->create("aodi"); car1->show(); car2->show(); 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 71 72 73 74 75
#include <iostream> #include <string.h> using namespace std; class Car{ public: Car(string name):_name(name){}; virtual void show() = 0; protected: string _name; }; class BMW: public Car{ public: BMW(string name):Car(name){}; void show(){cout << "this is BMW" << _name << endl;} }; class AUDI: public Car{ public: AUDI(string name):Car(name){}; void show(){cout << "this is AUDI" << _name << endl;} }; class Carlight{ public: virtual void show() = 0; }; class BMWlight: public Carlight{ public: void show(){cout << "this is BMW light" << endl;} }; class AUDIlight: public Carlight{ public: void show(){cout << "this is AUDI light" << endl;} }; class AbstractFactory // 抽象工厂,将目标产品的一些特征抽象出多个虚函数 { public: virtual Car* createCar(string name) = 0; // 工厂方法负责创建汽车 virtual Carlight* createCarLight() = 0; // 工厂方法负责创建车灯 }; class BMWFactory :public AbstractFactory { public: Car* createCar(string name) { return new BMW(name); } Carlight* createCarLight() { return new BMWlight; } }; class AUDIFactory :public AbstractFactory { public: Car* createCar(string name) { return new AUDI(name); } Carlight* createCarLight() { return new AUDIlight; } }; int main() { AbstractFactory* baoma = new BMWFactory(); AbstractFactory* aodi = new AUDIFactory(); Car* car1 = baoma->createCar("baoma"); Carlight *car1light = baoma->createCarLight(); Car* car2 = aodi->createCar("aodi"); Carlight *car2light = aodi->createCarLight(); car1->show(); car1light->show(); car2->show(); car2light->show(); return 0; }
-
-
建造者模式:建造者模式(Builder Pattern)将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象。【创建型模式】
-
代理模式:它为某个对象提供一个代理对象,并由代理对象来控制对原对象的访问。代理模式的主要目的是在不改变原始对象的前提下,通过代理对象来添加额外的功能或控制。【结构型模式】
-
适配器模式:软件工程中,适配器模式的作用是解决两个软件实体间的接口不兼容的问题。 【结构型模式】
-
装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构,它是作为现有的类的一个包装。装饰器模式通过将对象包装在装饰器类中,以便动态地修改其行为。【结构型模式】
-
模板方法模式:它定义了一个算法的骨架,并允许子类在不改变算法结构的情况下重新定义算法的某些步骤。【行为型模式】
-
责任链模式:它允许多个对象有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。责任链模式将这些对象连成一条链,并沿着这条链传递请求,直到有一个对象处理它为止。【行为型模式】
-
观察者模式:观察者模式,它定义了一种一对多的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使得它们能够自动更新自己。【行为型模式】
-
策略模式:在策略模式定义了一系列算法或策略,并将每个算法封装在独立的类中,使得它们可以互相替换。通过使用策略模式,可以在运行时根据需要选择不同的算法,而不需要修改客户端代码。【行为型模式】
-
状态模式:在状态模式(State Pattern)中,类的行为是基于它的状态改变的。【行为型模式】
介绍面向对象的三大特性
四大:抽象、封装、继承、多态
面向对象编程的三大特性是:封装、继承和多态。
- 封装:封装是指将数据(属性)和操作数据的函数(方法)打包在一起,形成一个“类”。这样可以隐藏内部实现细节,只暴露必要的接口。封装可以增强安全性和简化编程,使用者只需要知道对象提供了哪些服务,而不需要知道这些服务是如何实现的。
- 继承:继承是一种能够让某个类型的对象获得另一个类型的对象的属性和方法的机制。通过继承,我们可以创建一个通用类(父类),然后定义更具体的类(子类)来继承父类的属性和方法。子类除了继承父类的特性外,还可以定义自己特有的特性。
- 多态:多态意味着可以通过基类指针调用任何派生类的函数,也就是说,指向不同对象的指针可能会在运行时调用不同的函数。多态分为编译时多态(重载)和运行时多态(虚函数)。多态允许我们使用一个接口表示不同的实现,从而使得程序具有更好的可扩展性。
这三大特性使得面向对象编程更加灵活和强大,有助于提高代码的可读性、可维护性和可复用性。
简述一下 C++ 中的多态
在C++中,多态是面向对象编程的一个重要特性,它允许我们使用一个接口表示不同的实现。多态分为两种形式:编译时多态和运行时多态。
编译时多态:也被称为静态多态或早绑定。它主要通过函数重载和模板实现。编译器在编译阶段就能确定调用哪个函数。
运行时多态:也被称为动态多态或晚绑定。它主要通过虚函数和抽象类实现。具体调用哪个函数是在程序运行时才能确定。
以下是一个简单的运行时多态的例子:
在这个例子中,Base
类有一个虚函数print
,Derived
类重写了这个函数。我们创建了一个指向Derived
对象的Base
指针,并通过这个指针调用print
函数。虽然这个指针的类型是Base*
,但是调用的却是Derived
类的print
函数。这就是运行时多态的体现。
|
|
多态的好处
- 代码重用性
- 通过基类指针或引用操作派生类对象:基类指针或引用可以指向任何派生类对象,从而可以在不修改客户端代码的情况下添加新的派生类。这提高了代码的重用性。
- 代码的可扩展性
- 添加新功能时无需修改现有代码:通过多态性,可以在不修改现有代码的情况下添加新类。新类只需继承基类并实现相应的虚函数。
- 提高代码的灵活性
- 运行时绑定:多态性使得函数调用在运行时被绑定到实际的函数实现,从而允许程序根据运行时的条件来决定调用哪个函数。
C++的三种访问权限
- public:公共权限,类内可以访问,类外可以访问
- protected:保护权限,类内可以访问,类外不可以访问,继承的子类类内可以访问,类外不可以
- private:私有权限,类内可以访问, 类外不可以访问,继承的子类类内类外不可以访问
C++ 类对象的初始化和析构顺序
-
基类初始化顺序 如果当前类继承自一个或多个基类,它们将按照声明顺序进行初始化,但是在有虚继承和一般继承存在的情况下,优先虚继承。比如虚继承:class MyClass : public Base1, public virtual Base2,此时应当先调用 Base2 的构造函数,再调用 Base1 的构造函数。
-
成员变量初始化顺序 类的成员变量按照它们在类定义中的声明顺序进行初始化(这里一定要注意,成员变量的初始化顺序只与声明的顺序有关!!)。
-
执行构造函数 在基类和成员变量初始化完成后,执行类的构造函数。
记住一点即可,类的析构顺序和构造顺序完全相反
被隐藏的基类函数如何调用或者子类调用父类的同名函数和父类成员变量
调用被隐藏的父类同名函数
为了调用父类的同名函数,可以使用作用域解析运算符 ::
。
访问被隐藏的父类同名成员变量
同样地,使用作用域解析运算符 ::
可以访问父类的同名成员变量。需要注意的是,如果父类成员变量是私有的(private
),则子类不能直接访问它,必须通过父类的公有(public
)或受保护(protected
)的接口来访问。
虚函数是什么,和纯虚函数的区别
虚函数是一种特殊类型的函数,它在基类中被声明,并可以在任何派生类中被重写。虚函数允许我们通过基类指针来调用派生类的这个函数。这种机制被称为动态绑定或运行时多态。虚函数在基类中通常有一个默认的实现,但也可以没有。
在这个例子中,Base
类有一个虚函数foo
,Derived
类重写了这个函数。我们可以创建一个指向Derived
对象的Base
指针,并通过这个指针调用foo
函数。虽然这个指针的类型是Base*
,但是调用的却是Derived
类的foo
函数。
|
|
而纯虚函数是在基类中声明的虚函数,它在基类中没有定义(即没有实现),但要求任何派生类都要定义自己的实现方法。定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。纯虚函数的声明方式是在函数原型后加=0
。
|
|
父类的纯虚函数必须重写,否则子类也是一个虚类不可实例化。 父类中虚函数(非纯虚函数,即父类对其有定义),则子类也可以不重写,相当于原样继承了父类的虚函数。也可以重写,就相当于覆盖了父类的虚函数实现。不论是否重写虚函数都不影响子类的实例化~
虚函数与虚函数表
多态是由虚函数实现的,而虚函数主要是通过**虚函数表(V-Table)**来实现的。
如果一个类中包含虚函数(virtual修饰的函数),那么这个类就会包含一张虚函数表,虚函数表存储的每一项是一个虚函数的地址。如下图:
这个类的每一个对象都会包含一个虚指针(虚指针存在于对象实例地址的最前面,保证虚函数表有最高的性能),这个虚指针指向虚函数表。
注:对象不包含虚函数表,只有虚指针,类才包含虚函数表,派生类会生成一个兼容基类的虚函数表。
- 原始基类的虚函数表
下图是原始基类的对象,可以看到虚指针在地址的最前面,指向基类的虚函数表(假设基类定义了3个虚函数)
- 单继承时的虚函数(无重写基类虚函数)
假设现在派生类继承基类,并且重新定义了3个虚函数,派生类会自己产生一个兼容基类虚函数表的属于自己的虚函数表。
Derive class 继承了 Base class 中的三个虚函数,准确的说,是该函数实体的地址被拷贝到 Derive类的虚函数表,派生类新增的虚函数置于虚函数表的后面,并按声明顺序存放。
- 单继承时的虚函数(重写基类虚函数)
现在派生类重写基类的x函数,可以看到这个派生类构建自己的虚函数表的时候,修改了base::x()这一项,指向了自己的虚函数。
- 多重继承时的虚函数(Derived ::public Base1,public Base2)
这个派生类多重继承了两个基类base1,base2,因此它有两个虚函数表。
它的对象会有多个虚指针(据说和编译器相关),指向不同的虚函数表。
虚函数表存储在哪里,程序如何找到虚函数表
虚函数表的存储位置
- 虚函数表 是一个指针数组,其中每个指针指向一个虚函数的实际实现。每个类有一个虚函数表,用于存储该类及其继承层次结构中的虚函数指针。
- 虚函数表指针(vptr):每个包含虚函数的对象实例都有一个隐式的指针(通常称为
vptr
),指向该对象所属类的虚函数表。
虚函数表本身:
- 存储在全局数据区或静态区。每个类(如果有虚函数)有一个虚函数表,这个表通常是在编译期生成的。
- 虚函数表在程序加载时初始化,由编译器负责。
虚函数表指针 (vptr
):
- 存储在每个对象的内存布局中,通常是对象的最前面(即对象内存结构的第一个成员)。
对象构造时:当对象被构造时,编译器生成的构造函数代码会自动初始化对象的 vptr
,使其指向正确的虚函数表。例如,如果你创建的是 Derived
类的对象,构造函数会把 vptr
指向 Derived
类的虚函数表。
虚继承
虚拟继承是为了解决多重继承下公共基类的多份拷贝问题(钻石继承,菱形继承)。比如上边的例子中MyClassC的对象内包含MyClassA和MyClassB子对象,但是MyClassA和MyClassB内含有共同的基类MyClass。为了消除MyClass子对象的多份存在,我们需要让MyClassA和MyClassB都虚拟继承于MyClass,然后再让MyClassC多重继承于这两个父类。相对于上边的例子,类内的设计不做任何改动,先修改MyClassA和MyClassB的继承方式:
|
|
由于虚继承的本身语义,MyClassC内必须重写fun函数,因此我们需要再重写fun函数。这种情况下,MyClassC的对象模型如下:
虚继承的引入把对象的模型变得十分复杂,除了每个基类(MyClassA和MyClassB)和公共基类(MyClass)的虚函数表指针需要记录外,每个虚拟继承了MyClass的父类还需要记录一个虚基类表vbtable的指针vbptr。MyClassC的对象模型如图4所示。
虚基类表每项记录了被继承的虚基类子对象相对于虚基类表指针的偏移量。比如MyClassA的虚基类表第二项记录值为24,正是MyClass::vfptr相对于MyClassA::vbptr的偏移量,同理MyClassB的虚基类表第二项记录值12也正是MyClass::vfptr相对于MyClassA::vbptr的偏移量。
和虚函数表不同的是,虚基类表的第一项记录着当前子对象相对与虚基类表指针的偏移。MyClassA和MyClassB子对象内的虚表指针都是存储在相对于自身的4字节偏移处,因此该值是-4。假定MyClassA和MyClassC或者MyClassB内没有定义新的虚函数,即不会产生虚函数表,那么虚基类表第一项字段的值应该是0
类的内存结构
如果没有虚继承
1. 每一个基类都会有自己的虚函数表,派生类的虚函数表的数量根据继承的基类的数量来定。
2. 派生类的虚函数表的顺序,和继承时的顺序相同。
3. 派生类自己的虚函数放在第一个虚函数表的后面,顺序也是和定义时顺序相同。
4. 对于派生类如果要覆盖父类中的虚函数,那么会在虚函数表中代替其位置。
父类A的vfptr, 父类A的成员变量,父类B的vfptr,父类B的成员变量,派生类的成员变量
有虚继承,那么就会多一个虚基类指针,虚基类指针通常在虚表指针后面
- 虚继承中,派生类布局的起始位置增加了
vbptr
指针,该指针指向vbtable
,vbtable记录了派生类的vbptr到派生类入口的便宜,和到基类的偏移 - 虚继承中,基类的实例数据副本被放置在了派生类的末尾
单虚继承的例子
菱形继承的例子,B虚继承A,C虚继承A,D普通继承B,C,那么可以看到A的成员变量被合并为了一份
多态实现的三个条件、实现的原理
C++中的多态性(Polymorphism)是面向对象编程的核心特性之一,它允许一个接口有多种实现。实现多态性的三个主要条件是继承(Inheritance)、虚函数(Virtual Function)和指针或引用(Pointer or Reference)。下面详细介绍多态性的实现条件及其原理。
多态实现的三个条件
- 继承(Inheritance):
- 继承是多态性的基础。子类继承父类的接口和实现,从而可以在不同类之间共享代码和行为。
- 虚函数(Virtual Function):
- 虚函数是实现运行时多态的关键。通过在基类中声明虚函数,派生类可以重写这些函数,提供特定的实现。
- 指针或引用(Pointer or Reference):
- 多态性通过指针或引用来实现。通过基类的指针或引用指向派生类的对象,可以在运行时动态地调用派生类的实现。
实现原理
多态性的实现依赖于虚函数表(Virtual Table,vtable)和虚函数指针(Virtual Table Pointer,vptr)。以下是详细的实现原理:
1. 虚函数表(vtable)
- 虚函数表是一个指针数组,每个指针指向一个虚函数的实现。
- 每个包含虚函数的类都有一个虚函数表。基类和派生类各自维护自己的虚函数表。
2. 虚函数指针(vptr)
- 虚函数指针是每个对象中隐含的指针,它指向该对象所属类的虚函数表。
- 当对象创建时,构造函数会初始化这个虚函数指针,使其指向该对象所属类的虚函数表。
析构函数可以抛出异常吗?为什么不能抛出异常?除了资源泄露,还有其他需考虑的因素吗?
析构函数从语法上是可以抛出异常的,但是这样做很危险,请尽量不要这要做。原因在《More Effective C++》中提到两个:
(1)如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
(2)通常异常发生时,c++的异常处理机制在异常的传播过程中会进行栈展开(stack-unwinding),因发生异常而逐步退出复合语句和函数定义的过程,被称为栈展开。在栈展开的过程中就会调用已经在栈构造好的对象的析构函数来释放资源,此时若其他析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃。【双重异常,运行时无法处理多个同时存在的异常】
栈展开的过程
当一个异常被抛出后,C++运行时会执行以下步骤:
- 查找异常处理器:
- 运行时首先在当前函数的
try
块中查找匹配的catch
块。如果找到匹配的catch
块,异常会被捕获并处理,程序继续执行catch
块后的代码。
- 运行时首先在当前函数的
- 销毁局部对象:
- 如果当前函数中没有找到匹配的
catch
块,运行时会开始栈展开。它会逐步退出当前函数,并销毁在当前作用域中创建的所有局部对象。这包括调用这些对象的析构函数。
- 如果当前函数中没有找到匹配的
- 向上传递异常:
- 当前函数退出后,异常会被传递到调用该函数的上层函数,重复上述过程,直到找到能够处理异常的
catch
块。如果最终没有找到处理该异常的catch
块,程序会调用std::terminate
,导致程序异常终止。
- 当前函数退出后,异常会被传递到调用该函数的上层函数,重复上述过程,直到找到能够处理异常的
如果析构函数真的存在异常,如何处理?
1.析构函数应该捕获异常,然后吞下他们(不传播)或者结束程序(std::abort())
2.如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数而非析构函数执行该操作
虚析构,父类为啥要声明虚析构,为啥将析构函数声明为虚函数就可以调用到子类的析构
为什么父类需要声明虚析构函数
- 确保正确的资源释放:
- 当我们通过基类指针删除派生类对象时,如果基类的析构函数不是虚的,编译器只会调用基类的析构函数。这将导致派生类的析构函数不会被调用,进而可能导致派生类分配的资源(如动态分配的内存、打开的文件等)不会被正确释放,导致资源泄漏。
- 保证对象完整销毁:
- 如果基类的析构函数是虚的,当通过基类指针删除派生类对象时,编译器会通过虚函数表(vtable)找到正确的析构函数,从而首先调用派生类的析构函数,再调用基类的析构函数。这确保了对象的所有部分都被正确销毁。
为什么将析构函数声明为虚函数就可以调用到子类的析构
虚函数表,后续派生的虚函数也会添加在虚函数表中,然后先调用派生类析构函数,在调用基类的析构函数
|
|
什么情况下会调用拷贝构造函数(三种情况)
- 用一个对象初始化另一个对象时
当用一个对象来初始化另一个同类型的对象时,会调用拷贝构造函数。这种情况通常出现在对象声明时。
- 按值传递对象时:当函数参数是按值传递时,会调用拷贝构造函数来创建参数的副本。因为按值传递意味着将对象的一份副本传递给函数,而不是原对象本身。
- 函数返回对象时(按值返回):当函数按值返回一个对象时,会调用拷贝构造函数来创建返回值的副本。这是因为按值返回会将返回的对象复制一份,并将其传递给调用者。
C++中哪些函数不能声明为虚函数
- 普通函数(非类成员函数):只有类的成员函数才有可能被声明为虚函数。因为普通函数(非成员函数)只能被重载,不能被重写。声明为虚函数也没有什么意义,因此编译器会在编译时绑定函数。
- 构造函数:虚函数的机制依赖于虚函数表,而虚函数表的建立需要在调用构造函数之后才完成。因为构造函数是用来初始化对象的,而在对象的初始化阶段虚表还没有被建立,如果构造函数是虚函数,就会导致对象的初始化和多态机制的矛盾(这边虚函数表没有初始化指的是虚函数指针的初始化和更新)
- 内联函数:内联函数必须有实体,是在编译时展开。内联函数就是为了在代码中直接展开,减少函数调用花费的代价。虚函数是为了在继承后对象能够准确的执行自己的动作,这是不可能统一的。
- 静态成员函数:静态成员函数理论是可继承的。但是静态成员函数是编译时确定的,无法动态绑定,不支持多态,因此不能被重写,也就不能被声明为虚函数。
- 友元函数:友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
- 不会被继承的基类的析构函数:析构函数可以是虚函数,而且通常声明为虚函数。但是对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。
- 模版函数:
- 是在编译时进行实例化的。模板函数是一种编译时多态(compile-time polymorphism),它依赖于模板参数的具体类型生成具体的函数代码。
- 是运行时多态(runtime polymorphism),通过虚函数表(vtable)和虚表指针(vptr)来实现。在运行时,根据对象的实际类型调用适当的函数。
- 虚函数表(vtable)是为每个包含虚函数的类生成的,它包含了类的虚函数指针。虚表在编译时是固定的,不能根据模板参数的不同而改变。如果允许模板函数成为虚函数,那么编译器在生成虚表时将不知道如何处理不同类型的模板实例。
构造函数和析构函数是否可以调用虚函数
永远不要在构造函数或析构函数中调用虚函数 。
简要结论:
1. 从语法上讲,调用完全没有问题。
2. 但是从效果上看,往往不能达到需要的目的。
- a) 如果有继承,构造函数会先调用父类构造函数,而如果构造函数中有虚函数,此时子类还没有构造,所以此时的对象还是父类的,不会触发多态。更容易记的是基类构造期间,virtual函数不是virtual函数。
- b) 析构函数也是一样,子类先进行析构,这时,如果有virtual函数的话,子类的内容已经被析构了,C++会视其父类,执行父类的virtual函数。
不要重写继承过来的非虚函数
举个例子,A中有一个普通函数func,B继承A,并重写了func。
但是pa->func和pb->func的行为并不相同
这是因为非虚函数都是静态绑定的。这意味着pa被声明为A*,通过pa调用的非虚函数永远都是A所定义的版本,哪怕pa指向的是派生类对象
|
|
不要重新定义继承而来的缺省参数值
上面说到,不要重写非虚函数。因此这边以虚函数为例。
|
|
缺省参数值是静态绑定的,因此即使再派生类中重新定义了缺省参数值,还是使用的基类的缺省参数,因此第一个打印是B 1
那么重写的虚函数不重新定义缺省参数的话,就会继承父类的缺省参数,在基类指针指向派生类对象的时候。因此第二个是C 1
第三个,由于是静态绑定,不涉及多态,必须要指定参数,因为不会继承基类的缺省参数
函数模板是怎么实现的,为什么能实现多态
函数模板的定义和使用
函数模板通过在函数定义前加上template
关键字和模板参数列表来定义。例如:
|
|
编译时多态
函数模板的多态性是通过编译时多态实现的。编译时多态意味着在编译过程中,编译器会根据==模板参数的具体类型生成具体的函数代码==。编译器会进行模板实例化,生成与模板参数匹配的函数版本。
模板的好处:提高复用性,类型参数化
类模版和函数模板的区别
- 类模版没有自动类型推导
- 类模版在模板参数列表中可以有默认参数
虚函数表(vtable)是针对类还是针对对象的。
虚函数表是针对类的,而不是对象。每一个类都有一张虚函数表,存储中这个类所有虚函数的入口地址。同一个类的两个对象共享类的虚函数表。当一个对象被创建时,它会在内存中分配一块空间用于存储对象数据和指向虚函数表的指针。这个指针始终指向该类的虚函数表,不会因为对象的不同而改变。
如果派生类重写了基类的虚函数,那么派生类会在其自己的虚函数表中存储重写后的函数地址。如果派生类没有重写基类的虚函数,那么它会继承基类的虚函数地址,并将其复制到自己的虚函数表中。
如果存在多重继承的情况,则派生类会生成多个虚函数表的指针,分别维护来自不同基类的虚函数表,其规则和单继承相同。
总之,虚函数是针对类的,同一个类的两个对象共享同一张虚函数表,虚函数表的内容由类的层次结构和重载情况决定
虚析构和纯虚析构
在C++中,如果通过基类指针删除派生类对象,而基类的析构函数不是虚函数,将只调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类中特有的资源没有被正确释放,造成资源泄漏。多态使用时,如果子类中有属性开辟到堆区,那么==父类指针在释放时无法调用到子类的析构代码==
解决方式:将父类中的析构函数改为虚析构或者纯虚析构
虚析构和纯虚析构共性:
- 可以解决父类指针释放子类对象
- 都需要有具体的函数实现
虚析构和纯虚析构区别:
- 如果是纯虚析构,该类属于抽象类,无法实例化对象
|
|
输出
|
|
重写、重载和覆盖
1. 重写(Overriding)
重写是指在派生类中重新定义基类中已存在的虚函数。重写用于实现==多态性==,使得派生类可以提供基类函数的具体实现。
特点:
- 必须有相同的==函数签名==(参数和返回类型)。
- 基类中的函数必须是虚函数。
- 使用
override
关键字可以显式地表示重写,增加代码的可读性和安全性。
2. 重载(Overloading)
重载是指在同一个作用域中定义多个同名的函数,这些函数的参数列表不同(参数个数或类型不同)。重载用于提供相同操作的不同实现。
特点:
- 函数名相同,但参数列表不同。
- 可以在同一个类中,也可以在全局作用域中。
- 返回类型可以相同也可以不同,但==仅靠返回类型不同不能区分重载==。
3. 覆盖(Hiding)
覆盖(也称隐藏)是指在派生类中定义了与基类中同名的非虚函数或静态成员函数。这样基类的函数在派生类中被隐藏,派生类对象使用同名函数时调用的是派生类的版本,而不是基类的版本。
特点:
- 函数名相同,但基类中的函数不是虚函数。
- 参数列表可以相同也可以不同。
- 可以使用
using
声明来解除隐藏。
成员初始化列表的概念,为什么用成员初始化列表会快一些(性能优势)?
成员初始化列表(Member Initializer List)是C++中用于初始化类成员的一种语法。它在构造函数的定义中,使用冒号(:)后跟成员变量的初始化列表来初始化成员变量。这种方法不仅可以提高代码的可读性和可维护性,还可以带来性能上的优势。以下是详细解释。
成员初始化列表的概念
成员初始化列表允许在==构造函数体执行之前直接初始化类的成员变量==。其语法如下:
|
|
在上述示例中,成员变量a
和b
在构造函数体执行之前通过成员初始化列表直接初始化。
成员初始化列表的性能优势
使用成员初始化列表相比在构造函数体内赋值,具有以下性能优势:
-
避免额外的默认构造和赋值操作:
构造函数执行的两个阶段:
初始化阶段:类中所有类型的成员变量在初始化阶段都会进行初始化操作,不管该成员是否出现在初始化列表中 计算阶段:在构造函数的函数体内执行 如果不使用初始化列表,类会在初始化阶段先调用默认构造参数对成员变量进行初始化,然后在函数体内调用拷贝构造函数利用传入参数对成员变量进行赋值操作
如果使用初始化列表,类只会调用一次拷贝构造函数对成员变量进行初始化赋值操作,省去了调用默认构造函数的性能消耗
-
成员变量的初始化顺序:
- 成员初始化列表按照成员变量在类中的声明顺序进行初始化,而不是按照初始化列表中的顺序。
- 这可以确保成员变量在初始化时遵循正确的顺序,特别是当一个成员变量依赖于另一个成员变量时。
-
必须使用成员初始化列表的4种情况:
- 常量成员变量和引用成员变量必须在初始化列表中进行初始化,因为它们在对象的生存期内不能被赋值。
- 没有默认构造函数的类类型成员: 如果一个类类型成员没有默认构造函数(即没有无参构造函数),那么必须在成员初始化列表中调用其特定的构造函数进行初始化。
- 基类: 在派生类的构造函数中初始化基类时,必须使用成员初始化列表来指定基类的构造函数。
C++的pthread的锁在类中如何初 始化
1. 静态初始化
如果你想在声明时就初始化 pthread_mutex_t
,可以使用静态初始化。静态初始化允许你在类的构造函数之外(通常在成员声明时)直接初始化锁。
|
|
在这种方法中,PTHREAD_MUTEX_INITIALIZER
是一个静态宏,用于在编译时初始化 pthread_mutex_t
。
2. 动态初始化
你也可以在类的构造函数中使用 pthread_mutex_init
函数来动态初始化锁。
|
|
在这种方法中,你在类的构造函数中显式调用 pthread_mutex_init
函数,使用 nullptr
作为第二个参数表示使用默认的锁属性。
C++11创造一个空类,会默认生成哪些函数
默认构造函数:当你没有定义任何构造函数时,编译器会生成一个默认的无参构造函数。
析构函数:编译器会自动生成一个析构函数来清理对象。
拷贝构造函数:编译器生成的拷贝构造函数会逐个拷贝对象的成员。
拷贝赋值运算符:编译器生成的拷贝赋值运算符会逐个拷贝对象的成员。
移动构造函数:编译器生成的移动构造函数会使用移动语义来转移资源。
移动赋值运算符:编译器生成的移动赋值运算符会使用移动语义来转移资源。
|
|
需要注意的是,这些默认生成的成员函数只有在被需要的时候才会产生。例如,如果我们不创建类对象,则不会创建类的构造函数、析构函数等。
C++空类的大小
在C++中,一个空类的大小是1字节。这是为了满足C++标准中的一个要求,即每个对象必须有一个独特的地址。如果空类的大小为0,那么它的对象将没有地址或与其他对象共享同一个地址,这会导致内存地址管理和对象区分上的问题。
有一个有趣的规则是:如果一个空类做基类,那么在派生类中不需要用一个单独的字节来表示,例如
|
|
上面说明了 p1 和 p2(成员变量a的地址)所指向相同的地方,也就是父类没有占空间。
这种优化允许使用空类来表示一些简单的概念,而且无需额外开销。
大多数编译器都提供了这种“空基类优化”。
空结构体的大小
在C语言中,sizeof空结构体为0。
在C++中,视结构体为一个对象,那么是对象就必须有一个独一无二的地址,因此sizeof 结构体为1
拷贝构造函数和移动构造函数
拷贝构造函数和移动构造函数都是C++中的特殊成员函数,用于对象的构造。它们的作用分别是:
-
拷贝构造函数:当使用一个已有对象来初始化一个新对象时,会调用拷贝构造函数。拷贝构造函数的参数是一个常量引用,表示将要被拷贝的对象。拷贝构造函数的作用是创建一个新的对象,并将原对象的值复制到新对象中。通常情况下,拷贝构造函数执行的是深拷贝操作,以确保新对象和原对象不共享同一块内存。
-
移动构造函数:在C++11标准中引入了移动语义,移动构造函数用于支持移动语义。当一个对象需要被移动而不是被拷贝时,会调用移动构造函数。移动构造函数的参数是一个右值引用,表示将要被移动的对象。移动构造函数的作用是将原对象的内部资源直接转移到新对象中,而不是像拷贝构造函数一样复制一份。这样可以避免不必要的内存分配和数据复制,提高程序的效率。
1 2 3 4 5 6 7 8 9 10 11 12
class MyClass { public: MyClass(MyClass&& other) noexcept; // 移动构造函数 // 其他成员函数 }; int main() { MyClass a(10); // 正常构造 MyClass b(std::move(a)); // 使用移动构造函数 return 0; }
总的来说,拷贝构造函数和移动构造函数都是用来创建新对象的,它们的区别在于创建新对象的方式不同。拷贝构造函数是将一个已经存在的对象复制到一个新的对象中,而移动构造函数则是将一个对象的资源移动到一个新的对象中。
移动赋值运算符是C++11引入的一个特殊成员函数,用于支持移动语义。当一个对象需要被移动而不是被拷贝时,会调用移动赋值运算符。移动赋值运算符的参数是一个右值引用,表示将要被移动的对象。
移动赋值运算符:
移动赋值运算符用于将一个已有对象的资源转移到另一个已存在的对象中,释放目标对象原有的资源,并将资源所有权从源对象转移到目标对象。
以下是一个移动赋值运算符的示例:
|
|
浅拷贝和深拷贝的区别
浅拷贝和深拷贝是面向对象编程中常用的两个概念,它们主要的区别在于复制对象时是否复制对象所引用的其他对象。
浅拷贝只复制对象的基本成员变量,特别是指针的值,而不复制指针所指向的实际数据。这可能导致多个对象共享同一块内存,并引发双重释放等问题。
深拷贝不仅复制对象的基本成员变量,还复制指针所指向的实际数据,从而使每个对象都有自己的独立副本,避免了共享内存和潜在的资源管理问题。
移动拷贝构造原理,解决什么问题。
移动拷贝构造函数是C++11标准引入的一个新特性,它通过将资源“移动”而不是复制,可以避免浅拷贝和深拷贝可能出现的问题,并提高程序的性能。移动构造函数的主要目的是提高程序运行的效率,当类持有其它资源时,例如动态分配的内存、指向其他数据的指针等,拷贝构造函数中需要以深拷贝(而非浅拷贝)的方式复制对象的所有数据。深拷贝往往非常耗时,合理使用右值引用可以避免没有必要的深拷贝操作。
C++的移动构造函数是一种特殊的构造函数,它可以将一个对象的资源(如堆内存)“移动”到另一个对象,而不是创建一个完全新的副本。这可以提高效率,特别是在处理大型对象时。
**移动构造函数的工作原理是将声明的对象的指针指向临时对象的数据,并将临时对象的指针置空。**这样,就避免了在内存中不必要地复制数据。与复制构造函数(它会复制现有对象的数据并将其分配给新对象)不同,移动构造函数只是使声明的对象的指针指向临时对象的数据,并将临时对象的指针置空。
C++11
你了解哪些C++11新特性
C++11引入了许多新的语言特性,以下是一些主要的新特性:
- **auto:**C++ 11引入了类型推断能力,使用auto关键字,编译器可以自行推断变量的类型。
- **noexcept:**如果一个函数无法抛出异常,那么可以在C++ 11中将该函数声明为noexcept,这有助于解决未知类型的错误。
- **lambda:**C++ 11可以创建匿名函数,也称为Lambda函数。Lambda表达式允许我们在本地定义函数。此外,它还允许在调用处定义函数,从而消除了许多复杂性和安全风险。
- **nullptr:**C++ 11的新特性之一是允许程序员使用nullptr代替NULL或0来指定一个指向无值的指针。这与不定义任何值是不同的。
- **override标识符:**随着项目变得越来越大,需要更多的文件来完成一个任务。为了避免混淆,C++ 11引入了override标识符,用于明确表示子类中的函数覆盖了基类中的同名函数,用在子类重写基类方法时,将重写的检查交给编译器,减少人为出错的可能。
- **final标识符:**final 是在成员函数声明或类头部中使用时有特殊含义的标识符。其他语境中它未被保留,而且可用于命名对象或函数。final可以用于
- 类声明: 指定某个类不能被子类继承。
- 函数声明: 指定某个虚函数不能在子类中被覆盖。
- **无序容器:**C++ 11引入了无序容器,如unordered_map和unordered_set。
- **移动语义和右值引用:**通过引入移动构造函数和移动赋值操作符,C++ 11支持将对象的资源“移动”到另一个对象,而不是创建一个完全新的副本。
- **变长模板:**C++ 11支持变长模板,这使得模板可以接受可变数量的参数。
function
用来存储和调用任意类型的可调用对象,可以存储函数、lambda、函数对象等,并提供一个统一的接口来调用这些对象。常用场景:回调函数、事件处理、(function)作为函数参数和返回值
bind
std::bind
用于从一个可调用对象和其他部分参数创建新的函数对象,并返回一个新的可调用对象。这个新对象可以被调用时,不需要再传递那些已经绑定的参数。这在处理不完全的参数或者需要绑定特定对象时特别有用
|
|
auto跟decltype有什么区别?
auto和decltype都是C++11中引入的类型推导关键字,它们的主要作用是让编译器自动推导变量的类型,从而减少代码的冗余,提高代码的可读性和可维护性。
auto
关键字用于在变量声明时根据初始化表达式自动推导变量的类型。它允许你编写更简洁和灵活的代码。
decltype
关键字用于查询表达式的类型而不实际计算它。它主要用于类型推导,特别是在模板和泛型编程中。
auto的作用:
- auto可以根据初始化表达式自动推导出变量的类型,使得代码更加简洁,提高编程效率。
- auto可以用于迭代器和模板编程,使得代码更加通用。
decltype的作用:
- decltype可以根据表达式推导出类型,而不仅仅是根据初始化表达式。
- ==decltype可以保留表达式的所有类型信息(包括const、引用等),使得类型推导更加精确。==
两者的区别:
- auto和decltype在语法格式、初始化要求、对cv限定符(const和volatile)的处理、对引用类型的处理等方面都有所不同。
- auto在书写格式上比decltype简单,但是它的推导规则复杂,有时候会改变表达式的原始类型;而decltype比较纯粹,它一般会坚持保留原始表达式的任何类型,让推导的结果更加原汁原味。
为什么有了auto还需要decltype:
- auto虽然使用方便,但是它不能完全替代decltype。因为在某些情况下,我们需要精确地保留表达式的所有类型信息(包括const、引用等),这时候就需要使用decltype。
- 另外,当我们需要定义一个变量,但是又不想立即初始化它,或者想让它的类型与某个表达式完全一致时,也需要使用decltype。
NULL和nullptr的区别
在C++11之前,我们通常使用NULL来表示空指针。NULL
是一个宏,通常定义为整数值 0
。在 C++ 中,它主要用于表示空指针。然而,由于 NULL
实际上是一个整数,可能会导致类型不明确的问题,特别是在函数重载和模板编程中。
为了解决这个问题,C++11引入了nullptr关键字,它是一个特殊类型的常量——std::nullptr_t。nullptr可以被转换为任意指针类型,用以表示空指针。但是,它不能被转换为整数类型,因此避免了与整数0的混淆。
下面是一个示例代码来说明这个问题:
|
|
在这个例子中,当我们使用0或者NULL作为参数调用函数func时,会调用func(int)。但是当我们使用nullptr作为参数时,会调用func(char*)。
因此,在C++11及其后续版本中,建议使用nullptr来表示空指针。
lamda表达式捕获列表捕获的方式有哪些?
在C++中,lambda表达式的捕获列表(capture list)可以通过以下方式捕获它们所在函数中的变量:
- 按值捕获:这种方式会在lambda表达式创建时将指定的变量复制一份,并在函数体中使用这份副本。例如:
|
|
在上述代码中,a被按值捕获,因此即使在lambda表达式创建后a的值发生改变,lambda表达式也只会使用创建时a的值。
- 按引用捕获:这种方式允许lambda表达式直接访问引用所指向的变量。例如:
|
|
在上述代码中,a被按引用捕获,因此即使在lambda表达式创建后a的值发生改变,lambda表达式也会直接访问这个引用变量。
底层原理
Lambda表达式在底层通常被实现为一个匿名类(在C++中称为闭包类),该类重载了operator()
。捕获的变量成为该类的成员变量,并在类的构造函数中初始化。以下是对Lambda表达式底层实现的详细解释:
1. Lambda表达式转换为闭包类
上述Lambda表达式在底层等价于:
|
|
2. 捕获变量的初始化
- 按值捕获:将变量的副本存储在闭包类的成员变量中。
- 按引用捕获:存储变量的引用,以便Lambda表达式可以访问和修改原始变量。
3. Lambda表达式的调用
当调用Lambda表达式时,实际上是调用了闭包类的operator()
,该运算符执行Lambda表达式的函数体。
使用场景
用于定义匿名函数,通常用于在短期和局部使用函数,比如一次性回调函数、算法库中的自定义操作
sizeof lambda表达式是多少
通常和捕获的对象有关,因为lambda实际上就是一个闭包类
|
|
noexcept关键字
noexcept
是 C++11 引入的一个关键字,用于指定函数在运行时不会抛出异常。它可以用于声明函数是否会抛出异常,从而帮助编译器进行优化,提高程序的性能和稳定性。noexcept
关键字的使用可以分为以下几种情况:
基本用法
noexcept
可以用在函数声明和定义中,用于指明该函数不会抛出异常。如果函数在运行时确实抛出了异常,程序会直接调用 std::terminate
来终止。
|
|
带条件的 noexcept
noexcept
可以带有一个常量表达式参数,用于根据条件确定函数是否会抛出异常。条件表达式在编译时计算,其结果为 true
或 false
。
|
|
在这个示例中,func
的异常规格(是否会抛出异常)取决于 T
类型的 method
方法是否会抛出异常。
STL
迭代器类型
输入迭代器(Input Iterator): 只允许单向遍历,只能读不能写。
输出迭代器(Output Iterator): 只允许单向遍历,只能写不能读。
前向迭代器(Forward Iterator): 允许单向遍历,可读可写。std::vector, std::list
双向迭代器(Bidirectional Iterator): 允许双向遍历,可读可写。std::list, std::map
随机访问迭代器(Random Access Iterator): 支持随机访问,可进行算术运算,可读可写。std::vector, std::deque
|
|
可读指的是
*t
,可写指的是*t = 10
利用迭代器删除元素会发生什么?
在C++标准模板库(STL)中,利用迭代器删除容器中的元素时,需要特别注意迭代器的有效性。不同的容器对迭代器的处理方式不同,删除元素后迭代器的状态也不同。以下是对不同容器中使用迭代器删除元素的详细解释和示例。
1. std::vector
和 std::deque
对于std::vector
和std::deque
,在使用迭代器删除元素后,该迭代器和所有指向被删除元素之后的迭代器都将失效。因此,正确的做法是使用erase函数的返回值(即指向被删除元素的下一个元素的迭代器)来更新当前的迭代器。
示例:
|
|
输出结果:
|
|
在这个示例中,erase
操作删除了值为3的元素,并返回指向下一个元素的迭代器。我们将it
更新为这个新的迭代器,以确保循环继续正确地处理下一个元素。
2. std::list
对于std::list
,删除元素后只有指向被删除元素的迭代器会失效,其他迭代器仍然有效。因此,可以更安全地在迭代过程中删除元素。
示例:
|
|
输出结果:
|
|
在这个示例中,和std::vector
的情况类似,erase
操作删除了值为3的元素,并返回指向下一个元素的迭代器。std::list
的其他迭代器仍然保持有效。
3. std::map
和 std::set
对于关联容器如std::map
和std::set
,删除元素后只有指向被删除元素的迭代器会失效,其他迭代器仍然有效。
示例:
|
|
输出结果:
|
|
在这个示例中,erase
操作删除了键为2的元素,并返回指向下一个元素的迭代器。其他迭代器仍然有效。
小结
std::vector
和std::deque
:删除元素后,指向被删除元素和之后的所有迭代器都将失效。需要更新迭代器。std::list
:删除元素后,只有指向被删除元素的迭代器失效,其他迭代器仍然有效。std::map
和std::set
:删除元素后,只有指向被删除元素的迭代器失效,其他迭代器仍然有效。
map的[]和at有什么区别?
std::map的[]操作符和at函数都可以用来访问元素,但它们的行为有一些重要的区别
- 行为差异:
- [] 如果指定的键存在,则返回该键对应的值。如果指定的键不存在,则插入该键并初始化其对应的值(例如,对于
int
类型,初始化值为0
),然后返回该值。 - 如果指定的键存在,则返回该键对应的值。如果指定的键不存在,则抛出
std::out_of_range
异常。
- [] 如果指定的键存在,则返回该键对应的值。如果指定的键不存在,则插入该键并初始化其对应的值(例如,对于
- 使用场景:
- 当你想要访问元素,并且如果元素不存在则创建该元素时使用。
- 当你确定键存在时使用,或者想要处理键不存在的情况而不是默认创建新元素时使用。
以下是一些代码示例:
|
|
unordered_map底层原理
hashtable通过key能快速的找到value,使用拉链法解决hash冲突。具体而言,其维护一个buckets vector,通过hash(key) % bucket count,得到key应该放在哪个桶里面。如果发生hash冲突,那么就通过拉链法将冲突的key维护在该bucket桶的链表中。后续查找操作,通过key找到属于哪个对应的桶,依次遍历链表找到对应的key,获得value。
hashtable在设计bucket的数量上,维护了28个质数(因为质数发生哈希冲突的概率很低),在创建hashtable的时候,会根据元素数量选择对应的长度。
为了解决hash退化为链表,维护负载因子(load_factor和最大负载因子(max_load_factor),如果负载因子大于最大负载因子时,就会进行扩容。
扩容细节:
- 选择下一个更大的质数,创建bucket vector。
- 分配新的内存空间:
hash_map
会分配一块新的内存空间,其大小等于新的容量。这块新的内存空间将用于存储hash_map
中的元素。 - 重新哈希(rehashing):
hash_map
会遍历其所有的元素,根据新的容量来重新计算每个元素对应桶的位置。(哈希函数通常不变,根据取模即bucket count的值改变插入的位置) - 释放旧的内存空间:一旦所有的元素都被成功地插入到新的内存空间中,
hash_map
就会释放旧的内存空间(buckets数组),以防止内存泄漏。 - 更新容量:最后,
hash_map
会更新其容量,以反映新的内存空间的大小。
重建的过程如图所示,根据头插法找到新的buckets桶的位置插入,并非直接复制,而是改变指针
如果扩容时发生插入或者查询操作怎么办?
插入:直接插入新的hashtable中
查询:先查原来的hashtable,如果存在直接返回;如果不存在,等待扩容后,查询新表(可能扩容期间插入了新元素)
并发安全:hashtable并不是并发安全的,可以考虑给每个bucket加读写锁
在C++中,将类放入unordered_map
- 需要提供一个哈希函数,这可以通过特化
std::hash<T>
结构体或提供一个自定义函数来完成。 - 需要重载
==
,用于判断两个键是否相等
|
|
unordered_map和map的区别
std::map和std::unordered_map是C++中的两种关联容器,它们都可以存储键值对,但在内部实现和性能上有一些重要的区别:
- 内部实现:std::map内部使用==红黑树==实现,因此它的元素会按照==键的顺序进行排序==。而std::unordered_map则使用哈希表实现,元素存储的顺序是任意的,不保证任何特定的顺序。
- 查找时间:对于std::map,查找操作的时间复杂度为O(log n),其中n是元素的数量。对于std::unordered_map,在平均情况下,查找操作的时间复杂度为O(1),但在最坏情况下(例如发生大量哈希冲突时),时间复杂度可能会退化为O(n)。
- 插入和删除时间:对于std::map,插入和删除操作的时间复杂度为O(log n),其中n是元素的数量 ,插入删除需要调整树的结构保持平衡。对于std::unordered_map,在平均情况下,插入和删除操作的时间复杂度为O(1),但在最坏情况下(例如发生大量哈希冲突时),时间复杂度可能会退化为O(n)。
- 排序:如果你需要按照键的顺序遍历元素,那么应该使用std::map。如果你不需要保持任何特定的顺序,并且希望最大限度地提高查找、插入和删除操作的速度,那么应该使用std::unordered_map。
总的来说,选择使用哪种容器取决于你的具体需求。如果需要排序或者频繁进行查找操作,那l么std::map可能更合适。如果不需要排序,并且主要进行插入和删除操作,那么std::unordered_map可能会提供更好的性能
红黑树
红黑树是一种自平衡的二叉查找树
具有以下特点:
- 每个节点要么是红色,要么是黑色。
- 根节点是黑色。
- 每个叶子节点(NIL节点)是黑色。
- 如果一个节点是红色,则其子节点必须是黑色。
- 从任一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点。
红黑树的自平衡性质可以保证在进行插入、删除等操作后,树的高度保持在O(log n)内,从而保持了较高的查找、插入和删除效率。下面是红黑树插入节点的过程,这左旋右旋的操作,就是为了自平衡。
为什么要用红黑树而不是平衡二叉树?
- 平衡二叉树追求的是一种 “完全平衡” 状态:任何结点的左右子树的高度差不会超过 1,优势是树的结点是很平均分配的。这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋和右旋来进行调整,使之再次成为一颗符合要求的平衡树。
- 红黑树不追求这种完全平衡状态,而是追求一种 “弱平衡” 状态:整个树最长路径不会超过最短路径的 2 倍。优势是虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。与平衡树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁着调整,这也是我们为什么大多数情况下使用红黑树的原因。
map是线程安全的吗
在C++中,std::map
的读写操作本身并不是线程安全的。如果多个线程同时访问和修改同一个std::map
实例,可能会导致数据竞争和未定义行为。因此,在多线程环境中需要使用锁来确保线程安全。
std::shared_mutex
(C++17引入):支持读写锁,可以区分读操作和写操作,提高并发性能。
为什么选择std::shared_mutex
在读多写少的场景下,使用std::shared_mutex
可以显著提高性能,因为它允许多个线程同时读取数据,但在写入数据时仍然只有一个线程可以持有锁。对于纯粹的读操作,不需要排他性锁定,从而提高了并发度。
std::map
的读写操作本身不是线程安全的,需要使用锁来保护。- **
std::shared_mutex
**在读多写少的场景中表现优异,因为它允许多个读线程同时访问,提高并发性能。 - **
std::mutex
**适用于写操作较多的场景。
常用的容器并分析底层实现数据结构
以下是一些常用的STL容器及其底层实现:
- array:std::array是一个固定大小的容器,其底层实现为静态数组。它支持快速随机访问,但大小固定,不能动态扩展。
- vector:std::vector是一个动态数组。它支持快速随机访问,并可以在尾部进行高效的插入和删除操作。但在非尾部进行插入和删除操作时效率较低。
- deque:std::deque(双端队列)的底层实现为多个分段的动态数组。它支持在首尾两端进行高效的插入和删除操作,也支持随机访问。
- list:std::list的底层实现为双向链表。它支持在任意位置进行高效的插入和删除操作,但不支持快速随机访问
- set/multiset:std::set和std::multiset的底层实现为红黑树。它们支持快速查找,但不支持快速随机访问。
- map/multimap:std::map和std::multimap的底层实现为红黑树。它们支持根据键值进行快速查找,但不支持快速随机访问。
- unordered_set/unordered_multiset:这些无序容器的底层实现为哈希表。它们支持快速查找,但不支持快速随机访问。
- unordered_map/unordered_multimap:这些无序容器的底层实现为哈希表。它们支持根据键值进行快速查找,但不支持快速随机访问。
- stack和queue:std::stack是一个容器适配器,通常使用std::deque或std::list或std:vector作为其底层容器。
- priority_queue:std::priority_queue是一个容器适配器,通常使用std::vector作为其底层容器,并使用堆(heap)通常是二叉堆来管理底层容器以提供优先级队列功能。
STL使用过那些容器,说说各自查询时间复杂度
在C++的STL库中,常用的容器包括vector、deque、list、set、map、unordered_set、unordered_map等。这些容器的查询时间复杂度如下:
- vector:采用一维数组实现,元素在内存连续存放。查看操作的时间复杂度为:O(1)。
- deque:采用双向队列实现,元素在内存连续存放。查看操作的时间复杂度为:O(1)。
- list:采用双向链表实现,元素存放在堆中。查看操作的时间复杂度为:O(N)。
- set/map/multiset/multimap:这四种容器采用红黑树实现,红黑树是平衡二叉树的一种。查看操作的时间复杂度近似为: O(logN)。
- unordered_set/unordered_map/unordered_multiset/unordered_multimap:这四种容器采用哈希表实现。查看操作的时间复杂度为:O(1),最坏情况O(N)。
push_back和implace_back的区别
push_back是向容器末尾添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中
emplace_back是在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或者移动元素的过程
push_back比emplace_back多做了一次拷贝,emplace_back效率更高
vector内存是怎么增长的vector的底层实现
在C++中,std::vector
是一个动态数组容器,它的底层实现涉及到动态内存分配和管理。为了高效地处理元素的插入,std::vector
的内存增长策略采用了摊销(amortized)分配方式,以减少频繁的内存分配操作。以下是std::vector
内存增长机制和底层实现的详细解释。
内存增长机制
当 std::vector
需要扩展其容量以容纳更多元素时,它会按照一定的增长策略分配更多的内存空间。常见的增长策略是按比例扩展,而不是每次仅增加一个元素的空间。大多数标准库实现会将 vector
的容量翻倍,从而使得插入操作的摊销时间复杂度为 O(1)。
具体步骤:
- 初始分配:
- 当
std::vector
创建时,它通常不会立即分配任何内存,直到第一个元素插入。
- 当
- 容量检查:
- 在每次插入新元素时,
vector
会检查当前容量是否足够。如果容量足够,则直接插入元素。
- 在每次插入新元素时,
- 重新分配:
- 如果当前容量不足,
vector
会分配一个更大的内存块,通常是当前容量的两倍或其他比例。 - 将旧数据复制到新的内存块中。
- 释放旧的内存块。
- 如果当前容量不足,
vector的size和capacity的区别
size()
:表示当前vector
中实际存储的元素数量,是vector
的当前大小。
capacity()
:表示当前vector
已分配的存储容量,即不需要重新分配内存时可以存储的最大元素个数。
|
|
vector的resize和reverse区别
resize(n)
调整vector的大小为n,如果n大于当前大小,vector会向末尾添加元素,如果n小于当前大小,会删除超出部分的元素。如果n大于capacity,会自动扩容
reserver(n)
,预分配内存,确保vector可以容纳n个元素,不改变vector的当前大小,而是改变capacity。适用于已知需要添加大量的元素的情况下进行预分配 ,避免了频繁重新分配内存
vector缩容
shrink_to_fit()
方法:
C++11 引入: 该方法是 C++11 标准中引入的,用于请求容器将容量缩减到与当前元素数量相匹配的大小。
该方法是一个请求,而不是强制要求。编译器可能会选择忽略该请求,或者在认为不必要时不进行缩容。
|
|
vector和数组区别
在C++中,vector和数组都是用来存储数据的容器,但它们有一些重要的区别:
- 内存分配:数组在声明时就需要确定大小,并且在编译时会分配固定的连续内存空间。而vector是动态数组,它可以在运行时动态调整大小
- 灵活性:数组的长度在声明时就已经确定,不能更改。而vector可以根据需要动态调整大小,可以在末尾增加元素(使用push_back方法)
- 访问方式:数组和vector都可以使用下标操作进行处理,也都可以用迭代器进行操作。
- 内存管理:对于vector,当其生命周期结束后,它会自动释放所占用的内存。而对于数组,如果是动态分配的,需要手动释放内存。
- 性能:如果数组的长度确定的话,效率上vector差一些。因为vector需要管理动态内存,所以相比于数组会有额外的管理开销。
总的来说,选择使用数组还是vector主要取决于你的具体需求。如果你需要灵活性和易用性,那么vector可能是更好的选择。如果你追求效率,并且能提前知道数据的大小,那么使用数组可能更合适
C++17
std::optional
std::optional
是 C++17 标准库引入的一个==模板类==,它表示一个==可能包含或不包含值的对象==。它主要用于返回值可能为空的情况,避免了使用特殊值(如 nullptr
或错误码)来表示无效值的需要,从而提高代码的可读性和安全性。
主要功能
- 表示有或没有值:
std::optional
可以存储一个值或表示“没有值”的状态。 - 安全访问值:通过显式的检查和访问方法,避免了直接访问空值的风险。
- 默认构造和复制:
std::optional
支持默认构造、复制构造和移动构造。
基本用法
|
|
std::optional 的实现
std::optional
的实现主要涉及以下几个方面:
- 内部存储:使用联合体(union)来存储值或无效状态。
- 构造和析构:处理值的构造、析构和赋值操作。
- 状态管理:提供检查是否有值的方法。
内部存储
std::optional
使用一个联合体(union)来存储值。这允许 std::optional
在不占用额外空间的情况下存储任意类型的值或无效状态。
|
|
std::nullopt
是 C++17 引入的一个常量,它表示一个 std::optional
对象不包含值。它的类型是 std::nullopt_t
,用来构造一个空的 std::optional
对象或将其重置为空状态。
结构化绑定(Structured Bindings)
结构化绑定允许你将一个结构或类的成员拆解成独立的变量。
|
|
RPC
什么是RPC通信
RPC:Remote Procedure Call Protocol,指的是远程过程调用协议,一般使用在分布式业务或者微服务架构风格中。
即一个节点通过网络调用的方式来请求另一个节点提供的服务的过程,也可以简单的理解为client访问server上提供的函数(像调用本地函数一样,去调用一个远端服务)
RPC的原理
- 客户端(服务消费端):调用远程方法的一端。
- 客户端 Stub(桩):这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。
- 网络传输:网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最基本的 Socket 或者性能以及封装更加优秀的 Netty(推荐)。
- 服务端 Stub(桩):这个桩就不是代理类了。我觉得理解为桩实际不太好,大家注意一下就好。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去执行对应的方法然后返回结果给客户端的类。
- 服务端(服务提供端):提供远程方法的一端。
具体原理图如下,后面我会串起来将整个 RPC 的过程给大家说一下。
服务消费端(client)以本地调用的方式调用远程服务;
客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):RpcRequest
;
客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端;
服务端 Stub(桩)收到消息将消息反序列化为 Java 对象: RpcRequest
;
服务端 Stub(桩)根据RpcRequest
中的类、方法、方法参数等信息调用本地的方法;
服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:RpcResponse
(序列化)发送至消费方;
客户端 Stub(client stub)接收到消息并将消息反序列化为 Java 对象:RpcResponse
,这样也就得到了最终结果。over!
Protobuf 介绍
Protobuf(Protocol Buffers)是由 Google 开发的一种轻量级、高效的数据交换格式,它被用于结构化数据的序列化、反序列化和传输。相比于 XML 和 JSON 等文本格式,Protobuf 具有更小的数据体积、更快的解析速度和更强的可扩展性。
Protobuf 的核心思想是使用协议(Protocol)来定义数据的结构和编码方式。使用 Protobuf,可以先定义数据的结构和各字段的类型、字段等信息,然后使用 Protobuf 提供的编译器生成对应的代码,用于序列化和反序列化数据。由于 Protobuf 是基于二进制编码的,因此可以在数据传输和存储中实现更高效的数据交换,同时也可以跨语言使用。
相比于 XML 和 JSON,Protobuf 有以下几个优势:
- 更小的数据量:Protobuf 的二进制编码通常只有 XML 和 JSON 的 1/3 到 1/10 左右,因此在网络传输和存储数据时可以节省带宽和存储空间。
- 更快的序列化和反序列化速度:由于 Protobuf 使用二进制格式,所以序列化和反序列化速度比 XML 和 JSON 快得多。
- 跨语言:Protobuf 支持多种编程语言,可以使用不同的编程语言来编写客户端和服务端。这种跨语言的特性使得 Protobuf 受到很多开发者的欢迎(JSON 也是如此)。
- 易于维护可扩展:Protobuf 使用 .proto 文件定义数据模型和数据格式,这种文件比 XML 和 JSON 更容易阅读和维护,且可以在不破坏原有协议的基础上,轻松添加或删除字段,实现版本升级和兼容性。