C++ Interview Record
C++ Summary
This markdown is a file which helps me to review cc for interview. 🤓
内存管理 🔧
图示
1 | | Text | low address //代码区(文本段) |
代码段:它包含可执行指令(机器代码),代码段通常是只读,即程序代码区
数据段:
初始化数据段:所有全局、静态static和常量数据都存储在数据段中,即静态存储区 .data (const
变量虽然属于常量,但是本质还是变量,不存储于代码段)
Data 段:
- 分为 已初始化数据段 和 常量数据段。
- 已初始化数据段:存储显式初始化为非 0 的全局变量和静态变量。
- 常量数据段:存储只读的数据,如字符串字面量和
const
修饰的全局变量。
未初始化数据段:所有未初始化的全局变量和静态变量都存储在该段中,也称为BSS段.bss
BSS 段:
- 存储所有未显式初始化或初始化为 0 的全局变量和静态变量。
- 这些变量在程序运行时自动初始化为 0。
- BSS 段在编译后的可执行文件中并不占用实际空间,因为它只记录了需要多少空间,而在程序运行时由操作系统分配和清零。
堆段:当程序在运行时使用calloc
和malloc
函数分配内存时,内存在堆中分配,向高地址增长
栈段:栈用于存储局部变量、函数参数和其他与函数相关的信息,向低地址增长
堆栈区别
堆 | 栈 | |
---|---|---|
管理方式 | 堆中资源由程序员控制(容易产生memory leak) | 栈资源由编译器自动管理,无需手工控制 |
内存管理机制 | 系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删除空闲结点链表中的该结点,并将该结点空间分配给程序 | 只要栈的剩余空间大于所申请空间,系统为程序提供内存,否则报异常提示栈溢出 |
空间大小 | 堆是不连续的内存区域 | 栈是一块连续的内存区域 |
碎片问题 | 对于堆,频繁的new/delete会造成大量碎片,使程序效率降低 | 对于栈,它是有点类似于数据结构上的一个先进后出的栈,进出一一对应,不会产生碎片 |
生长方向 | 向高地址方向增长。 | 向低地址方向增长 |
分配效率 | 堆由C/C++函数库提供,效率低 | 栈是其系统提供的数据结构,计算机在底层对栈提供支持,效率高 |
变量问题
作用域:
全局变量:全局作用域,可以通过extern作用于其他非定义的源文件。
静态全局变量 :全局作用域+文件作用域,所以无法在其他文件中使用。只能在本源文件中使用
局部变量:局部作用域,函数内的变量等
静态局部变量 :局部作用域,只被初始化一次,直到程序结束
所在空间:
除了局部变量在栈上外,其他都在静态存储区
生命周期:
局部变量在栈上,出了作用域就回收内存;而全局变量、静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存
静态变量什么时候初始化
C语言:全局和静态变量初始化发生在任何代码执行之前,属于编译期初始化。
C++标准规定:全局或静态对象当且仅当对象首次用到时才进行构造
静态成员函数不能访问非静态成员
静态成员函数不属于任何一个对象,因此C++规定静态成员函数没有this指针,只能访问静态成员,不能访问普通成员
区域名称 | 用途 | 内存管理 | 生命周期 | 典型存储内容 |
---|---|---|---|---|
静态存储区 | 存储全局变量、静态变量和常量 | 自动分配 | 从程序开始到结束 | 全局变量、静态变量、常量字符串 |
栈区 | 存储局部变量、函数参数和返回地址 | 自动分配和释放 | 函数调用期间 | 局部变量、函数参数、返回地址 |
堆区 | 动态内存分配 | 手动分配和释放 | 由程序员控制 | 动态分配的内存(如对象、数组) |
常量区 | 存储程序代码和常量 | 自动分配 | 程序运行期间 | 字符串字面量、程序代码 |
static
隐藏:静态函数或静态全局变量只能在本源文件中使用
持久性:在数据段分配内存,维护值直到程序结束
默认值0:默认初始化为 0
内存泄露
new
和malloc
申请资源使用后,没有用delete
和free
释放;子类继承父类时,父类析构函数不是虚函数;资源句柄未释放;智能指针shared_ptr
形成环形引用,无法释放
内存碎片
内存池
内存泄漏检测方法
Windows上除了使用VS自带的性能检测窗口观察内存分配,还可以使用_CrtDumpMemoryLeaks();
调试时输出窗口会输出内存泄漏的消息。
Linux上使用 Val grind
内存模型🍳
内存分区模型
内存顺序模型
概念:内存模型是描述编程语言在支持多线程编程中对共享内存访问的顺序,主要目的是为了确保在多线程环境中,程序行为是可预测的且一致的。
内存顺序模型是为了解决多线程程序中的内存一致性和可见性问题而引入的。在多线程环境下,不同线程可能同时访问和修改共享的内存,这会引发一系列并发性问题,如竞态条件、数据竞争等。内存顺序模型的目的是通过定义不同操作之间的执行顺序和可见性规则,来保证多线程程序的正确性和可预测性
在多线程环境下,不同的线程可以同时访问和修改相同的内存位置。这种并发访问会导致**数据竞争(Data Race)和指令重排序(Instruction Reordering)**等问题。因此,需要一种机制来约束线程之间的内存访问顺序,这就是内存模型的核心任务
现代处理器和编译器为了优化性能,可能会对指令进行重排序(Out-of-Order Execution)或缓存一致性操作,在多线程环境下可能导致不可预期的行为,缓存效应(Cache Effects)与指令重排序(Instruction Reordering)
- 缓存效应(Cache Effects):一个线程的内存修改可能被暂时保存在其本地缓存中,而其他线程看到的仍然是旧值。
- 指令重排序(Instruction Reordering):编译器或CPU可能会改变指令的执行顺序以优化性能,这可能导致一个线程看到的内存操作顺序与另一个线程不一致。
内存顺序:
关系
sequenced-before
单线程下A指令序列先于B
happens-before
多线程下A指令序列先于B
synchronizes-with
线程修改某变量的之后的结果能被其它线程可见
Carries dependency
同一个线程内,表达式A sequenced-before 表达式B,并且表达式B的值是受表达式A的影响的一种关系
6种顺序
1 | enum memory_order { |
由上至下为宽松到严格
关键字 🔑
volatile
与mutable
- 概念
mutable
:被mutable修饰的变量,将永远处于可变的状态,即使处于const
修饰
volatile
:关键字可以用来提醒编译器使用 volatile 声明的变量随时有可能改变,要求取内存读取值而非寄存器读取
volatile
主要有三个应用场景:
(1)外围设备的特殊功能寄存器。
(2)在中断服务函数中修改全局变量。
(3)在多线程中修改全局变量(多线程中修改全局变量存在什么问题?怎么解决?)
const
- 概念
对被修饰对象进行限定,在初始化后对象在后续过程中不能进行修改操作,类似于将其视作常量。4种修饰类型:修饰变量、修饰指针、修饰引用、修饰函数
对于局部常量,存放在栈区; 对于全局常量,编译期⼀般不分配内存,有时编译器会把放在符号表中以提⾼访问效率;字⾯值常量,⽐如字符串,放在常量区;
const
指针
底层const和顶层const
顶层const:指的是const修饰的变量本身是一个常量,无法修改,指的是指针,就是 * 号的右边
底层const:指的是const修饰的变量所指向的对象是一个常量,指的是所指变量,就是 * 号的左边
1 | const int a = 5; // 顶层const |
顶层const和底层const:从以下代码看出const修饰的影响,前者表示指针本身是常量(不可更改),后者表示指针所指对象是常量(对象不可更改)。通常意义上来讲顶层const的限制弱于底层 const。所以在进行拷贝操作时,顶层const基本上不受影响,而底层const的限制性更大,当进行拷贝考入考出时,左右对象必须是相同的底层const资格(因为执行对象拷贝时有限制,常量的底层const不能赋值给非常量的底层const)
需要记住const修饰对象将会导致一个不可更改的对象即可。对待这个东西我个人的处理看待方式就是,先搞清楚目标对象是什么样的const,然后再做处理,也许和指针引用搭配的时候是有点绕。在确定对象是在当前过程中readonly的,那么我们就应该对其const修饰,对于非内部数据的输入参数,应该将单纯的值类型A a
更改为const A& a
,避免了拷贝,同时避免了对其修改,提高了效率
this
- 概念
在类的非静态成员函数中,this
指针是隐式可用的,它指向调用成员函数的对象实例。
在类T
的成员函数内,this
的类型是T* const
。这意味着this
是一个指向T
类型的常量指针,你不能改变this
指针的指向,即不能让this
指向另一个对象,但可以修改这个对象的成员(除非成员是const
)底层const。
在 const 成员函数中,this
指针的类型是const T* const
,这意味着你既不能改变this
指针的指向,也不能通过this
指针修改对象的成员
- 补充
1.可否delete this?
delete this
是合法的,但是要非常小心使用。delete this
语句用于在对象的成员函数中释放该对象的内存,但这种操作需要确保在调用该语句之后,不再访问已释放的内存,否则会导致未定义的行为或程序崩溃
2.类的析构函数中delete this
?
无限递归导致 stack overflow
3.this
赋值nullptr
后调用成员函数?
访问成员函数将导致未定义行为,唯一的例外是静态成员函数。静态成员函数不依赖于任何对象,因此不需要 this
指针。可以使用 nullptr
调用静态成员函数,因为静态成员函数不涉及对象的成员访问
inline
- 概念
内联函数是C++中的一种函数声明方式,它告诉(建议)编译器在调用函数时将函数的代码插入到调用处,而不是像普通函数那样通过跳转执行。这样做可以减少函数调用的开销,提高程序的执行效率。编译器不一定会遵循inline
关键字,它可能会根据具体情况决定是否将函数内联。
优点:
内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。
缺点:
包括可能导致代码体积增加,因为函数的代码会被复制到每个调用处,以及可能增加编译时间
- 补充
sizeof
- 概念
sizeof(var)
返回变量所占大小、sizeof(pointer)
返回4或者8,这个与编译对象目标平台有关,sizeof(array)
返回 类型大小*数组长度
size_of
是在编译期在确定
sizeof(空结构体)
在C中是0,在C++中是1。C++标准规定,不同的对象不能拥有相同的内存地址
对类进行sizeof
,看类中函数是不是虚函数。如果不是虚函数,则结果是1,如果是虚函数,则大小是4或者8
extern
- 概念
被 extern 限定的函数或变量是 extern 类型的;被 extern "C"
修饰的变量和函数是按照 C 语言方式编译和链接的
explict
- 概念
explicit
修饰构造函数时,可以防止隐式转换和复制初始化;explicit
修饰转换函数时,可以防止隐式转换
?_cast
- 概念
引入 static_cast
、dynamic_cast
、const_cast
、reinterpret_cast
等类型转换关键字,主要是为了提供类型转换的安全性和可控性。与C语言中的传统类型转换(使用强制类型转换操作符 (type)
)相比,这些关键字更加清晰、可读,并且在编译时提供更多的类型检查。
引入原因
安全性:提供更严格的类型检查,避免类型转换中的潜在错误。在编译时检测类型转换的合法性,减少运行时错误。
可读性:通过不同的关键字表示不同类型的转换,代码意图更明确。提高代码的可维护性,程序员可以更清晰地理解转换的目的和风险。
控制性:允许程序员在不同的上下文中选择合适的转换类型。提供对类型转换过程的精细控制,避免滥用强制类型转换。
兼容性:保持与旧代码和C语言代码的兼容性,同时引入更现代化的类型转换机制。
类型
-
static_cast
static_cast
是最常用的类型转换形式,用于基本数据类型之间的转换,如整型和浮点型、指针类型之间的转换(只要它们是相关的类型),以及类层次结构中向上(子类指针或引用转换为基类指针或引用)的转换。 -
dynamic_cast
dynamic_cast
专门用于处理类的层次结构中的向下转换(基类指针或引用转换为派生类指针或引用),并在运行时检查类型的安全性。它要求至少有一个基类声明为虚拟的(即至少有一个虚函数)。 -
const_cast
const_cast
用于修改类型的const
或volatile
属性,比如将一个const
指针转换为非const
指针,允许修改所指向的数据。它不能改变非const
对象的const
性质,也不能改变对象的类型。 -
reinterpret_cast
用于进行任意类型的指针转换。通常用于底层、硬件相关或系统编程中,进行较低级别的类型转换。
struct
union
class
- C++区别
union | struct | class |
---|---|---|
定义多个成员,使用一个 | 定义多个成员,使用多个 | 定义多个成员,使用多个 |
默认public | 默认public | 默认private |
不可以定义模板参数 | 不可以定义模板参数 | 可以定义模板参数 |
-
C\C++\C#之间的
struct
和class
区别C:
struct
只能定义数据成员,不能包含成员函数\没有访问控制C++:参见1.
C#:
struct
是一种值类型,而class
是一种引用类型,分配内存位置不同,前者不可继承 -
struct
字节对齐为什么:许多硬件平台对特定类型的数据访问有最优的内存对齐要求。正确对齐的数据可以直接从内存读取,而未对齐的数据可能需要多次访问才能完成读取,这样会降低性能。
数据对齐:起始位置的偏移量必须是该变量类型大小的整数倍
- 结构体变量的首地址是其最宽基本成员类型大小的整数倍。
- 结构体每个成员相对于结构体首地址的偏移量都是成员大小的整数倍。
- 结构体的总大小为结构体最宽基本成员类型大小的整数倍。
new
delete
malloc
free
特性 | new / delete |
malloc / free |
---|---|---|
语言支持 | C++ 运算符 | C 标准库函数,也可在 C++ 中使用 |
类型安全 | 类型安全,编译时进行类型检查 | 类型不安全,需要显式类型转换 |
构造函数调用 | new 分配内存并调用构造函数 |
malloc 只分配内存,不调用构造函数 |
析构函数调用 | delete 调用析构函数并释放内存 |
free 只释放内存,不调用析构函数 |
申请内存 | 无须指定内存块的大小,编译器会根据类型信息自行计算 | 显式地指出所需内存的大小 |
内存分配失败处理 | 抛出 std::bad_alloc 异常 |
返回 nullptr |
数组支持 | new[] 和 delete[] 提供数组分配和释放支持 |
需要手动计算大小和类型转换 |
使用语法 | 简洁,适用于对象和数组的分配 | 复杂,需要显式类型转换 |
内存对齐 | 自动处理内存对齐 | 取决于具体的实现,可能需要手动处理 |
性能和优化 | 通常更优化,特别是在处理复杂对象时 | 性能依赖于具体的标准库实现 |
运算符重载 | 支持运算符重载,自定义内存分配策略 | 不支持运算符重载 |
异常处理 | 提供异常处理机制 | 需要手动检查返回值并处理错误 |
代码示例 | MyClass* obj = new MyClass(); delete obj; |
MyClass* obj = (MyClass*)malloc(sizeof(MyClass)); free(obj); |
返回类型 | new 返回目标对象类型的指针 |
malloc 返回void 的指针 |
NULL
nullptr
- 概念
NULL
:NULL
通常被定义为整数0
,或者是一个宏定义,如#define NULL 0
或#define NULL ((void*)0)
。它的实际类型可能因编译器而异。
nullptr
:nullptr
是C++11引入的一个新的关键字,它表示一个指向任何类型的空指针。nullptr
的类型是std::nullptr_t
,可以隐式转换为任何指针类型。
auto
decltype
- 概念
auto 和 decltype都是类型推断关键字;
auto
关键字用于在变量声明时根据初始化表达式自动推导变量的类型。
decltype
关键字用于从表达式中推导出类型,它不会对表达式求值,只是获取其类型
auto
会去掉引用和顶层 const
,auto
必须有一个初始化表达式。
decltype
会保留表达式的引用和 const
属性,decltype
可以用于没有初始化表达式的场合。
macro
宏 | const | inline | typedef | |
---|---|---|---|---|
定义 | 字符替换 | 常量声明 | 函数声明 | 定义新别名 |
内存 | 不分配内存 | 分配 | \ | \ |
处理 | 预处理器 | 编译器 | 编译器 | 编译器 |
安全检查 | 无 | 有 | 有 | 有 |
存储 | 代码段 | 数据段 | \ | \ |
阶段 | 预编译执行 | 编译阶段 | 编译阶段 | 编译阶段 |
STL 📦
引入
容器:数据结构,容器通过迭代器暴露其元素,使得算法可以操作这些元素
迭代器:访问容器的泛型指针,让用户通过特定的接口访问容器的数据,不需要了解容器内部的底层数据结构
算法:数据操作方式
函数对象(仿函数):函数对象是重载了函数调用操作符(()
)的类实例。STL中的函数对象可以用作算法的某些操作,如定义比较行为(less
,greater
等),定义算法作用与容器的行为。
sort(a.begin(), a.end(),less<int>());
适配器:可以修改或扩展迭代器、容器和仿函数的行为,使其能够以新的方式被算法使用或操作。(stack\queue)
空间配置器:在更底层被容器使用来管理内存分配的,但它通常对于 STL 的使用者是透明的,除非需要自定义内存管理行为。【可以作为说是STL优化策略】【堆中申请内存】
容器以 class template 完成;
算法以 function templates 完成;
仿函数是一种将 operator() 重载的 class template;
迭代器是一种将 operator++ 和 operator* 等指针习惯常行为重载的 class template;
配接器中的 container adapter 和 iterator adapter 都是一种 class template, 而 function adapter.
容器分类
概念
1总览
1 | STL: |
2表格
容器 | 底层数据结构 | 时间复杂度 | 有无序 | 可不可重复 | 其他 |
---|---|---|---|---|---|
array | 数组 | 随机读改 O(1) | 无序 | 可重复 | 支持随机访问 |
vector | 数组 | 随机读改、尾部插入、尾部删除 O(1) 头部插入、头部删除 O(n) | 无序 | 可重复 | 支持随机访问 |
deque | 双端队列 | 头尾插入、头尾删除 O(1) | 无序 | 可重复 | 一个中央控制器 + 多个缓冲区,支持首尾快速增删,支持随机访问 |
forward_list | 单向链表 | 插入、删除 O(1) | 无序 | 可重复 | 不支持随机访问 |
list | 双向链表 | 插入、删除 O(1) | 无序 | 可重复 | 不支持随机访问 |
stack | deque / list | 顶部插入、顶部删除 O(1) | 无序 | 可重复 | deque 或 list 封闭头端开口,不用 vector 的原因应该是容量大小有限制,扩容耗时 |
queue | deque / list | 尾部插入、头部删除 O(1) | 无序 | 可重复 | deque 或 list 封闭头端开口,不用 vector 的原因应该是容量大小有限制,扩容耗时 |
priority_queue | vector + max-heap | 插入、删除 O(log2n) | 有序 | 可重复 | vector容器+heap处理规则 |
set | 红黑树 | 插入、删除、查找 O(log2n) | 有序 | 不可重复 | |
multiset | 红黑树 | 插入、删除、查找 O(log2n) | 有序 | 可重复 | |
map | 红黑树 | 插入、删除、查找 O(log2n) | 有序 | 不可重复 | |
multimap | 红黑树 | 插入、删除、查找 O(log2n) | 有序 | 可重复 | |
unordered_set | 哈希表 | 插入、删除、查找 O(1) 最差 O(n) | 无序 | 不可重复 | |
unordered_multiset | 哈希表 | 插入、删除、查找 O(1) 最差 O(n) | 无序 | 可重复 | |
unordered_map | 哈希表 | 插入、删除、查找 O(1) 最差 O(n) | 无序 | 不可重复 | |
unordered_multimap | 哈希表 | 插入、删除、查找 O(1) 最差 O(n) | 无序 | 可重复 |
算法 | 底层算法 | 时间复杂度 | 可不可重复 |
---|---|---|---|
find | 顺序查找 | O(n) | 可重复 |
sort | 内省排序 | O(n*log2n) | 可重复 |
- 补充
- 为什么空间配置其使用内存池机制实现
内存池实现机制采用allocate 包装 malloc,deallocate包装free,如果将内存申请交给每个STL容器自己去申请管理,一是不安全容易内存泄漏二是频繁申请小块内存导致内存碎片,影响程序运行效率。
- 为什么空间配置器是一级和二级的,为什么要二级空间配置器(1大1小)
一级空间配置器对于大块内存非常有效,直接与内存管理机制交互减少额外开销,但是频繁分配和释放小块内存的场景,将会导致性能下降和内存碎片,所以引入二级空间配置器,底层原理是链表构成的内存
- STL内存管理
STL内存管理使用二级内存配置器来优化内存分配和回收。这种机制通过减少系统调用(例如 malloc
和 free
)的次数来提高性能,特别是在频繁进行小块内存操作的场景下,
一级内存配置器(first level allocator):直接使用标准的内存分配和回收方法,如 malloc
和 free
。当申请的内存大小超过128字节时,默认使用一级内存配置器
二级内存配置器(second level allocator):主要用于处理小块内存请求,尤其是小于128字节的请求。二级内存配置器的设计目的是减少内存碎片和提高内存分配效率。减少了对系统级内存管理函数调用,对于小块内存的请求能快速响应,提高了内存分配的速度,通过固定大小的内存块管理,可以有效减少内存碎片。
vector
- 概念
动态增长数组,底层是类似于一个array,但是比array灵活,内部数据连续存储,是一种可以动态增长的序列容器,元素在内存连续存放,随机存取时间复杂度O(1),尾端操作最佳性能,头部操作O(n)。
增加元素时若超过自身最大容量,则扩充自身容量2倍(不同的编译器,vector有不同的扩容大小。在vs下是1.5倍,在GCC下是2倍)扩充空间需要经过的步骤:重新配置空间,元素移动,释放旧的内存空间。一旦vector空间重新配置,则指向原来vector的所有迭代器都失效了,因为vector的地址改变了
- 补充
-
为什么加倍扩充,而申请固定容量?扩容为啥1.5倍或2倍?
加倍扩容将会有更多的空余空间,不然假设我们一边扩一个一边加一个将导致不停的内存拷贝复制,时间复杂度本来是O(1)将会增长为O(n)
使用k=2增长因子的问题在于,每次扩展的新尺寸必然刚好大于之前分配的总和,也就是说,之前分配的内存空间不可能被使用。这样对内存不友好,最好把增长因子设为(1, 2),也就是1-2之间的某个数值。1.5倍扩容能更好的利用之前的内存块,降低碎片问题。
-
push_back
和emplace_back
区别emplace_back() 和 push_back() 的主要区别,就在于底层实现的机制不同。push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程
-
迭代器失效
resize、reserve、insert、assign、push_back等会引起其底层空间改变的操作,都有可能使迭代器失效,实际就是迭代器底层对应指针所指向的 空间被销毁了
-
使用vector交换技巧释放空间
1
2std::vector<int>(vec).swap(vec); // shrink
std::vector<int>().swap(vec); // 清空内容,且释放内存 -
元素类型可以是引用吗
vector的底层实现要求连续的对象排列,引用并非对象,没有实际地址,因此vector的元素类型不能是引用
- 引用的不可重新绑定性:引用一旦被绑定到某个对象,就不能被重新绑定。容器在内部需要能够移动和复制元素,这对于引用类型来说是不可能的,因为引用类型一旦创建就绑定到某个对象,无法重新指向其他对象。
- 引用类型的内存管理:容器在内部可能会重新分配内存并移动元素位置,这对于引用类型是不适用的。因为引用本质上是某个对象的别名,而不是独立存在的对象,因此无法被复制或移动
list
-
概念
-
补充
deque
- 概念
双端队列, 用空间换了时间,支持首尾(中间不能)快速增删(与vector最大差异),支持随机访问。deque
的内部实现通常不是一个连续的内存区域,而是由一系列固定大小的数组(称为块或段)构成,这些数组通过一些额外的中央控制结构(如索引数组)连接
优点:
- 两端插入/删除操作高效:
std::deque
允许在容器的前端和后端进行快速插入和删除操作,时间复杂度大约是 O(1),这比std::vector
的前端插入和删除效率要高得多。【这也是vector最大差异】 - 随机访问:与
std::vector
类似,std::deque
也支持通过索引进行快速随机访问,访问时间复杂度为 O(1)
缺点:
- 内存使用可能更高:由于
std::deque
的实现通常需要额外的中央控制结构来管理各个块,以及块内的间接寻址,这可能导致其比std::vector
使用更多的内存。 - 缓存利用率可能较低:虽然
std::deque
支持随机访问,但其元素可能分布在不连续的内存块中,这可能导致在遍历时缓存命中率低于std::vector
。 - 某些操作可能较慢:虽然在两端的操作很快,但在
std::deque
的中间插入或删除元素仍然需要移动一部分元素,这可能比在std::vector
中慢,尤其是当元素分布在多个内存块中时
- 补充
deque
的中控器
deque
的中控器是一个指针数组,每个指针指向一个固定大小的内存块(称为缓冲区),这些缓冲区存储实际的数据元素。中控器本身通常是动态分配的,可以根据需要扩展或缩小,以容纳更多的缓冲区。这种结构允许 deque
在两端动态增长而无需重新分配整个容器的内存,这是 vector
扩展时通常需要做的事情。
map
- 概念
底层实现为一个自平衡的二叉搜索树(红黑树,红黑树的旋转操作比AVL树少,红黑树的这种宽松平衡使其在插入和删除操作中相对更高效,因为它不需要像AVL树那样频繁地进行平衡调整,但是这也意味着在最坏情况下,红黑树的查找操作可能会稍慢于AVL树,因为其树高可能稍高),意味着在对数时间复杂度内完成插入、查找和删除的操作,内部元素排列是有序的。
- 补充
红黑树?
中所有节点非红即黑。
根节点必为黑节点。
红节点的子节点必为黑(黑节点子节点可为黑)
从根到叶子的任何路径上黑结点数相同
O() 查询
set
- 概念
底层是一个基于红黑树实现,存储唯一的元素(不重复),按照特定的顺序进行排序的,关联容器。并且set仅存储单个值而非键值对,他的值就是键。
- 补充
- 为什么map和set和插入删除效率比其他序列容器高,而且每次insert之后,以前保存的iterator不会失效
**关于效率:**set和map是一种关联式容器,底层实现是红黑树实现的,他们的插入和删除效率高于其他容器是因为插入删除操作均是在节点进行操作,对红黑树节点的操作也只是指针操作,节点的存储内存不变。所以效率高,而vector往中间插入会涉及到对后序的内存中的元素复制再拷贝。
关于迭代器失效: 在 std::map
和 std::set
中,插入和删除操作不会使指向其他元素的迭代器失效。这是因为这些操作只影响到特定的节点,并且由于红黑树的性质,树的其他部分保持有效
- map和set不能像vector一样有个reserve函数来预分配数据
vector是一种序列式容器,底层实现是一个连续的内存空间,可以动态添加删除数据。而map&set基于红黑树结构他们并不支持连续的内存布局,他们的底层设计和数据结构决定了它们不支持 reserve
功能,
priority_queue
- 概念
std::priority_queue
默认使用 std::vector
作为其底层容器,并使用 std::less
作为比较函数,这意味着最大的元素总是位于队列的前端。它不提供遍历其元素的能力,因为 std::priority_queue
只允许访问最顶端的元素。插入和删除操作(添加和移除队列中的元素)的时间复杂度大约是 O(log n)
容器其它补充
迭代器 ++it 与 it++
- 概念
++it
(前缀递增)
行为:首先增加迭代器的值(即让迭代器指向下一个元素),然后返回增加后的迭代器的引用。普通++i
就是这样,++i
首先将 i
的值增加+1再返回
性能:通常推荐使用前缀递增,因为它不需要创建迭代器的临时副本。在迭代器或者对象本身较大时,使用前缀递增可以避免不必要的复制,从而提高效率
it++
(后缀递增)
行为:首先创建当前迭代器的一个副本,然后增加原迭代器的值(让原迭代器指向下一个元素),最后返回副本。这意味着返回的是增加之前的迭代器的值。普通i++
就是这样,i++
首先返回 i
当前的值(增加前的值),然后再+1
性能:后缀递增需要创建迭代器的一个临时副本,这可能导致额外的性能开销,尤其是对于那些复制成本较高的迭代器(如某些容器的迭代器)
STL线程安全性
- 概念
单线程访问:所有 STL 容器都是线程安全的。
多线程只读访问:多个线程可以安全地同时读取同一个容器。
多线程读写访问:需要使用同步机制(如互斥锁)来保护对容器的访问,以防止数据竞争。
容器动态链接可能产生的问题
- 概念
一般程序中,局部容器、参数传递容器、参数传递容器的引用以及参数传递容器的指针通常可以正常运行。然而,当涉及到动态链接库(DLL)时,给动态库函数传递容器对象本身可能会导致内存堆栈破坏的问题。
标准库实现差异;编译器和ABI兼容性;内存分配器不一致
析构与构造 📚
- 概念
解释 | 备注 | |
---|---|---|
普通构造函数 | 完成对象的初始化工作 | 含参数 |
默认构造函数 | 完成对象的初始化工作 | 无参数 |
拷贝构造函数 | 复制构造函数用于复制本类的对象,拷贝构造函数在用一个对象初始化另一个新对象时被调用 | 默认实现是浅拷贝而非深拷贝 |
转换构造函数 | 转换构造函数用于将其他类型的变量,隐式转换为本类对象 | |
移动构造函数 | 特殊的构造函数,用于在对象之间转移资源的所有权,而不是复制资源 | 接受一个右值引用作为参数;noexcept不抛出异常 |
委托构造函数 | 构造函数可以委托同类型的另一个构造函数对对象进行初始化 |
构造过程:
-
内存分配:首先为对象分配内存,包括其所有成员变量。
-
成员初始化
-
使用初始化列表:如果提供了初始化列表,则成员变量将通过在列表中指定的构造函数直接初始化。这意味着对于类类型成员,将直接调用相应的构造函数(可能是参数化构造函数或复制构造函数),而对于内置类型成员,则直接进行赋值。
初始化列表只做一次赋值操作,相比构造函数体内进行赋值的话,构造函数体内进行赋值会等于是一次默认构造加一次赋值。
-
未使用初始化列表:如果没有为类类型的成员变量提供初始化列表,则这些成员变量首先通过默认构造函数进行初始化。之后,如果在构造函数体内有赋值操作,这些成员变量将经历一次赋值操作。
-
-
执行构造函数体:完成成员的初始化后,执行构造函数体中的代码
对于类类型的成员变量,如果在构造函数体内对它们进行赋值,而不是在初始化列表中直接初始化,会发生以下情况:
- 默认构造阶段:在进入构造函数体之前,类类型的成员需要被初始化。如果没有在初始化列表中明确指定如何初始化,那么这些成员将通过默认构造函数进行初始化。这是成员对象的生命周期的开始。
- 赋值操作:在构造函数体内,对这些已经默认构造的成员进行赋值,实际上是调用赋值操作符。这不是成员对象的初始化,而是在一个已经构造的对象上进行的赋值。
浅拷贝(Shallow Copy) 深拷贝(Deep Copy) 零拷贝
构造\析构执行顺序
构造:基类构造函数->成员对象的构造函数->派生类构造函数
有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序
构造子类构造函数的参数
子类调用基类构造函数
基类设置vptr
基类初始化列表内容进行构造
基类函数体调用
子类设置vptr
子类初始化列表内容进行构造
子类构造函数体调用
- 补充
-
构造函数与析构函数进行内联
将构造函数和析构函数声明为inline是没有什么意义即编译器并不真正对声明为
inline
的构造和析构函数进行内联操作原因:因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。即实际上这两个函数很复杂,编译器不会对其
inline
-
虚函数内联
当是指向派生类的指针(多态性)调用
inline
的虚函数时,不会内联展开;当是对象本身调用 inline 的虚函数时,会内联展开。虚函数是为了实现运行时多态性而设计的,它允许在派生类中重写基类中的同名函数,而在运行时动态地确定应该调用哪个版本的函数,内联函数允许在调用处直接展开函数的代码,以减少函数调用的开销。但是,对于虚函数来说,编译器需要在运行时确定实际调用的函数版本,这与内联函数的特性相矛盾。因此,虚函数通常不会被声明为内联函数。虚函数的实现通常涉及虚函数表(vtable)和虚函数指针(vptr),而内联函数的展开是在编译期间完成的,这两者的机制不兼容
内联问题 虚函数内联(类中本身调用才展开,指针调用情况下不会) 构造函数内联(没意义) 析构函数内联(没意义) -
什么时候合成构造函数
没有任何构造函数,但他含有一个成员对象,该成员对象含有默认构造函数
没有任何构造函数的类派生自一个带有默认构造函数的基类
带有虚函数的类
含有虚基类
-
什么时候合成拷贝构造函数
被当做参数交给某个函数
如果返回的对象是一个函数参数或类的成员变量,而不是局部对象,则会调用拷贝构造函数
没有拷贝构造函数,但是含有一个类类型的成员变量,该类型含有拷贝构造函数
没有拷贝构造函数,但是该类继承自含有拷贝构造函数的基类
带有虚函数的类
含有虚基类
-
返回一个局部对象,返回值的创建
返回一个局部对象,编译器会优先调用移动构造函数来创建返回值,而不是拷贝构造函数
多态与虚函数 🧊
多态
- 概念
- 编译时多态:也称为静态多态,主要通过方法重载和运算符重载实现。在编译时,编译器就决定了应该使用哪个函数,基于函数签名(如参数类型和数量)来进行区分。
- 运行时多态:也称为动态多态,主要依赖于继承和接口实现。在运行时,系统根据对象的实际类型来决定调用哪个实现,这种决定通常依赖于虚函数。
- 补充
- 引用和指针才能展现多态,非引用或者指针呢?
非指针和非引用将导致切割问题:指针类型的时候按照多态的行为去解释(即去查虚函数表,然后实现运行时多态),而非指针类型,直接使用基类类型不会按照多态行为解释(虚函数表指针丢失)
虚xxx
- 概念
- 普通虚函数(Virtual Functions)
定义与目的:通过在函数声明前添加virtual
关键字来定义虚函数。虚函数允许派生类重写(override)基类中的成员函数,实现运行时多态
运行时多态:虚函数的调用是在运行时决定的,而非编译时。这意味着当通过基类指针或引用调用一个虚函数时,将执行对象实际类型的函数版本
编译时多态:函数重载或者模板【问模板的原理?】
- 纯虚函数(Pure Virtual Functions)
定义:纯虚函数是一种没有实现的虚函数,通过在函数声明的结尾处添加= 0
来指定
抽象基类:包含至少一个纯虚函数的类称为抽象基类。抽象基类不能实例化对象
- 虚析构函数(Virtual Destructors)
目的:确保通过基类指针删除派生类对象时能够调用正确的析构函数,从而避免资源泄漏
实现:将基类的析构函数声明为虚函数
- 虚继承
解决问题:用于解决多重继承中的菱形继承问题(钻石问题),避免基类被多次继承导致的成员重复
实现:编译器会为使用虚继承的类构建一个虚基类表。这个表用于存储虚基类相对于派生类对象的偏移量
- 补充
不可以为虚函数的函数 | 备注 |
---|---|
构造函数 (不可以) | 虚指针未初始化,矛盾 |
内联函数 (不推荐) | 派生类指针调用不会内联展开;对象本身调用内联展开 |
静态函数 (不可以) | 因为不属于对象属于类,静态成员函数没有this指针 |
友元函数 (不可以) | 因为友元函数不属于类的成员函数,不能被继承,不会进入虚表 |
-
静态函数可以声明为虚函数吗
不可以,静态函数不是对象的实际成员,无法和实例进行绑定
-
构造函数可以为虚函数吗
不可以,虚函数表和虚指针是在对象被创建完成后才进行初始化的。在对象的构造函数执行之前,对象的内存空间已经分配,但虚指针尚未初始化。因此,在构造函数中无法使用虚函数表和虚指针来实现动态绑定
当一个虚函数被调用时,它通过虚表(vtable)来解析,而虚函数的调用需要我们通过虚指针去查询虚表进行访问,但此时我们的虚指针的构造是跟随着对象构造函数一起走的,既然我们的虚指针还未初始化那我们访问虚表的行为可能就是未知的,得不到相应调用。此时就会导致一个悖论:
解释1:虚指针还未还没有初始化,对象未完成构造,编译器就需要使用它来调用构造函数;
解释2:虚函数是在运行时动态确定其类型的。在构造一个对象时对象还未创建成功,编译器无法知道对象的实际类型,无法进行调用
'鸡和蛋相互依赖:鸡来自蛋,而蛋又是由鸡生的。这导致了一个看似无法解决的循环因果关系,同样,在构造函数和虚函数的情况下,也存在一种循环依赖:
- 为了调用虚函数,对象需要通过vptr访问其虚表。
- 但是,访问虚表我们需要一个已经初始化好了的vptr才行,而虚指针是在构造函数执行期间初始化的,对象完成构造后才能得到虚指针
- 如果构造函数本身是一个虚函数,那么在调用构造函数之前,就需要访问尚未初始化的虚指针进而访问虚表,很明显这有问题。
这就产生了一个类似于"先有鸡还是先有蛋"的悖论:为了调用构造函数(虚函数),需要虚指针访问虚表;但为了初始化虚指针,需要先调用构造函数’
对象的虚指针(指向虚表的指针)设置通常是在构造函数开始执行的早期阶段进行的,这是为了确保即使在构造过程中,对象的虚函数也能正确解析到当前构造阶段对应类的实现。这意味着,虚指针和虚表的设置发生在对象的构造过程中,而不是在构造函数完成后【是逐步逐步完成的】。
假设有三个类,
Base
,Derived1
继承自Base
,以及Derived2
继承自Derived1
。每个类都有其虚函数和相应的虚表。构造
Derived2
对象时的过程:- 当
Derived2
的构造函数被调用时,它首先会调用Derived1
的构造函数。 - 在
Derived1
的构造函数执行之前,Base
的构造函数会被自动调用。 - 在
Base
构造函数执行的早期阶段,对象的虚指针被设置为指向Base
的虚表。这确保了即使在Base
构造函数内部,任何虚函数调用都能正确地解析到Base
类的实现。 - 然后,控制权回到
Derived1
的构造函数,在它开始执行的早期,对象的虚指针更新为指向Derived1
的虚表。 - 同样地,当
Derived2
的构造函数开始执行时,对象的虚指针最终被更新为指向Derived2
的虚表。 - 当
Derived2
的构造函数执行完毕,整个对象被完全构造完成,此时虚指针指向Derived2
的虚表,确保任何对虚函数的调用都会解析到Derived2
或它正确的基类实现。
因此,虚指针的设置和更新发生在每个构造阶段的开始,确保了即使在构造过程中发生虚函数调用,也能够调用到正确版本的函数【但是一般不在构造函数中调用虚函数,不推荐】。这也意味着虚指针和虚表的“生成”(或更准确地说,虚指针的设置)是在构造对象的过程中逐步完成的,而不是在整个对象构造完成后
-
析构函数可以为虚函数吗
有一个指向派生类对象的基类指针,并且通过这个基类指针删除对象时,如果基类的析构函数不是虚的,那么只有基类的析构函数会被调用,导致派生类部分可能不会被正确清理,从而造成资源泄漏或其他问题。这是因为在这种情况下,编译器无法确定要调用哪个析构函数,因为它只依据指针的静态类型来做决定。
-
构造函数和析构函数可以调用虚函数吗
对于构造函数:此时子类的对象还没有完全构造,编译器会去虚函数化,只会用当前类的函数, 如果是纯虚函数,就会调用到纯虚函数,会导致构造函数抛异常
对于析构函数: 同样,由于对象不完整,编译器会去虚函数化,函数调用本类的虚函数,如果本类虚函数是纯虚函数,就会到账析构函数抛出异常
语法上讲可以,但不推荐这种做法【effective C++: 绝不在构造和析构过程中调用虚函数】
《Effective C++》的解释是: 派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类类型。 同样,进入基类析构函数时,对象也是基类类型
- 在构造函数中调用虚函数,调用的是当前正在构造的类的版本,而不是最终的重写版本
- 在析构函数中调用虚函数,调用的是当前正在析构的类的版本,而不是原始的基类版本
父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编【构造函数和析构函数调用虚函数时都不使用动态联编】
vptr 与 vtable
-
概念
-
虚指针 vptr
定义:虚指针是一个指针,每个使用虚函数的对象都会持有一个指向相应虚表的虚指针。
作用:虚指针使得对象能够在运行时通过虚表找到正确的虚函数实现。当对象的虚函数被调用时,编译器通过对象的虚指针访问虚表,从而找到对应的函数实现进行调用。在构造函数执行时会对虚表指针进行初始化
-
虚表 vtable
定义:虚表是一个包含指向类的虚函数地址的数组。每个有虚函数的类或包含虚函数的类的派生类都有一个为它生成的虚表,这个表是一个函数指针数组,每个元素指向一个虚函数的实现。如果类中有虚函数,每个对象实例会包含一个指向其类虚函数表的指针(通常在对象内存布局的开始位置)。
作用:当调用对象的虚函数时,编译器会使用虚表来决定需要调用的实际函数。这允许在运行时进行函数的动态绑定,是实现多态的关键。
-
工作原理
初始化:当一个对象被创建时,编译器会自动在对象的内存布局中添加一个虚指针(vptr),并将其初始化为指向该类的虚表
在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表
即导致一种情况:覆盖
当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面
继承与多态:如果一个类被继承,派生类会有它自己的虚表(如果它覆盖了基类的虚函数或添加了新的虚函数)。当通过基类指针或引用调用虚函数时,运行时会查找对象实际类型的虚表,从而调用正确的函数实现
继承中的虚函数表
-
无新虚函数,无覆盖:
如果派生类没有引入新的虚函数,也没有重写基类的任何虚函数,则派生类可以直接使用基类的虚函数表。
-
覆盖基类虚函数:
如果派生类重写(覆盖)了基类的虚函数,派生类的虚函数表将包含指向新实现的指针,替换掉原有函数的位置。这保证了通过基类指针调用虚函数时,能够执行到派生类中的覆盖版本。
-
引入新的虚函数:
如果派生类引入了新的虚函数,这些新函数会被添加到虚函数表的末尾。派生类的虚函数表首先包含指向从基类继承的所有虚函数(包括被覆盖的和未被覆盖的)的指针,随后是新增虚函数的指针。
-
-
-
补充
- vptr和vtable是什么时候创建
虚表是在编译阶段为每个含有虚函数的类生成的,而虚指针在类的对象实例化过程中,具体是在构造函数执行过程中初始化,并指向相应的虚表
虚表和虚指针创建和初始化的过程,涉及到类的实例化:
**编译阶段:**编译器在编译阶段识别出哪些类含有虚函数,并为这些类生成虚表。虚表中存储了虚函数的地址。如果类有继承,并且子类覆盖了基类的虚函数,子类的虚表中会用子类函数的地址替换相应的基类函数地址。
**构造函数执行阶段:**当创建一个类的对象时,该对象的构造函数会被调用。在构造函数的执行过程中,对象的虚指针(vptr)被初始化,指向其对应类的虚表。如果有继承关系,每个构造函数(从基类到派生类)在其执行过程中都可能更新vptr以指向正确的虚表,以反映当前构造阶段对象的动态类型。
**构造函数完成后:**一旦对象的构造函数(包括所有基类和派生类的构造函数,如果有继承的话)执行完成,对象的vptr将最终指向最派生类的虚表。此时,对象已经完全构建好,可以正常使用虚函数了。虚指针 (vptr)
- 创建和初始化时机:每个含有虚函数的类的对象都会有一个虚指针。这个虚指针是在对象创建时自动被编译器添加到对象中的。对于类的每个实例,虚指针在对象的构造函数中被初始化。
- 指向:虚指针指向相应的虚表。
所以我们说只有在vptr完成初始化后才能访问到虚表
虚表 (vtable)
- 创建时机:虚表是在编译时期创建的。对于每一个含有虚函数的类,编译器会生成一个虚表。虚表是类的一个静态属性,因此对于该类的所有实例来说,虚表是共享的。
- 初始化时机:虚表在编译期间就已经被初始化了。编译器会将类中所有的虚函数地址填入虚表中。这意味着,当程序编译完成后,每个含有虚函数的类对应的虚表中已经填充了所有虚函数的地址。
- 填入虚函数地址:虚函数的地址是在编译器编译时期填入虚表的。这个过程发生在程序编译阶段,而不是运行时或者类的初始化时期
引用与指针 📎
区别
在编译的时候,则是将“指针变量名-指针变量的地址”添加到符号表中,所以说,指针包含的内容是可以改变的,允许拷⻉和赋值,有const
和⾮ const
区别,甚⾄可以为空,sizeof
指针得到的是指针类型的⼤⼩。
引⽤来说,它只是⼀块内存的别名,在添加到符号表的时候,是将"引⽤变量名-引⽤对象的地址"添加到符号表中,符号表⼀经完成不能改变,所以引⽤必须⽽且只能在定义时被绑定到⼀块内存上,后续不能更改,也不能为空,也没有 const
和⾮const
区别。
-
指针是一个变量,存储地址,引用是别名
-
指针可以有多级,引用只有一级
-
指针可以为空,引用必须初始化
-
指针在初始化后可以改变指向,而引用在初始化之后不可再改变
-
当把指针作为参数进行传递时,也是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参。而使用引用作为参数传值对这个引用进行修改则会引起源对象变化
参数传递方式
指针传递、引用传递、值传递
指针分辨
右左法则:使用右左法则时,你从变量名开始,然后根据优先级(括号 > 数组/函数 > 指针)向右看,如果到达声明的末尾,就转向左边继续
int *p[10]
- 分析: 首先看到的是
[]
,表示p
是一个数组;然后移动到左边,看到*
,表示数组的元素是指针;最后到最左边,看到int
,表示这些指针指向整型数据。 - 结论:
p
是一个有10个元素的数组,每个元素是指向int
类型数据的指针。
int (*p)[10]
- 分析: 首先看到的是
(*p)
,括号表示p
是被*
解引用的,所以p
是一个指针;然后外面是[]
,表示这个指针指向的是一个数组;最后是int
,表示数组的元素是整型数据。 - 结论:
p
是一个指针,指向一个有10个整型元素的数组。
int *p(int)
- 分析: 这是一个函数声明。首先看到的是
(int)
,表示有一个整型参数的函数;然后到左边,看到*p
,这里p
是函数名,*
表示函数返回的是指针;最后到最左边,int
表示这个指针指向整型数据。 - 结论:
p
是一个函数,接受一个整型参数,返回一个指向整型数据的指针。
int (*p)(int)
- 分析: 首先看到的是
(*p)
,括号里的*
表示p
是一个指针;外面的(int)
表示这个指针指向的是一个接受整型参数的函数;最后是int
,表示这个函数返回整型数据。 - 结论:
p
是一个指针,指向一个函数,该函数接受一个整型参数并返回一个整型数据。
左右值引用
左值:指的是表达式结束后依然存在的对象。左值可以出现在赋值表达式的左侧。例如,变量、对数组元素的引用、解引用指针、返回引用的函数调用都是左值。
右值:通常指的是表达式结束就不再存在的临时对象。右值不能出现在赋值表达式的左侧。字面量(除了字符串字面量外)、返回非引用的函数调用、算术表达式的结果都是右值。
右值引用
优点:
- 支持移动语义:右值引用允许将资源(如动态分配的内存)从一个对象移动到另一个对象,而不是传统的复制,从而提高性能和效率。
- 可用于完美转发:右值引用可以用于实现完美转发,即在函数模板中将参数以原始形式传递给其他函数,保留参数的值类别(左值或右值)【即左值被处理后仍然是左值】
引用折叠:
出现了左值引用最后折叠的结果都是左值引用,只有右值应用和右值引用折叠才能变成右值引用
T& & => T&
T& && => T&
T&& & => T&
T&& && => T&&
野指针 与 悬空指针
野指针(Wild Pointer)
野指针是指未初始化的指针。其指向的内存地址是不确定的,因为它没有被明确初始化为NULL或指向有效的内存地址。使用野指针的结果是不可预测的,因为它可能指向任何内存地址,包括系统保留的内存。尝试访问或操作野指针所指向的内存通常会导致不可预测的行为,包括程序崩溃、数据损坏或者安全漏洞。
悬空指针(Dangling Pointer)
悬空指针是指向了一块已经被释放的内存的指针。当使用例如free
或delete
操作释放了一块内存之后,原本指向那块内存的指针就成了悬空指针。与野指针不同,悬空指针之前是指向有效内存的,但在内存释放后继续使用它就变得危险了。因为那块内存可能已经被重新分配给其他用途,对悬空指针的引用或操作可能会导致数据损坏或者不稳定的行为。
智能指针
分类
智能指针是一个类,指向动态分配对象,负责自动释放动态分配的对象,防止内存泄漏
auto_ptr
C++98 引入的一种智能指针,可以自动管理动态分配(使用 new
)的对象的生命周期,以避免内存泄漏,当 std::auto_ptr
的实例离开作用域时,它会自动删除所管理的对象. 由于缺陷建议被3大智能指针替代
std::auto_ptr
的主要问题:
- 所有权转移:
std::auto_ptr
在进行拷贝或赋值时,会发生所有权的转移。这意味着原std::auto_ptr
会失去对对象的所有权(变为nullptr
),而新的std::auto_ptr
获得所有权。这种行为很容易导致意外的所有权转移,造成资源管理上的困惑。 - 容器兼容性:由于所有权转移的行为,
std::auto_ptr
不能安全地用于标准容器(如std::vector
、std::list
等)。尝试存储std::auto_ptr
的拷贝在容器中会导致未定义行为,因为容器操作经常需要元素的复制和赋值。 - 多线程问题:
std::auto_ptr
没有考虑到多线程环境中的安全性问题。其设计不包含对并发访问的保护,这在多线程程序中可能会导致数据竞争和其他同步问题。
std::unique_ptr
:是一种独占所有权的智能指针,意味着同一时间内只能有一个unique_ptr
指向给定资源。当unique_ptr
离开作用域时,它指向的对象会被自动销毁。这种智能指针不允许复制,确保了资源的独占性,但它允许移动,使得资源的所有权可以转移
std::shared_ptr
:是一种共享所有权的智能指针,允许多个shared_ptr
实例指向同一个对象。内部使用引用计数机制来跟踪有多少个shared_ptr
共享同一个资源。当最后一个指向对象的shared_ptr
被销毁时,对象会被自动删除。shared_ptr
适用于需要通过多个指针访问同一资源的情况。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁
std::weak_ptr
:是一种不拥有对象的智能指针,它设计用来解决shared_ptr
间的循环引用问题。weak_ptr
指向shared_ptr
管理的对象,但不增加引用计数。因此,weak_ptr
不会阻止其指向的对·象被销毁。通常,weak_ptr
用于临时访问shared_ptr
管理的对象,而不希望对对象的生命周期产生影响。可以通过lock函数检查对象是否失效,未失效则转换到shared_ptr
拷贝
当使用一个智能指针来初始化另一个智能指针时,会调用智能指针的拷贝构造函数。不同的智能指针类型,其拷贝构造行为也不尽相同:
std::unique_ptr
: 由于unique_ptr
是独占所有权的,不允许拷贝构造,只能进行移动构造。std::shared_ptr
: 拷贝构造会使新的shared_ptr
与原shared_ptr
共享同一个控制块,从而共享对象的所有权。引用计数会加1。std::weak_ptr
: 拷贝构造不会影响引用计数,新旧weak_ptr
指向同一个控制块。
赋值
对智能指针进行赋值操作时,行为也因类型而异:
std::unique_ptr
: 不允许普通的赋值操作,只能通过移动赋值转移所有权。std::shared_ptr
: 赋值后,两个shared_ptr
共享同一个控制块,引用计数会加1。std::weak_ptr
: 赋值不会影响引用计数
循环引用:指两个或多个对象相互持有对方的强引用(如 shared_ptr
),导致这些对象的引用计数永远不会降为零,从而造成内存泄漏
实现智能指针
1 |
|
模板 📏
- 概念
函数模板:允许以任意类型执行相同的操作。例如,可以写一个排序函数,用于任何可以比较元素的数据类型。
类模板:允许生成操作任意类型数据的类的实例。例如,std::vector<T>
可以存储任何类型T
的动态数组
模板的底层实现机制称为模板实例化。模板本身不是直接编译成机器代码的,而是在编译器遇到模板使用时(例如,通过指定模板参数来创建对象或调用函数)【两次编译】在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译
函数模板实例化:当你使用特定类型调用一个函数模板时,编译器会生成一个该类型的特定版本的函数。如果你用相同的类型参数再次调用该函数模板,编译器通常会重用之前生成的实例。这意味着对于每一种类型的函数调用,编译器都可能生成一个专门的函数实例。
类模板实例化:类模板的实例化过程与函数模板类似。当你声明一个类模板的实例(即对象)时,需要为模板参数提供具体的类型。编译器随后为这些具体类型生成一个特定的类定义。每个不同的参数化类型都会生成一个新的类实例。
- 补充
-
模板中
typename
和class
区别class
用于声明一个类型参数,可以是任何类型包括基本数据类型等;而typename
也能这样用,但他主要用于依赖类型的情况,即模板内部依赖于模板参数的类型,其主要是解决编译器解析依赖类型名称时的歧义(例如T::iterator
就是一个依赖于模板参数的类型)
其它 🔦
C++ 与 C
- 面向对象支持:C++是一种面向对象的编程语言,支持类封装、继承、多态等面向对象的特性,同时引入模板概念呢。而C语言则是一种面向过程的编程语言,没有直接支持面向对象的特性。
- 标准库:C标准库主要提供了基本输入输出、字符串处理等功能函数。而C++标准库除了包含了所有C标准库函数外,还添加了对面向对象特性(如容器、算法)的支持,以及更重要的STL库
- 安全性:应对C的不安全性,C++增加了许多机制和特性来应对这些安全性问题,例如cast转换、异常处理机制、智能指针、
nullptr
类型 - 内存分配:
new
delete
取代malloc
free
C++ 与 C#
C#
- C#通常运行在托管环境中,例如.NET Framework或.NET Core。它依赖于CLR(Common Language Runtime)来执行代码,并使用CIL(Common Intermediate Language)作为中间语言
- C#具有自动垃圾回收机制,开发人员不需要手动释放内存。CLR会周期性地检查不再使用的对象,并自动释放它们所占用的内存
- C#通过.NET Core实现了跨平台支持
C++
- C++可以编译为本地机器代码,不依赖于特定的运行时环境
- C++需要手动管理内存,开发人员需要显式地分配和释放内存,避免内存泄漏和野指针等问题
- C++本身是跨平台的,可以通过适当的编译器和工具链在各种平台上进行开发和运行
编译过程
**预处理阶段:**在这个阶段,预处理器处理源文件中的预处理指令,处理器会根据这些指令展开头文件并替换宏定义,生成一个经过预处理的源文件.ii
**编译阶段:**编译器将预处理后的源文件转换成汇编代码。编译器会对源文件进行词法分析、语法分析和语义分析,并生成相应的中间代码或汇编代码 .s
汇编阶段:汇编器将汇编代码转换成机器码或者目标文件。汇编器会将汇编代码转换成可重定位的机器码,并生成目标文件 .o
链接阶段:链接器将目标文件和库文件链接在一起,生成最终的可执行文件 .out
启动过程
main
函数执行前后干了什么
初始化.data
和.bss
段的内容-在main
前调用全局对象的构造函数-传递argc
和argv
参数
全局对象析构函数在main
函数之后执行
动态\静态
链接
静态链接
在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件
优点:可执行程序中已经具备了所有执行程序所需要的任何东西, 在执行的时候运行速度快
缺点:导致内存中副本代码过多,源库代码修改了,链接过该库的程序要重新进行编译链接
动态链接
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形 成一个完整的程序
优点:多个程序在执行时共享同一份副本,更新时只需要替换原来的目标文件,而无需将所有程序再重新链接一遍
缺点:为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失
断言
静态断言(Static Assertion):
静态断言是在编译时进行的,即在代码被编译之前就会执行。
使用静态断言可以对编译期间已知的条件进行检查。
静态断言使用静态表达式来定义条件,并且如果条件为假,则会导致编译错误。
静态断言通常用于验证编译期常量、类型属性或其他与类型相关的约束。
动态断言(Dynamic Assertion):
动态断言是在运行时进行的,即在程序执行过程中才会执行。
使用动态断言可以对运行时条件进行检查。
动态断言使用 assert() 宏来定义条件,并且如果条件为假,则会触发一个运行时错误,并终止程序执行。
动态断言通常用于验证假设、调试程序或捕获意外情况。
类型
静态类型:编译期间绑定,静态类型的语言要求在编译时确定所有变量的类型
动态类型:运行期决定,动态类型的语言允许变量在运行时改变其类型
绑定
静态绑定:绑定的是静态类型,将函数和对应的属性依赖绑定到相应的对象的静态类型。指的是对象的方法调用在编译时就已经解析,编译器知道具体调用哪个方法
动态绑定:绑定的是动态类型,将函数和对应的属性依赖绑定到相应的对象的动态类型(例如虚函数)。指的是方法调用的目标在运行时才确定
多态(略)
RAII
Resource Acquisition Is Initialization(RAII) 核心思想是:在对象的生命周期内获取所需的资源,并在对象销毁时释放这些资源。这种方式利用了C++的构造函数和析构函数自动调用的特性,确保资源使用安全且高效,这也是为什么C++比C安全的原因之一。智能指针便是RAII最具代表的实现。
RTTI
Run Time Type Identification 通过运行时类型识别,程序能够使用基类的指针或引用来检查着这些指针或引用所指的对象的实际派生类型 typeid dynamic_cast
OOP
OOP | POP | |
---|---|---|
概念 | 将问题分解成一系列步骤或称之为过程的操作序列。编程的焦点是在执行具体任务的过程和函数 | 将问题分解成一组相互作用的对象,每个对象代表现实世界中的实体或概念。编程的焦点是对象及其交互 |
单位 | 函数 | 类和对象 |
适用场景 | 较小、单一任务或脚本,尤其是简单的、逻辑线性的任务 | 大型的、复杂的系统,特别是需要多次迭代和维护的项目 |
实现抽象 | 抽象通常限于函数 | 支持较高级别的抽象,如类抽象和多态 |
封装:信息隐藏
继承:继承是使用已存在的类的定义作为基础建立新类
多态:发出的方法调用在编程时并不确定,而是在程序运行期间才确定
- 重载多态(Ad-hoc Polymorphism,编译期):函数重载、运算符重载
- 子类型多态(Subtype Polymorphism,运行期):虚函数
- 参数多态性(Parametric Polymorphism,编译期):类模板、函数模板
- 强制多态(Coercion Polymorphism,编译期/运行期):基本类型转换、自定义类型转换
锁
std::mutex
:
- 最基本的互斥锁。
- 提供了排他性访问,即一次只有一个线程可以持有锁。
std::recursive_mutex
:
- 递归互斥锁,允许同一个线程多次获取同一互斥锁。
std::timed_mutex
和 std::recursive_timed_mutex
:
- 这些锁提供了基于时间的锁定功能。
- 允许尝试在指定的时间段内获取锁。
std::shared_mutex
(自 C++14 开始支持, 在 C++17 中更名为 std::shared_timed_mutex
):
- 允许多个线程同时读取共享数据,但写入时需要独占访问。
- 适用于读多写少的场景,可以提高并发性。
std::lock_guard
:
- 是一个作用域锁,保护一个代码段,使之在多线程环境下线程安全,使用 RAII(资源获取即初始化)方式管理
std::mutex
。 - 当
std::lock_guard
对象创建时自动加锁,当作用域结束时自动解锁,避免忘记解锁的问题。
std::unique_lock
:
- 比
std::lock_guard
更灵活的作用域锁。 - 可以在生命周期内多次锁定和解锁。
- 支持条件变量
std::condition_variable
。
多线程
- 补充
使用线程池可能带来哪些风险和有哪些好处?
好处:减少创建和销毁线程的开销与降低系统资源消耗、更好的线程管理和调度
风险:资源争用和死锁、复杂性增加
线程池参数有哪些?
大小、任务队列容量、线程创建策略、线程拒绝策略、线程活跃时间、核心线程数、最大线程数量、线程优先级
线程池大小如何确定合理?是否和处理器有关?
CPU密集型任务:如果任务主要是计算密集型的(例如数学运算),那么线程池的大小通常设为处理器核心数的1到1.5倍。这样可以最大化CPU使用率,同时留有少量空间处理其他可能同时发生的系统任务或其他程序。在完全的CPU密集型场景中,线程数设置为核心数相等通常也是合理的,因为更多的线程不会带来性能提升,反而可能因上下文切换导致效率下降。
I/O密集型任务:如果任务涉及大量的输入/输出操作,如文件操作、网络通信等,这些操作通常涉及等待,此时CPU核心会空闲。在这种情况下,线程池大小可以设置得比核心数多,通常是核心数的2到3倍,甚至更多,以确保CPU在等待I/O操作完成时可以切换到其他任务。
常用的线程池工作队列?
无界队列、有界队列、优先级队列、同步队列、延迟队列
线程池的设计思路
实现线程池有以下几个步骤: (1)设置一个生产者消费者队列,作为临界资源。
(2)初始化n个线程,并让其运行起来,加锁去队列里取任务运行
(3)当任务队列为空时,所有线程阻塞。
(4)当生产者队列来了一个任务后,先对队列加锁,把任务挂到队列上,然后使用条件变量去通知阻塞中的一个线程来处理。
如果是CPU密集型应用,则线程池大小设置为:核数目+1
如果是IO密集型应用,则线程池大小设置为:2CPU数目+1
常用Linux命令
命令 | 功能 |
---|---|
man | 帮助命令 |
ls命令 | 查看当前文件与目录信息 |
cd命令 | 用于切换当前目录 |
pwd命令 | 用于显示工作目录。 |
mkdir命令 | mkdir 命令用于创建文件夹。 |
rm命令 | 删除文件或文件夹命令 |
rmdir 命令 | 从一个目录中删除一个或多个子目录项 |
mv命令 | 移动文件或文件夹命令 |
cp命令 | 复制命令 |
cat命令 | 查看文件内容;连接文件 |
more命令 | more 会以一页一页的显示文件内容 |
less命令 | less 与 more 类似,但使用 less 可以随意浏览文件 |
grep命令 | 该命令常用于分析一行的信息,若当中有我们所需要的信息,就将该行显示出来,该命令通常与管道命令一起使用,用于对一些命令的输出进行筛选加工。 |
ps命令 | 查看进程情况 |
top命令 | 可以查看操作系统的信息,如进程、CPU占用率、内存信息等 |
kill命令 | 向进程发送终止信号 |
分布式
分布式系统中的通信方式有哪些?
- 消息传递:节点之间通过发送和接收消息进行通信。
- 远程过程调用(RPC):允许程序调用远程节点上的函数或方法。
- 共享存储:节点通过共享的存储系统进行数据交换。
什么是一致性问题?
一致性问题指在分布式系统中,多个副本的数据状态需要保持一致。常见的一致性模型包括:
- 强一致性:所有读操作都能看到最新的写操作结果。
- 弱一致性:系统不保证读操作能看到最新的写操作结果。
- 最终一致性:在没有新更新的情况下,所有副本最终会达到一致状态
分布式系统中的负载均衡是什么?
负载均衡是将工作负载均匀分配到多个节点上的技术,以提高系统的性能和可靠性。常见的负载均衡算法包括:
- 轮询(Round Robin):依次将请求分配给每个节点。
- 最小连接数(Least Connections):将请求分配给当前连接数最少的节点。
分布式系统中的故障处理有哪些策略?
- 容错(Fault Tolerance):通过冗余设计和备份来应对节点故障。
- 重试机制:在操作失败时自动重试,以应对暂时性故障。
- 故障检测和恢复:检测故障节点并自动恢复服务
分布式系统中的一致性协议有哪些?
- Paxos:一种广泛使用的分布式一致性协议,保证在部分节点故障情况下仍能达成一致。
- Raft:一种相对易于理解和实现的一致性协议,提供领导选举和日志复制机制。
什么是CAP定理?
CAP定理指出,在分布式系统中,无法同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition Tolerance)。在设计系统时,必须在这三者之间进行权衡。通常选择分区容忍性,然后在一致性和可用性之间做出取舍
分布式系统中的事务处理如何实现?
分布式事务处理需要确保跨多个节点的操作要么全部成功,要么全部失败。常见模型包括:
- 两阶段提交(2PC):协调者首先请求所有参与者准备提交,然后在所有参与者准备好后发出提交命令。
- 三阶段提交(3PC):在两阶段提交基础上增加一个预提交阶段,以提高容错性。
问题记录1
答题模板:
各自概念
对比\优缺点
C/C++ 语言基础部分
常见问题:智能指针、多态、虚函数、stl原理。
-
智能指针实现原理
-
智能指针,里面的计数器何时会改变
-
智能指针和管理的对象分别在哪个区(智能指针本身在栈区,托管的资源在堆区,利用了栈对象超出生命周期后自动析构的特征,所以无需手动delete释放资源。
-
面向对象的特性:多态原理
-
介绍一下虚函数,虚函数怎么实现的
-
多态和继承在什么情况下使用
-
除了多态和继承还有什么面向对象方法
-
C++内存分布。什么样的数据在栈区,什么样的在堆区
-
C++内存管理(RAII啥的)
C++中的内存管理涉及多个方面:首先,内存被分为栈、堆、全局/静态存储区和常量区。栈上的内存分配是自动管理的,而堆上的内存需要我们手动管理,通常使用
new
和delete
或者智能指针来避免内存泄漏和悬挂指针的问题。智能指针如std::unique_ptr
和std::shared_ptr
提供了自动化的内存管理,尤其是在复杂的应用场景中非常有用。此外,C++还支持RAII原则,通过构造和析构函数自动管理资源,这有助于确保在异常或早期返回时释放资源。常见的内存管理问题包括内存泄漏、悬挂指针、缓冲区溢出和双重释放。我在项目中通常使用智能指针来管理资源,以避免这些问题,并遵循RAII的原则确保异常安全性 -
C++从源程序到可执行程序的过程
-
一个对象=另一个对象会发生什么(赋值构造函数)
-
如果new了之后出了问题直接return。会导致内存泄漏。怎么办(智能指针,raii)
-
c++11的智能指针有哪些。weak_ptr的使用场景。什么情况下会产生循环引用
-
多进程fork后不同进程会共享哪些资源
-
多线程里线程的同步方式有哪些
-
size_of是在编译期还是在运行期确定
-
函数重载的机制。重载是在编译期还是在运行期确定
-
指针常量和常量指针
-
vector的原理,怎么扩容
-
介绍一下const
-
引用和指针的区别
-
Cpp新特性知道哪些
-
类型转换
-
RAII基于什么实现的(生命周期、作用域、构造析构
-
手撕:Unique_ptr,控制权转移(移动语义)
-
手撕:类继承,堆栈上分别代码实现多态
-
unique_ptr和shared_ptr区别
-
右值引用
-
函数参数可不可以传右值
-
参考c/c++堆栈实现自己的堆栈。要求:不能用stl容器。
-
stl容器了解吗?底层如何实现:vector数组,map红黑树,红黑树的实现
-
完美转发介绍一下 去掉std::forward会怎样?
-
介绍一下unique_lock和lock_guard区别?
-
C代码中引用C++代码有时候会报错为什么?
-
静态多态有什么?虚函数原理 虚表是什么时候建立的 为什么要把析构函数设置成虚函数?
-
map为啥用红黑树不用avl树?(几乎所有面试都问了map和unordered_map区别)
-
inline 失效场景
-
C++ 中 struct 和 class 区别
-
如何防止一个头文件 include 多次
-
lambda表达式的理解,它可以捕获哪些类型
-
友元friend介绍
-
move函数
-
模版类的作用
-
模版和泛型的区别
-
内存管理:C++的new和malloc的区别
-
new可以重载吗,可以改写new函数吗
-
C++中的map和unordered_map的区别和使用场景
-
他们是线程安全的吗
-
c++标准库里优先队列是怎么实现的?
-
gcc编译的过程
-
C++ Coroutine
-
extern C有什么作用
-
c++ memoryorder/elf文件格式/中断对于操作系统的作
-
C++的符号表
-
C++的单元测试
数据结构
常见问题:链表、排序、二叉树。
- 数组和链表区别和优缺点
- 快速排序
- 堆排序是怎么做的
- 冒泡排序
- 二分查找(复杂度)
- hash表数据很大。rehash的代价很高,怎么办
- 二叉树前序遍历非递归
- 链表反转
- 二叉树输出每一层最右边的节点
- 千万级数组如何求最大k个数?(用最小堆反之最大堆) 千万数据范围有限,0到1000,有很多重复的,按频率排序怎么处理?
- 计算二叉树层高。
- 给一个连续非空子数组,找它乘积最大的(动态规划)
- 排序算法. 哪些是稳定的,哪些不稳定的
- 树的深度和高度。一开始分别用了一个层序遍历和一个dfs,然后面试官问能否都在一个dfs里面呢,提示了一下在dfs是否可以传一个参数,然后解决了。
- 布隆过滤器介绍
- 为什么不用布隆过滤器
- .数据结构相关,图的种类,表示方法,图有哪些经典算法+描述算法
- 求最大的k个数字,解法:优先队列(堆)或者快速排序
- 一个大数问题,解法:转换为字符串解决,这题没写好,leetcode应该有很多类似的问题
- hash解决冲突 ( 开放定址法、链地址法、再哈希法、建立公共溢出区 ),四种方式详细的过程、思路
- 链地址法和再哈希法之间的关联和区别,两者分别适用场景,两者底层的数据结构,关联和区别
- 链表和数组的底层结构设计、关联、区别、应用场景
gdb/gcc/g++
- 什么是GDB?它用于做什么?
- GDB的常用命令有哪些?
- 如何在GDB中设置断点?
- 如何在GDB中查看变量的值?
- 如何使用GDB进行程序调试时,定位内存泄漏问题?
- 请解释GCC和G++之间的区别。
- GCC和G++都可以编译C++代码吗?如果可以,有什么不同之处?
- GCC的常用编译选项有哪些?
- G++的常用编译选项有哪些?
- 如何在GCC/G++中指定特定版本的标准(如C++11或C++14)进行编译?
- 怎么debug,怎么看内存泄漏。
- gdb 使用 -> 多线程程序切换到某线程栈帧 -> 如何查看寄存器值
- 怎么分析C++的core文件
- GDB有哪些命令
- gcc和g++的区别
- Linux下程序有问题,如何调试?(答GDB打开,打上Breakpoint进行调试)