C++笔记
分类参考《C++ primer》第五版。
C++基础
1 变量和基本类型
1.1 引用
在C++中,引用(Reference) 的本质是为对象起的别名,引用并非一个独立的对象。
1.1.1 基础使用
1 | int ival = 1024; |
1.1.2 注意项
- 引用必须被初始化
- 无法获取引用的地址
- 在 C++ 内存模型中,只有对象和函数拥有可寻址的地址
- 对引用取地址,得到的结果是它指向的原始变量的地址
- 内存占用
- 逻辑上,引用只是对象的别名,不占用额外的内存空间
- 对引用执行
sizeof操作,得到的结果是它指向的原始变量的大小
- 对引用执行
- 物理上
- 当引用作为局部变量时,编译器会将其直接优化掉,所有对引用的操作,都会被优化为对它指向的原始变量的地址的操作,因此没有额外的内存开销
- 当引用不作为局部变量,它在编译器内部通常被存储为类似常量指针的形式,当定义
int &r = i;,编译器内部会将其存储为int* const p = &i;,因此占用一个指针大小的内存
- 逻辑上,引用只是对象的别名,不占用额外的内存空间
- 引用无法重新绑定
- 引用一旦初始化完成,它就和初始对象“死死绑定”在一起。你无法让一个引用转而去指向另一个对象
1.2 指针
指针就是一个变量,它里面存的是“另一个变量的内存地址”。所有类型的指针在32bit下是4字节,64bit下是8字节。
不同于引用,指针本身就是一个独立的对象,因此:
- 允许对指针进行赋值和拷贝,且在其生命周期内可以先后指向几个不同的对象
- 无须在定义时赋初值
1.2.1 基础使用
1 定义指针
1 | int a = 10; |
2 定义多个指针
1 | int *p,*q; |
如果如下定义:
1 | int* p,q; // p是指向int类型的指针,而q是int类型 |
3 用于条件判断
1 | int* pi = 0; |
1.2.2 空指针/野指针/悬空指针
1 空指针
1 | int* p = nullptr; // 指针变量p指向内存地址编号为0的空间,也可以写为int* p = 0;或者int* p = NULL; |
2 野指针
指针变量指向非法的内存空间(指针未初始化,指向随机地址)。
3 悬空指针
指针指向的对象已经释放,但指针未置空。
1.2.3 void*
void*指针可以用于存放任意对象的地址,但因此我们不能直接操作其指向的对象,因为我们不能确定对象是什么类型。
1 | double obj = 3.14; |
1.2.4 指向指针的引用
1 | int i = 42; |
1.2.5 普通函数指针
声明语法:返回类型 (*指针变量名)(参数列表);
1 | int add(int a, int b) { return a + b; } |
1.2.6 类成员函数指针
类成员函数指针与普通函数指针有着本质的区别,因为类成员函数必须依赖对象实例(this
指针)才能运行。
1 | class Hero { |
为什么类成员函数指针比普通函数指针大?
在 64 位系统下,普通函数指针是 8 字节,但类成员函数指针通常是 16 字节。这是因为成员函数可能涉及虚函数多态。
为了实现多态,成员函数指针内部通常是一个结构体(以 GCC 为例):
- ptr / fptr:如果是非虚函数,存的是函数绝对地址;如果是虚函数,存的是该函数在虚表(vtable)中的索引偏移量
- adj (adjustment):用于处理多重继承下的
this指针偏移。当一个类继承自多个基类时,调用不同基类的成员函数需要调整this指针的指向位置
1.2.7 静态成员函数指针
静态成员函数(static)不属于类成员函数指针。
因为静态函数没有 this
指针,它的行为和全局函数完全一样。
1 | class Hero { |
1.3 引用 VS 指针
引用是对象的别名,指针里存放的是对象的地址,它们都是C++里间接访问对象的一种方式。引用并非一个独立的对象,而指针是一个独立的对象。
| 特性 | 指针 (Pointer) | 引用 (Reference) |
|---|---|---|
| 本质 | 一个存储地址的对象 | 一个已存在对象的别名 |
| 初始化 | 可以不初始化(虽然危险) | 必须在声明时初始化 |
| 空值 (Null) | 可以为 nullptr |
不存在空引用(理论上) |
| 重绑定 | 可以随时指向另一个对象 | 一旦初始化,不可更改绑定对象 |
| 内存占用 | 占用独立空间(4/8 字节) | 逻辑上不占用额外内存空间 |
| 层级 | 可以有多级指针(int** p) |
只有一级(没有引用的引用) |
| sizeof | 返回指针本身的大小 | 返回所指向对象的大小 |
1.4 const
const主要作用是声明常量和限制修改。
1.4.1 基础使用
1 | const int bufSize = 512; // 正确:定义并初始化 |
const修饰的变量必须在声明时初始化(除非是extern或类成员的特殊情况)。
1.4.2 extern关键字:跨文件共享常量
默认情况下,const
对象仅在文件内有效。如果你在两个文件中定义了同名的 const
变量,它们被视为独立的。如果要在多个文件间共享同一个 const
常量,可以用extern关键字。
1 | // 在头文件 .h 中 |
extern的语义是“在别处定义”,因此如果在声明处加了初始化(如extern const int x = 10;),大多数编译器会报错或警告。
1.4.3 const修饰类成员变量
const修饰的类成员变量,需要在声明处直接进行初始化,或通过初始化列表进行初始化。
1 | class Widget { |
const成员属于每个对象,它的值可以因对象不同而不同(不像static const那样是类的共享常量)。
所以它可以在构造时再进行初始化。
注意:const成员只能在声明处或者在初始化列表处进行初始化,不能在构造函数函数体内进行初始化。
原因const成员在对象构造完成后就成为“只读”状态,而构造函数函数体执行时,对象已经进入构造完成阶段,此时再“赋值”属于修改已构造完成的const成员,这是语言规则所禁止的。
1.4.4 const修饰类成员函数
1 const修饰类成员函数
用const修饰成员函数,表示该函数内不做任何成员变量的修改(除了mutable成员)。
1 | class Player { |
本质上是对this指针进行了const修饰,也就是将其变成了一个指针常量(const T* this),所以this指针指向的内容不可修改。
2 multable
multable是一个成员变量修饰符,它的核心作用是:允许在const修饰的成员函数里修改成员变量。
为什么需要multable:区分对象的物理状态和逻辑状态。
const成员函数的语义是:
“我承诺在这个函数里不会修改对象的任何状态” 。
但在实际开发中,有些成员变量的修改并不影响对象的“逻辑状态”,只是用于:
统计访问次数
缓存计算结果
调试信息
内部日志
互斥锁(mutex)状态
lazy initialization(延迟初始化)
这些属于“物理状态”而非“逻辑状态”,修改它们不应该破坏const的语义。
这就是mutable存在的意义。
1.4.5 const修饰指针
1 几种不同的修饰
| 写法 | 含义 | 谁不能改 |
|---|---|---|
const T* p |
指向常量的指针 | 指针指向的内容不可修改 |
T* const p |
常量指针 | 指针的指向不可修改 |
const T* const p |
指针指向的内容和指针的指向都不可修改 |
记忆方法:从后往前看。
例如:
对于const T* p,从后往前看就是指针指向了const T,也就是指针指向的内容被const修饰,因此指向的内容不可修改。
对于T* const p,从后往前看就是常量指针指向了T,也就是指针被const修饰,因此指针的的指向不可修改。
2 顶层const
- 表示对象本身是一个常量。一旦指向某个地址,就不能再指向别处
int const* p;
3 底层const
- 表示指针或引用所指的对象是一个常量。不能通过这个指针修改对象的值
const int *p;
1.5 constexpr
1.5.1 基础使用
constexpr的全称是const expression(常量表达式)。
constexpr的主要作用是告诉编译器一个变量的结果必须在编译期间被计算出来,并且在后续无法对该变量的值进行修改。
1.5.2 if constexpr
这是一个非常强大的功能,用于模板编程(函数模板没有偏特化,可以用这种方式实现)。它允许在编译期根据条件“删除”代码:
1 | template <typename T> |
1.6 const VS constexpr
1.6.1 const的“二义性”
const在C++中具有二义性:
作为编译期常量
如果用字面量进行初始化,就是编译期常量。
1 | const int N = 10; |
这里 N 是用字面量 10
初始化的。编译器在编译阶段就明确知道 N 等于
10。它会进行常量折叠(Constant
Folding),在它眼里,int arr[N] 几乎就等同于
int arr[10]。所以这是合法的。
作为运行时只读变量
如果用变量初始化,它只是一个运行时的只读变量。
1 | int x = 10; |
虽然 N 被声明为
const,但它的初始值取决于变量
x。x 的值理论上在运行期间才确定。
在标准 C++ 中,数组的大小必须是一个常量表达式(Compile-time
constant)。此时的 N
虽然是“只读”的,但它不是“编译时常量”。
为了解决const这种模糊性,引入了constexpr。
1.6.2 区别
- 主要作用不同
const保证的是运行期间只读- 而
constexpr保证的是编译期常量
- 计算时机不同
const的值可以等到程序运行起来再确定(比如const修饰的值让用户来输入)constexpr的值必须编译期间计算出来,如果算不出来就会报错
- 功能权限
constexpr变量自动拥有const属性
1.7 类型别名:typedef/using
1.7.1 typedef
1 | typedef double wages; // 表示wages为double的别名 |
1.7.2 using
1 | using SI = Sales_item; // 表示SI为Sales_item的别名 |
1.7.3 typedef VS using
更加推荐using,using在定义上观感更清晰,且可以定义模板的别名。
1 | template <typename T> |
1.8 数据类型推断:auto/decltype
1.8.1 auto
需要注意:auto在推断数据类型时并不总是一比一还原。
忽略顶层const,保留底层
const:
当初始值是一个const对象时,auto通常会忽略掉“顶层”的const特性。
1 | const int ci = i; // 表示任意一种类型是一个常量->顶层const |
将引用转换为对象:
1 | int x = 42; |
1.8.2 decltype
在C++中,如果说auto是为了“让我省点事,你帮我推断类型并初始化”,那么decltype的存在则是为了“我只想知道这个表达式的类型,但我现在不想用它来初始化变量”。
1 基础使用
1 | decltype(f()) sum; // sum 的类型就是函数 f 的返回值类型 |
编译器并不真正调用函数
f(),而是在编译阶段通过查看函数的声明来确定其返回类型。
1.9 auto VS decltype
decltype保留顶层const和引用:不同于
auto 会忽略顶层 const
并将引用转为对象,decltype 会完整地保留包括
const
和引用在内的所有限定符。另外decltype可以仅推断类型而不进行初始化。
2 数组
2.1 原生数组
2.1.1 基础使用
1 | int arr[10]; |
2.1.2 注意项
不允许赋值操作,如
arr2 = arr1;- C++原生数组名在表达式中几乎总是退化为指向首元素的指针,而赋值运算符需要左值对象完整语义
- 编译器生成代码时,数组是连续内存块,如果允许赋值,编译器需要生成隐式逐元素拷贝代码,如果数组很大,开销不可控,而C++追求“零开销对象”
数组退化
定义:在大多数表达式上下文中,数组名会自动退化为指向数组首元素的指针,从而丢失大小信息(这样做是为了效率:传整个数组内容开销大,传指针,64bit下只需要8字节)
函数传参中的退化(最经典):
```c++ // 此时arr是指向数组首元素的指针 void func(int arr[]) { //sizeof(arr),返回指针大小(64bit-8字节) }
1
2
3
4
5
- 赋值给指针的时候:
- ```c++
int* p = arr;
数组不发生退化的场景
- 使用
sizeof的时候,sizeof(arr)返回的是整个数组的大小 - 使用
&取地址符时,得到的是指向整个数组的指针(类型为T (*)[N]),而不是指向指针的指针(意味着数组没有退化)- 且不仅仅是“指向数组的指针”。虽然它的值和首元素地址相同,但其步长(Pointer
Arithmetic)不同:
arr + 1移动一个元素的距离,而&arr + 1移动整个数组的距离
- 且不仅仅是“指向数组的指针”。虽然它的值和首元素地址相同,但其步长(Pointer
Arithmetic)不同:
- 作为字符串字面量(
"Hello")用来初始化字符串数组时
- 使用
2.2 array
C++11引入的模板类,是C++原生数组的现代包装。其设计目的是保留原生数组的高性能和栈分配优势,同时提供容器般的安全和便利性。
1 | std::array<int,5> arr = {5,4,3,2,1}; |
解决数组退化
array在函数传参时可以按值/引用传递,不会退化为指针,丢失大小信息(array类自带数量参数)。
如何按引用传递:
1 | //值传递(拷贝一份数据) |
允许赋值操作
支持arr2=arr1;本质上是深拷贝。
3 类型转换
3.1 static_cast
隐式类型转换,可以实现C++中内置基本数据类型之间的相互转换,enum、struct、
int、char、float等,能进行类层次间的向上类型转换和向下类型转换(向下不安全,因为没有进行动态类型检查)。它不能进行无关类型(如非基类和子类)指针之间的转换,也不能作用包含底层const的对象。
1 | double d = 3.14159; |
处理顶层const:通常会丢失顶层const。
1 | int x = 10; |
3.2 dynamic_cast
动态类型转换,用于将基类的指针或引用安全地转换成派生类的指针或引用(也可以向上转换),若指针转换失败返回nullptr,若引用转换失败抛出std::bad_cast异常。dynamic_cast是在运行时进行安全性检查,因此使用dynamic_cast父类一定要有虚函数(需要虚函数表来拿具体的类型信息),否则编译不通过。
1 | Base* pb = new Derived; |
3.3 const_cast
把const属性去掉,即将const转为非const,反过来也可以。const_cast只能用于指针或引用,并且只能改变底层const(即指向或引用的对象为常量)。
只在“确实不会修改内容但接口要求非const”的极端情况下使用。
1 | void func(const std::string& s) { |
3.4 reinterpret_cast
reinterpret是重新解释的意思,此标识符的意思即为将数据的二进制形式重新解释,但是不改变其值,有着和C风格的强制转换同样的能力。它可以转化任何内置的数据类型为其他任何的数据类型,也可以转化任何指针类型为其他的类型。它甚至可以转化内置的数据类型为指针,无须考虑类型安全或者常量的情形。不到万不得已绝对不用(比较不安全)。
1 | int i = 42; |
它本身是零开销的:在汇编层面,reinterpret_cast通常不产生任何机器指令,它只是改变了编译器在编译阶段对某个内存地址的看法
3.5
static_cast和dynamic_cast的区别
3.5.1 区别
二者都会做类型安全检查,只是static_cast在编译期进行类型检查,dynamic_cast在运行期进行类型检查。后者需要父类具备虚函数,而前者不需要。
3.5.2
为什么说用static_cast做向下类型转换不安全?
1 为什么向下类型转换不安全
首先,我们要明确,将一个父类对象转换为子类对象是不合法的。
假设我们有两个类,Parent 占用 4 字节,Child
因为多了成员,占用 12 字节。
安全的向下类型转换
你先 new Child(),然后用 Parent*
指向它。此时,内存里那个“盒子”确实是 12
字节大。当你再把它 static_cast 回
Child* 时,你访问第 5-12
字节是安全的,因为那里确实有数据。
危险的向下类型转换
你 new Parent(),内存里只分配了 4
字节。
1 | Parent* p = new Parent(); // 内存申请了 4 字节 |
此时,c 认为自己可以访问 12 字节。当你执行
c->child_member = 100; 时:
- 底层操作:CPU 会计算地址
p + 偏移量 - 后果:由于原内存只有 4 字节,这个操作会直接写到不属于该对象的邻近内存区域。这可能修改了其他变量,也可能直接触发系统的段错误
2 为什么用static_cast做向下转换不安全?
编译器只检查路径是否合法。只要 Child 确实继承自
Parent,编译器就认为
static_cast<Child*>(parent_ptr)
在语法上是成立的,并直接生成转换地址的指令。编译器无法预知在程序跑起来以后,这个
parent_ptr 到底是指向一个真正的 Child
对象,还是指向一个纯粹的 Parent 对象。
3 为什么用dynamic_cast做向下转换安全?
dynamic_cast 利用
RTTI(运行时类型信息),去查看对象虚函数表(vtable)里的类型信息。如果发现
p 确实不是 Child 类型,它会直接返回
nullptr。这种检查是在运行时发生的,需要查表,所以会有额外的性能开销;而
static_cast 是零开销的,它选择“无条件相信程序员”。
4 函数
4.1 函数调用过程
参考:视频
这里的栈指的是函数调用栈。
首先,栈在内存中是向下生长的(高地址->低地址),也就是说,栈底元素比栈顶元素地址大。
EBP (Base Pointer):基址指针,指向当前栈帧的底部,用于寻址
ESP (Stack Pointer):栈指针,始终指向栈顶,随数据入栈出栈而移动
步骤
- 第一阶段:现场保护与参数传递
- 首先,调用者会将参数按照调用约定(从右向左)压入栈中或放入寄存器,随后执行
CALL指令。该指令会自动将返回地址压入栈顶,这是为了函数结束后能顺利返回
- 首先,调用者会将参数按照调用约定(从右向左)压入栈中或放入寄存器,随后执行
- 第二阶段:栈帧建立
- 进入函数后,会建立自己的栈帧。具体动作是:先将旧的 EBP(栈基址指针) 压栈备份,再将当前 ESP(栈顶指针) 赋值给 EBP 作为新基准,最后通过移动 ESP 为局部变量开辟空间。此时,EBP 就像一个锚点,向上找参数,向下找局部变量
- 第三阶段:现场恢复与返回
- 函数执行完毕后,会将返回值放入寄存器(如 EAX)。随后销毁栈帧:将 ESP
回退到 EBP 位置以释放局部变量,弹出旧 EBP 恢复调用者的环境,最后执行
RET指令弹出返回地址,跳回主程序继续执行
- 函数执行完毕后,会将返回值放入寄存器(如 EAX)。随后销毁栈帧:将 ESP
回退到 EBP 位置以释放局部变量,弹出旧 EBP 恢复调用者的环境,最后执行
部分相关问题
1.局部变量为什么在函数结束后失效?
因为ESP指针回退了,那块空间会在逻辑上被注销,会被后续函数覆盖。
2.如果局部变量太多会怎么样?
会导致ESP移动超出了栈边界,导致栈溢出。
3.内联函数是如何打破这个过程提升性能的?
函数调用大约需要10~20条汇编指令,调用本身有一定的开销。而函数内联直接将函数体的代码直接复制并替换到每一个调用该函数的地方,没有了函数调用的开销。
4.2 内联函数 inline
内联函数的主要目的是让编译器在调用函数的地方直接展开函数体,从而避免调用函数导致的开销(参数压榨、跳转、返回等),达到更高的运行时性能。
1 | // 方式1:直接在定义处加 inline 关键字(最常见) |
但内联只是给编译器的建议,编译器可以决定采不采纳。
4.3 constexpr函数
constexpr的作用是让某些函数在编译期就能被完整求值(当实参都是编译期常量时),从而获得零运行时开销、更早的错误发现、更强的静态检查能力。
4.4 explicit修饰单参数构造函数
explicit修饰单参数构造函数时(单参数构造函数支持隐式转换),即向编译器表明,禁止隐式转换。
1 | class MyString { |
为什么MyString s = "hello";是隐式转换?
"hello"本身是一个const char[6](字符串字面量,包含结尾的\0),可以隐式衰减为const char*,而MyString类有单参数构造函数MyString(const char*),编译器允许隐式调用这个构造函数,将"hello"转换为MyString临时对象,再通过拷贝初始化把临时对象赋给s。
实际上它执行了两个步骤:
1 | MyString s = MyString("hello"); // 隐式构造临时对象,再拷贝给 s |
5 类:友元 friend
友元的核心作用是允许外部函数或类访问某个类的私有(private)和受保护(protected)成员。
| 形式 | 写法示例 | 谁获得了访问权限 | 常见使用场景 |
|---|---|---|---|
| 友元函数(非成员函数) | friend void print(const Class& obj); |
某个独立的全局/命名空间函数 | 输出运算符 <<、比较运算符、调试打印函数 |
| 友元类 | friend class AnotherClass; |
整个另一个类 | 紧密耦合的类对(如迭代器与容器) |
| 友元成员函数 | friend void OtherClass::func(const Class&); |
另一个类的某个特定成员函数 | 两个类之间有限制的、定向的访问需求 |
6 Lambda表达式
6.1 基础使用
Lambda是匿名函数。
最简单的例子:
1 | []() { std::cout << "Hello lambda!\n"; }(); |
上面这行代码:
- 定义了一个没有参数、没有捕获的 lambda
- 立刻调用它(后面的 () 就是立即执行)
完整语法
\[[capture] (parameters) mutable -> return\_type \{ body \}\]
[capture](捕获列表):决定外部变量以何种方式进入 Lambda 内部(parameters)(参数列表):和普通函数参数一致mutable(可选):默认情况下,Lambda 内部不能修改按值捕获的变量,加上这个关键字可以修改。本质上是让生成的仿函数中的operator()成员函数去掉const限定符-> return_type(可选):尾置返回类型(通常可以由编译器自动推导)
6.2 捕获列表
| 写法 | 含义 | 实际效果(对外部变量的影响) | 常见场景 / 注意事项 |
|---|---|---|---|
[] |
不捕获任何外部变量 | — | 最安全,性能最高 |
[=] |
按值捕获所有用到的外部变量(拷贝) | 拷贝发生在 lambda 创建时 | 安全,但拷贝开销大 |
[&] |
按引用捕获所有用到的外部变量 | 直接操作外部变量 | 方便,但极易产生悬垂引用(dangling reference) |
[x, &y] |
x 按值,y 按引用 | 混合捕获 | 最推荐,精确控制 |
[&, x] |
除 x 外全按引用,x 按值 | — | 常见写法 |
[=, &y] |
除 y 外全按值,y 按引用 | — | 常见写法 |
[z = expr] |
新变量 z,初始化为 expr 的值 | C++14+ 特性 | 非常实用,避免拷贝大对象 |
[this] |
捕获 this 指针 | 成员函数 lambda 常用 | 容易悬垂(对象析构后 lambda 仍存在) |
[*this] |
捕获 *this(对象副本,C++17+) | 拷贝整个对象 | 推荐,避免 this 悬垂 |
6.3 底层实现
对于一个lambda表达式,编译器底层会生成一个结构体,lambda从底层实现逻辑上来看,是一个仿函数。
代码:
1 | int i = 10; |
编译器:
1 | // 编译器生成的“伪代码” |
6.4 C++的Lambda VS C#的Lambda
| 特性 | C++ Lambda | C# Lambda |
|---|---|---|
| 本质 | 匿名类对象(仿函数) | 委托 (Delegate) 或 表达式树 |
| 捕获方式 | 显式手动 ([=], [&],
[x]) |
隐式自动(由编译器自动分析) |
| 内存位置 | 默认在栈上,随作用域销毁 | 始终在堆上(闭包会被包装成类对象) |
| 生命周期 | 危险:需开发者保证捕获变量存活 | 安全:GC 自动延长被捕获变量的寿命 |
| 性能 | 极高(可直接内联,零开销抽象) | 较高(有委托包装和 GC 压力) |
C++:由于 Lambda 的类型是唯一的,编译器通常能实现极致的内联。在执行效率上,它和原地写的代码几乎没有区别。
C#:调用 Lambda 涉及到委托的调用(有一定的间接寻址开销),且由于闭包对象在堆上分配,会给 垃圾回收(GC) 带来一定压力。
7 动态内存
7.1 智能指针
智能指针通过RAII(资源获取即初始化)技术,把原本脆弱的裸指针封装成一个对象。让栈上的对象析构时,自动处理堆上的内存。智能指针分为独占指针(unique_ptr)、共享指针(shared_ptr)和弱指针(weak_ptr)。
为什么要使用智能指针?(解决下面三个问题)
- 内存泄漏:忘了写
delete - 野指针/悬空指针:对象删了,但是指针还指着原来的内存地址,下次访问会直接崩溃
- 二次释放:两个地方都写了
delete
7.1.1 unique_ptr
1 描述
它是最常用、开销最小(几乎零额外开销)的智能指针。
- 核心逻辑: 同一时间只能有一个
unique_ptr指向该对象 - 底层行为:
它删除了拷贝构造函数。你不能把它赋值给别人,但你可以用
std::move把所有权“转让”出去 - 适用场景: 局部变量、类成员变量,明确知道生命周期跟随某个特定对象的场景
2 使用
当你确认一个资源有且仅有一个负责人时,用unique_ptr。
比如在Unity里一个GameObject拥有一个Transform,这个Transform组件的生命周期完全跟随GameObject,当GameObject销毁时,Transform必销毁。
1 | class GameObject{ |
也很适合做工厂模式的返回值,代表了“我创建了它并交给你管理,以后就是你的了”。
3 unique_ptr是怎么做到独占的
在 C++ 中,一个对象如果要被“共享”或“复制”,通常是通过
拷贝构造函数 (Copy Constructor) 或
拷贝赋值运算符 (Copy Assignment)
完成的。
unique_ptr
在底层直接把这两个功能给删掉了(使用
delete 关键字):
1 | template <typename T> |
留个后门:移动语义
虽然不能“克隆”,但所有权是可以“转让”的。unique_ptr
允许移动(Move)。
它实现了 移动构造函数:
1 | // 允许移动构造:unique_ptr p2(std::move(p1)); |
7.1.2 shared_ptr
1 描述
- 核心逻辑: 允许多个指针指向同一个对象。内部维护一个引用计数
- 底层行为:
每拷贝一次,计数器原子递增;每析构一次,计数器原子递减。当计数减到 0
时,触发
delete - 适用场景: 资源共享,多个对象需要共同持有某一块内存的访问权(如多个游戏实体共享同一个纹理资源)
2 使用场景
当一个资源需要被多个系统引用,且你无法确定谁会最后离开时,用shared_ptr。
例如资源加载器中的纹理:想象一张“草地”纹理。场景里可能有 100 个石块模型都引用了这张纹理。你不能在第一个石块销毁时就把纹理删了,必须等所有引用它的对象都销毁。
1 | class Stone { |
7.1.3 weak_ptr
1 描述
它是为了解决 shared_ptr 的致命缺陷而生的。
核心逻辑: 它指向
shared_ptr管理的对象,但不增加引用计数底层行为: 它不直接操作指针。要使用它,必须先调用
.lock()方法,看它指向的对象是否还活着适用场景: 解决循环引用,或者作为“缓存”机制,观察一个对象是否被释放
2 使用
实例A:解决循环引用问题
如果父亲持有儿子的 shared_ptr,儿子 也持有父亲的
shared_ptr。他们会互相等对方先释放,导致内存永久泄漏。
方案是让一方(通常是下级对上级)持有weak_ptr。
1 | class Node { |
实例B:UI系统的对象追踪
你的 UI 界面上显示了一个怪物的血条,UI需要实时更新怪物的坐标。
风险: 如果 UI 用 shared_ptr
盯着怪物,怪物被打死了,后端逻辑想销毁怪物,却发现 UI
还拽着它不放,导致怪物“死而不僵”
方案:UI 持有怪物的 weak_ptr。每帧通过
lock() 检查一下怪物还在不在,不在了就把血条关掉
1 | if (auto monster = m_monsterWeakPtr.lock()) { // 检查怪物是否还活着 |
3 weak_ptr.lock()
简单来说,这个方法是一个“提升”的操作,它的功能是:检查它观察的对象是否还活着。如果活着,就临时产生一个
shared_ptr
来让你安全地使用它;如果对象已经销毁,则返回一个空的
shared_ptr。
.lock()内部做了什么?
当你调用.lock()时,它在底层执行了以下逻辑:
- 访问控制块:找到记录引用计数的那个堆内存区域
- 检查强引用计数
- 如果>0,说明对象还在,它会将强引用计数原子性地+1,然后返回一个新的
shared_ptr - 如果=0,说明对象已经被析构,它返回一个空的
shared_ptr
- 如果>0,说明对象还在,它会将强引用计数原子性地+1,然后返回一个新的
7.1.4 手写一个shared_ptr
1 | template <typename T> |
7.2 new/delete && malloc/free
7.2.1 区别
| 对比维度 | new | malloc | 谁更推荐(C++) |
|---|---|---|---|
| 所属语言 | C++(C 中没有 new) | C 和 C++ 都可用 | — |
| 返回类型 | 返回具体类型的指针(如
int*、string*) |
返回 void*(需要强制类型转换) |
new 更安全 |
| 内存分配失败时 | 默认抛出 std::bad_alloc 异常 |
返回 NULL |
取决于风格 |
| 调用构造函数 | 会调用类的构造函数 | 不会调用构造函数 | new 更符合面向对象 |
| 调用析构函数 | delete 会调用析构函数 | free 不会调用析构函数 | new 更安全 |
| 分配方式 | 运算符(可以被重载) | 函数(不能重载) | new 更灵活 |
| 分配单个对象 | new T、new T()、new T(args...) |
malloc(sizeof(T)) |
— |
| 分配数组 | new T[n] |
malloc(n * sizeof(T)) |
new 更清晰 |
| 释放方式 | delete ptr / delete[] ptr |
free(ptr) |
— |
| 与智能指针配合 | 非常友好(make_unique、make_shared) | 不友好(需要手动管理) | new 胜出 |
| 可读性 / 现代性 | 更符合 C++ 风格 | 更偏 C 风格 | new 更现代 |
| 性能 | 几乎无差别(new 内部通常调用 malloc) | 几乎无差别 | 平手 |
1 |
|
7.2.2 free怎么知道要释放多少内存?
当我们用free去释放数组的时候,只需要指向该数组首地址的指针传给free即可。
但是为什么呢?
当你调用 malloc
时,内存管理器实际上会多分配一点点空间(通常在返回给你的地址之前的几个字节)。这段隐藏的“元数据(Metadata)”记录了这块内存的大小。
调用 free(arr) 时,底层库会查看 arr
指针之前的管理信息,从而知道该收回多少空间。
7.2.3 delete[]怎么知道要析构多少个对象?
对于 简单内置类型(如 int,
char, float),delete[]
其实并不太关心到底有多少个元素,因为它不需要调用析构函数,直接按地址回收整块内存即可。
但对于 自定义类(Class/Struct),如果它有析构函数,编译器就会在分配内存时多申请 4 或 8 个字节(取决于系统位数)来记录数组的长度。
布局: 编译器会将对象的个数 \(N\) 存储在这块内存的最开头
指针偏移:
new[]返回给你的指针,实际上是跳过了这 4/8 字节后、指向第一个元素起点的地址
当你执行 delete[] p; 时,底层发生了以下客观步骤:
向回寻址: 编译器将指针
p向前移动 4/8 字节,读取存储在那里的整数(假设是 \(N\))逆序析构: 编译器根据这个 \(N\),从第 \(N\) 个对象开始,从后往前依次调用每一个对象的析构函数
整块释放: 析构完成后,系统调用
free(或底层的释放函数)回收整块内存(包括那个存储 \(N\) 的空间)
7.2.4 为什么使用delete释放数组会崩溃?
逻辑错误:
delete p只会调用第一个元素的析构函数内存崩溃: 更致命的是,
delete会尝试直接从指针p指向的地址开始释放内存。但正如前面所说,内存块的真正起点是p - 4的位置。将一个错误的起始地址交给操作系统的内存管理器,会导致程序直接 Crash
7.3 扩展:内存对齐
内存对齐是指数据在内存中的起始地址必须是某个数值(通常是 2、4 或 8)的倍数。
7.3.1 为什么需要内存对齐
这主要受限于 CPU 读取内存的方式。
- 物理事实: CPU 并不是一个字节一个字节读内存的,而是以 “块”(通常是 4 或 8 字节) 为单位
- 对齐的场景: 比如 64 位 CPU 读一个 8 字节的
double。如果它对齐在地址 0x08,CPU 只需要 1 次 存取指令就能拿走 - 未对齐的场景: 如果这个
double跨在了地址 0x0C(一半在第一个 8 字节块,一半在第二个块),CPU 必须读 2 次 内存,还要进行复杂的位移和拼接 - 结论: 对齐是为了压榨 CPU 的访存性能,甚至在某些硬件(如 ARM)上,访问未对齐内存会直接导致程序崩溃(硬件异常)
7.3.2 示例
1 | struct Example { |
性能优化:手动重排
通过把大的成员放在前面,我们能有效减少 Padding 的产生。
1 | struct Optimized { |
7.4 内存分区
7.4.1 分区
- 栈
- 由编译器自动分配和释放,存放局部变量、函数参数等,速度很快,因为有专门的寄存器处理(如ESP/EBP),空间有限(通常只有几MB)
- 底层结构类似于栈
- 堆
- 由程序员手动进行分配和释放(malloc/free)
- 底层结构类似于链表,分配速度相对较慢,且容易产生内存碎片,忘记释放还可以导致内存泄漏
- 内存碎片:系统动态分配的过程中产生的无法被利用的空闲内存
- 外部碎片:内存中存在足够的空闲总和,但它们是不连续的小块,导致无法满足一个较大的连续内存分配请求,主要是因为系统频繁地申请大小不一的内存块导致的
- 内部碎片:这通常是由内存分配对齐策略引起的
- 内存泄漏:程序在堆上申请了内存,但失去了对这块内存的控制,导致系统无法重新分配给其他任务
- 自由存储区
- 通过new和delete分配和释放空间的内存,具体实现可能是堆或者内存池
- 自由存储区是C++的术语,指的是通过new和delete动态分配和释放对象的抽象概念;基本上C++也会用堆区实现自由存储,但程序员可以通过重载操作符,改用其他内存实现自由存储,比如全局变量做的对象池
- 全局/静态存储区
- 在程序编译时分配,程序结束时回收
- 存放全局变量和静态变量
- 细分
- .data 段:存放已初始化且不为 0 的全局/静态变量
- .bss 段:存放未初始化或初始化为 0 的全局/静态变量(不占用磁盘空间,只在加载时清零)
- 常量存储区
- 专门用来存放常量
- 只读
- 代码区
- 存放程序的二进制机器指令
- 只读且共享。防止程序在运行中意外修改自己的指令,且多个进程运行同一个程序时可以共享这块物理内存
7.4.2 堆和栈的内存有什么区别?
- 堆中的内存需要手动申请和手动释放,栈中内存是自动申请和自动释放的
- 栈上的内存块永远是连续的,因为释放操作总是发生在最后分配的那块内存上
- 在堆中,碎片产生是因为对象的生命周期不确定
- 堆能分配的内存较大,栈能分配的内存较小
- 在堆中分配和释放内存会产生内存碎片,栈不会产生内存碎片
- 堆的分配效率低,栈的分配效率高
- 堆地址从低向上,栈从高向下
- 栈和堆被设计为相向而行,本质上是为了最大化利用虚拟空间(它们共同分享中间大块的未分配区域)
7.4.3 const变量存储在哪
- 局部
const:通常在栈区。它只是编译器层面的“不可修改”检查,本质上还是个局部变量 - 全局
const:通常在常量存储区 - 字符串字面量:永远在常量存储区
7.4.4 静态初始化、动态初始化和constinit
constinit的存在是为了解决动态初始化时顺序随机的问题。
比如文件1和文件2分别有全局变量A和全局变量B,B的值依赖于A,由于文件1、2的动态初始化顺序不确定,所以有可能当B在进行动态初始化时,A的动态初始化还没有开始,导致程序报错。
constinit让原本可以静态初始化也可以动态初始化的变量确定进行静态初始化。
8 左值、右值和右值引用
8.1 概念
简单来说,左值是指有持久身份、可以取地址的对象,右值是指临时的、即将销毁的值。
可以从以下三个方向进行区分:
- 看能不能取地址
- 左值有明确的内存地址,可以用取地址符
&取地址 - 右值一般无法取地址(纯右值不能,将亡值可以)
- 纯右值:传统右值,如42、"Hello"、临时对象
- 将亡值:可以取地址,但马上就要被销毁的对象,它是引入右值引用和移动语义后诞生的
std::move(x)的结果- 返回右值引用的函数调用,如果一个函数的返回值是右值引用,那么这个函数调用的表达式就是一个将亡值
- 左值有明确的内存地址,可以用取地址符
- 看生命周期
- 左值通常是在作用域内持续存在的变量
- 右值通常是表达式产生的中间结果或字面量,它们在当前语句执行完后就销毁了
- 看赋值位置
- 左值既可以出现在赋值符号的左边,也可以出现在右边
- 右值只能出现在右边
8.2 右值引用 &&
右值引用是为了支持资源的移动操作而引出的一个概念,使用移动操作可以避免无谓的拷贝,提高性能。
使用std::move()函数可以将一个左值转换为右值引用。
std::move()底层是一个强制类型转换(static_cast),它唯一的职责是,接收一个左值或右值,然后把它强转成右值引用类型返回。
8.2.1 基础使用
1 | int&& rref = 10; // 正确,绑定到纯右值(字面量) |
1 | void swap(T& a, T& b) noexcept |
右值引用本身不产生任何运行时代码,它只是一个编译期类型标记。
让程序员能够显式表达:”我要转移资源的所有权,而不是拷贝它“这种意图。
真正产生效率提升的是:程序员(或标准库作者)根据这个标记,写了不同的实现路径(move 路径 vs copy 路径)。
1 | // 这行代码本身不移动任何东西 |
8.2.2 两大核心用途
1 实现移动语义
1 | class BigData { |
应用:高效swap。
1 | void swapBigData(BigData& a, BigData& b) noexcept { |
2 实现完美转发(模板参数中的&&实现了万能引用,是完美转发的必要步骤之一)
完美转发就是:把函数接收到的参数,以“调用者传入时的原貌”完整无损地转发给另一个函数(包括左值、右值、const、volatile(一个标记,禁止编译器对变量进行任何形式的优化))。
为什么需要完美转发?
一个失败案例:
1 | template<typename T> |
问题:
- 左值(lvalue)被拷贝了一次(性能浪费)
- 右值(rvalue)也被拷贝了(本应移动构造)
- 无法区分调用者传入的是左值还是右值
完美转发的经典写法:
1 | template<typename T> |
实现完美转发的步骤:
第一步:万能引用
万能引用就是让参数既可以接收左值,也可以接收右值。
通过 T&&
这种特殊形式,编译器利用引用折叠(Reference
Folding)规则来记录原始身份:
- 传左值
int&\(\rightarrow\)T被推导为int&\(\rightarrow\)arg是int& - 传右值
int&&\(\rightarrow\)T被推导为int(非引用) \(\rightarrow\)arg是int&&
第二步:std::forward(还原原始属性)
这是最后的一记助推。std::forward<T>(arg)
会根据第一步推导出的 T 来做决策:
- 如果
T是int&(说明原始是左值),它返回static_cast<int&>(arg) - 如果
T是int(说明原始是右值),它返回static_cast<int&&>(arg)
8.3 noexcept
8.3.1 noexcept的用法
作为说明符:告诉编译器,我这个函数不抛异常
- ```c++ void Swap(MyData& a, MyData& b) noexcept { ... }
1
2
3
4
5
6
7
8
9
10
- 如果标了`noexcept`但是偏偏函数体内部抛出了异常,它会直接调用`std::terminate`,强制你的程序立刻崩溃并退出
- 因为编译器为了优化,已经**去掉了所有异常回滚代码**,当异常发生时,为了防止内存状态混乱导致不可预测的后果,自杀是保护系统的唯一手段
**作为运算符:在编译期检查一个表达式是否可能抛出异常**
- ```c++
// 如果 Swap 是 noexcept 的,那么这行编译出来就是 true
bool isSafe = noexcept(Swap(x, y));
8.3.2 使用场景
使用场景:
- 移动构造与移动赋值
- 通过给移动语义加上
noexcept担保,确保 STL 容器在扩容时执行高效的物理移动而非沉重的深度拷贝 vector在底层使用了一个探测工具叫做std::move_if_noexcept,如果对象的移动构造函数标了noexcept,它就会使用移动构造
- 通过给移动语义加上
- 析构函数
- C++11 后默认就是
noexcept。绝对不要让异常逃离析构函数,否则程序会崩溃 - 你的代码正在处理一个异常(比如文件读取失败),此时程序开始栈回滚
- 在栈回滚过程中,局部对象会被自动销毁,于是触发了析构函数
- 如果此时析构函数也抛出了一个异常,那么现在就有两个异常同时存在
- C++规定,当两个异常同时存在时,系统无法判断该处理哪个,于是会立即调用
std::terminate强制让程序崩溃
- C++11 后默认就是
- 叶子节点函数
- 那些只是做简单运算、位移、指针交换、或者纯内存操作的函数,果断加上
- 消除函数调用时的异常处理开销,精简机器码
- 交换函数(Swap)
- 许多算法依赖于安全的
swap
- 许多算法依赖于安全的
9 虚函数、纯虚函数和抽象类
9.1 虚函数
9.1.1 基础使用
在基类中用 virtual 关键字声明的成员函数,允许派生类重写(override)它,并在通过基类指针或引用调用时,表现出运行时多态(动态绑定)。
- 动态绑定(运行时决议) 通过基类指针/引用调用虚函数时,实际调用的是真正对象的版本(而非指针/引用类型的版本)
- 必须通过指针或引用调用才会触发多态,直接用对象调用不会多态
- 且必须将派生类对象的地址(或引用)赋值给基类类型的指针(或引用),然后用基类类型指针进行虚函数调用
- 一旦声明为 virtual,后续派生类中同名同参同返回的函数自动成为虚函数(即使不写 virtual)
1 |
|
对象切片指的是在赋值Base b2 = d;时,发生了对象切片(object
slicing):只拷贝了Base部分的成员,Derived特有的部分被直接丢弃了。
9.1.2 虚函数表
- 每个有虚函数的类会有一个虚函数表(vtable),里面存的是该类的虚函数地址
- 虚函数表在编译时确定,并存储在常量区
- 每个对象有一个隐藏的虚表指针(vptr),指向自己类的虚函数表
- 虚表指针存储在对象内存的最前端
- 调用虚函数时:对象->vptr -> vtable[索引] -> 真正函数地址
9.2 纯虚函数
9.2.1 基础使用
1 | virtual 返回类型 函数名(参数列表) = 0; // =0 表示纯虚 |
9.3 虚函数 VS 纯虚函数
| 特性 | 普通虚函数(virtual) | 纯虚函数(virtual ... = 0) |
|---|---|---|
| 是否必须在基类实现 | 可以不实现(但通常会实现默认) | 必须声明,不能有实现(除特殊情况) |
| 派生类是否必须重写 | 不必须(可以用基类版本) | 必须重写,否则派生类也是抽象类 |
| 类是否能实例化 | 可以 | 不能(抽象类) |
| 典型用途 | 提供默认行为,可被选择性覆盖 | 定义接口,强制子类实现 |
9.4 抽象类
至少有一个纯虚函数的类,叫抽象基类。
不能实例化抽象基类的对象。
1 | class GameObject { |
9.5 虚析构函数
当一个类准备作为基类并支持多态删除时,其析构函数就需要被声明为虚函数。
简单来说,如果你打算通过基类指针去
delete
一个子类对象,基类的析构函数就必须是虚的。
1 | Animal* ptr = new Dog(); |
正确写法:
1 | virtual ~Animal() = default; // 或写 {} 也行 |
10 虚继承
虚继承主要是为了解决菱形继承的问题。是为了确保在复杂的继承网络中,公共基类的实例在派生类对象中只存在一份。
注意:在C#中不存在菱形继承问题,因为C#不允许继承多个类,类似于菱形继承的菱形实现接口情况,因为接口只提供定义,即使有多条路径到达同一个接口方法,也只需要实现一次(没有二义性问题)。
10.1 菱形继承
场景描述:
- 类
A有一个成员变量int data; - 类
B和类C都继承自A - 类
D同时继承自B和C
如果不使用虚继承:
D对象的内存布局里会包含两份A。一份来自B,一份来自C- 物理后果:当你访问
D.data时,编译器会报错,因为它不知道你是想找B里的data还是C里的data - 资源浪费:如果
A是一个占用内存很大的类,D就会平白无故浪费一倍的空间
使用虚继承后:
- 编译器保证在整个继承体系中,公共基类
A只会存在一个实例。无论有多少条路径继承它,最终都指向同一个内存地址 - 注意:虚基类
A的构造函数调用时机虽然在所有中间类的构造函数之前,但真正决定调用哪个构造函数、重载版本、传入什么参数的,是最底层的最终派生类D
10.2 底层原理
10.2.1 虚基类表
正如虚函数依赖 vtable,虚继承依赖的是
vbtable (Virtual Base Table)。
- vbptr (Virtual Base Pointer):每个包含虚基类的派生类对象中,都会增加一个隐藏的指针,称为“虚基类表指针”
- vbtable 内容:这张表里存储的是偏移量
(Offset)。它记录了从当前
vbptr所在的位置到虚基类子对象在内存中起始位置的距离
10.2.2 内存布局的改变
在普通继承中,基类在派生类内存的最前面;但在虚继承中,布局发生了“反转”:
- 共享部分(虚基类)被挪到了最后: A 放在整个对象 D 的最末尾
- 派生类部分在前:B 和 C
的成员变量排在前面,它们各自携带一个
vbptr
1 | [ D 的内存块 ] |
为什么把A放在最末尾?
假设把A放在最前面:
- 类 B 的布局:
[ A | B的成员 ]-> 访问 B 成员的偏移量是固定的sizeof(A) - 类 C 的布局:
[ A | C的成员 ]-> 访问 C 成员的偏移量是固定的sizeof(A)
当它们拼成D时:
由于 A 是虚基类,在 D
中只能有一份。如果我们坚持把 A 放在最前面,布局只能是:
[ A | B的成员 | C的成员 | D的成员 ]。
现在,我们扮演编译器,去访问一段普通的C++函数:
1 | void updateC(C* ptrC) { |
- 情况1
- 传入的是一个纯粹的
C对象 - 编译器根据
C的定义,知道c_data在开头偏移sizeof(A)的位置。成功
- 传入的是一个纯粹的
- 情况2
- 传入的是
D中的C部分 - 在
D的布局[ A | B的成员 | C的成员 | D的成员 ]中,C的开头在哪里? 为了兼容,ptrC必须指向A的开头(因为C以为自己是从A开始的)。 此时,编译器依然按照C的定义,从ptrC开始偏移sizeof(A)字节去写数据。 灾难: 偏移sizeof(A)之后,它正好写在了B的成员所在的内存上
- 传入的是
为什么放最后能够解决这个问题?
因为如果 A 在最后,B 和 C
的“核心躯干”就不再以 A
为基准,而是以自己为基准:
- 类 B 的核心:
[ B的成员 | vbptr ](A 在后面,不影响前面) - 类 C 的核心:
[ C的成员 | vbptr ](A 在后面,不影响前面)
在 类 D 里的布局:
[ B的核心 ] [ C的核心 ] [ D的核心 ] [ 共享的 A ]
此时,updateC(C* ptrC) 接收到的 ptrC
会直接指向 D 内存中 [ C的核心 ]
的起始位置。
- 无论是在
C还是在D里面,C访问自己的成员偏移量永远是 0(或者是固定的偏移) - 只有当它需要找
A时,它才会通过那个vbptr说:“帮我看看,在这个特定的对象里,A被踢到后面多远的地方了?”
总结:把A放最后,是为了保证派生类(B,
C)访问自己成员时的“静态偏移”性能。在 C++
的哲学里,“访问自己的成员”必须是最快的,而“访问虚基类”可以稍微慢一点。所以,被牺牲(位置变动)的是
A。
10.2.3 访问虚基类成员的步骤
当你写 ptrB->dataA = 10;
时,底层并不是直接根据固定地址写入,而是经历了以下过程:
- 取指针:找到对象中的
vbptr - 查表:从
vbtable中读取对应的偏移量值 - 计算地址:
当前地址 + 偏移量 = 虚基类 A 的实际起始地址 - 操作数据:在计算出的地址处写入
10
10.2.4 谁来构造基类
在底层,虚继承规定了“最底层的派生类负责构造虚基类”。
- 当构造
D时,D的构造函数会直接调用A的构造函数 - 此时,
B和C的构造函数中对A的调用会被屏蔽(Ignore) - 底层原因:如果 B 和 C 都去构造 A,那 A 就会被初始化两次,这违背了“只有一份实例”的初衷
10.3 示例
1 |
|
11 继承&复合&委托关系下的构造和析构顺序
11.1 继承关系(is-a)
1 | class Base { ... }; |
顺序
- 构造顺序
- 先构造基类,再构造派生类
- 析构顺序
- 先析构派生类,再析构基类
11.2 复合/组合关系(has-a)
1 | class Engine { ... }; |
顺序
- 构造顺序
- 按类中声明的顺序从上到下构造成员,最后执行Car的构造函数
- 析构顺序
- 先析构Car,然后按类中声明的逆序从下到上析构成员
11.3 委托关系
1 | // 写法1:裸指针(不推荐) |
顺序
- 构造顺序
- Car 的值成员(按声明顺序)
- Car 的构造函数体(通常在这里 new / make_unique)
- 析构顺序
- Car 的析构函数体(通常在这里 delete / reset)
- Car 的值成员(逆序)
12 面向对象程序设计几大特性
12.1 封装
将数据和操作数据的方法捆绑在一起,对外隐藏实现细节,只暴露必要的接口。
实现手段
类/结构体
通过访问修饰符
private、protected、public
12.2 继承
子类可以复用父类的代码,并可扩展或修改父类的行为。
12.2.1 三种继承方式
1 | class Base {}; |
三种方式只影响外部代码通过派生类对象访问基类成员的访问权限。
12.2.2 虚继承
见9,略。
12.3 多态
所谓多态,就是同一个函数名具有多种状态,或者说一个接口具有不同的行为。
12.3.1 编译时多态
函数重载、模板。
12.3.2 运行时多态
当基类指针或引用指向派生类对象,并调用虚函数时,运行时会通过虚表(vtable)查找真正对象的函数地址,从而执行派生类的重写版本,这种动态绑定发生在程序运行期间,因此称为运行时多态。
C++动态多态的三个条件:
- 存在继承关系
- 存在虚函数覆盖
- 基类声明虚函数
- 派生类重写虚函数
- 通过基类指针或引用调用,且必须指向一个派生类对象
12.4 抽象
提取一类对象的共同特征,而不关注具体实现。
C++中最典型的实现方式:抽象基类。
封装关注的是如何隐藏,侧重于数据保护。抽象关注的是逻辑模型的提取。
面试题
static
1 基础
static
关键字的作用根据其出现的位置可以分为三大类。它的核心逻辑永远围绕着两个维度:生命周期(存活多久)和可见性(谁能看到)。
- 在函数内部(静态局部变量)
- 当在函数内部声明一个
static变量时,它不再存储在栈区,而是存储在静态存储区。它的生命周期和程序一样长,从程序开始时分配内存空间,从第一次执行到声明处进行初始化,在程序结束时销毁 - 函数执行完返回后,该变量的值保持不变。下次进入函数,它依然持有上次的值
- 当在函数内部声明一个
- 在文件全局范围内(静态全局变量/函数)
- 全局变量默认是跨文件可视的,但当它加上
static关键字,就变成仅文件内可视
- 全局变量默认是跨文件可视的,但当它加上
- 在类内部(静态成员)
- 该变量不属于某个具体的对象(Instance),而是属于整个类
- 无论你创建了 10 个还是 100 个对象,静态变量在内存中只有一份
- 通常需要在类外进行初始化(因为它不随对象创建而创建)
- 无
this指针:因为它属于类而不属于对象,所以它没有this指针- 它只能访问类的静态成员(变量或函数),不能直接访问非静态成员(因为它不知道操作哪个对象的数据)
- 该变量不属于某个具体的对象(Instance),而是属于整个类
| 位置 | 改变了什么? | 具体表现 |
|---|---|---|
| 函数内 | 生命周期 | 函数退出后变量不销毁,下次进入仍有效 |
| 文件全局 | 可见性 | 该变量/函数对其他 .cpp 文件不可见 |
| 类内部 | 所属权 | 变量/函数属于类而非对象,全局仅此一份 |
2 为什么静态成员函数不能声明为virtual
- 设计层面就是互斥的
- 静态成员函数代表的是类的共有逻辑
- 而虚函数代表的是需要根据对象类型的不同调用不同的逻辑
- 静态成员函数没有
this指针- 静态成员函数不能声明为
virtual,最根本的原因可以归结为一句话:virtual函数依赖于“对象实例”来查表,而static函数是脱离“对象实例”存在的
- 静态成员函数不能声明为
static和const的区别
它们的侧重点是不一样的,static侧重于生命周期和作用域限制,而const侧重于不可变性。
static存储在静态区,而const视使用情况存储在常量区或者栈上。
当static和const修饰类成员变量时,static是同一个类只有一份的,而const是每个对象都有一份的。
当static和const修饰类成员函数时,static函数没有this指针,只能访问其他静态成员变量或者静态成员函数,而const则是用const修饰了this指针(指针常量,无法修改指针指向的内容),以此做到在函数体内不对类成员变量进行修改。
float占用几个字节?64位中呢?float类型数据在while循环中一直加一,会溢出吗?
1 float内存占用
float无论是32位系统还是64位系统,固定占用4个字节,double则固定占用8字节。
2 float在循环中一直加1
它不会发生传统意义上的“数值溢出(Overflow)”,但它会进入一种更可怕的状态——“原地踏步”。
如果你写出
while(true) { f += 1.0f; },你会发现程序在运行到某一个特定的数值后,f
的值再也不会增加了。
为什么?
浮点数是由 阶码(Exponent) 和 尾数(Mantissa) 组成的:\[Value = \text{尾数} \times 2^{\text{阶码}}\]。
float 的尾数位只有 23
位+1位隐藏位。这意味着它能精确表示的连续整数最大是
\(2^{24}\)(即 16,777,216)。
浮点数计算有一个对齐阶码的过程,在数学上,如果你要计算 \(1.23 \times 10^2 + 4.56 \times 10^1\),你不能直接把 \(1.23\) 和 \(4.56\) 相加。你必须先统一它们的指数:
- 要么把 \(4.56 \times 10^1\) 变成 \(0.456 \times 10^2\)
- 然后才能做加法:\((1.23 + 0.456) \times 10^2 = 1.686 \times 10^2\)
在计算机硬件(ALU)中,浮点数加法的逻辑是一模一样的:必须先让两个数的“阶码”一致,才能对“尾数”求和。
当浮点数来到:float f = 16777216.0f(即 \(2^{24}\)),此时我们对齐进行加1操作。
- \(f\) 的阶码:\(24\)
- \(1.0f\) 的阶码:\(0\)
- 阶差:\(24 - 0 = 24\)
为了加法,硬件必须把 \(1.0f\) 的阶码也变成 \(24\)。 这意味着 \(1.0f\) 的尾数需要向右移动 24 位。
由于 float 的尾数位一共只有 23 位,当你把 \(1.0f\) 的有效位右移 24
位后,它所有的有效数字都从低位溢出(被丢弃)了。
- 原本是:\(1.0 \times 2^0\)
- 试图对齐后变成:\(0.0000000000000000000000001 \times 2^{24}\)
- 但在 23 位容量的限制下,它直接变成了:\(0 \times 2^{24}\)
因此直接被忽略。
注意:float并不是不能表示比\(2^{24}\)更大的数,而是一旦超过这个数,整数就会开始丢失奇数位,出现精度误差。
extern的作用
extern的本质是“仅声明,不定义”,用于告诉编译器某个变量/函数/模板的实体在其他翻译单元中已经存在,从而实现跨文件的共享、避免重复定义,并支持C/C++混合编程。
- 跨文件共享常量
extern constconst默认内部链接,普通变量默认外部链接
- 控制模板实例化
extern template class std::vector<int>; // 告诉编译器不要在本文件实例化这个模板 extern C- 因为C++支持函数重载,因此C++编译器会对函数名进行重整,但是C不支持函数重载,因此编译器会直接采用原始函数名
extern C告诉编译器:请用C的链接规则。从而使C和C++代码能够无缝链接
什么是移动构造函数,和拷贝构造函数的区别?
移动构造函数需要传递的参数是一个右值引用,移动构造函数不分配新内存,而是接管传递而来对象的内存,并在移动之后把源对象销毁。拷贝构造函数需要传递一个左值引用,可能会造成重新分配内存,性能更低。
内联函数和宏
1 内联函数有什么作用?存不存在什么缺点?
- 作用是使编译器在函数调用点上展开函数,可以避免函数调用的开销
- 内联函数的缺点是可能造成代码膨胀,尤其是递归的函数,会造成大量内存开销,exe太大,占用CPU资源。此外,内联函数不方便调试,每次修改会重新编译头文件,增加编译时间
2 内联函数和宏有什么区别,有了宏为什么还需要内联函数?
- define宏命令是在预处理阶段对命令进行替换,inline是在编译阶段在函数调用点处直接展开函数,节省了函数调用的开销
- define的话是不会对参数的类型进行检查的,因此会出现类型安全的问题,比如定义一个max命令,但是传递的时候可能会传递一个整数和一个字符串,就会出错,但是内联函数在编译阶段会进行类型检查
- 使用宏要加很多括号,容易出错
带参数的宏:
1 |
nullptr和Null的区别
| 特性 | NULL | nullptr (C++11 引入) |
|---|---|---|
| 本质是什么 | 整数常量,通常是 0 或 (void*)0 | 专用的空指针常量(类型是 std::nullptr_t) |
| 类型 | int / long / (void*)(依赖实现) | std::nullptr_t(独立类型) |
| 可以隐式转换为什么 | 可以隐式转换为任何指针类型 | 只能隐式转换为指针类型(不能转 int) |
| 重载函数歧义问题 | 容易引起歧义 | 不会引起歧义 |
| 与模板的兼容性 | 容易导致模板推导错误 | 模板推导正确(视为指针) |
| 可读性 / 意图清晰度 | 看起来像整数 0 | 明确表示“这是空指针” |
类成员变量 通过初始化列表进行初始化 VS 在声明处直接进行初始化
初始化列表的优先级高于声明处初始化。
- 声明处初始化:相当于一个“保底方案”或“默认设置”。
- 初始化列表:是“定制方案”。
- 编译器行为:编译器在生成构造函数时,会查看初始化列表。如果列表里没有显式初始化某个成员,编译器就会去查找该成员是否有声明处的初始值;如果有,就用它;如果都没有,才进行默认初始化。
| 特性 | 声明处初始化 (C++11) | 初始化列表 |
|---|---|---|
| 灵活性 | 较低。所有对象初始值相同。 | 极高。可以根据构造函数参数设置不同值。 |
| 执行时机 | 进入构造函数体之前(被编译器合并到初始化列表中)。 | 进入构造函数体之前。 |
| 重复性 | 减少代码重复(多个构造函数共用)。 | 如果有多个构造函数,可能每个都要写一遍。 |
| 覆盖关系 | 如果初始化列表也写了,声明处的会被忽略。 | 始终生效,且会覆盖声明处的初始值。 |
i++和++i分别是左值还是右值?
在C++中,++i是左值,而i++是右值。
为什么 ++i 是左值?
- 逻辑: 前置递增是“先加后用”。它直接修改变量本身的值,并返回变量本身
- 内存表现: 执行完
++i后,返回的是i所在的那个内存地址。因为它有明确的内存位置(持久化的存储),所以它是左值 - 验证: 你可以写
++i = 10;这样的代码(虽然逻辑奇怪,但语法合法)
为什么 i++ 是右值?
- 逻辑:
后置递增是“先用后加”。为了实现这个效果,编译器必须先产生一个临时变量来保存
i修改前的值,然后修改i,最后返回那个临时变量 - 内存表现: 那个返回的临时变量在表达式结束之后就销毁了。它没有持久的内存位置(或者说你无法合法地获取它的地址),因此它是右值
- 验证: 你写
i++ = 10;会直接报错,因为你不能给一个临时生成的中间值赋值
虚函数相关
1 构造函数和析构函数可以是虚函数吗?
构造函数不能是虚函数。
首先我们要知道虚函数的调用过程是通过对象存储的虚函数指针去查询虚函数表里对应的虚函数,然后进行调用。那么这就要求对象已经存在,即对象已经完成了构造。但在构造函数执行完毕之前,对象是没有完全构造完毕的。因此这二者是矛盾的。
析构函数则通常必须是虚函数。
如果一个类有子类,请务必将析构函数声明为
virtual ~ClassName()。否则通过父类指针 delete
子类对象时,只会调用父类的析构函数,导致子类特有的资源(如堆内存)发生内存泄漏。
2 在构造函数或析构函数里调用虚函数会发生什么?
在构造函数和析构函数里调用虚函数,不会触发动态绑定(多态),而是调用当前类定义的版本。
为什么会这样?
构造时:子类成员变量的初始化发生在父类构造函数执行之后。如果父类构造函数能调用子类的虚函数,而该虚函数访问了子类的成员变量,那么此时这些变量还未初始化(是随机值),会导致程序崩溃。
析构时:子类成员在进入父类析构函数之前就已经被销毁了。如果此时调用子类的虚函数去访问已销毁的成员,同样会导致未定义行为。
现在有一个裸指针 p,用这个裸指针 p 分别构建了 shared_ptr p1 以及 shared_ptr p2,这段代码有没有问题?
当你用同一个裸指针 p 分别构造 p1 和
p2 时,std::shared_ptr
的内部机制会发生以下客观事实:
- p1 认为它是拓荒者:它为
p创建了一个引用计数控制块,计数为 1。 - p2 也认为它是拓荒者:因为它并不知道
p1的存在,它也为p创建了一个全新的引用计数控制块,计数同样为 1
当这两个智能指针生命周期结束时:
- p1 析构:引用计数从 1 降为 0,执行
delete p。此时内存被释放 - p2 析构:引用计数也从 1 降为 0,再次尝试执行
delete p - 结果:对已经释放的内存进行二次释放(Double Free),触发操作系统异常,程序直接崩溃
如果你需要多个 shared_ptr
共享同一个对象,应该基于已有的智能指针进行拷贝,而不是基于裸指针重复构造。
1 | int* p = new int(10); |
C++ STL和泛型编程
可参考博客:【STL】概述及总结
1 模板
1.1 模板的作用
- 代码复用:对于不同数据类型但逻辑相同的操作,我们不需要编写多份代码
- 泛型编程的基础,C++ STL是基于模板实现的
- 实现静态多态:这是模板与“面向对象(虚函数)”最大的区别
- 静态多态(例如模板):
在编译阶段,编译器就已经把
T替换成了具体的类型(如int),并生成了特定的机器码- 作用: 这意味着模板代码的运行速度与你手工编写的特定类型代码完全一样快。编译器甚至可以对生成的代码进行针对性的优化(如内联优化)
- 动态多态(例如虚函数): 在程序运行时,通过查找虚函数表来决定调用哪个函数。这会有微小的性能损耗
- 静态多态(例如模板):
在编译阶段,编译器就已经把
1.2 函数模板
1.2.1 基础使用
1 | template <typename T> |
其中,template <typename T>也可以写为template <class T>,二者等同。
但class容易导致误导,误以为只能传递class类型,其实struct类型也可以进行传递,所以尽量优先使用
1.2.2 核心机制:自动类型推导
当你调用函数模板时,通常不需要手动指定类型,编译器会根据你传入的实参自动推断
T。
1 | compare(1, 0); // 推断 T 为 int |
1.2.3 非类型模板参数
除了类型
T,模板还可以接受具体的值。这在定义固定大小的数组操作时非常有用:
1 | template<unsigned N, unsigned M> |
限制:非类型参数必须是常量表达式。
一个经典的应用std::array<int, 5>,注意,数组大小是模板参数的一部分,因此std::array<int, 5>
和 std::array<int, 10>
在编译器眼中是完全不同的两个类型。
1.2.4 普通函数VS函数模板
- 普通函数调用时可以发生自动类型转换(隐式类型转换)
- 函数模板调用时,如果利用自动类型推导,不会发生隐式类型转换
- 如果利用显式指定类型的方式,可以发生隐式类型转换
- 如果函数模板和普通函数都可以实现,优先调用普通函数
- 通过空模板参数列表强制调用函数模板:
test<>(0,1);
- 通过空模板参数列表强制调用函数模板:
1.3 类模板
与函数模板不同的是,编译器不能为类模板推断模板参数类型,因此我们需要在使用时显示地提供额外信息(C++17里引入了CTAD-类模板实参推断)。
1.3.1 基础使用
1 | template <typename T> |
1.3.2 类模板的成员函数
类模板的成员函数既可以定义在类内,也可以定义在类外:
- 类内定义: 隐式视为内联函数(inline),语法较简洁
- 类外定义: 必须以关键字
template开始,后跟类模板参数列表,并且类名必须包含模板参数
1 | template <typename T> |
成员函数的延迟实例化
这是类模板一个非常客观的特性:一个类模板的成员函数只有当程序用到它时才进行实例化。
- 好处: 如果某个类型
T不支持模板中的某个成员函数所要求的操作(例如T没有默认构造函数),只要你不调用那个特定的成员函数,程序依然可以编译通过并正常运行其他功能
1.4 成员模板
成员模板即在一个类(普通类或模板类)内部定义的函数模板。它不能为虚函数。
1 | template <typename T> |
如果在类外定义这个成员模板:
1 | template <typename T> // 类的参数 |
1.5 控制实例化
1.5.1 问题的由来:重复实例化
在默认情况下,当多个不同的源文件(.cpp)都使用了同一个模板(例如
vector<int>)时,每个文件在编译时都会独立地实例化一份该模板的代码。
- 后果: 编译器在链接阶段会通过复杂的机制去重,保留一份代码。但这导致了重复编译——明明是同一份代码,编译器却在每个文件中都算了一遍,大大拖慢了编译速度
1.5.2 解决:显式实例化
分两步:
第一步:专门找一个文件,进行显式实例化定义(显式实例化会实例化该模板的所有成员)。
1 | template class Stack<int>; // 注意:前面没有 extern |
这句话的意思是:“编译器,不管别人用不用,你现在立刻、马上在这个地方给我生成一份stack<int>的完整代码。”
第二步:显式实例化声明。
在要使用到的源文件的开头写下:
1 | extern template class Stack<int>; // 注意:这里有 extern |
这句话的意思是:“编译器,当你在这个文件里看到stack<int>时,别自己动手造!”
1.6 模板特例化
1.6.1 函数模板
1 全特化
1 | // 1. 原有的通用模板 |
但如果想给函数模板搞特殊化,优先使用函数重载而不是模板特化。
2 函数模板没有偏特化
通过函数重载实现
原因:因为函数支持重载,偏特化完全可以通过函数重载来实现。
1 | // 基础模板 |
通过if constexpr实现
if constexpr:编译时分支,后面只能带常量表达式。
1 | template <typename T> |
1.6.2 类模板
1 全特化
1 | template <typename T> |
2 偏特化
1 | template <typename T, typename U> |
2 STL六大组件
2.1 容器(Container)
用于存储数据的类模板,如vector、deque、list。
2.2 算法(Algorithms)
用于执行操作的函数模板,如std::sort、std::find、std::for_each。
2.3 迭代器(Iterators)
2.3.1 概念
是一种行为类似指针的类模板。它通过重载
operator*、operator->、operator++
等运算符,提供了一套统一的接口,使得算法(如
sort)不需要知道容器(如 list 或
vector)的底层结构(是链表还是数组),就能遍历其中的元素。
| 迭代器类型 | 支持的操作 | 代表容器 |
|---|---|---|
| 输入迭代器 (Input) | 只读,只能一次性向前读取 | istream_iterator |
| 输出迭代器 (Output) | 只写,只能一次性向前写入 | ostream_iterator |
| 前向迭代器 (Forward) | 可读写,可多次向前移动 | forward_list, unordered_map |
| 双向迭代器 (Bidirectional) | 可读写,可前后移动 | list, map, set |
| 随机访问迭代器 (Random Access) | 支持 it + n, it[n], <
等算术运算 |
vector, deque, array |
注意:std::sort不能用于list,原因:std::sort要求的是随机访问迭代器,而list提供的是双向迭代器,因此list必须使用自己成员函数里的list::sort。
2.3.2 迭代器失效问题
当容器发生结构性改变(插入或删除)时,原有的迭代器可能会因指向错误的内存而导致失效。
涉及问题:如何正确地在循环中删除元素?
1 | // 错误写法 |
2.4 仿函数(Functors/Function Objects)
2.4.1 概念
如果在一个类或者一个结构体里重载了operator()方法,这个类或者结构体的对象就可以像函数一样被调用。
1 | struct Adder { |
2.4.2 为什么使用仿函数(区别于普通函数)
- 状态存储
- 普通函数如果要记录被调用了多少次,必须使用全局变量或
static变量。仿函数可以把状态存在自己的成员变量里
- 普通函数如果要记录被调用了多少次,必须使用全局变量或
- 性能优势——可以内联
- 函数指针的局限性:
函数指针存储的是一个内存地址。由于指针指向的具体函数在运行时可能会改变,编译器在处理
std::sort(vec.begin(), vec.end(), pFunc)时,很难在编译阶段断定pFunc到底指向哪个代码块。因此,编译器通常无法进行内联优化,有函数调用开销 - 仿函数是唯一的类类型,因此编译器可以直接内联,省去了函数调用的开销
- 函数指针的局限性:
函数指针存储的是一个内存地址。由于指针指向的具体函数在运行时可能会改变,编译器在处理
2.4.3 std::function
std::function是一个类型擦除的容器,在C++中,函数指针、仿函数、Lambda是完全不同的类型,但通过std::function可以将它们包装成同一个类型。
它属于运行时多态,当你赋值 f = myLambda
时,编译器会做两件事:
- 实例化一个派生类/静态函数:这个函数知道
myLambda的具体类型,并知道如何调用它 - 存储接口:
std::function内部存储这个函数的地址
当你调用 f() 时(两次指针跳转):
- 它先通过内部指针找到那个“知道怎么调用
myLambda”的中间函数 - 中间函数再负责把
f内部存的数据转回myLambda类型并执行
1 |
|
进阶——类成员函数的特殊处理
类成员函数隐含了一个this指针,我们通常配合
std::bind 或者 Lambda 来把它包装进
std::function。
1 |
|
2.5 配接器(Adapters)
2.5.1 概念
不从零开始实现新功能,而是通过改装现有的组件,使其接口符合特定的需求。
- 容器配接器: 如
stack,queue,priority_queue(stack和queue底层默认使用deque,priority_queue底层默认使用vector) - 迭代器配接器: 如
reverse_iterator - 函数配接器: 如
bind,not1(用于改变仿函数的参数或返回值)
2.5.2
stack/queue底层实现为什么使用deque而不用vector?
- 扩容代价
vector的问题:vector是单块连续空间。当容量不足时,它必须申请一块两倍大的新空间,并把旧元素全部搬运(拷贝或移动)过去,然后释放旧内存。这会导致某一次push操作突然出现明显的耗时抖动deque的优势:deque是分段连续空间。当需要更多空间时,它只需要申请一个新的固定大小的“缓冲区(buffer)”并链接到控制表上。它不需要搬运旧元素
- 内存利用率
vector的“只增不减”:vector的容量(Capacity)在生命周期内通常只会增长。即使你pop掉了所有元素,它占用的内存依然不会自动归还给操作系统deque的分段释放: 由于deque是分段存储的,当某一整段缓冲区(buffer)里的元素都被pop掉后,deque可以主动释放该段内存。对于生命周期较长、频繁进出的queue来说,deque对内存更加友好
- 首尾操作的特性
queue的需求:queue需要在尾部插入(push),在头部删除(pop)。- 如果用
vector做底层,pop_front()会导致后面所有元素整体向前移动,复杂度是 \(O(n)\),效率极低 deque对头部和尾部操作都是 \(O(1)\),且性能是对称的
- 如果用
stack的需求: 虽然stack只在尾部操作,vector也能胜任,但基于上述第 1、2 点关于扩容平稳性和内存释放的优势,deque依然是更通用的默认选择
2.5.3
priority_queue底层实现为什么使用vector而不用deque?
- 随机访问的效率
- 堆的操作(上浮
push_heap和下沉pop_heap)依赖于完全二叉树的父子节点索引计算,这种算法极其依赖随机访问迭代器 vector是原生连续内存,随机访问v[i]是一次简单的指针偏移;而deque是分段连续内存,其随机访问需要经过两次指针跳转(先查控制表找到缓冲区,再找缓冲区内的偏移)。在高频的堆调整中,这种细微的性能差异会被放大
- 堆的操作(上浮
- 缓存友好性
vector: 数据在物理内存上是绝对连续的。当 CPU 访问一个节点时,其周围的节点会被预取到 Cache Line 中,非常适合堆这种在小范围内频繁交换数据的结构deque: 由于是分段存储,节点之间可能跨越不同的内存块(Page),这会增加 Cache Miss 的概率,导致 CPU 等待数据从内存中加载
- 为什么不用像
stack和queue一样担心扩容代价?- 堆的操作(
push/pop)复杂度是 \(O(\log n)\)。虽然vector偶尔会有 \(O(n)\) 的扩容开销,但它的常数项效率(随机访问和 Cache 命中)在绝大多数时间内远优于deque,所以权衡之下选择使用vector
- 堆的操作(
2.6 分配器(Allocators)
分配器是 STL 容器用于管理内存的类模板。它将容器的逻辑行为(如
vector 的
push_back)与物理内存管理(如何申请、释放内存)彻底解耦。它使得一个同一个容器可以根据需求,在不修改内部算法代码的前提下,灵活切换不同的内存策略(堆内存、栈内存或预分配内存)。
作用
- 容器逻辑行为和物理内存管理的解耦
- 自定义分配器,提升分配效率
- 针对海量的小对象(如
std::list的节点),分配器可以专门维护一组固定大小的空闲链表,当需要内存时,直接从链表头部取出一个块,复杂度是稳定的\(O(1)\)
- 针对海量的小对象(如
- 解决内存碎片问题、提高CPU缓存命中率
- 频繁申请和释放小内存会导致严重的外部碎片。分配器可以通过预先申请大块连续空间并手动切分,来减少碎片。同时,自定义分配器可以将逻辑相关的对象在物理内存上尽可能靠拢,从而提高CPU缓存命中率,显著提升遍历性能
2.7 部分容器/适配器
- 序列型容器
std::vector- 数组
std::deque- 双端队列
std::list- 双向链表
std::forward_list- 单向链表
std::array- 静态数组
- 关联式容器:内部元素自动排序。 底层通常是 红黑树 (Red-Black Tree)。
std::set- 存key
std::map- 存{key,value}
std::multiset/multimap- 对比
set/map,允许key重复
- 对比
- 无序关联容器:底层是 哈希表 (Hash Table)。
std::unordered_mapstd::unordered_set
- 容器适配器
std::stack- 栈,基于deque
std::queue- 队列,基于deque
std::priority_queue- 优先队列,底层是堆结构,基于vector
3 string
3.1 概念
string本质上是实际上是 basic_string
类模板的一个特定实例化的类型别名。
1 | using string = basic_string<char>; |
内部结构:
- 个指向字符数组的指针(char*)
- 一个记录当前长度 (size) 的变量
- 一个记录当前容量 (capacity) 的变量
- 一个静态小数组(通常为 15-22 字节)
3.2 基础使用
略。
3.3 string VS char*
- char*是一个指针
- 依赖于"\0"作为终结符
- char*是静态存储,大小固定
- string是一个类,类内部封装了char*和很多其他的成员方法
- 记录了当前长度,因此不依赖于"\0"
- string是动态存储,大小不固定
3.4 核心机制:SSO(Small String Optimization)
如果你定义一个很短的字符串(通常小于 16 或 23
字节,取决于编译器),string
不会在堆上分配内存。而是直接存放在 string
对象自身的内存里(栈上)。
原理:它内部有一个小型的固定字符数组。
好处:避开 new/delete
的性能开销,速度极快。
4 vector
4.1 底层内存模型
vector 的核心是物理连续。
- 数据结构: 底层通过三个原生指针维护:
_Myfirst:指向连续空间的起始位置。_Mylast: 指向当前最后一个元素之后的地址。_Myend:指向整块连续空间的末尾。
- 计算公式:
size() = _Mylast - _Myfirstcapacity() = _Myend - _Myfirst
4.2 扩容机制
当 size() == capacity()
且继续插入元素时,会触发扩容:
- 申请新空间: 申请一块更大的内存(通常是原容量的 1.5 倍或 2 倍)
- 元素搬运: 将旧空间的对象拷贝或移动到新空间
- 释放旧空间: 销毁旧对象并释放内存
关于1.5倍和2倍扩容的选择
2倍扩容的缺陷:容易造成内存碎片。
在数学上,如果扩容系数是 \(k=2\),那么每次申请的新空间都会大于之前释放的所有空间之和。
- 假设起始容量为 16:
- 第 1 次扩容:申请 32,释放 16。此时总空闲空间 = 16
- 第 2 次扩容:申请 64,释放 32。此时总空闲空间 = 16 + 32 = 48
- 第 3 次扩容:申请 128,释放 64。此时总空闲空间 = 16 + 32 + 64 = 112
- 结论: 发现规律了吗?\(16 + 32 + 64 = 112 < 128\)
- 后果: 无论扩容多少次,之前释放的“内存空洞”加起来,都填不下当前这一次的新需求。这就迫使内存分配器必须一直向后寻找新的地址。这会导致内存碎片增加,且对 CPU 缓存不友好(因为新旧空间离得越来越远)
1.5倍扩容的优势:
如果系数 \(k < 1 + \sqrt{5}/2 \approx 1.618\)(黄金分割比),在经过有限次的扩容后,新申请的内存块就有可能复用之前释放的内存碎片。
- 假设起始容量为 16:
- 第 1 次:申请 24,释放 16
- 第 2 次:申请 36,释放 24。此时总空闲 = 16 + 24 = 40
- 第 3 次:申请 54,释放 36。此时总空闲 = 40 + 36 = 76
- 第 4 次:申请 81,释放54。此时总空闲为 = 76 + 54 = 130
- 第 5 次:申请121,释放81...
- 结论:第5次内存申请时,前面释放的内存已经超过了要申请的内存
- 后果: 内存分配器有机会把之前
vector自己用过并释放的内存重新拼凑起来给它用。这大大提高了内存的利用率,并减少了系统向 OS 申请新页(Page)的频率
但为什么有时候还是用2倍扩容:
- 2倍扩容下,扩容频率更低
- 2倍计算简单,在底层上更快
4.3 优势和缺陷
优势
- 支持随机访问,\(O(1)\)
- 由于内存是连续的,访问
v[i]只需要起始地址 + i * 元素大小。这是物理级别的偏移计算,没有任何多余的逻辑跳转
- 由于内存是连续的,访问
- CPU缓存友好性
- 由于
vector是连续内存存储,因此对CPU缓存非常友好
- 由于
- 内存开销低
- 除了存储元素本身,
vector只多占用了三个指针的空间
- 除了存储元素本身,
- 尾部操作高效,\(O(1)\)
缺陷
- 中间插入/删除代价高昂 ,\(O(n)\)
- 扩容时的瞬间抖动
- 一旦触发扩容(1.5或2倍),就会发生“申请新内存 -> 拷贝全部旧数据 -> 释放旧内存”的重型操作。这在实时性要求极高的游戏帧逻辑中可能导致瞬间的掉帧
- 内存浪费
vector的capacity通常大于size。为了保证扩容效率,它总是预留了一部分空间。如果你的vector很大且有很多空余,这部分内存是被白白占用的
4.4 reserve() VS resize()
reserve(n): 仅预留空间。只改变
capacity,不创建对象。
resize(n): 改变大小。不仅改变
capacity,还会调用构造函数创建 \(n\) 个对象,改变 size。
4.5 push_back() VS emplace_back()
push_back:先构造一个临时对象,再拷贝/移动到容器内
emplace_back:
接收构造函数所需的参数,在原地构造对象(placement new)。
结论:emplace_back少了一次拷贝/移动的开销,性能理论上更优
1 |
|
placement new
普通的 new 操作符其实做了两件事:1. 申请内存;2.
调用构造函数。
而placement new是一道“半自动”指令:它不申请内存,只是在你已经提供好的内存地址上,强行触发构造函数。
1 |
|
4.6
特殊实现:vector<bool>
在 C++ 中,std::vector<T> 应该是一个存放
T 类型元素的容器。但 vector<bool>
并不是真的存了一堆 bool 变量。
- 客观实现: 为了节省空间,它在内部进行了位压缩(Bit-packing)。它用 1
个 bit 来存储一个
bool值,而不是通常的一个bool占用 1 个 byte - 后果: 这意味着你无法通过指针或引用直接指向其中的某个“元素”,因为 CPU 无法直接寻址内存中的某一个 bit(最小寻址单位是 byte)
operator[]operator[]返回的不是bool&,而是一个特殊的代理类对象(Proxy Object)- 如果你写
auto& val = v[0];,代码会编译失败。因为代理类对象是一个临时右值,无法绑定到左值引用上。这导致它在泛型编程(Template)中表现得像个“异类”,很多通用算法遇到它都会报错
- 非线程安全
- 普通
vector: 如果你同时修改v[0]和v[1],在普通 vector 中是安全的,因为它们在不同的内存地址 vector<bool>: 因为v[0]到v[7]可能都挤在同一个 byte 里。当你尝试同时修改它们时,多个线程会竞争同一个内存字节,导致竞态条件(Race Condition)
- 普通
4.7 vector几种删除元素的方式
删除最后一个元素:pop_back()
- 原理:直接销毁最后一个元素,并使
size-1 - 性能:\(O(1)\),因为它不涉及任何其他元素的移动
删除迭代器指向的元素:erase()
- 写法:
v.erase(v.begin()+2); //删除第3个元素 - 原理:被删元素之后的所有元素都要依次向前挪动一个位置,以填补空缺
- 性能:\(O(n)\)
删除特定值:Erase-Remove
1 | v.erase(std::remove(v.begin(), v.end(), 10), v.end()); |
std::remove:并不真的删除,而是把不需要删的元素“搬”到前面,返回一个指向“新逻辑末尾”的迭代器v.erase:把新末尾到旧末尾之间的“垃圾数据”真正清理掉。
游戏开发黑科技:Swap-and-Pop
1 | // 假设要删掉下标为 i 的元素 |
- 原理:用最后一个元素覆盖掉要删的元素,然后只处理末尾
- 性能:\(O(1)\)
5 list
5.1 底层内存模型
std::list 是一个 双向链表 (Doubly Linked
List)。
- 物理分布: 每个元素(节点)在内存中是完全离散的,由分配器单独申请
- 节点组成: 每个节点通常包含三部分:
- Data:实际存储的数据
- Prev 指针:指向前一个节点
- Next 指针:指向后一个节点
5.2 优势和劣势
优势
- 增删效率高,\(O(1)\)
- 最稳定的迭代器
- 除非你删除了某个迭代器指向的元素,否则该迭代器、指针或引用在
list的整个生命周期内永远有效 - 即使
list进行了成千上万次插入,原有的元素地址绝对不会改变
- 除非你删除了某个迭代器指向的元素,否则该迭代器、指针或引用在
劣势
- 无法通过下标访问,必须从头开始遍历
- CPU缓存非常不友好
- 节点四散分布,缓存不命中概率很高
- 内存开销很大
- 即使你存一个 4 字节的
int,你也要额外付出 16 字节(两个指针)的代价
- 即使你存一个 4 字节的
- 海量小对象问题
- 频繁申请离散的小块内存会产生严重的内存碎片
6 deque
6.1 底层内存模型
deque 的物理结构由两级构成:
- 中控器 (Map): 一个连续的指针数组,每个指针指向一块具体的“缓冲区”
- 缓冲区 (Buffer/Node): 存放实际元素的连续内存块,通常大小固定(如 512 字节)
6.2 扩容机制
- 中控器扩容
- 通常遵循两倍扩容策略
- 过程
- 申请一个两倍大的新指针数组
- 将旧的指针(即指向各个缓冲区的地址)拷贝到新数组的中间位置
- 为什么中间?
- 因为
deque是双端队列,两头都要留出余地,方便后续在头部或尾部继续快速挂载新的缓冲区
- 释放旧的指针数组
- 缓冲区扩容
- 当你在
push_back或push_front时,如果当前的缓冲区(Buffer,通常是 512 字节)满了,deque会立即申请一个新的、固定大小的缓冲区
- 当你在
6.3 优势和缺陷
优势
- 头尾增删皆为\(O(1)\)
- 支持随机访问,\(O(1)\)
- 索引 \(i \rightarrow\) 计算属于哪个缓冲区 \(A = i / BufferSize\) \(\rightarrow\) 计算在缓冲区内的偏移 \(B = i \% BufferSize\)
- 涉及两次计算和两次指针跳转,性能低于
vector,但远高于list
- 内存分配更平稳
- 当空间不足时,
deque只需要申请一个新的固定大小的缓冲区 - 区别:
vector扩容需要申请 1.5/2 倍的大块内存并拷贝/移动所有旧数据;deque扩容中控器时虽然也要搬运指针,但指针的数据量远小于元素本身
- 当空间不足时,
缺陷
- 迭代器开销极大
deque的迭代器为了在不同缓冲区间“跳跃”,内部维护了cur,first,last,node等多个指针。这导致其迭代器的递增(++)和解引用操作比vector慢得多
- CPU缓存不友好
- 数据物理上是分段的,Cache Miss相比
vector更高频
- 数据物理上是分段的,Cache Miss相比
- 内存浪费
- 即使只存几个元素,
deque也会申请一个完整的缓冲区
- 即使只存几个元素,
面试题
1.map和unordered_map的区别,unordered_map的扩容和删除
map和unordered_map的区别主要源于它们的底层实现方式不同。map基于红黑树实现,而unordered_map基于哈希表实现。
有序性
红黑树是平衡二叉搜索树,因此map是有序的,而unordered_map基于哈希表实现,因此无序
效率
map查找效率为\(O(logn)\),而unordered_map平均为\(O(1)\),最坏为\(O(n)\)(极端哈希碰撞时退化为链表)map插入删除效率稳定为\(O(logn)\),unordered_map平均 O(1),最坏 O(n)(极端哈希碰撞时退化为链表)- 在 n = 10万 ~ 100万 的常见规模下,unordered_map 的平均查找速度通常比 map 快 3–10 倍(取决于 hash 质量)
- map 的稳定性更好,适合对延迟抖动敏感的场景
内存占用
map在内存占用上仅有节点占用(left指针、right指针、parent指针、颜色值、键值对),unordered_map除了节点本身的内存占用(next指针、hashcode、键值对),还有桶数组。
| 数据规模 | 哪种通常更省内存 | 主要原因 | 典型 crossover 点(粗略) |
|---|---|---|---|
| 非常小(< 几十个元素) | map 明显更省 | unordered_map 有固定开销(桶数组 + 容器本身 ≈ 50–150 字节起步) | — |
| 小规模(几十 ~ 几百个元素) | map 往往更省 | 桶数组浪费 + 每个节点开销较高,unordered_map 的额外结构开始显现 | — |
| 中等规模(几百 ~ 几千) | 接近持平或看 load_factor | unordered_map 的桶浪费开始明显,但如果 load_factor 较高可能还持平 | ≈ 300–1000 个元素左右 |
| 大规模(几千 ~ 几十万+) | unordered_map 往往更省 | map 每个元素固定 32–48 字节开销累积起来很大;unordered_map 平均每个元素有效开销更低(尤其 load_factor ≥ 0.8 时) | > 几千元素后常见反转 |
扩容和删除
- 扩容
map- 没有扩容这个概念,插入节点后进行平衡调整
unordered_map- 最大负载因子:
max_load_factor - 当
size()/bucket_count()>max_load_factor时触发扩容,通常扩到2倍或者更多(很多实现用质数表),扩容后,所有已有元素重新哈希(分配到新桶数组,然后释放旧桶数组)
- 最大负载因子:
- 删除
map- 删除节点后进行平衡调整,内存会立即释放
unordered_map- 只删除对应节点,桶数组不变,删除后内存基本不下降
范围查询
map有范围查询的功能。
| 函数 | 逻辑含义 | 形象理解 |
|---|---|---|
lower_bound(k) |
大于或等于 k |
找第一个“不小于” k 的位置 |
upper_bound(k) |
严格大于 k |
找第一个“比 k 大”的位置 |
实际选择
- 需要有序遍历、范围查询(lower_bound 等)、或者对最坏延迟敏感 → 用 map
- 追求平均最高性能、key 分布均匀、不关心顺序 → 用 unordered_map
2.vector扩容,push_back效率
2.1 扩容
当vector空间已满(size()==capacity())继续往里添加元素,会触发扩容,一般为1.5倍或者2倍(1.5倍在内存利用率上理论更优,因为可以重复利用之前释放的内存)。
扩容后会将元素从旧内存依次迁移到新内存中(关键优化:使用移动语义,比深拷贝更快)。
2.2 push_back效率
平均复杂度为\(O(1)\)。
3.怎么释放vector的内存
通过与一个临时的空vector进行交换,强制释放原vector的所有内存。
1 | std::vector<int> v = {1, 2, 3, 4, 5}; |
std::vector<int>() 创建了一个临时的空 vector(其
capacity 为 0)。swap
会交换两者的内部指针。当这一行结束时,临时变量销毁,原本 v
占用的内存随之释放。