最近在找实习,但我也不想对于知识的理解仅限于八股,我希望能够真正理解而不是死记硬背,这一篇会偏底层,一些简单的语法和基础会略过。

c++面试八股看了一些主要考点:多态、虚函数实现、指针、内存模型、STL

其次是我个人感兴趣的:函数指针、链接

参考:zhuanlan.zhihu.com超全 C/C++ 技术面试八股文面试题!(2025 年更新)

还在更新中。

语法

左值右值

左值 (Lvalue):指的是有名字、有固定地址的对象。你可以通过地址操作符 & 取到它的地址。它通常是持久的。

  • 例如:变量名、返回左值引用的函数调用、赋值表达式。

右值 (Rvalue):指的是临时、没有名字、不可寻址的值。它们通常在表达式结束后就消失了。

  • 例如:字面量(42)、算术表达式(a + b)、返回临时对象的函数。

也有右值引用,与移动构造和完美转发有关,移动构造见下文

模板

类型转换

转换符

安全性

检查时机

核心用途

static_cast

编译时

相关类型、基本类型、上行转换

dynamic_cast

运行时

多态类之间的安全转换

const_cast

编译时

增减 const 或 volatile

reinterpret_cast

极低

编译时

底层位模式重新解释

static_cast:最常用的转换

static_cast 用于良性相关类型之间的转换,这些转换在编译时就能确定。

  • 使用场景:

    • 基本数据类型转换(如 intdouble)。

    • 类层次结构中,将子类指针/引用转换为基类(上行转换,绝对安全)。

    • 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*)。

    • 常用于底层驱动、位操作或序列化数据。

  • 注意事项:

    • 不可移植:不同平台的指针长度和内存布局可能不同。

    • 编译器不保证转换后的结果有任何逻辑意义,程序员必须自负盈亏。

多态和虚函数的实现

什么是多态

同一个函数名的多种状态。多态分为编译时多态和运行时多态,编译时多态通过重载和模板,运行时多态通过继承和虚函数

虚函数表和虚函数表指针

vptrvtable

当一个类声明了一个虚函数时,编译器会为这个类创建一个静态的函数指针数组,每一项都指向该类对应的虚函数实现。

当一个对象被创建时,会在其内存布局中添加一个指向该类的虚拟函数表的指针。当调用虚函数时,实际上是通过该指针找到对应的虚拟函数表,并根据函数的索引调用相应的虚函数。

vtable本身通常存储在可执行文件的只读数据段(.rdata),vptr在对象中,通常是对象起始位置。

继承时的行为

如果子类 Derived 继承自 Base 但没有重写任何函数:

  1. 编译器会为 Derived 创建一个全新的 vtable

  2. Basevtable 内容完整拷贝一份到 Derivedvtable 中。

  3. 此时,子类对象的 vptr 指向的是子类的 vtable,但表里的地址依然指向父类的函数实现。

如果子类重写了父类的某个虚函数(例如 func1):

  1. 编译器会在子类的 vtable 中,将原本指向父类 Base::func1 的指针替换为指向子类 Derived::func1 的地址。

  2. 这就是多态的本质:通过基类指针调用函数时,程序通过 vptr 找到 vtable,由于表中的地址已经被替换,最终执行的是子类的代码。

如果子类定义了父类没有的虚函数:

  1. 这些新函数的地址会被追加到子类 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 对象在内存中的布局通常如下:

  1. 第一部分(Base1 子对象):包含指向 Base1 虚表的 vptr1Base1 的成员变量。

  2. 第二部分(Base2 子对象):包含指向 Base2 虚表的 vptr2Base2 的成员变量。

  3. 第三部分(Derived 成员):Derived 类自己新增的成员变量。

  • 当你执行 Base1* p1 = new Derived(); 时,p1 指向对象的起始地址。

  • 当你执行 Base2* p2 = new Derived(); 时,编译器必须将 p2 的地址向前移动(Offset),使其指向 Derived 对象内部 Base2 子对象的起始位置。

如果 Base2 没有自己的 vptr,那么 p2 就无法在不知道 Derived 具体结构的情况下找到属于 Base2 的虚函数。

在多重继承中,Derived 会为每个基类准备一份虚表(或一份大虚表的不同部分)。

  1. 主要虚表(Primary vtable):关联第一个基类(Base1)。Derived 新增的虚函数(如 func3)通常会挂在第一个基类的虚表末尾。

  2. 次要虚表(Secondary vtable):关联后续基类(Base2)。

多继承下this指针指向哪里?Thunk技术

前面提到,一个derived对象有多个Base基类,那么在这个对象里面其实有多个子对象的内存段,每一个内存段的开头都有各自的vptr。

假设 Derived 对象 d 内存地址从 0x1000 开始:

  1. Base1 部分:占据 0x1000 - 0x100F

  2. 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()

  1. 你手里有一个 Base2* ptr = &d;。此时 ptr 的值是 0x1010

  2. 你执行 ptr->f2();

  3. 程序查找 0x1010 处的虚表指针,找到了 DerivedBase2 准备的虚表。

  4. 矛盾出现了Derived::f2() 是子类的函数,它可能要访问 Base1 的成员 a 或者 Derived 自己的成员。它期望的 this0x1000(对象的开头),但你传给它的是 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

虚继承的底层实现:

  1. 共享基类Animal 在整个 Liger 对象中只存在一份,通常被放在内存的最末尾。

  2. 虚基类表(vbtable)或偏移量

    1. LionTiger 的对象切片中不再直接存放 Animal 的成员。

    2. 它们会多出一个指针(vbptr),指向一个“虚基类表”,或者在自己的虚函数表(vtable)中记录一个偏移量(vbase_offset)

    3. 运行期间,当需要访问 Animal 的成员时,程序通过这个偏移量动态计算出 Animal 在内存中的位置。

代价:虚继承的对象访问成员变量变慢了,因为多了一层内存寻址。

注意

构造函数不能是虚函数:因为在执行构造函数时,对象的 vptr 可能尚未初始化完成。

虚析构函数至关重要:如果基类析构函数不是 virtual,通过基类指针删除子类对象时,编译器只会调用基类的析构函数,导致子类特有的资源(如堆内存)无法释放,造成内存泄漏

内存模型

内存分区与管理

内存区域总结对比表

区域

管理方式

生命周期

读写权限

常见风险

编译器自动

函数作用域

读写

栈溢出 (Stack Overflow)

程序员手动

手动 delete

读写

内存泄漏、野指针

全局/静态

链接器/OS

程序运行全过程

读写

静态初始化顺序问题

常量区

OS

程序运行全过程

只读

修改导致段错误

代码区

OS

程序运行全过程

只读/执行

new和malloc区别 ,及自由存储区和堆的区别

在 C++ 的语境下,“自由存储区”(Free Store)是一个经常被与“堆”(Heap)混淆,但又具有特定含义的概念。

简单来说:自由存储区是 C++ 中通过 newdelete 动态分配和释放对象的抽象概念。

虽然在几乎所有的现代编译器(如 GCC, Clang, MSVC)中,new 的底层都是调用 malloc 来实现的(也就是说,自由存储区通常物理上就位于堆上),但 C++ 标准允许自由存储区由非堆内存实现。例如,你可以重载 operator new,让它从一个静态数组或特定的内存池中分配空间。

特性

堆 (malloc/free)

自由存储区 (new/delete)

处理对象

原始内存块(Bytes)

类型化的对象(Objects)

构造/析构

不调用。只负责划拨内存。

自动调用。new 会先分配内存再调构造函数。

返回值

void*,需要强转。

返回具体类型的指针,类型安全。

失败处理

返回 NULL。

默认抛出 std::bad_alloc 异常。

大小计算

需要手动计算 sizeof(T)。

编译器自动计算。

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

  1. 当你执行 new T[n] 时,如果 T 有析构函数,编译器通常会在分配的内存头部额外多申请 4 或 8 个字节,用来存储数组长度 n

  2. 当执行 delete[] p 时,它先读取这个 n,然后循环调用 n 次析构函数,最后才把整个大块内存连同存储 n 的头部一起释放。

  3. 这就是为什么 new[] 必须对应 delete[]。如果用错,编译器找不到数组长度,或者把对象数据误认为长度,会导致严重的内存崩溃。

指针

指针与引用

引用的本质其实是一个别名,底层汇编中的实现机制是常量指针

// 你的代码
int a = 10;
int& ref = a;
ref = 20;

// 编译器底层实际干的活(逻辑示意)
int* const p = &a;
*p = 20;

特性

指针 (Pointer)

引用 (Reference)

初始化

可以不初始化(虽然危险),产生野指针

必须在声明时初始化

可更改性

可以随时指向另一个对象(重定向)

一旦绑定,不可更改指向

空值 (Null)

可以为 nullptr

不存在“空引用”,必须绑定到有效对象

内存开销

占用额外的内存(4/8 字节)来存地址

逻辑上不占空间

间接级别

可以有多级指针(如 int** p)

只有一级,没有“引用的引用”

操作符

需要解引用 *p 和取地址 &x

使用起来像普通变量,无需特殊符号

为什么说引用逻辑上不占空间

如果说引用的本质是常量指针,那为什么说引用不占空间?

  • 抽象的纯粹性

    • 通过在逻辑上规定引用不占空间、不可重定向、必须初始化,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 接管了

智能指针

种类

所有权模型

特点

std::unique_ptr

独占所有权

不允许拷贝,只能移动(Move)。性能几乎等同于原始指针。

std::shared_ptr

共享所有权

允许多个指针指向同一对象,使用引用计数管理生命周期。

std::weak_ptr

无所有权观测

指向 shared_ptr 管理的对象,但不增加引用计数。用于解决循环引用。

unique_ptr 是最轻量级的。它的底层非常简单:

  • 内部存储:只封装了一个原始指针。

  • 析构函数:在析构时直接执行 delete

  • 拷贝控制:显式删除了拷贝构造函数和赋值运算符(= delete),但实现了移动构造函数。

一个 shared_ptr 对象在内存中实际上由两个指针组成:

  1. 指向对象的指针:指向堆上的实际数据。

  2. 指向控制块(Control Block)的指针:指向一个专门的内存区域,用于管理共享信息。

控制块(Control Block)包含什么?

  • 强引用计数 (Strong Ref Count):记录有多少个 shared_ptr 指向该对象。当计数归零时,销毁对象

  • 弱引用计数 (Weak Ref Count):记录有多少个 weak_ptr 指向该对象。当计数也归零时,销毁控制块本身

  • 自定义删除器 (Deleter):如果用户提供了特殊的释放方式。

引用计数的原子性

为了保证线程安全,引用计数的加减操作使用的是原子操作(Atomic Operations)。这意味着在多线程下增加或减少计数是安全的,但要注意:智能指针对象本身的读写并不是线程安全的。

UE中智能指针的实现

STL

排序时间复杂度

哈希表数据结构

函数指针

设计模式