最近在找实习,但我也不想对于知识的理解仅限于八股,我希望能够真正理解而不是死记硬背,这一篇会偏底层,一些简单的语法和基础会略过。
c++面试八股看了一些主要考点:多态、虚函数实现、指针、内存模型、STL
其次是我个人感兴趣的:函数指针、链接
参考:zhuanlan.zhihu.com、超全 C/C++ 技术面试八股文面试题!(2025 年更新)
还在更新中。
语法
左值右值
左值 (Lvalue):指的是有名字、有固定地址的对象。你可以通过地址操作符 & 取到它的地址。它通常是持久的。
例如:变量名、返回左值引用的函数调用、赋值表达式。
右值 (Rvalue):指的是临时、没有名字、不可寻址的值。它们通常在表达式结束后就消失了。
例如:字面量(
42)、算术表达式(a + b)、返回临时对象的函数。
也有右值引用,与移动构造和完美转发有关,移动构造见下文
模板
类型转换
static_cast:最常用的转换
static_cast 用于良性、相关类型之间的转换,这些转换在编译时就能确定。
使用场景:
基本数据类型转换(如
int转double)。类层次结构中,将子类指针/引用转换为基类(上行转换,绝对安全)。
将
void*指针转回原始类型的指针。调用类定义的转换构造函数或转换操作符。
注意事项:
它不进行运行时检查。如果将基类指针转为子类指针(下行转换),即使类型不匹配,编译器也不会报错,这可能导致运行时崩溃。
dynamic_cast:安全的类层次转换
dynamic_cast 专门用于处理多态体系下的指针或引用转换。它会在运行时检查转换是否合法。
使用场景:
下行转换:安全地将基类指针/引用转换为子类。
注意事项:
前提条件:基类必须至少有一个虚函数(通常是析构函数)。
返回值:转换失败时,如果是指针则返回
nullptr;如果是引用则抛出std::bad_cast异常。由于需要运行时类型信息(RTTI),它的开销比
static_cast大。
const_cast:移除或添加 const
这是唯一能改变表达式“常量属性”的转换符。
使用场景:
通常用于调用一个参数定义为非
const,但实际上并不会修改内容的旧代码接口。
注意事项:
严禁修改本质为 const 的变量:如果一个变量最初被声明为
const,你用const_cast去掉常量性并修改它,这是未定义行为(Undefined Behavior),可能会引发程序崩溃或逻辑错误。
reinterpret_cast:底层的重新解释
它是最危险的转换,本质上是告诉编译器:“别管逻辑,直接把这段内存里的位(bits)看作另一种类型”。
使用场景:
将指针转换为整数(例如
uintptr_t)。在两个完全不相关的指针类型之间转换(如
char*转StructA*)。常用于底层驱动、位操作或序列化数据。
注意事项:
不可移植:不同平台的指针长度和内存布局可能不同。
编译器不保证转换后的结果有任何逻辑意义,程序员必须自负盈亏。
多态和虚函数的实现
什么是多态
同一个函数名的多种状态。多态分为编译时多态和运行时多态,编译时多态通过重载和模板,运行时多态通过继承和虚函数
虚函数表和虚函数表指针
即vptr和vtable
当一个类声明了一个虚函数时,编译器会为这个类创建一个静态的函数指针数组,每一项都指向该类对应的虚函数实现。
当一个对象被创建时,会在其内存布局中添加一个指向该类的虚拟函数表的指针。当调用虚函数时,实际上是通过该指针找到对应的虚拟函数表,并根据函数的索引调用相应的虚函数。
vtable本身通常存储在可执行文件的只读数据段(.rdata),vptr在对象中,通常是对象起始位置。
继承时的行为
如果子类 Derived 继承自 Base 但没有重写任何函数:
编译器会为
Derived创建一个全新的vtable。将
Base的vtable内容完整拷贝一份到Derived的vtable中。此时,子类对象的
vptr指向的是子类的vtable,但表里的地址依然指向父类的函数实现。
如果子类重写了父类的某个虚函数(例如 func1):
编译器会在子类的
vtable中,将原本指向父类Base::func1的指针替换为指向子类Derived::func1的地址。这就是多态的本质:通过基类指针调用函数时,程序通过
vptr找到vtable,由于表中的地址已经被替换,最终执行的是子类的代码。
如果子类定义了父类没有的虚函数:
这些新函数的地址会被追加到子类
vtable的末尾。
多重继承情况下的布局
多重继承情况下,一个对象拥有多个vptr
class Base1 {
public:
virtual void func1() { /*...*/ }
int data1;
};
class Base2 {
public:
virtual void func2() { /*...*/ }
int data2;
};
class Derived : public Base1, public Base2 {
public:
void func1() override { /*...*/ } // 重写 Base1
void func2() override { /*...*/ } // 重写 Base2
virtual void func3() { /*...*/ } // Derived 新增
int data3;
};在这个例子中,Derived 对象在内存中的布局通常如下:
第一部分(Base1 子对象):包含指向
Base1虚表的vptr1和Base1的成员变量。第二部分(Base2 子对象):包含指向
Base2虚表的vptr2和Base2的成员变量。第三部分(Derived 成员):
Derived类自己新增的成员变量。
当你执行
Base1* p1 = new Derived();时,p1指向对象的起始地址。当你执行
Base2* p2 = new Derived();时,编译器必须将p2的地址向前移动(Offset),使其指向Derived对象内部Base2子对象的起始位置。
如果 Base2 没有自己的 vptr,那么 p2 就无法在不知道 Derived 具体结构的情况下找到属于 Base2 的虚函数。
在多重继承中,Derived 会为每个基类准备一份虚表(或一份大虚表的不同部分)。
主要虚表(Primary vtable):关联第一个基类(
Base1)。Derived新增的虚函数(如func3)通常会挂在第一个基类的虚表末尾。次要虚表(Secondary vtable):关联后续基类(
Base2)。
多继承下this指针指向哪里?Thunk技术
前面提到,一个derived对象有多个Base基类,那么在这个对象里面其实有多个子对象的内存段,每一个内存段的开头都有各自的vptr。
假设 Derived 对象 d 内存地址从 0x1000 开始:
Base1 部分:占据
0x1000 - 0x100F。Base2 部分:占据
0x1010 - 0x101F(它有一个偏移量 Offset)。
如果你通过 d.f2_non_virtual() 调用 Base2 的非虚函数: 编译器发现 f2_non_virtual 属于 Base2,它会自动将 &d 增加一个偏移量(+16字节),把 0x1010 作为 this 指针传给函数。这样 Base2 的代码才能正确访问自己的成员变量 b。
但是如果是通过一个Base2*的指针指向derived对象,然后调用Base2虚函数,且这个虚函数里面使用了Base1的成员变量呢?
假设 Derived 重写了 Base2::f2()。
你手里有一个
Base2* ptr = &d;。此时ptr的值是0x1010。你执行
ptr->f2();。程序查找
0x1010处的虚表指针,找到了Derived为Base2准备的虚表。矛盾出现了:
Derived::f2()是子类的函数,它可能要访问Base1的成员a或者Derived自己的成员。它期望的this是0x1000(对象的开头),但你传给它的是0x1010。
所以为了解决这个问题,编译器使用了Thunk技术,即指针调整码
在
Base2对应的虚表中,func2的条目并不直接指向Derived::func2的代码。它指向一小段额外的指令(Thunk),这段指令先把
this指针减去一个偏移量(使其指向Derived的开头),然后再跳转到真正的Derived::func2实现。Thunk汇编:
this = this - 16;(把指针调回到 0x1000)
jmp Derived::f2;(跳转到真正的函数实现)钻石继承——虚继承
钻石继承是指两个子类继承同一个父类,而又有第三个类同时继承这两个子类。
class Animal { int age; };
class Lion : public Animal { ... };
class Tiger : public Animal { ... };
class Liger : public Lion, public Tiger { ... };在 Liger 对象中,会有两份 Animal。如果你访问 liger.age,编译器会报错,因为它不知道你是要 Lion 里的 age 还是 Tiger 里的 age。
虚继承的底层实现:
共享基类:
Animal在整个Liger对象中只存在一份,通常被放在内存的最末尾。虚基类表(vbtable)或偏移量:
Lion和Tiger的对象切片中不再直接存放Animal的成员。它们会多出一个指针(
vbptr),指向一个“虚基类表”,或者在自己的虚函数表(vtable)中记录一个偏移量(vbase_offset)。运行期间,当需要访问
Animal的成员时,程序通过这个偏移量动态计算出Animal在内存中的位置。
代价:虚继承的对象访问成员变量变慢了,因为多了一层内存寻址。
注意
构造函数不能是虚函数:因为在执行构造函数时,对象的 vptr 可能尚未初始化完成。
虚析构函数至关重要:如果基类析构函数不是 virtual,通过基类指针删除子类对象时,编译器只会调用基类的析构函数,导致子类特有的资源(如堆内存)无法释放,造成内存泄漏。
内存模型
内存分区与管理
内存区域总结对比表
new和malloc区别 ,及自由存储区和堆的区别
在 C++ 的语境下,“自由存储区”(Free Store)是一个经常被与“堆”(Heap)混淆,但又具有特定含义的概念。
简单来说:自由存储区是 C++ 中通过 new 和 delete 动态分配和释放对象的抽象概念。
虽然在几乎所有的现代编译器(如 GCC, Clang, MSVC)中,new 的底层都是调用 malloc 来实现的(也就是说,自由存储区通常物理上就位于堆上),但 C++ 标准允许自由存储区由非堆内存实现。例如,你可以重载 operator new,让它从一个静态数组或特定的内存池中分配空间。
free函数和delete的行为
free和delete都是利用一个元数据判断指针需要释放的内存大小:
当你调用 void* p = malloc(100); 时,堆管理器实际分配的空间通常大于 100 字节。它会在返回给你的指针 之前 的位置,偷偷藏入一段元数据(Metadata)。
内存布局:
Header (控制块):存放这块内存的大小(Size)、校验位(Magic Number)、以及指向下一个/上一个内存块的指针(用于链表管理)。
Payload (有效载荷):这才是返回给你的
p指向的地址。
释放过程: 当你调用
free(p)时:它将指针
p向后偏移(通常是 8 或 16 字节),找到 Header。从 Header 中读取该内存块的大小。
将该内存块标记为“空闲(Free)”,并尝试将其合并到相邻的空闲块中(避免内存碎片)。
区别是:delete能够调用析构函数。free无法判断指针是否合法,delete可以。
对于数组delete[] T
当你执行
new T[n]时,如果T有析构函数,编译器通常会在分配的内存头部额外多申请 4 或 8 个字节,用来存储数组长度n。当执行
delete[] p时,它先读取这个n,然后循环调用n次析构函数,最后才把整个大块内存连同存储n的头部一起释放。这就是为什么
new[]必须对应delete[]。如果用错,编译器找不到数组长度,或者把对象数据误认为长度,会导致严重的内存崩溃。
指针
指针与引用
引用的本质其实是一个别名,底层汇编中的实现机制是常量指针
// 你的代码
int a = 10;
int& ref = a;
ref = 20;
// 编译器底层实际干的活(逻辑示意)
int* const p = &a;
*p = 20;为什么说引用逻辑上不占空间
如果说引用的本质是常量指针,那为什么说引用不占空间?
抽象的纯粹性
通过在逻辑上规定引用不占空间、不可重定向、必须初始化,C++ 提供了一种比指针更安全的抽象。如果你承认引用是对象,那么你就得允许“引用的引用”、“引用的指针”,这会让语法变得极其混乱(如
int&&& p)。
B. 编译器优化(真正的“不占空间”)在很多情况下,引用真的可以不占空间:
内联优化:如果编译器发现引用只是在函数内部使用,它会直接进行“符号替换”。在最终生成的机器码里,所有
ref都会被替换成对a的直接访问,此时连那个存放地址的指针空间都被优化掉了。寄存器优化:引用可能仅仅存在于 CPU 寄存器中,而从未进入内存。
右值引用优化
上文提到,右值只是一个临时的变量,是无法取地址的,但是在一些需要大对象的场景下,资源很大的右值需要被频繁回收与分配内存,造成性能消耗,所以c++11提出了右值引用
右值引用 (T&&) 的出现,就是为了给临时数据一个名字,从而延长它的生命周期。
语法对比:
左值引用:
int& ref = a;(只能绑定左值)常量左值引用:
const int& ref = 42;(可以绑定右值,但只能读不能改)右值引用:
int&& ref = a + b;(专门捕捉右值,且允许修改这个临时值)
使用场景
想象你有一个 std::vector 里面存了 1GB 的数据。
拷贝 (Copy):创建一个新 vector,申请 1GB 内存,把旧数据全部复制一遍。
移动 (Move):直接把旧 vector 里的指针指向新 vector,然后把旧 vector 的指针置空。
移动语义的底层逻辑: 由于右值引用捕捉的是一个“即将销毁”的对象,我们可以安全地“掠夺”它的内部指针。因为它反正要死了,没人会关心它的指针是否变成了 nullptr。
class BigData {
int* data;
size_t size;
public:
// 移动构造函数
BigData(BigData&& other) noexcept {
// 1. 偷走别人的资源
data = other.data;
size = other.size;
// 2. 将别人置空,防止他析构时把我们的资源 delete 掉
other.data = nullptr;
other.size = 0;
}
};注意这里的移动构造函数不能是T(const T&&); ,因为需要再函数当中把原本的指针置空,如果不能置空,资源会随着原本的析构函数一起被回收。
move函数
std::move 这个名字起得很具迷惑性。它其实并不移动任何东西。
它的本质是一个强制类型转换。它把一个“左值”强转成“右值引用”类型,目的是告诉编译器:“虽然这个变量有名字,但我不再需要它了,你可以把它当成临时对象
BigData a;
BigData b = std::move(a); // 现在 a 的资源被偷走了,b 接管了智能指针
unique_ptr 是最轻量级的。它的底层非常简单:
内部存储:只封装了一个原始指针。
析构函数:在析构时直接执行
delete。拷贝控制:显式删除了拷贝构造函数和赋值运算符(
= delete),但实现了移动构造函数。
一个 shared_ptr 对象在内存中实际上由两个指针组成:
指向对象的指针:指向堆上的实际数据。
指向控制块(Control Block)的指针:指向一个专门的内存区域,用于管理共享信息。
控制块(Control Block)包含什么?
强引用计数 (Strong Ref Count):记录有多少个
shared_ptr指向该对象。当计数归零时,销毁对象。弱引用计数 (Weak Ref Count):记录有多少个
weak_ptr指向该对象。当计数也归零时,销毁控制块本身。自定义删除器 (Deleter):如果用户提供了特殊的释放方式。
引用计数的原子性
为了保证线程安全,引用计数的加减操作使用的是原子操作(Atomic Operations)。这意味着在多线程下增加或减少计数是安全的,但要注意:智能指针对象本身的读写并不是线程安全的。
UE中智能指针的实现
STL
排序时间复杂度
哈希表数据结构
参与讨论
(Participate in the discussion)
参与讨论