C++ 是一门庞大且复杂的语言。为了帮你高效复习,我将 C++ 基础知识体系化为 7 大核心模块。这份清单涵盖了从语法基础到现代 C++(C++11/14/17/20)的关键特性,适合作为面试准备或项目开发的自查表。

在腾讯光子(以及大多数游戏/引擎开发岗位)的面试中,这个问题不仅考察语法区别,更考察底层原理、内存模型以及在实际游戏开发中的工程实践

以下是C和C++内存分配的核心区别与面试回答要点:

1. C语言:手动、原始、面向字节

C语言的内存分配是过程式的,程序员直接操作原始内存块。

  • 栈 (Stack): 由编译器自动分配释放。局部变量、函数参数等。大小固定,速度快,但空间有限。
  • 堆 (Heap): 通过标准库函数手动管理。
    • malloc(size_t size): 分配指定字节数的未初始化内存。返回 void*
    • calloc(size_t num, size_t size): 分配并清零内存。
    • realloc(void *ptr, size_t new_size): 调整已分配内存块的大小(可能涉及数据拷贝)。
    • free(void *ptr): 释放内存。必须与malloc/calloc/realloc配对使用
  • 关键特点:
    • 无类型感知malloc 不知道你要存什么对象,只关心字节数。
    • 无构造/析构:不会调用构造函数或析构函数。对于结构体只是分配裸内存。
    • 错误处理:失败返回 NULL,需手动检查。
    • 易出错:忘记free导致泄漏;多次free导致UB;野指针等。

2. C++:多层次、面向对象、RAII

C++兼容C的分配方式,但引入了类型安全、对象生命周期管理和抽象层

基础层(兼容C,但不推荐在现代C++中直接使用)
  • malloc/free 依然可用,但绝不应用于非POD类型(因为不调用构造/析构)。
核心层:new / delete 表达式

这是C++区别于C的本质:

  • new T(args...):
    1. 调用 operator new(sizeof(T)) 分配原始内存(类似malloc)。
    2. 在分配的内存上调用T的构造函数
    3. 返回类型安全的 T*
  • delete ptr:
    1. 调用 ptr 指向对象的析构函数
    2. 调用 operator delete(ptr) 释放内存。
  • 数组版本new[] / delete[] (必须严格配对,否则UB)。
  • 失败处理:默认抛出 std::bad_alloc 异常(也可用 nothrow 版本返回nullptr)。
高级层:智能指针与RAII(现代C++首选)
  • std::unique_ptr<T>: 独占所有权,零开销抽象,离开作用域自动delete。
  • std::shared_ptr<T>: 引用计数共享所有权,线程安全的计数操作。
  • std::make_unique / std::make_shared: 推荐创建方式,避免内存泄漏风险,make_shared还有性能优势(单次分配)。
容器与分配器
  • std::vector, std::string 等容器内部管理动态内存,遵循RAII。
  • 自定义Allocator: STL容器支持模板参数指定分配器,这在游戏开发中极其重要(如内存池、线性分配器)。

3. 面试高分对比总结表

维度 C (malloc/free) C++ (new/delete & RAII)
本质 内存分配函数 对象创建/销毁表达式 + 内存管理抽象
类型安全 ❌ 返回 void*,需强转 ✅ 返回正确类型指针
构造/析构 ❌ 不调用 ✅ 自动调用
失败处理 返回 NULL 抛异常 (或 nothrow)
可重载性 ❌ 不可重载 operator new/delete 可全局/类级重载
内存管理哲学 手动配对,谁申请谁释放 RAII,所有权语义,自动化生命周期
游戏开发实践 底层系统/C接口/遗留代码 业务逻辑/现代引擎/工具链

4. 🎮 针对腾讯光子的加分回答点

面试官问这个问题,通常想引出以下话题,建议主动提及:

  1. “在游戏开发中,我们很少直接用 new/malloc”

    • 原因:频繁的系统级内存分配会导致碎片化性能抖动
    • 实践:使用自定义内存分配器(Memory Pool, Linear/Stack Allocator, Free List),重写类的 operator new 或使用带Allocator的STL容器。
  2. placement new 的重要性

    • 在预分配的内存缓冲区(如内存池的一块)上原地构造对象,分离"内存分配"和"对象构造"两个步骤。这是实现高效内存池的基础。
    void* buf = pool.allocate(sizeof(MyClass));
    MyClass* obj = new(buf) MyClass(args); // placement new
    obj->~MyClass(); // 手动析构(注意:不是delete!)
    pool.deallocate(buf);
    
  3. 对齐问题

    • SIMD指令(SSE/AVX)、GPU数据传输需要特定内存对齐。C++17起 new 支持过对齐分配;C11有 aligned_alloc。游戏引擎中通常需要自己处理对齐分配。
  4. C/C++混编场景

    • 光子很多项目是C++引擎+C脚本/中间件。跨语言传递对象时,通常用C接口封装(extern “C”),内部用C++管理生命周期,对外暴露不透明句柄。此时要明确哪一侧负责分配和释放。

💡 回答话术建议

"C和C++内存分配最核心的区别在于:C是面向字节的原始内存管理,malloc/free只做分配和释放;而C++的new/delete是面向对象的,将内存分配与构造/析构绑定在一起,并通过RAII和智能指针实现了所有权的自动化管理。

但在实际游戏引擎开发中,两者都不是最优解。我们通常会基于C++的operator new重载机制或placement new,构建自定义内存分配器,比如内存池和线性分配器,来避免碎片化、保证缓存友好性,并精确控制内存布局以满足SIMD对齐等需求。"

这样的回答既展示了基础扎实,又体现了对游戏开发实际问题的理解,非常契合光子工作室的技术栈特点。

1. 基础语法与数据类型

这是 C++ 的基石,重点在于理解“底层”行为。

  • 基本类型: int, float/double, char, bool, void。注意各类型在不同平台下的字节大小取值范围
  • 类型修饰符: const(只读)、volatile(防优化)、signed/unsignedstatic(静态存储期/局部可见性)。
  • 引用 vs 指针:
    • 指针: 可为空、可重新赋值、有二级指针、需手动管理内存。
    • 引用: 必须初始化、不可更改绑定、无空引用、更安全。
  • 类型转换:
    • C风格:(type)value(不安全)。
    • C++风格:static_cast(编译期安全转换)、dynamic_cast(运行时多态转换)、const_cast(去const)、reinterpret_cast(二进制重解释,危险)。
  • 作用域与生命周期: 全局、局部、块级作用域;栈对象自动销毁 vs 堆对象手动销毁。

2. 面向对象编程 (OOP)

C++ 的核心范式,面试和工程中的重中之重。

  • 封装: public/protected/private 访问控制;friend 友元机制。
  • 继承: 单继承、多继承(注意菱形继承问题及虚继承 virtual 解决方案)。
  • 多态:
    • 编译期多态: 函数重载、运算符重载、模板。
    • 运行期多态: 虚函数 (virtual)、纯虚函数 (=0)、抽象类。
    • ⚠️ 关键点: 基类析构函数必须声明为 virtual,否则通过基类指针删除派生类对象会导致未定义行为(内存泄漏)。
  • 构造与析构:
    • 构造函数初始化列表 vs 函数体内赋值(性能差异)。
    • 拷贝构造函数、移动构造函数。
    • 拷贝赋值运算符、移动赋值运算符。
    • Rule of Five / Rule of Zero: 如果定义了析构/拷贝/移动中的任何一个,通常需要定义全部五个;或者使用智能容器让编译器自动生成。

2.1 多态
多态的实现是基于虚函数virtual
C++ 的虚函数表(Virtual Table,简称 vtable)机制是实现运行时多态(Runtime Polymorphism)的核心底层技术。当你在 C++ 中使用 virtual 关键字时,编译器通常会自动生成 vtable 和相关的指针来支持动态绑定。

以下是该机制的深度解析:

1. 核心概念

  • vtable (虚函数表): 本质上是一个静态数组(或类似结构),存储在程序的只读数据段中。每个包含虚函数的类都有自己独立的一张 vtable。表中存储了该类所有虚函数的函数指针
  • vptr (虚表指针): 一个隐藏的指针成员,被编译器自动插入到每个包含虚函数的类的对象实例中。它指向该对象所属类的 vtable。

2. 内存布局模型

类的 vtable 结构

假设有一个基类 Base 和一个派生类 Derived

class Base {
public:
    virtual void funcA();
    virtual void funcB();
    void nonVirtual(); // 不在vtable中
};

class Derived : public Base {
public:
    void funcA() override; // 重写
    virtual void funcC();  // 新增虚函数
};

内存中的 vtable 大致如下:

Base::vtable Derived::vtable
[0] &Base::funcA [0] &Derived::funcA (被覆盖)
[1] &Base::funcB [1] &Base::funcB (继承)
[2] &Derived::funcC (新增)

关键点Derived 的 vtable 中,funcA 的位置与 Base 中相同,但指向的是 Derived::funcA 的实现。这保证了通过基类指针调用时能正确索引。

对象的内存布局

当你创建 Derived d; 时,对象内存通常如下(以单继承为例):

+------------------+
| vptr             | -----> Derived::vtable
+------------------+
| Base 成员变量     |
+------------------+
| Derived 成员变量  |
+------------------+

3. 虚函数调用过程

当你执行 basePtr->funcA() 时,CPU 实际执行的步骤是:

  1. 从对象起始地址读取 vptr
  2. 通过 vptr 找到对应的 vtable
  3. 根据 funcA 在表中的固定偏移量(offset)取出函数指针。
  4. 跳转到该函数指针指向的地址执行代码。

伪代码表示:

// basePtr->funcA() 等价于:
(*(basePtr->vptr)[OFFSET_OF_funcA])(basePtr);

4. 复杂场景处理

多重继承 (Multiple Inheritance)

如果一个类继承了多个带虚函数的基类,对象中会有多个 vptr,分别指向不同基类子对象的 vtable。

+------------------+
| vptr (Base1)     | -----> Base1_vtable / Derived_vtable_for_Base1
+------------------+
| Base1 成员        |
+------------------+
| vptr (Base2)     | -----> Base2_vtable / Derived_vtable_for_Base2 (可能带偏移调整)
+------------------+
| Base2 成员        |
+------------------+
| Derived 自有成员  |
+------------------+

当通过 Base2* 指针调用虚函数时,编译器可能需要使用 Thunk 技术来调整 this 指针,使其正确指向 Derived 对象的完整起始地址。

虚继承 (Virtual Inheritance)

为了解决菱形继承问题,虚继承引入了额外的间接层。vtable 中不仅包含虚函数指针,还可能包含虚基类偏移量(vbptr/vbtable),用于在运行时定位共享的虚基类子对象。这使得虚继承的对象布局和调用开销比单继承/多重继承更大。

5. 性能与开销

方面 说明
空间开销 每个含虚函数的类多一张 vtable;每个对象多一个 vptr(通常 8 字节/64位系统)
时间开销 每次虚函数调用增加 1-2 次内存间接寻址(读 vptr → 读 vtable → 跳转)
内联优化 虚函数通常无法内联,因为编译器在编译期不知道实际调用的目标函数(除非能静态推断出具体类型)
缓存友好性 vtable 访问可能导致 cache miss,尤其在大量不同类型对象混合调用时

6. 重要注意事项

  • 非标准规定: vtable 是编译器的实现细节,C++ 标准并未规定必须使用 vtable(尽管几乎所有主流编译器都这么做)。
  • ABI 兼容性: 不同编译器(甚至同一编译器的不同版本/选项)生成的 vtable 布局可能不同,这就是为什么跨编译器链接 C++ 代码经常出问题。Itanium C++ ABI 试图标准化这一布局。
  • 构造/析构期间: 在构造函数和析构函数执行期间,vptr 会逐步更新。不要在构造/析构函数中调用虚函数,因为此时 vptr 可能尚未指向最终派生类的 vtable,导致行为不符合预期。
  • RTTI 关联: typeiddynamic_cast 通常也依赖 vtable 中的额外信息(如 typeinfo 指针)来实现运行时类型识别。

总结

vtable 机制是用空间换时间的经典设计:通过在对象中嵌入指针、在类中维护函数指针表,将“调用哪个函数”的决策从编译期推迟到运行期,从而实现了面向对象编程中最核心的多态特性。理解它有助于写出更高效的 C++ 代码,并在调试复杂继承问题时快速定位根源。

3. STL 标准模板库

不要重复造轮子,熟练掌握 STL 是 C++ 工程师的基本素养。

分类 常用组件 关键知识点
容器 vector, deque, list, array 内存布局、扩容机制(vector 1.5x/2x)、迭代器失效条件
关联容器 map/set, unordered_map/set 红黑树 vs 哈希表、查找复杂度 O(logN) vs O(1)
适配器 stack, queue, priority_queue 底层容器选择、优先队列的比较器
算法 sort, find, transform, accumulate Lambda 配合、自定义比较器、时间复杂度
迭代器 Input/Output/Forward/Bidirectional/RandomAccess 迭代器类别决定可用算法

⚠️ 避坑指南: 永远不要在遍历容器时直接 erase,应使用 erase-remove 惯用法或 C++20 的 std::erase_if

4. 内存管理

C++ 区别于其他高级语言的核心难点。

  • 栈 vs 堆: 栈由编译器管理(快、小),堆由程序员管理(慢、大)。
  • 原始指针: new/delete, new[]/delete[](必须配对使用)。
  • 智能指针(现代 C++ 必用):
    • std::unique_ptr:独占所有权,零开销,推荐默认使用。
    • std::shared_ptr:引用计数共享所有权,注意循环引用问题。
    • std::weak_ptr:解决 shared_ptr 循环引用,不增加引用计数。
  • RAII: 资源获取即初始化。将资源生命周期绑定到对象生命周期,确保异常安全。
  • 内存对齐: alignof, alignas,结构体填充规则。

5. 模板与泛型编程

C++ 最强大的特性之一,也是编译错误的重灾区。

  • 函数模板 & 类模板: 模板参数推导、特化(全特化/偏特化)。
  • SFINAE: “Substitution Failure Is Not An Error”,std::enable_if
  • Concepts (C++20): 约束模板参数,替代 SFINAE,大幅提升可读性和错误信息质量。
  • 可变参数模板: template<typename... Args>,完美转发 std::forward
  • 编译期计算: constexpr, consteval (C++20), if constexpr

6. 现代 C++ 核心特性 (C++11 ~ C++20)

如果你还在写 C++98 风格的代码,请务必更新知识库。

  • C++11: auto, nullptr, 范围for循环, Lambda, std::move, 右值引用, override/final, enum class, std::thread
  • C++14: 泛型Lambda (auto参数), std::make_unique, 返回值推导。
  • C++17: std::optional/variant/any, 结构化绑定 auto [x,y] = ..., if constexpr, 并行算法, std::string_view
  • C++20: Modules (取代头文件), Coroutines (协程), Ranges (惰性求值管道), Concepts, std::format, 三路比较 <=>
  • C++23/26 展望: std::print, std::expected, 反射提案等。

7. 并发编程

  • 线程管理: std::thread, std::jthread (C++20, 自动join)。
  • 同步原语: std::mutex, std::lock_guard, std::unique_lock, std::scoped_lock (C++17, 防死锁)。
  • 条件变量: std::condition_variable,注意虚假唤醒。
  • 原子操作: std::atomic,内存序 (memory_order)。
  • 异步: std::future/promise, std::async

更多推荐