分类参考《C++ primer》第五版。

C++基础

1 变量和基本类型

1.1 引用

在C++中,引用(Reference) 的本质是为对象起的别名,引用并非一个独立的对象

1.1.1 基础使用

1
2
3
int ival = 1024;
int& refVal = ival;
int& refVal2; // 报错:引用必须被初始化

1.1.2 注意项

  • 引用必须被初始化
  • 无法获取引用的地址
    • 在 C++ 内存模型中,只有对象和函数拥有可寻址的地址
    • 对引用取地址,得到的结果是它指向的原始变量的地址
  • 内存占用
    • 语义上不占空间
      • 对引用执行sizeof操作,得到的结果是它指向的原始变量的大小
    • 物理上
      • 引用在编译器内部通常被存储为类似常量指针的形式,当定义int &r = i;,编译器内部:int* const p = &i
      • 当引用作为局部变量时,编译器会将其直接优化掉,直接操作原始变量,因此没有内存开销
      • 当引用不作为局部变量,由于引用在编译器内部被存储为类似常量指针的形式,当定义int &r = i;,编译器内部会将其存储为int* const p = &i;,因此占用一个指针大小的内存
  • 引用无法重新绑定
    • 引用一旦初始化完成,它就和初始对象“死死绑定”在一起。你无法让一个引用转而去指向另一个对象
    • 底层原因:当你定义 int &r = i; 时,编译器内部将其存储为类似 int* const p = &i; 的指针,即一个常量指针,因此不允许其更改朝向

1.2 指针

指针是C++的核心特性,可以通过它间接访问内存,所有类型的指针在32bit下是4字节,64bit下是8字节。

不同于引用,指针本身就是一个独立的对象,因此:

  • 允许对指针进行赋值和拷贝,且在其生命周期内可以先后指向几个不同的对象
  • 无须在定义时赋初值

1.2.1 基础使用

1 定义指针
1
2
3
4
int a = 10;
int *p;
p = &a; // $是取址符号
cout<<*p<<endl; // *在这里是解引用符号
2 定义多个指针
1
int *p,*q;

如果如下定义:

1
int* p,q;   // p是指向int类型的指针,而q是int类型
3 用于条件判断
1
2
3
4
int* pi = 0;
if(pi){ //此时等同于False,而任何非0指针对应的都是True
//...
}

1.2.2 空指针/野指针

1 空指针
1
2
int* p = nullptr;   // 指针变量p指向内存地址编号为0的空间,也可以写为int* p = 0;或者int* p = NULL;
cout<<*p<<endl; // 内存编号0~255为系统占用内存,不允许用户访问
2 野指针:指针变量指向非法的内存空间
1
2
3
// 指向内存编号为0x1100的空间
int* p = (int*)0x1100;
cout<<*p<<endl; // 访问野指针报错

1.2.3 void*

void*指针可以用于存放任意对象的地址,但因此我们不能直接操作其指向的对象,因为我们不能确定对象是什么类型。

1
2
double obj = 3.14;
void* pv = obj;

1.2.4 指向指针的引用

1
2
3
int i = 42;
int* p;
int*& r = p;

1.3 const

const主要作用是声明常量和限制修改。

1.3.1 基础使用

1
2
3
const int bufSize = 512;    // 正确:定义并初始化
bufSize = 1024; // 错误:试图修改常量
const int i; // 错误:未初始化

const修饰的变量必须在声明时初始化(除非是extern或类成员的特殊情况)。

1.3.2 extern关键字:跨文件共享常量

默认情况下,const 对象仅在文件内有效。如果你在两个文件中定义了同名的 const 变量,它们被视为独立的。如果要在多个文件间共享同一个 const 常量,可以用extern关键字。

1
2
3
4
5
// 在头文件 .h 中
extern const int BUFFER_SIZE; // 只是声明,不初始化

// 在对应的 .cpp 文件中
const int BUFFER_SIZE = 4096; // 这里才真正定义 + 初始化

extern的语义是“在别处定义”,因此如果在声明处加了初始化(如extern const int x = 10;),大多数编译器会报错或警告。

1.3.3 const修饰类成员变量

1
2
3
4
5
6
7
8
9
class Widget {
public:
Widget(int v) : value(v) {} // 必须在初始化列表中初始化

// Widget() {} ← 如果这样写,会编译错误:const 成员未初始化

private:
const int value; // 声明,不初始化
};

const成员属于每个对象,它的值可以因对象不同而不同(不像static const那样是类的共享常量)。 所以它可以在构造时再进行初始化。

注意:const成员只能在声明处或者在初始化列表处进行初始化,不能在构造函数函数体内进行初始化。

原因const成员在对象构造完成后就成为“只读”状态,而构造函数函数体执行时,对象已经进入构造完成阶段,此时再“赋值”属于修改已构造完成的const成员,这是语言规则所禁止的。

1.3.4 const修饰类成员函数

1 const修饰类成员函数

const修饰成员函数,表示该函数内不做任何成员变量的修改(除了mutable成员)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Player {
public:
int GetLevel() const { // const 成员函数
// level_ = 999; // 错误!不能修改任何成员
return level_;
}

void SetLevel(int lv) { // 普通成员函数
level_ = lv;
}

private:
int level_ = 1;
};

本质上是对this指针进行了const修饰,也就是将其变成了一个指针常量(const T* this),所以this指针指向的内容不可修改。

2 multable

multable是一个成员变量修饰符,它的核心作用是:允许在const修饰的成员函数里修改成员变量。

为什么需要multable:区分对象的物理状态和逻辑状态。

const成员函数的语义是: “我承诺在这个函数里不会修改对象的任何状态” 。

但在实际开发中,有些成员变量的修改并不影响对象的“逻辑状态”,只是用于:

  • 统计访问次数

  • 缓存计算结果

  • 调试信息

  • 内部日志

  • 互斥锁(mutex)状态

  • lazy initialization(延迟初始化)

这些属于“物理状态”而非“逻辑状态”,修改它们不应该破坏const的语义。

这就是mutable存在的意义。

1.3.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.4 constexpr

constexpr的主要作用是让某些表达式/函数/变量能够在编译期被求值。

对比const

1
2
const int sz = get_size();       // 这一行代码的含义是:“我声明一个叫 sz 的整数,一旦它在程序运行起来拿到值后,就不许再改了。”本质上是运行时常量
constexpr int sz2 = get_size(); // 这一行代码的含义是:“我声明一个叫 sz2 的整数,而且我要求编译器在编译阶段就把它的结果算出来,直接填进程序里。”本质上是编译时常量

1.5 类型别名:typedef/using

1.5.1 typedef

1
typedef double wages;   // 表示wages为double的别名

1.5.2 using

1
using SI = Sales_item;  // 表示SI为Sales_item的别名

1.6 数据类型推断:auto/decltype

1.6.1 auto

需要注意:auto在推断数据类型时并不总是一比一还原。

忽略顶层const,保留底层 const 当初始值是一个const对象时,auto通常会忽略掉“顶层”的const特性。

1
2
3
4
5
const int ci = i;   // 表示任意一种类型是一个常量->顶层const
auto b = ci; // b 是 int,而不是 const int(顶层 const 被忽略)

const int *p = &ci; // 表示指针指向的值是一个常量->底层const
auto c = p; // c 是 const int*(底层 const 被保留,c 指向常量)

将引用转换为对象

1
2
3
4
int x = 42;
const int& ref = x;

auto a = ref; // a 是 int(去除了 const &)

1.6.2 decltype

在C++中,如果说auto是为了“让我省点事,你帮我推断类型并初始化”,那么decltype的存在则是为了“我只想知道这个表达式的类型,但我现在不想用它来初始化变量”。

1 基础使用
1
decltype(f()) sum; // sum 的类型就是函数 f 的返回值类型

编译器并不真正调用函数 f(),而是在编译阶段通过查看函数的声明来确定其返回类型。

1.6.3 decltype VS auto

decltype保留顶层const和引用:不同于 auto 会忽略顶层 const 并将引用转为对象,decltype 会完整地保留包括 const 和引用在内的所有限定符。

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
      6
      7

      ### 2.2 array

      C++11引入的模板类,是C++原生数组的现代包装。其设计目的是保留原生数组的高性能和栈分配优势,同时提供容器般的安全和便利性。

      ```c++
      std::array<int,5> arr = {5,4,3,2,1};

解决数组退化

array在函数传参时可以按值/引用传递,不会退化为指针,丢失大小信息(array类自带数量参数)。

如何按引用传递:

1
2
3
4
5
//值传递(拷贝一份数据)
void func(std::array<int, 10> arr) {}

//引用传递(不用拷贝一份数据,性能较好)
void func_mod(std::array<int, 10>& arr) {}

允许赋值操作

支持arr2=arr1;本质上是深拷贝。

3 类型转换

3.1 四种强制类型转换的方法对比

四种强制类型转换的方法对比:

  • static_cast做普通转换最安全
  • dynamic_cast做多态向下转型最可靠
  • const_cast只用来去const(且别改内容)
  • reinterpret_cast不到万不得已别碰

3.2 static_cast

1
2
3
4
5
6
7
8
9
double d = 3.14159;
int i = static_cast<int>(d); // 3(截断小数)

float f = 1.0f;
void* pv = static_cast<void*>(&f);
float* pf = static_cast<float*>(pv); // 相关类型指针转换

Base* pb = new Derived;
Derived* pd = static_cast<Derived*>(pb); // 向上/向下转型(不检查安全性)

3.3 dynamic_cast

用于将基类的指针或引用安全地转换为派生类的指针或引用。

1
2
3
4
5
6
7
8
9
Base* pb = new Derived;

if (Derived* pd = dynamic_cast<Derived*>(pb)) {
// 安全:pb 确实指向 Derived 或其派生类
pd->DerivedFunc();
}
else {
// pb 不是 Derived 类型
}

只能用于

  • 有虚函数的类(多态类)
  • 指针或引用类型

失败时

  • 指针返回 nullptr
  • 引用抛出 std::bad_cast 异常

3.4 const_cast

只在“确实不会修改内容但接口要求非const”的极端情况下使用。

1
2
3
4
5
void func(const std::string& s) {
// 某些老接口需要 char*
char* p = const_cast<char*>(s.c_str());
legacy_function(p); // 只读使用,不修改内容
}

3.5 reinterpret_cast

1
2
3
4
5
int i = 42;
int* pi = &i;
char* pc = reinterpret_cast<char*>(pi); // int* → char*

uintptr_t addr = reinterpret_cast<uintptr_t>(pi); // 指针 → 整数地址

典型用途(极少见但合法):

  • 底层内存操作、序列化/反序列化
  • 与硬件/驱动交互
  • 在 union 中不同成员间转换(C++20 前常用)

4 函数

4.1 函数调用过程

参考:视频

这里的栈指的是函数调用栈。

首先,栈在内存中是向下生长的(高地址->低地址),也就是说,栈底元素比栈顶元素地址大。

  • EBP (Base Pointer):基址指针,指向当前栈帧的底部,用于寻址

  • ESP (Stack Pointer):栈指针,始终指向栈顶,随数据入栈出栈而移动

步骤

  • 第一阶段:现场保护与参数传递
    • 首先,调用者会将参数按照调用约定(从右向左)压入栈中或放入寄存器,随后执行 CALL 指令。该指令会自动将返回地址压入栈顶,这是为了函数结束后能顺利返回
  • 第二阶段:栈帧建立
    • 进入函数后,会建立自己的栈帧。具体动作是:先将旧的 EBP(栈基址指针) 压栈备份,再将当前 ESP(栈顶指针) 赋值给 EBP 作为新基准,最后通过移动 ESP 为局部变量开辟空间。此时,EBP 就像一个锚点,向上找参数,向下找局部变量
  • 第三阶段:现场恢复与返回
    • 函数执行完毕后,会将返回值放入寄存器(如 EAX)。随后销毁栈帧:将 ESP 回退到 EBP 位置以释放局部变量,弹出旧 EBP 恢复调用者的环境,最后执行 RET 指令弹出返回地址,跳回主程序继续执行

部分相关问题

1.局部变量为什么在函数结束后失效?

因为ESP指针回退了,那块空间会在逻辑上被注销,会被后续函数覆盖。

2.如果局部变量太多会怎么样?

会导致ESP移动超出了栈边界,导致栈溢出。

3.内联函数是如何打破这个过程提升性能的?

函数调用大约需要10~20条汇编指令,调用本身有一定的开销。而函数内联直接将函数体的代码直接复制并替换到每一个调用该函数的地方,没有了函数调用的开销。

4.2 内联函数 inline

内联函数的主要目的是让编译器在调用函数的地方直接展开函数体,从而避免调用函数导致的开销(参数压榨、跳转、返回等),达到更高的运行时性能。

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
// 方式1:直接在定义处加 inline 关键字(最常见)
inline int add(int a, int b) {
return a + b;
}

// 方式2:类内定义的成员函数默认是内联的(即使没写 inline)
class Point {
public:
int x, y;

// 隐式内联
int getX() const { return x; }

// 显式内联(效果相同)
inline int getY() const { return y; }
};

// 方式3:声明和定义分离时,在定义处加 inline
// .h 文件
inline double square(double x);

// .cpp 文件 这里的inline可写可不写
inline double square(double x) {
return x * x;
}

但内联只是给编译器的建议,编译器可以决定采不采纳。

4.3 constexpr函数

constexpr的作用是让某些函数在编译期就能被完整求值(当实参都是编译期常量时),从而获得零运行时开销、更早的错误发现、更强的静态检查能力。

5 类:友元 friend

友元的核心作用是允许外部函数或类访问某个类的私有(private)和受保护(protected)成员。

形式 写法示例 谁获得了访问权限 常见使用场景
友元函数(非成员函数) friend void print(const Class& obj); 某个独立的全局/命名空间函数 输出运算符 <<、比较运算符、调试打印函数
友元类 friend class AnotherClass; 整个另一个类 紧密耦合的类对(如迭代器与容器)
友元成员函数 friend void OtherClass::func(const Class&); 另一个类的某个特定成员函数 两个类之间有限制的、定向的访问需求

6 动态内存

6.1 智能指针

解决动态内存使用困难的问题(使用不当容易导致内存泄漏、悬挂指针等等问题)。相比于常规指针,智能指针重要的区别在于它负责自动释放所指向的对象。

6.1.1 shared_ptr

1 描述

shared_ptr允许多个指针指向同一个对象。

物理结构:双指针模型

当你定义一个 shared_ptr<T> p 时,这个 p 在栈上通常占用 16 字节(64位系统),内部包含两个裸指针:

  • Ptr:指向堆内存中的对象实体
  • Control Block Ptr:指向一个名为控制块(Control Block)的堆空间
2 make_shared

make_shared可以用于安全地分配动态内存。

原因:对于普通初始化shared_ptr<int> p(new int(42)),会执行两次堆分配。

  • 执行new int
  • 构造shared_ptr

而如果使用make_shared进行初始化,仅进行一次堆分配。

1
shared_ptr<int> p3 = make_shared<int>(42);
3 shared_ptr的引用计数

每个shared_ptr都有一个关联的计数器(存在控制块中),会记录有多少个shared_ptr指向同一个对象。当计数归零时,自动释放内存。

6.1.2 unique_ptr

同一时刻只能有一个unique_ptr指向给定对象。

6.1.3 weak_ptr

为了解决shared_ptr的循环引用问题,它指向由 shared_ptr 管理的对象,但不会增加引用计数。

6.2 new/delete VS malloc/free

可参考博客

对比维度 new malloc 谁更推荐(C++)
所属语言 C++(C 中没有 new) C 和 C++ 都可用
返回类型 返回具体类型的指针(如 int*string* 返回 void*(需要强制类型转换) new 更安全
内存分配失败时 默认抛出 std::bad_alloc 异常 返回 nullptr 取决于风格
调用构造函数 会调用类的构造函数 不会调用构造函数 new 更符合面向对象
调用析构函数 delete 会调用析构函数 free 不会调用析构函数 new 更安全
分配方式 运算符(可以被重载) 函数(不能重载) new 更灵活
分配单个对象 new Tnew 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
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
#include <iostream>
#include <string>
using namespace std;

class Student {
public:
Student() { cout << "Student 构造函数\n"; }
~Student() { cout << "Student 析构函数\n"; }
string name = "默认名字";
};

int main()
{
// ---------------------- new 的用法 ----------------------
Student* s1 = new Student; // 调用构造函数
Student* s2 = new Student(); // 同上
Student* s3 = new Student{"小明"}; // C++11 列表初始化(如果有对应构造函数)

cout << s1->name << endl;

delete s1; // 会调用析构函数
delete s2;
delete s3;

// ---------------------- malloc 的用法 ----------------------
Student* s4 = (Student*)malloc(sizeof(Student)); // 不会调用构造函数!
// s4->name 此时是未定义的随机值

// 如果要初始化,只能手动调用 placement new
new (s4) Student; // 显式调用构造函数(placement new)

cout << s4->name << endl;

s4->~Student(); // 必须手动调用析构函数
free(s4); // 释放内存

// ---------------------- 数组对比 ----------------------
int* arr1 = new int[10](); // 值初始化为 0
int* arr2 = (int*)malloc(10 * sizeof(int)); // 值未定义

delete[] arr1;
free(arr2);

return 0;
}

6.3 扩展:内存对齐

内存对齐(Memory Alignment)简单来说就是:要求数据的起始地址必须是其自身大小的整数倍。

6.3.1 为什么需要内存对齐

这主要受限于 CPU 读取内存的方式

  • 物理事实: CPU 并不是一个字节一个字节读内存的,而是以 “块”(通常是 4 或 8 字节) 为单位
  • 对齐的场景: 比如 64 位 CPU 读一个 8 字节的 double。如果它对齐在地址 0x08,CPU 只需要 1 次 存取指令就能拿走
  • 未对齐的场景: 如果这个 double 跨在了地址 0x0C(一半在第一个 8 字节块,一半在第二个块),CPU 必须读 2 次 内存,还要进行复杂的位移和拼接
  • 结论: 对齐是为了压榨 CPU 的访存性能,甚至在某些硬件(如 ARM)上,访问未对齐内存会直接导致程序崩溃(硬件异常)

6.3.2 示例

1
2
3
4
5
6
7
struct Example {
char a; // 1 字节
// [Padding: 3 字节] -> 为了让下一个 int 站在 4 的倍数地址上
int b; // 4 字节
char c; // 1 字节
// [Padding: 3 字节] -> 为了让整个结构体大小是 4 的倍数(因为最大成员是 int)
};

性能优化:手动重排

通过把大的成员放在前面,我们能有效减少 Padding 的产生。

1
2
3
4
5
6
struct Optimized {
int b; // 4 字节
char a; // 1 字节
char c; // 1 字节
// [Padding: 2 字节] -> 补齐到 4 的倍数
};

6.4 内存分区

7 左值、右值和右值引用

7.1 概念

简单来说,左值是指有持久身份、可以取地址的对象,右值是指临时的、即将销毁的值。

可以从以下三个方向进行区分:

  • 看能不能取地址
    • 左值有明确的内存地址,可以用取地址符&取地址
    • 右值一般无法取地址(纯右值不能,将亡值可以)
      • 纯右值:传统右值,如42、"Hello"、临时对象
      • 将亡值:可以取地址,但马上就要被销毁的对象,比如std::move(x)的结果
  • 看生命周期
    • 左值通常是在作用域内持续存在的变量
    • 右值通常是表达式产生的中间结果或字面量,它们在当前语句执行完后就销毁了
  • 看赋值位置
    • 左值既可以出现在赋值符号的左边,也可以出现在右边
    • 右值只能出现在右边

7.2 右值引用 &&

7.2.1 概念

让程序员能够显式表达:”我要转移资源的所有权,而不是拷贝它“这种意图。

1
2
3
int&& rref = 10;            // 正确,绑定到纯右值(字面量)
int x = 20;
int&& rref2 = std::move(x); // 正确,绑定到将亡值
1
2
3
4
5
6
void swap(T& a, T& b) noexcept
{
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}

右值引用本身不产生任何运行时代码,它只是一个编译期类型标记。

真正产生效率提升的是:程序员(或标准库作者)根据这个标记,写了不同的实现路径(move 路径 vs copy 路径)。

1
2
3
4
5
// 这行代码本身不移动任何东西
std::move(x); // 只是把 x 的类型从 T& 变成了 T&&(xvalue)

// 真正移动发生在下面这行
vector<string> v = std::move(big_vector); // 调用移动构造

7.2.2 两大核心用途

1 实现移动语义

移动操作必须声明为noexcept,否则很多容器不会用移动而用拷贝。

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
class BigData {
std::string* data;
size_t len;

public:
// 移动构造
BigData(BigData&& other) noexcept: data(other.data), len(other.len)
{
other.data = nullptr; // 偷走资源
other.len = 0;
}

// 移动赋值
BigData& operator=(BigData&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
len = other.len;
other.data = nullptr;
other.len = 0;
}
return *this;
}

~BigData() { delete[] data; }
};

应用:高效swap。

1
2
3
4
5
6
7
8
9
10
11
void swapBigData(BigData& a, BigData& b) noexcept {
BigData temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}

int main() {
BigData d1(100000);
BigData d2(200000);
swapBigData(d1, d2);
}
2 实现转发引用(模板里的T&&

即让一个函数模板可以同时高效处理左值和右值。

模板中写T&&时,它其实是转发引用(forwarding reference),也叫万能引用。

1
2
3
4
5
template<typename... Args>
void emplace_back(Args&&... args) {
// 内部实现
new (ptr) T(std::forward<Args>(args)...);
}
  • 当你传左值进去 → std::forward 会保持为左值(避免不必要的拷贝)
  • 当你传右值进去 → std::forward 会保持为右值(触发移动或原地构造)

怎么判断是不是转发引用?

  • 它是一个函数模板
  • 类型参数写为T&&
  • T是通过模板类型推导而来的,而没有被显式指定类型

8 虚函数、纯虚函数和抽象类

8.1 虚函数

可参考博客

8.1.1 基础使用

在基类中用 virtual 关键字声明的成员函数,允许派生类重写(override)它,并在通过基类指针或引用调用时,表现出运行时多态(动态绑定)。

  • 动态绑定(运行时决议) 通过基类指针/引用调用虚函数时,实际调用的是真正对象的版本(而非指针/引用类型的版本)
  • 必须通过指针或引用调用才会触发多态,直接用对象调用不会多态
  • 一旦声明为 virtual,后续派生类中同名同参同返回的函数自动成为虚函数(即使不写 virtual)
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
#include <iostream>

class Base {
public:
virtual void who() const {
std::cout << "我是 Base\n";
}
};

class Derived : public Base {
public:
void who() const override {
std::cout << "我是 Derived\n";
}
};

int main() {
// 情况1:通过指针(多态生效)
Base* ptr = new Derived();
ptr->who(); // 输出:我是 Derived

// 情况2:通过引用(多态生效)
Base& ref = *ptr;
ref.who(); // 输出:我是 Derived

// 情况3:直接用对象(不发生多态)
Derived d;
d.who(); // 输出:我是 Derived (这里看起来正常)

Base b;
b.who(); // 输出:我是 Base

// 最关键的情况:把派生类对象“当作”基类对象来用
Base b2 = d; // ← 对象切片(object slicing)!
b2.who(); // 输出:我是 Base (!!!)

delete ptr;
}

对象切片指的是在赋值Base b = d;时,发生了对象切片(object slicing):只拷贝了Base部分的成员,Derived特有的部分被直接丢弃了。

8.1.2 虚函数表

运行时多态的一种表现。

  • 每个有虚函数的类会有一个虚函数表(vtable),里面存的是该类的虚函数地址
  • 每个对象有一个隐藏的虚表指针(vptr),指向自己类的虚函数表
  • 调用虚函数时:对象->vptr -> vtable[索引] -> 真正函数地址

8.2 纯虚函数

8.2.1 基础使用

1
virtual 返回类型 函数名(参数列表) = 0;   // =0 表示纯虚

8.3 虚函数 VS 纯虚函数

特性 普通虚函数(virtual) 纯虚函数(virtual ... = 0)
是否必须在基类实现 可以不实现(但通常会实现默认) 必须声明,不能有实现(除特殊情况)
派生类是否必须重写 不必须(可以用基类版本) 必须重写,否则派生类也是抽象类
类是否能实例化 可以 不能(抽象类)
典型用途 提供默认行为,可被选择性覆盖 定义接口,强制子类实现

8.4 抽象类

至少有一个纯虚函数的类,叫抽象基类。

不能实例化抽象基类的对象。

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
class GameObject {
public:
virtual ~GameObject() = default; // 虚析构(必须!)

// 纯虚函数:子类必须实现
virtual void Update(float deltaTime) = 0;
virtual void Render() const = 0;

// 可以有非纯虚函数(提供默认实现)
virtual void OnCollision(GameObject* other) {
std::cout << "Default collision handling\n";
}

protected:
Vector2 position_;
bool active_ = true;
};

class Player : public GameObject {
public:
void Update(float deltaTime) override {
// 移动逻辑
position_.x += velocity_.x * deltaTime;
}

void Render() const override {
// 画玩家模型
}
};

class Enemy : public GameObject {
// 必须实现 Update 和 Render
};

8.5 虚析构函数

只要类中有一个虚函数,就应该把析构函数声明为虚函数(或 =default)。

1
2
3
Animal* ptr = new Dog();
delete ptr; // 如果 ~Animal 不是虚函数,只会调用 Animal 的析构函数
// Dog 的析构函数不会被调用 → 资源泄漏

正确写法:

1
virtual ~Animal() = default;   // 或写 {} 也行

9 虚继承

虚继承主要是为了解决菱形继承的问题。

注意:在C#中不存在菱形继承问题,因为C#不允许继承多个类,类似于菱形继承的菱形实现接口情况,因为接口只提供定义,即使有多条路径到达同一个接口方法,也只需要实现一次(没有二义性问题)。

9.1 菱形继承

场景描述:

  • A 有一个成员变量 int data;
  • B 和类 C 都继承自 A
  • D 同时继承自 BC

如果不使用虚继承:

  • D 对象的内存布局里会包含两份 A。一份来自 B,一份来自 C
  • 物理后果:当你访问 D.data 时,编译器会报错,因为它不知道你是想找 B 里的 data 还是 C 里的 data
  • 资源浪费:如果 A 是一个占用内存很大的类,D 就会平白无故浪费一倍的空间

使用虚继承后:

  • 编译器保证在整个继承体系中,公共基类 A 只会存在一个实例。无论有多少条路径继承它,最终都指向同一个内存地址
  • 注意:虚基类A的构造函数调用时机虽然在所有中间类的构造函数之前,但真正决定调用哪个构造函数、重载版本、传入什么参数的,是最底层的最终派生类D

9.2 虚继承的代价

为了实现“共享基类”,编译器引入了更复杂的底层机制:

  • 虚基类指针(vbptr):类 BC 内部会多出一个隐藏指针,指向各自的“虚基类表(vbtable)”
    • 虚基类表,记录了从当前类的 vbptr 位置(或 this 调整后的地址点)到各个虚基类子对象起始地址的偏移量(offset to virtual base)
  • 虚继承需要在运行时定位虚基类:从当前对象起始地址到虚基类对象的偏移量,在编译期无法确定为一个固定的常量(受到各种因素影响,比如B、C继承顺序导致的内存对齐不同以致于偏移量不同等等)
  • 性能损耗:访问虚基类的成员比普通继承要慢,因为多了一层指针跳转

9.3 示例

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
#include <iostream>
using namespace std;

class A {
public:
int value = 888;
A() { cout << "A 构造\n"; }
~A() { cout << "A 析构\n"; }
};

class B : virtual public A { // 虚继承 A
public:
B() { cout << "B 构造\n"; }
~B() { cout << "B 析构\n"; }
};

class C : virtual public A { // 虚继承 A
public:
C() { cout << "C 构造\n"; }
~C() { cout << "C 析构\n"; }
};

class D : public B, public C {
public:
void print() {
// 现在只有一份 A::value
cout << "value = " << value << endl; // 直接访问,没二义性
cout << "this->value = " << this->value << endl;
cout << "B::value = " << B::value << endl; // 也可以这样写
}
};

int main() {
cout << "=== 构造开始 ===\n";
D d;
cout << "\n=== 访问成员 ===\n";
d.print();
cout << "\n=== 析构开始 ===\n";
return 0;
}

10 继承&复合&委托关系下的构造和析构顺序

10.1 继承关系(is-a)

1
2
class Base { ... };
class Derived : public Base { ... };

顺序

  • 构造顺序
    • 先构造基类,再构造派生类
  • 析构顺序
    • 先析构派生类,再析构基类

10.2 复合/组合关系(has-a)

1
2
3
4
5
6
7
class Engine { ... };

class Car {
Engine engine; // 值成员
Wheel wheels[4];
std::string name;
};

顺序

  • 构造顺序
    • 按类中声明的顺序从上到下构造成员,最后执行Car的构造函数
  • 析构顺序
    • 先析构Car,然后按类中声明的逆序从下到上析构成员

10.3 委托关系

1
2
3
4
5
6
7
8
9
10
// 写法1:裸指针(不推荐)
class Car {
Engine* engine; // 拥有关系,但生命周期不严格绑定
};

// 写法2:智能指针(现代推荐)
class Car {
std::unique_ptr<Engine> engine;
std::shared_ptr<WheelManager> wheels;
};

顺序

  • 构造顺序
    • Car 的值成员(按声明顺序)
    • Car 的构造函数体(通常在这里 new / make_unique)
  • 析构顺序
    • Car 的析构函数体(通常在这里 delete / reset)
    • Car 的值成员(逆序)

面试题

1.static关键字修饰函数中一个临时变量时,它的生命周期是什么样的

  • 编译期间分配空间
    • 在程序编译期间,编译器会在内存的静态存储区为其分配空间
  • 运行时初始化
    • 当程序第一次执行到该变量的定义语句时,对其进行初始化
    • 系统会维护一个初始化标志位,会在这个时候进行检查
  • 程序结束时销毁
    • 静态局部变量的销毁顺序与它们的初始化顺序严格相反(类似栈,先初始化的后销毁)
1
2
3
4
5
6
7
8
9
10
11
void test() {
static int count = 0; // 只有第一次运行到这里会初始化为 0
count++;
std::cout << count << " ";
}

int main() {
test(); // 输出 1
test(); // 输出 2
test(); // 输出 3
}

count 并不在 test 函数的栈帧里。当你调用 test 时,它只是去静态区访问那个名为 count 的固定内存地址。

比较有名的用法:单例模式

2.float占用几个字节?64位中呢?float类型数据在while循环中一直加一,会溢出吗?

2.1 float内存占用

float无论是32位系统还是64位系统,固定占用4个字节,double则固定占用8字节。

2.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}\)

因此直接被忽略。

3.static修饰全局变量的意义?

static 全局变量是实现文件级封装的重要手段之一,它让一个全局变量不可被其他文件访问。

修饰方式 链接属性 能否被其他 .c/.cpp 文件访问 典型使用场景
普通全局变量 外部链接 可以(其他文件 extern 声明后使用) 需要跨文件共享的变量
static 全局变量 内部链接 不可以(仅当前 .c/.cpp 文件可见) 模块内部私有变量、防止命名冲突

与生命周期的关系:

修饰符 存储位置 生命周期 初始化时机 作用域
普通全局 数据段 整个程序运行期间 程序启动前 整个项目(外部链接)
static 全局 数据段 整个程序运行期间 程序启动前 仅当前文件
普通局部变量 函数调用期间 进入函数时 函数内
static 局部变量 数据段 整个程序运行期间 第一次进入函数时 函数内

C++ STL和泛型编程

可参考博客:【STL】概述及总结

1 模板

1.1 模板的作用

  • 代码复用:对于不同数据类型但逻辑相同的操作,我们不需要编写多份代码
  • 泛型编程的基础,C++ STL是基于模板实现的
  • 实现静态多态:这是模板与“面向对象(虚函数)”最大的区别
    • 静态多态(例如模板):编译阶段,编译器就已经把 T 替换成了具体的类型(如 int),并生成了特定的机器码
      • 作用: 这意味着模板代码的运行速度与你手工编写的特定类型代码完全一样快。编译器甚至可以对生成的代码进行针对性的优化(如内联优化)
    • 动态多态(例如虚函数): 在程序运行时,通过查找虚函数表来决定调用哪个函数。这会有微小的性能损耗

1.2 函数模板

1.2.1 基础使用

1
2
3
4
5
6
template <typename T>
int compare(const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}

其中,template <typename T>也可以写为template <class T>,二者等同。

class容易导致误导,误以为只能传递class类型,其实struct类型也可以进行传递,所以尽量优先使用

1.2.2 核心机制:自动类型推导

当你调用函数模板时,通常不需要手动指定类型,编译器会根据你传入的实参自动推断 T

1
2
compare(1, 0);       // 推断 T 为 int
compare(3.14, 1.0); // 推断 T 为 double

1.2.3 非类型模板参数

除了类型 T,模板还可以接受具体的值。这在定义固定大小的数组操作时非常有用:

1
2
3
4
5
6
7
8
template<unsigned N, unsigned M>
int compare(const char (&p1)[N], const char (&p2)[M]) {
return strcmp(p1, p2);
}
// 调用:compare("hi", "mom");
// 编译器实例化出:int compare(const char (&p1)[3], const char (&p2)[4])
// 问:为什么这里是数组大小是3和4而不是2和3呢?
// 答:编译器会在一个字符串字面量的末尾插入一个空字符('\0')作为终结符

限制:非类型参数必须是常量表达式。

一个经典的应用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
2
3
4
5
6
7
8
9
template <typename T>
class Stack {
private:
std::vector<T> elems; // 使用模板参数 T 作为容器的元素类型
public:
void push(const T& elem); // 入栈
void pop(); // 出栈
T top() const; // 获取栈顶
};

1.3.2 类模板的成员函数

类模板的成员函数既可以定义在类内,也可以定义在类外:

  • 类内定义: 隐式视为内联函数(inline),语法较简洁
  • 类外定义: 必须以关键字 template 开始,后跟类模板参数列表,并且类名必须包含模板参数
1
2
3
4
template <typename T>
void Stack<T>::push(const T& elem) {
elems.push_back(elem);
}

成员函数的延迟实例化

这是类模板一个非常客观的特性:一个类模板的成员函数只有当程序用到它时才进行实例化。

  • 好处: 如果某个类型 T 不支持模板中的某个成员函数所要求的操作(例如 T 没有默认构造函数),只要你不调用那个特定的成员函数,程序依然可以编译通过并正常运行其他功能

1.4 成员模板

成员模板即在一个类(普通类或模板类)内部定义的函数模板。它不能为虚函数

1
2
3
4
5
6
7
template <typename T>
class Blob {
public:
// 构造函数本身是一个模板
template <typename It>
Blob(It b, It e);
};

如果在类外定义这个成员模板:

1
2
3
4
5
template <typename T>   // 类的参数
template <typename It> // 函数的参数
Blob<T>::Blob(It b, It e) {
// 实现逻辑
}

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
2
3
4
5
6
7
8
// 1. 原有的通用模板
template <typename T> int compare(const T&, const T&);

// 2. 特例化版本
template <> // 告诉编译器:这是一个特化版本,不再需要新的类型参数
int compare(const char* const &p1, const char* const &p2) {
return strcmp(p1, p2); // 使用专门的字符串比较函数
}

但如果想给函数模板搞特殊化,优先使用函数重载而不是模板特化。

2 函数模板没有偏特化

通过函数重载实现

原因:因为函数支持重载,偏特化完全可以通过函数重载来实现。

1
2
3
4
5
6
7
8
// 基础模板
template <typename T>
void f(T a) { ... }

// 你想要的“偏特化”(处理指针)
// 实际上通过重载就能实现:
template <typename T>
void f(T* a) { ... }

通过if constexpr实现

if constexpr:编译时分支,后面只能带常量表达式。

1
2
3
4
5
6
7
8
template <typename T>
void f(T t) {
if constexpr (std::is_pointer_v<T>) {
// 处理指针逻辑
} else {
// 通用逻辑
}
}

1.6.2 类模板特例化

1 全特化
1
2
3
4
5
template <typename T> 
class Box { /* 通用逻辑 */ };

template <>
class Box<bool> { /* 专门为 bool 优化的逻辑,比如用位存储 */ };
2 偏特化
1
2
3
4
5
6
7
8
9
10
template <typename T, typename U> 
class MultiBox { ... };

// 偏特化 1:当第二个参数是 int 时
template <typename T>
class MultiBox<T, int> { ... };

// 偏特化 2:当参数是指针类型时(非常常用)
template <typename T>
class MultiBox<T*, T*> { ... };

2 STL六大组件

2.1 容器(Container)

用于存储数据的类模板,如vectordequelist

2.2 算法(Algorithms)

用于执行操作的函数模板,如std::sortstd::findstd::for_each

2.3 迭代器(Iterators)

2.3.1 概念

是一种行为类似指针的类模板。它通过重载 operator*operator->operator++ 等运算符,提供了一套统一的接口,使得算法(如 sort)不需要知道容器(如 listvector)的底层结构(是链表还是数组),就能遍历其中的元素。

迭代器类型 支持的操作 代表容器
输入迭代器 (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
3
4
5
6
7
8
9
10
11
12
13
// 错误写法
for(auto it = vec.begin();it != vec.end(); ++it){
if (*it == target) vec.erase(it); // 崩溃!it 已经失效,++it 会报错
}

// 正确写法:利用erase的返回值——erase会返回指向被删除元素的下一个位置的有效迭代器
for (auto it = vec.begin(); it != vec.end(); ) {
if (*it == target) {
it = vec.erase(it); // 更新迭代器
} else {
++it;
}
}

2.4 仿函数(Functors/Function Objects)

2.4.1 概念

如果在一个类或者一个结构体里重载了operator()方法,这个类或者结构体的对象就可以像函数一样被调用。

1
2
3
4
5
6
7
8
9
struct Adder {
int operator()(int a, int b) const {
return a + b;
}
};

// 使用方法
Adder addObj;
int sum = addObj(10, 20); // 这里的 addObj 是一个对象,但写起来像函数调用

2.4.2 为什么使用仿函数(区别于普通函数)

  • 状态存储
    • 普通函数如果要记录被调用了多少次,必须使用全局变量或 static 变量。仿函数可以把状态存在自己的成员变量里
  • 性能优势——可以内联
    • 函数指针的局限性: 函数指针存储的是一个内存地址。由于指针指向的具体函数在运行时可能会改变,编译器在处理 std::sort(vec.begin(), vec.end(), pFunc) 时,很难在编译阶段断定 pFunc 到底指向哪个代码块。因此,编译器通常无法进行内联优化,有函数调用开销
    • 仿函数是唯一的类类型,因此编译器可以直接内联,省去了函数调用的开销

2.5 配接器(Adapters)

2.5.1 概念

不从零开始实现新功能,而是通过改装现有的组件,使其接口符合特定的需求。

  • 容器配接器: 如 stack, queue, priority_queuestackqueue底层默认使用 dequepriority_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 等待数据从内存中加载
  • 为什么不用像stackqueue一样担心扩容代价?
    • 堆的操作(push/pop)复杂度是 \(O(\log n)\)。虽然 vector 偶尔会有 \(O(n)\) 的扩容开销,但它的常数项效率(随机访问和 Cache 命中)在绝大多数时间内远优于 deque,所以权衡之下选择使用vector

2.6 分配器(Allocators)

分配器是 STL 容器用于管理内存的类模板。它将容器的逻辑行为(如 vectorpush_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_map
    • std::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 - _Myfirst
    • capacity() = _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...
  • 结论: 在第 3 次扩容时,新申请的 54 字节小于之前释放的总和 76 字节
  • 后果: 内存分配器有机会把之前 vector 自己用过并释放的内存重新拼凑起来给它用。这大大提高了内存的利用率,并减少了系统向 OS 申请新页(Page)的频率

但为什么有时候还是用2倍扩容:

  • 2倍扩容下,扩容频率更低
  • 2倍计算简单,在底层上更快

4.3 优势和缺陷

优势

  • 支持随机访问,\(O(1)\)
    • 由于内存是连续的,访问 v[i] 只需要 起始地址 + i * 元素大小。这是物理级别的偏移计算,没有任何多余的逻辑跳转
  • CPU缓存友好性
    • 由于vector是连续内存存储,因此对CPU缓存非常友好
  • 内存开销低
    • 除了存储元素本身,vector 只多占用了三个指针的空间
  • 尾部操作高效,\(O(1)\)

缺陷

  • 中间插入/删除代价高昂 ,\(O(n)\)
  • 扩容时的瞬间抖动
    • 一旦触发扩容(1.5或2倍),就会发生“申请新内存 -> 拷贝全部旧数据 -> 释放旧内存”的重型操作。这在实时性要求极高的游戏帧逻辑中可能导致瞬间的掉帧
  • 内存浪费
    • vectorcapacity 通常大于 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
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
#include <iostream>
#include <vector>
#include <string>

class Item {
public:
std::string name;
int value;

// 构造函数
Item(std::string n, int v) : name(n), value(v) {
std::cout << " [构造] 调用了构造函数\n";
}

// 移动构造函数
Item(Item&& other) noexcept : name(std::move(other.name)), value(other.value) {
std::cout << " [移动] 调用了移动构造函数\n";
}

// 析构函数
~Item() {
std::cout << " [析构] 调用了析构函数\n";
}
};

int main() {
std::vector<Item> v;
v.reserve(10); // 预留空间,避免扩容干扰我们观察

std::cout << "--- 使用 push_back ---\n";
// 必须先创建一个临时对象,再移动进去
v.push_back(Item("Sword", 100));

std::cout << "\n--- 使用 emplace_back ---\n";
// 直接传入零件,原地构造
v.emplace_back("Shield", 50);

std::cout << "\n--- 结束程序 ---\n";
return 0;
}

placement new

普通的 new 操作符其实做了两件事:1. 申请内存;2. 调用构造函数。 而placement new是一道“半自动”指令:它不申请内存,只是在你已经提供好的内存地址上,强行触发构造函数。

1
2
3
4
5
6
7
#include <new>

// 指向一块已经存在的、足够大的内存
void* buf = malloc(sizeof(Item));

// 在 buf 指向的地址上,原地构造一个 Item 对象
Item* p = new (buf) Item("Sword", 100);

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)

5 list

5.1 底层内存模型

std::list 是一个 双向链表 (Doubly Linked List)

  • 物理分布: 每个元素(节点)在内存中是完全离散的,由分配器单独申请
  • 节点组成: 每个节点通常包含三部分:
    • Data:实际存储的数据
    • Prev 指针:指向前一个节点
    • Next 指针:指向后一个节点

5.2 优势和劣势

优势

  • 增删效率高,\(O(1)\)
  • 最稳定的迭代器
    • 除非你删除了某个迭代器指向的元素,否则该迭代器、指针或引用在 list 的整个生命周期内永远有效
    • 即使 list 进行了成千上万次插入,原有的元素地址绝对不会改变

劣势

  • 无法通过下标访问,必须从头开始遍历
  • CPU缓存非常不友好
    • 节点四散分布,缓存不命中概率很高
  • 内存开销很大
    • 即使你存一个 4 字节的 int,你也要额外付出 16 字节(两个指针)的代价
  • 海量小对象问题
    • 频繁申请离散的小块内存会产生严重的内存碎片

6 deque

6.1 底层内存模型

deque 的物理结构由两级构成:

  • 中控器 (Map): 一个连续的指针数组,每个指针指向一块具体的“缓冲区”
  • 缓冲区 (Buffer/Node): 存放实际元素的连续内存块,通常大小固定(如 512 字节)

6.2 扩容机制

  • 中控器扩容
    • 通常遵循两倍扩容策略
    • 过程
      • 申请一个两倍大的新指针数组
      • 将旧的指针(即指向各个缓冲区的地址)拷贝到新数组的中间位置
        • 为什么中间?
        • 因为 deque 是双端队列,两头都要留出余地,方便后续在头部或尾部继续快速挂载新的缓冲区
      • 释放旧的指针数组
  • 缓冲区扩容
    • 当你在 push_backpush_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更高频
  • 内存浪费
    • 即使只存几个元素,deque 也会申请一个完整的缓冲区

面试题

1.mapunordered_map的区别,unordered_map的扩容和删除

mapunordered_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)\)