Appearance
题海扩展:C++ 手撕与追问
实现一个不可拷贝类
标准答案
实现不可拷贝类,核心就是把拷贝构造函数和拷贝赋值运算符禁掉。C++11 以后推荐直接用 = delete,这样一旦有人写拷贝代码,编译期就会报错。
代码实现
c
class NonCopyable // 定义一个不可拷贝基类
{ // 类体开始
protected: // 构造和析构放 protected,表示它通常只作为基类使用
NonCopyable() = default; // 保留默认构造,让子类可以正常构造
~NonCopyable() = default; // 保留默认析构,让子类可以正常析构
public: // 对外明确声明哪些操作被禁止
NonCopyable(const NonCopyable&) = delete; // 删除拷贝构造,禁止 NonCopyable b(a)
NonCopyable& operator=(const NonCopyable&) = delete; // 删除拷贝赋值,禁止 b = a
}; // 类定义结束
class FileHandle : public NonCopyable // 文件句柄类继承不可拷贝基类
{ // 类体开始
public: // 对外提供构造和析构
FileHandle() = default; // 默认构造,实际项目里可以打开文件或初始化句柄
~FileHandle() = default; // 默认析构,实际项目里可以关闭文件句柄
}; // 类定义结束底层原理
如果一个类没有自己声明拷贝构造和拷贝赋值,编译器可能会自动生成。对普通数据没问题,但对文件句柄、Socket、互斥锁、GPU Buffer、纹理资源这种“独占资源”很危险,因为两个对象可能同时管理同一份资源,最后容易出现双释放、悬空句柄、状态错乱。
所以不可拷贝类的本质是:从语法层面禁止复制所有权。
补充一句面试加分
不可拷贝不等于不可移动。如果资源可以转移所有权,可以删除拷贝,但保留移动构造和移动赋值,比如 std::unique_ptr 就是典型例子。
实现一个只能移动的类
标准答案
只能移动的类就是:不能拷贝,但可以通过移动语义转移资源所有权。典型例子是 std::unique_ptr、文件句柄、Socket、GPU Buffer、线程对象。
代码实现
c
#include <utility> // 使用 std::move 和 std::exchange
#include <cstddef> // 使用 std::size_t
class MoveOnlyBuffer // 定义一个只能移动的 Buffer 类
{ // 类体开始
public: // 对外接口开始
explicit MoveOnlyBuffer(std::size_t size) // 构造函数,根据 size 创建资源
: size_(size) // 初始化资源大小
, data_(size == 0 ? nullptr : new int[size]) // 如果 size 不为 0,就申请堆内存
{ // 构造函数体开始
} // 构造函数体结束
MoveOnlyBuffer(const MoveOnlyBuffer&) = delete; // 删除拷贝构造,禁止 MoveOnlyBuffer b(a)
MoveOnlyBuffer& operator=(const MoveOnlyBuffer&) = delete; // 删除拷贝赋值,禁止 b = a
MoveOnlyBuffer(MoveOnlyBuffer&& other) noexcept // 移动构造,从 other 接管资源
: size_(std::exchange(other.size_, 0)) // 把 other.size_ 转给自己,并把 other.size_ 置 0
, data_(std::exchange(other.data_, nullptr)) // 把 other.data_ 转给自己,并把 other.data_ 置空
{ // 移动构造函数体开始
} // 移动构造函数体结束
MoveOnlyBuffer& operator=(MoveOnlyBuffer&& other) noexcept // 移动赋值,从 other 接管资源
{ // 移动赋值函数体开始
if (this != &other) // 防止自己移动给自己
{ // if 代码块开始
delete[] data_; // 先释放自己原来持有的资源
size_ = std::exchange(other.size_, 0); // 接管 other 的 size,并把 other 的 size 清零
data_ = std::exchange(other.data_, nullptr); // 接管 other 的指针,并把 other 的指针置空
} // if 代码块结束
return *this; // 返回当前对象,支持连续赋值语法
} // 移动赋值函数体结束
~MoveOnlyBuffer() // 析构函数,负责释放当前对象拥有的资源
{ // 析构函数体开始
delete[] data_; // 释放数组资源,data_ 为 nullptr 时 delete[] 也是安全的
} // 析构函数体结束
std::size_t Size() const // 获取 Buffer 大小
{ // Size 函数体开始
return size_; // 返回当前对象持有的资源大小
} // Size 函数体结束
bool IsValid() const // 判断当前对象是否还持有资源
{ // IsValid 函数体开始
return data_ != nullptr; // data_ 不为空,说明当前对象仍拥有资源
} // IsValid 函数体结束
private: // 私有数据开始
std::size_t size_ = 0; // 记录资源大小
int* data_ = nullptr; // 指向堆内存资源
}; // 类定义结束
int main() // 示例入口函数
{ // main 函数体开始
MoveOnlyBuffer a(1024); // 创建一个拥有 1024 个 int 的 Buffer
MoveOnlyBuffer b(std::move(a)); // 通过移动构造,把 a 的资源转移给 b
MoveOnlyBuffer c(256); // 创建另一个 Buffer
c = std::move(b); // 通过移动赋值,把 b 的资源转移给 c
// MoveOnlyBuffer d = c; // 取消注释会编译失败,因为拷贝构造已经被 delete
return 0; // 程序正常结束
} // main 函数体结束底层原理
std::move 本身不移动资源,它只是把左值转换成右值引用,让编译器优先匹配移动构造或移动赋值。真正的移动发生在我们写的 MoveOnlyBuffer(MoveOnlyBuffer&&) 和 operator=(MoveOnlyBuffer&&) 里。
移动以后,源对象必须仍然处于可析构的有效状态,所以要把源对象的指针置为 nullptr,避免两个对象析构时释放同一块内存。
面试加分点
移动构造和移动赋值最好加 noexcept。比如 std::vector 扩容时,如果元素的移动构造是 noexcept,容器更愿意移动元素,否则可能为了异常安全退回拷贝,而只能移动类又不能拷贝,就容易出问题。
实现一个引用计数对象
标准答案
引用计数对象的核心是:对象内部保存一个计数器,外部每多一个拥有者就 AddRef,每少一个拥有者就 Release,当计数变成 0 时自动销毁对象。
下面是一个简化版“侵入式引用计数”,类似很多引擎资源对象的做法。
c
#include <atomic> // 引入原子变量,用来保证引用计数加减在多线程下是安全的
class RefCounted // 定义引用计数基类
{ // 类体开始
public: // 对外开放引用计数接口
void AddRef() const // 增加一次引用
{ // AddRef 函数体开始
refCount_.fetch_add(1, std::memory_order_relaxed); // 引用数加 1,只保证计数原子递增
} // AddRef 函数体结束
void Release() const // 减少一次引用
{ // Release 函数体开始
if (refCount_.fetch_sub(1, std::memory_order_acq_rel) == 1) // 如果减少前是 1,说明减少后变成 0
{ // if 代码块开始
delete this; // 没有任何拥有者了,销毁当前对象
} // if 代码块结束
} // Release 函数体结束
int RefCount() const // 获取当前引用计数
{ // RefCount 函数体开始
return refCount_.load(std::memory_order_relaxed); // 返回当前引用计数值
} // RefCount 函数体结束
protected: // 构造和析构放 protected,避免外部随便 delete
RefCounted() = default; // 默认构造函数
virtual ~RefCounted() = default; // 虚析构,保证派生类能正确析构
private: // 私有数据区
mutable std::atomic<int> refCount_{0}; // 引用计数器,mutable 允许 const 函数里修改计数
}; // RefCounted 类结束
template <typename T> // 定义模板智能引用
class RefPtr // RefPtr 负责自动 AddRef 和 Release
{ // 类体开始
public: // 对外接口开始
RefPtr() = default; // 默认构造,内部指针为空
explicit RefPtr(T* ptr) : ptr_(ptr) // 从裸指针构造 RefPtr
{ // 构造函数体开始
AddRef(); // 持有对象后,引用计数加 1
} // 构造函数体结束
RefPtr(const RefPtr& other) : ptr_(other.ptr_) // 拷贝构造,两个 RefPtr 指向同一对象
{ // 拷贝构造函数体开始
AddRef(); // 新增一个拥有者,引用计数加 1
} // 拷贝构造函数体结束
RefPtr(RefPtr&& other) noexcept : ptr_(other.ptr_) // 移动构造,直接偷走 other 的指针
{ // 移动构造函数体开始
other.ptr_ = nullptr; // 把源对象置空,避免源对象析构时 Release
} // 移动构造函数体结束
RefPtr& operator=(const RefPtr& other) // 拷贝赋值
{ // 拷贝赋值函数体开始
if (this != &other) // 防止自己给自己赋值
{ // if 代码块开始
Reset(); // 先释放当前持有的对象
ptr_ = other.ptr_; // 再指向新对象
AddRef(); // 新对象引用计数加 1
} // if 代码块结束
return *this; // 返回当前对象
} // 拷贝赋值函数体结束
RefPtr& operator=(RefPtr&& other) noexcept // 移动赋值
{ // 移动赋值函数体开始
if (this != &other) // 防止自己移动给自己
{ // if 代码块开始
Reset(); // 释放当前对象
ptr_ = other.ptr_; // 偷走 other 的指针
other.ptr_ = nullptr; // 把 other 置空
} // if 代码块结束
return *this; // 返回当前对象
} // 移动赋值函数体结束
~RefPtr() // 析构函数
{ // 析构函数体开始
Reset(); // 离开作用域时自动 Release
} // 析构函数体结束
void Reset(T* ptr = nullptr) // 重新设置指向的对象
{ // Reset 函数体开始
if (ptr_ != nullptr) // 如果当前持有对象
{ // if 代码块开始
ptr_->Release(); // 当前对象引用计数减 1
} // if 代码块结束
ptr_ = ptr; // 改成新的对象指针
AddRef(); // 如果新对象不为空,引用计数加 1
} // Reset 函数体结束
T* Get() const // 获取裸指针
{ // Get 函数体开始
return ptr_; // 返回内部指针
} // Get 函数体结束
private: // 私有工具函数和数据
void AddRef() const // 内部封装 AddRef
{ // AddRef 函数体开始
if (ptr_ != nullptr) // 如果指针不为空
{ // if 代码块开始
ptr_->AddRef(); // 给对象引用计数加 1
} // if 代码块结束
} // AddRef 函数体结束
T* ptr_ = nullptr; // 当前持有的对象指针
}; // RefPtr 类结束底层原理
RefPtr 构造、拷贝时让对象计数加 1;RefPtr 析构、重置时让对象计数减 1。最后一个 RefPtr 消失时,Release() 发现计数从 1 变成 0,就 delete this 销毁对象。
注意点
引用计数不能解决循环引用。比如 A 持有 B,B 又持有 A,它们的计数都不会归零。真实项目里要引入 weak_ptr 或弱引用句柄来打破环。
实现一个简化版 unique_ptr
标准答案
简化版 unique_ptr 的核心是:独占所有权、析构自动释放、禁止拷贝、允许移动。它不做引用计数,所以通常开销接近裸指针。
代码实现
c
#include <utility> // 引入 std::swap,用于交换两个智能指针内部的裸指针
template <typename T> // 定义模板,让 MyUniquePtr 可以管理任意类型的对象
class MyUniquePtr // 定义简化版 unique_ptr
{ // 类体开始
public: // 对外接口开始
explicit MyUniquePtr(T* ptr = nullptr) noexcept // 构造函数,接管一个裸指针
: ptr_(ptr) // 初始化内部裸指针
{ // 构造函数体开始
} // 构造函数体结束
~MyUniquePtr() // 析构函数,负责释放当前拥有的对象
{ // 析构函数体开始
delete ptr_; // delete 空指针也是安全的
} // 析构函数体结束
MyUniquePtr(const MyUniquePtr&) = delete; // 删除拷贝构造,禁止两个指针拥有同一对象
MyUniquePtr& operator=(const MyUniquePtr&) = delete; // 删除拷贝赋值,避免双重释放
MyUniquePtr(MyUniquePtr&& other) noexcept // 移动构造,从 other 接管资源
: ptr_(other.ptr_) // 直接拿走 other 内部的裸指针
{ // 移动构造函数体开始
other.ptr_ = nullptr; // 把 other 置空,避免 other 析构时释放资源
} // 移动构造函数体结束
MyUniquePtr& operator=(MyUniquePtr&& other) noexcept // 移动赋值,从 other 接管资源
{ // 移动赋值函数体开始
if (this != &other) // 防止自己移动给自己
{ // if 代码块开始
delete ptr_; // 先释放自己原来拥有的对象
ptr_ = other.ptr_; // 接管 other 的裸指针
other.ptr_ = nullptr; // 把 other 置空,避免重复释放
} // if 代码块结束
return *this; // 返回当前对象,支持连续赋值
} // 移动赋值函数体结束
T& operator*() const // 重载解引用运算符
{ // operator* 函数体开始
return *ptr_; // 返回指针指向的对象引用
} // operator* 函数体结束
T* operator->() const noexcept // 重载箭头运算符
{ // operator-> 函数体开始
return ptr_; // 返回内部裸指针
} // operator-> 函数体结束
T* Get() const noexcept // 获取内部裸指针但不放弃所有权
{ // Get 函数体开始
return ptr_; // 返回内部裸指针
} // Get 函数体结束
T* Release() noexcept // 放弃管理权,但不释放对象
{ // Release 函数体开始
T* old = ptr_; // 保存当前裸指针
ptr_ = nullptr; // 当前智能指针不再拥有对象
return old; // 把裸指针交给调用者,调用者之后要自己 delete
} // Release 函数体结束
void Reset(T* ptr = nullptr) noexcept // 释放旧对象,并接管新对象
{ // Reset 函数体开始
if (ptr_ != ptr) // 避免传入同一个指针导致误删
{ // if 代码块开始
delete ptr_; // 释放旧对象
ptr_ = ptr; // 接管新对象
} // if 代码块结束
} // Reset 函数体结束
void Swap(MyUniquePtr& other) noexcept // 交换两个 MyUniquePtr 管理的对象
{ // Swap 函数体开始
std::swap(ptr_, other.ptr_); // 交换两个内部裸指针
} // Swap 函数体结束
explicit operator bool() const noexcept // 判断当前是否持有对象
{ // bool 转换函数体开始
return ptr_ != nullptr; // 非空表示当前拥有对象
} // bool 转换函数体结束
private: // 私有成员开始
T* ptr_ = nullptr; // 保存当前唯一拥有的裸指针
}; // MyUniquePtr 类定义结束使用示例
c
struct Monster // 定义一个测试结构体
{ // 结构体开始
int hp = 100; // 怪物血量
}; // 结构体结束
int main() // 程序入口
{ // main 函数体开始
MyUniquePtr<Monster> a(new Monster()); // a 独占一个 Monster 对象
MyUniquePtr<Monster> b(std::move(a)); // b 通过移动接管 a 的对象
b->hp = 80; // 像普通指针一样访问对象成员
b.Reset(new Monster()); // 释放旧 Monster,并接管新 Monster
Monster* raw = b.Release(); // b 放弃管理权,把裸指针交出去
delete raw; // Release 后需要调用者自己释放裸指针
return 0; // 程序正常结束
} // main 函数体结束面试加分点
Release() 是放弃管理但不释放,所以调用者必须自己处理裸指针;Reset() 是释放旧资源并接管新资源。这两个区别一定要说清楚。
实现一个简化版 shared_ptr
标准答案
简化版 shared_ptr 的核心是:多个智能指针共享同一个对象,同时共享一个控制块;控制块里记录引用计数,拷贝时加 1,析构时减 1,最后一个拥有者离开时释放对象。
代码实现
c
#include <cstddef> // 引入 std::size_t,用来表示引用计数
template <typename T> // 定义模板,让 MySharedPtr 可以管理任意类型
class MySharedPtr // 定义简化版 shared_ptr
{ // 类体开始
private: // 私有区域开始
struct ControlBlock // 定义控制块,保存共享引用计数
{ // 控制块开始
explicit ControlBlock(T* p) : ptr(p), strong(1) {} // 创建控制块时,默认有 1 个拥有者
T* ptr = nullptr; // 保存真实对象指针
std::size_t strong = 0; // 保存强引用计数
}; // 控制块结束
public: // 公有接口开始
MySharedPtr() noexcept = default; // 默认构造,表示不管理任何对象
explicit MySharedPtr(T* ptr) // 从裸指针构造 shared_ptr
: ptr_(ptr) // 保存真实对象指针
, ctrl_(ptr == nullptr ? nullptr : new ControlBlock(ptr)) // 如果 ptr 不为空,就创建控制块
{ // 构造函数体开始
} // 构造函数体结束
MySharedPtr(const MySharedPtr& other) noexcept // 拷贝构造,共享同一对象
: ptr_(other.ptr_) // 复制对象指针
, ctrl_(other.ctrl_) // 复制控制块指针
{ // 拷贝构造函数体开始
AddRef(); // 引用计数加 1
} // 拷贝构造函数体结束
MySharedPtr(MySharedPtr&& other) noexcept // 移动构造,接管 other 的指针
: ptr_(other.ptr_) // 接管对象指针
, ctrl_(other.ctrl_) // 接管控制块指针
{ // 移动构造函数体开始
other.ptr_ = nullptr; // 把源对象的对象指针置空
other.ctrl_ = nullptr; // 把源对象的控制块指针置空
} // 移动构造函数体结束
MySharedPtr& operator=(const MySharedPtr& other) noexcept // 拷贝赋值
{ // 拷贝赋值函数体开始
if (this != &other) // 防止自己给自己赋值
{ // if 代码块开始
Release(); // 先释放当前持有的引用
ptr_ = other.ptr_; // 指向新对象
ctrl_ = other.ctrl_; // 指向新控制块
AddRef(); // 新控制块引用计数加 1
} // if 代码块结束
return *this; // 返回当前对象
} // 拷贝赋值函数体结束
MySharedPtr& operator=(MySharedPtr&& other) noexcept // 移动赋值
{ // 移动赋值函数体开始
if (this != &other) // 防止自己移动给自己
{ // if 代码块开始
Release(); // 释放当前持有的引用
ptr_ = other.ptr_; // 接管 other 的对象指针
ctrl_ = other.ctrl_; // 接管 other 的控制块指针
other.ptr_ = nullptr; // 清空 other 的对象指针
other.ctrl_ = nullptr; // 清空 other 的控制块指针
} // if 代码块结束
return *this; // 返回当前对象
} // 移动赋值函数体结束
~MySharedPtr() // 析构函数
{ // 析构函数体开始
Release(); // 离开作用域时减少引用计数
} // 析构函数体结束
T* Get() const noexcept { return ptr_; } // 获取裸指针,但不改变所有权
std::size_t UseCount() const noexcept { return ctrl_ == nullptr ? 0 : ctrl_->strong; } // 返回引用计数
T& operator*() const noexcept { return *ptr_; } // 像普通指针一样解引用
T* operator->() const noexcept { return ptr_; } // 像普通指针一样访问成员
private: // 私有工具函数开始
void AddRef() noexcept // 增加引用计数
{ // AddRef 函数体开始
if (ctrl_ != nullptr) { ++ctrl_->strong; } // 有控制块时,强引用计数加 1
} // AddRef 函数体结束
void Release() noexcept // 减少引用计数
{ // Release 函数体开始
if (ctrl_ == nullptr) { return; } // 没有控制块,直接返回
--ctrl_->strong; // 强引用计数减 1
if (ctrl_->strong == 0) { delete ptr_; delete ctrl_; } // 最后一个拥有者释放对象和控制块
ptr_ = nullptr; // 当前 shared_ptr 不再指向对象
ctrl_ = nullptr; // 当前 shared_ptr 不再指向控制块
} // Release 函数体结束
T* ptr_ = nullptr; // 保存真实对象指针
ControlBlock* ctrl_ = nullptr; // 保存控制块指针
}; // MySharedPtr 类结束底层原理
shared_ptr 不是简单包一层裸指针,它的关键是控制块。控制块记录当前有多少个 shared_ptr 正在共享这个对象。拷贝 shared_ptr 时不会复制真实对象,只会让计数加 1;析构时计数减 1;计数归零才真正 delete 对象。
注意点
这个版本是面试简化版,没有实现 weak_ptr、自定义 deleter、线程安全计数、make_shared、数组版本。真实项目里还要注意循环引用,A 持有 B、B 又持有 A 时,计数可能永远不归零,需要 weak_ptr 打破环。
实现一个对象池模板
标准答案
对象池模板的核心是:对象提前创建,运行时不频繁 new/delete,而是从池里借出,用完重置状态再归还。它适合子弹、特效、飘字、UI Item、怪物 AI 临时对象这类“短生命周期、高频创建销毁”的对象。
代码实现
c
#include <cstddef> // 引入 std::size_t,用来表示容量和数量
#include <memory> // 引入 std::unique_ptr,用来让对象池拥有对象内存
#include <stdexcept> // 引入 std::runtime_error,用来抛出池耗尽或重复归还错误
#include <unordered_set> // 引入 std::unordered_set,用来记录哪些对象正在使用中
#include <vector> // 引入 std::vector,用来保存对象和空闲列表
template <typename T> // 定义对象池模板,T 需要默认构造,并提供 Reset 方法
class ObjectPool // 定义对象池类
{ // 类体开始
public: // 公有接口开始
explicit ObjectPool(std::size_t initialCapacity = 0, bool allowExpand = true) // 构造函数,传入初始容量和是否允许扩容
: allowExpand_(allowExpand) // 初始化是否允许扩容
{ // 构造函数体开始
Reserve(initialCapacity); // 预创建 initialCapacity 个对象
} // 构造函数体结束
ObjectPool(const ObjectPool&) = delete; // 禁止拷贝对象池,避免多个池管理同一批对象
ObjectPool& operator=(const ObjectPool&) = delete; // 禁止拷贝赋值,避免所有权混乱
T* Acquire() // 从对象池借出一个对象
{ // Acquire 函数体开始
if (freeList_.empty()) // 如果没有空闲对象
{ // if 代码块开始
if (!allowExpand_) // 如果不允许自动扩容
{ // if 代码块开始
throw std::runtime_error("ObjectPool exhausted"); // 抛出池耗尽错误
} // if 代码块结束
AddObject(); // 允许扩容时,新建一个对象放入池中
} // if 代码块结束
T* obj = freeList_.back(); // 取出空闲列表最后一个对象
freeList_.pop_back(); // 从空闲列表移除这个对象
inUse_.insert(obj); // 记录这个对象已经被借出
return obj; // 返回对象指针给调用者使用
} // Acquire 函数体结束
void Release(T* obj) // 把对象归还给对象池
{ // Release 函数体开始
if (obj == nullptr) // 如果传入空指针
{ // if 代码块开始
return; // 空指针不需要归还,直接返回
} // if 代码块结束
auto it = inUse_.find(obj); // 查找对象是否确实处于使用中
if (it == inUse_.end()) // 如果找不到,说明对象不是本池借出的,或者已经归还过
{ // if 代码块开始
throw std::runtime_error("Object not in use or already released"); // 抛出重复归还或非法归还错误
} // if 代码块结束
inUse_.erase(it); // 从使用中集合移除
obj->Reset(); // 重置对象状态,清理位置、速度、引用、计时器等运行时数据
freeList_.push_back(obj); // 把对象重新放回空闲列表
} // Release 函数体结束
void Reserve(std::size_t count) // 保证池里至少有 count 个对象
{ // Reserve 函数体开始
while (storage_.size() < count) // 如果当前总对象数小于目标数量
{ // while 循环开始
AddObject(); // 新增一个对象
} // while 循环结束
} // Reserve 函数体结束
std::size_t Capacity() const // 获取对象池总容量
{ // Capacity 函数体开始
return storage_.size(); // 返回池中总对象数量
} // Capacity 函数体结束
std::size_t FreeCount() const // 获取当前空闲对象数量
{ // FreeCount 函数体开始
return freeList_.size(); // 返回空闲列表大小
} // FreeCount 函数体结束
std::size_t ActiveCount() const // 获取当前正在使用的对象数量
{ // ActiveCount 函数体开始
return inUse_.size(); // 返回使用中集合大小
} // ActiveCount 函数体结束
private: // 私有实现开始
void AddObject() // 新增一个池内对象
{ // AddObject 函数体开始
std::unique_ptr<T> obj = std::make_unique<T>(); // 创建一个新对象,并交给 unique_ptr 管理
T* raw = obj.get(); // 取出裸指针,用于放入空闲列表
storage_.push_back(std::move(obj)); // 把对象所有权保存到 storage_ 中
freeList_.push_back(raw); // 把对象指针加入空闲列表,表示可以被借出
} // AddObject 函数体结束
bool allowExpand_ = true; // 是否允许池子不够时自动扩容
std::vector<std::unique_ptr<T>> storage_; // 保存所有对象的真实所有权
std::vector<T*> freeList_; // 保存当前可借出的对象指针
std::unordered_set<T*> inUse_; // 保存已经借出的对象指针,用来防止重复归还
}; // ObjectPool 类结束使用示例
c
struct Bullet // 定义一个子弹对象
{ // 结构体开始
int damage = 0; // 子弹伤害
bool active = false; // 子弹是否正在飞行
void Reset() // 对象归还池子前的重置函数
{ // Reset 函数体开始
damage = 0; // 清空伤害
active = false; // 标记为未激活
} // Reset 函数体结束
}; // Bullet 结构体结束
int main() // 程序入口
{ // main 函数体开始
ObjectPool<Bullet> pool(32); // 创建一个初始容量为 32 的子弹池
Bullet* bullet = pool.Acquire(); // 从池中借出一个子弹
bullet->damage = 10; // 设置子弹伤害
bullet->active = true; // 激活子弹
pool.Release(bullet); // 子弹命中或飞出屏幕后归还对象池
return 0; // 程序正常结束
} // main 函数体结束面试加分点
对象池不是“不释放对象”,而是把频繁的创建销毁改成借出和归还。真正容易出 bug 的地方不是 Acquire,而是 Release:归还时必须重置状态,还要防止重复归还、归还外部对象、切场景后对象还在飞行中这些问题。
实现一个固定块内存池
标准答案
固定块内存池就是:一次申请一大块连续内存,把它切成固定大小的小块,用空闲链表管理这些块。分配时从链表头取一块,释放时把块插回链表头,所以分配和释放都是 O(1)。
代码实现
c
#include <cstddef> // 使用 std::size_t、std::byte、std::max_align_t
#include <cstdint> // 使用 std::uintptr_t 做地址整数计算
#include <new> // 使用 ::operator new 和 placement new
#include <stdexcept> // 使用 std::invalid_argument 抛出非法释放错误
#include <vector> // 使用 std::vector<bool> 做调试版重复释放检查
class FixedBlockMemoryPool // 定义固定块内存池类
{ // 类体开始
private: // 私有区域开始
struct FreeNode // 空闲块链表节点,直接复用空闲内存块本身
{ // FreeNode 结构体开始
FreeNode* next = nullptr; // 指向下一个空闲块
}; // FreeNode 结构体结束
public: // 公有接口开始
FixedBlockMemoryPool(std::size_t blockSize, std::size_t blockCount) // 构造函数,传入块大小和块数量
: blockSize_(AlignUp(blockSize < sizeof(FreeNode) ? sizeof(FreeNode) : blockSize, kAlignment)) // 块大小至少能放下 next 指针,并按最大基础对齐修正
, blockCount_(blockCount) // 保存块数量
, freeCount_(blockCount) // 初始时所有块都是空闲块
, used_(blockCount, false) // 初始化使用标记,false 表示未借出
{ // 构造函数体开始
if (blockSize == 0 || blockCount == 0) // 检查参数是否合法
{ // if 代码块开始
throw std::invalid_argument("blockSize and blockCount must be positive"); // 参数非法时直接抛异常
} // if 代码块结束
buffer_ = static_cast<std::byte*>(::operator new(blockSize_ * blockCount_)); // 一次性申请整块连续内存
BuildFreeList(); // 把整块内存切成小块并串成空闲链表
} // 构造函数体结束
~FixedBlockMemoryPool() // 析构函数
{ // 析构函数体开始
::operator delete(buffer_); // 释放整块连续内存
} // 析构函数体结束
FixedBlockMemoryPool(const FixedBlockMemoryPool&) = delete; // 禁止拷贝,避免两个池管理同一块内存
FixedBlockMemoryPool& operator=(const FixedBlockMemoryPool&) = delete; // 禁止拷贝赋值,避免释放两次
void* Allocate() // 分配一个固定大小的内存块
{ // Allocate 函数体开始
if (freeList_ == nullptr) // 如果空闲链表为空
{ // if 代码块开始
return nullptr; // 返回空指针,表示池子耗尽
} // if 代码块结束
FreeNode* node = freeList_; // 取出链表头,也就是一个空闲块
freeList_ = freeList_->next; // 链表头后移
--freeCount_; // 空闲块数量减一
std::size_t index = IndexOf(node); // 计算这个块在池中的编号
used_[index] = true; // 标记为正在使用
return node; // 返回这块内存给调用者
} // Allocate 函数体结束
void Free(void* ptr) // 释放一个内存块回池子
{ // Free 函数体开始
if (ptr == nullptr) // 如果传入空指针
{ // if 代码块开始
return; // 空指针无需处理
} // if 代码块结束
if (!Owns(ptr)) // 检查这个指针是不是属于当前内存池
{ // if 代码块开始
throw std::invalid_argument("pointer does not belong to this pool"); // 非本池指针不能释放
} // if 代码块结束
std::size_t index = IndexOf(ptr); // 计算块编号
if (!used_[index]) // 如果这个块当前没有被借出
{ // if 代码块开始
throw std::invalid_argument("double free detected"); // 说明重复释放或释放了未分配块
} // if 代码块结束
used_[index] = false; // 标记为未使用
FreeNode* node = static_cast<FreeNode*>(ptr); // 把归还的内存块当成空闲链表节点
node->next = freeList_; // 让归还块指向当前链表头
freeList_ = node; // 把归还块插到链表头
++freeCount_; // 空闲块数量加一
} // Free 函数体结束
std::size_t FreeCount() const // 查询当前空闲块数量
{ // FreeCount 函数体开始
return freeCount_; // 返回空闲块数量
} // FreeCount 函数体结束
private: // 私有实现开始
static constexpr std::size_t kAlignment = alignof(std::max_align_t); // 使用基础最大对齐,满足大多数普通对象
static std::size_t AlignUp(std::size_t value, std::size_t alignment) // 向上对齐工具函数
{ // AlignUp 函数体开始
return ((value + alignment - 1) / alignment) * alignment; // 把 value 调整到 alignment 的整数倍
} // AlignUp 函数体结束
bool Owns(void* ptr) const // 判断指针是否属于当前内存池
{ // Owns 函数体开始
std::uintptr_t address = reinterpret_cast<std::uintptr_t>(ptr); // 把指针转成整数地址
std::uintptr_t start = reinterpret_cast<std::uintptr_t>(buffer_); // 计算池内存起始地址
std::uintptr_t end = start + blockSize_ * blockCount_; // 计算池内存结束地址
return address >= start && address < end && ((address - start) % blockSize_ == 0); // 判断是否在范围内且正好对齐到块起点
} // Owns 函数体结束
std::size_t IndexOf(void* ptr) const // 根据指针计算块编号
{ // IndexOf 函数体开始
std::uintptr_t address = reinterpret_cast<std::uintptr_t>(ptr); // 把指针转成整数地址
std::uintptr_t start = reinterpret_cast<std::uintptr_t>(buffer_); // 计算池起始地址
return static_cast<std::size_t>((address - start) / blockSize_); // 返回块下标
} // IndexOf 函数体结束
void BuildFreeList() // 构建初始空闲链表
{ // BuildFreeList 函数体开始
freeList_ = nullptr; // 初始链表为空
for (std::size_t i = 0; i < blockCount_; ++i) // 遍历每一个固定块
{ // for 循环体开始
std::byte* block = buffer_ + i * blockSize_; // 计算第 i 个块的起始地址
FreeNode* node = reinterpret_cast<FreeNode*>(block); // 把这块内存解释为空闲节点
node->next = freeList_; // 当前块指向旧链表头
freeList_ = node; // 当前块成为新链表头
} // for 循环体结束
} // BuildFreeList 函数体结束
std::byte* buffer_ = nullptr; // 保存整块连续内存的起始地址
std::size_t blockSize_ = 0; // 保存对齐后的单块大小
std::size_t blockCount_ = 0; // 保存块数量
std::size_t freeCount_ = 0; // 保存当前空闲块数量
FreeNode* freeList_ = nullptr; // 保存空闲链表头
std::vector<bool> used_; // 调试用标记,防止重复释放
}; // FixedBlockMemoryPool 类结束使用方式
c
struct Bullet // 定义一个测试对象
{ // Bullet 结构体开始
int damage = 0; // 子弹伤害
}; // Bullet 结构体结束
FixedBlockMemoryPool pool(sizeof(Bullet), 128); // 创建能容纳 128 个 Bullet 的固定块内存池
void* memory = pool.Allocate(); // 从内存池申请一块内存
Bullet* bullet = new (memory) Bullet(); // 使用 placement new 在这块内存上构造对象
bullet->damage = 10; // 正常使用对象
bullet->~Bullet(); // 归还内存前先手动调用析构函数
pool.Free(bullet); // 把内存块归还给内存池面试加分点
固定块内存池快,是因为它不需要在堆里查找合适大小的空闲块,只是改链表头指针。但代价是只能处理固定大小对象,不同尺寸通常要多个池。另外,真实项目里要特别注意对齐、重复释放、越界释放、多线程安全。
实现一个环形队列
标准答案
环形队列就是:用固定数组当缓冲区,head 指向出队位置,tail 指向入队位置,下标走到末尾后通过 % capacity 回到开头。 它的入队、出队都是 O(1),常用于日志缓冲、网络消息队列、音频缓冲、生产者消费者模型。
代码实现
c
#include <cstddef> // 引入 std::size_t,用来表示下标、容量和元素数量
#include <stdexcept> // 引入 std::invalid_argument,用来处理非法容量
#include <vector> // 引入 std::vector,用来作为底层固定数组
template <typename T> // 定义模板,让环形队列可以存任意类型
class RingQueue // 定义环形队列类
{ // 类体开始
public: // 公有接口开始
explicit RingQueue(std::size_t capacity) // 构造函数,传入队列容量
: buffer_(capacity) // 创建固定大小的底层数组
, capacity_(capacity) // 保存容量
{ // 构造函数体开始
if (capacity == 0) // 如果容量为 0
{ // if 代码块开始
throw std::invalid_argument("capacity must be positive"); // 抛出非法参数异常
} // if 代码块结束
} // 构造函数体结束
bool Enqueue(const T& value) // 入队函数,把元素写入 tail 位置
{ // Enqueue 函数体开始
if (IsFull()) // 如果队列已经满了
{ // if 代码块开始
return false; // 入队失败
} // if 代码块结束
buffer_[tail_] = value; // 把元素写到 tail 指向的位置
tail_ = Next(tail_); // tail 前进一格,走到末尾后会回到 0
++count_; // 当前元素数量加 1
return true; // 入队成功
} // Enqueue 函数体结束
bool Dequeue(T& out) // 出队函数,把 head 位置的元素取出来
{ // Dequeue 函数体开始
if (IsEmpty()) // 如果队列为空
{ // if 代码块开始
return false; // 出队失败
} // if 代码块结束
out = buffer_[head_]; // 读取 head 指向的元素
head_ = Next(head_); // head 前进一格,走到末尾后会回到 0
--count_; // 当前元素数量减 1
return true; // 出队成功
} // Dequeue 函数体结束
bool IsEmpty() const // 判断队列是否为空
{ // IsEmpty 函数体开始
return count_ == 0; // count 为 0 表示没有元素
} // IsEmpty 函数体结束
bool IsFull() const // 判断队列是否已满
{ // IsFull 函数体开始
return count_ == capacity_; // count 等于容量表示队列已满
} // IsFull 函数体结束
std::size_t Size() const // 获取当前元素数量
{ // Size 函数体开始
return count_; // 返回当前队列元素个数
} // Size 函数体结束
std::size_t Capacity() const // 获取队列容量
{ // Capacity 函数体开始
return capacity_; // 返回底层数组容量
} // Capacity 函数体结束
private: // 私有实现开始
std::size_t Next(std::size_t index) const // 计算下一个下标
{ // Next 函数体开始
return (index + 1) % capacity_; // 用取模实现环形回绕
} // Next 函数体结束
std::vector<T> buffer_; // 底层固定数组
std::size_t capacity_ = 0; // 队列最大容量
std::size_t head_ = 0; // head 指向下一个要出队的位置
std::size_t tail_ = 0; // tail 指向下一个要入队的位置
std::size_t count_ = 0; // 当前队列里的元素数量
}; // RingQueue 类结束底层原理
普通数组下标走到末尾就不能继续走了,环形队列用 (index + 1) % capacity 让下标回到 0。 这里用 count 区分空和满:count == 0 是空,count == capacity 是满。否则只看 head == tail 会分不清到底是空还是满。
复杂度
时间复杂度:入队 O(1),出队 O(1)。 空间复杂度:O(n),n 是队列容量。
实现一个线程池
标准答案
线程池的核心是:提前创建一组工作线程,外部只提交任务,任务进入队列,工作线程从队列取任务执行。这样可以避免频繁创建销毁线程,也能限制并发数量。
代码实现
c
#include <condition_variable> // 使用条件变量,让工作线程在没有任务时睡眠
#include <functional> // 使用 std::function 和 std::bind 保存任务
#include <future> // 使用 std::future 返回异步任务结果
#include <memory> // 使用 std::shared_ptr 保存 packaged_task
#include <mutex> // 使用 std::mutex 保护任务队列
#include <queue> // 使用 std::queue 保存待执行任务
#include <stdexcept> // 使用 std::runtime_error 表示提交失败
#include <thread> // 使用 std::thread 创建工作线程
#include <type_traits> // 使用 std::invoke_result_t 推导返回值
#include <utility> // 使用 std::forward 完美转发参数
#include <vector> // 使用 std::vector 保存工作线程
class ThreadPool // 定义线程池类
{ // 类体开始
public: // 公有接口开始
explicit ThreadPool(std::size_t threadCount) // 构造函数,传入工作线程数量
{ // 构造函数体开始
for (std::size_t i = 0; i < threadCount; ++i) // 创建指定数量的工作线程
{ // for 循环体开始
workers_.emplace_back([this]() { WorkerLoop(); }); // 每个线程都执行 WorkerLoop
} // for 循环体结束
} // 构造函数体结束
~ThreadPool() // 析构函数,负责停止线程池
{ // 析构函数体开始
{ // 加锁作用域开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护 stop_ 标志
stop_ = true; // 标记线程池准备停止
} // 加锁作用域结束
condition_.notify_all(); // 唤醒所有可能正在等待的工作线程
for (std::thread& worker : workers_) // 遍历所有工作线程
{ // for 循环体开始
if (worker.joinable()) // 如果线程还可以 join
{ // if 代码块开始
worker.join(); // 等待线程安全退出
} // if 代码块结束
} // for 循环体结束
} // 析构函数体结束
ThreadPool(const ThreadPool&) = delete; // 禁止拷贝线程池
ThreadPool& operator=(const ThreadPool&) = delete; // 禁止拷贝赋值线程池
template <typename F, typename... Args> // 定义提交任务的模板函数
auto Submit(F&& func, Args&&... args) -> std::future<std::invoke_result_t<F, Args...>> // 提交任务并返回 future
{ // Submit 函数体开始
using ReturnType = std::invoke_result_t<F, Args...>; // 推导任务返回值类型
auto task = std::make_shared<std::packaged_task<ReturnType()>>(std::bind(std::forward<F>(func), std::forward<Args>(args)...)); // 把函数和参数包装成 packaged_task
std::future<ReturnType> result = task->get_future(); // 从 packaged_task 里拿到 future
{ // 加锁作用域开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护任务队列
if (stop_) // 如果线程池已经停止
{ // if 代码块开始
throw std::runtime_error("submit on stopped ThreadPool"); // 禁止继续提交任务
} // if 代码块结束
tasks_.emplace([task]() { (*task)(); }); // 把任务放入队列
} // 加锁作用域结束
condition_.notify_one(); // 唤醒一个等待中的工作线程
return result; // 返回 future,调用者可用 get 等待结果
} // Submit 函数体结束
private: // 私有实现开始
void WorkerLoop() // 工作线程循环函数
{ // WorkerLoop 函数体开始
while (true) // 工作线程持续循环
{ // while 循环体开始
std::function<void()> task; // 保存当前要执行的任务
{ // 加锁作用域开始
std::unique_lock<std::mutex> lock(mutex_); // 使用 unique_lock 配合条件变量等待
condition_.wait(lock, [this]() { return stop_ || !tasks_.empty(); }); // 没任务时睡眠,有任务或停止时醒来
if (stop_ && tasks_.empty()) // 如果已经停止并且没有剩余任务
{ // if 代码块开始
return; // 退出工作线程
} // if 代码块结束
task = std::move(tasks_.front()); // 取出队首任务
tasks_.pop(); // 从队列移除任务
} // 加锁作用域结束
task(); // 在锁外执行任务,避免阻塞其他线程取任务
} // while 循环体结束
} // WorkerLoop 函数体结束
std::vector<std::thread> workers_; // 保存所有工作线程
std::queue<std::function<void()>> tasks_; // 保存待执行任务队列
std::mutex mutex_; // 保护任务队列和 stop_ 标志
std::condition_variable condition_; // 用来唤醒等待任务的工作线程
bool stop_ = false; // 标记线程池是否正在停止
}; // ThreadPool 类结束底层原理
工作线程并不是一直 while 空转,而是用 condition_variable.wait() 睡眠。主线程提交任务后调用 notify_one(),唤醒一个线程来取任务。析构时设置 stop_ = true,再 notify_all(),让所有线程醒来检查退出条件,最后 join() 等它们结束。
面试加分点
任务执行一定要放在锁外,否则一个任务跑很久会卡住整个任务队列。 wait 要带谓词,因为条件变量可能虚假唤醒。 游戏里线程池适合做资源解压、寻路、日志、网络解析、IO,但不能在子线程直接操作 Unity API。
实现一个任务队列
标准答案
任务队列的核心是:生产者把任务安全放入队列,消费者从队列安全取出任务;队列为空时消费者阻塞等待,停止时唤醒所有等待线程退出。
代码实现
c
#include <condition_variable> // 使用条件变量,让消费者在没有任务时阻塞等待
#include <mutex> // 使用互斥锁保护任务队列
#include <queue> // 使用 std::queue 保存任务
#include <utility> // 使用 std::move 移动任务对象
template <typename T> // 定义模板任务队列,T 可以是 std::function<void()> 或自定义任务结构
class TaskQueue // 定义线程安全任务队列
{ // 类体开始
public: // 公有接口开始
bool Push(T task) // 生产者调用 Push 提交任务
{ // Push 函数体开始
{ // 加锁作用域开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护队列和 stopped_ 状态
if (stopped_) // 如果队列已经停止
{ // if 代码块开始
return false; // 停止后拒绝新任务
} // if 代码块结束
queue_.push(std::move(task)); // 把任务移动进队列
} // 加锁作用域结束
condition_.notify_one(); // 唤醒一个等待中的消费者
return true; // 入队成功
} // Push 函数体结束
bool TryPop(T& out) // 非阻塞取任务
{ // TryPop 函数体开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护队列
if (queue_.empty()) // 如果队列为空
{ // if 代码块开始
return false; // 立即返回失败
} // if 代码块结束
out = std::move(queue_.front()); // 取出队首任务
queue_.pop(); // 移除队首任务
return true; // 取任务成功
} // TryPop 函数体结束
bool WaitPop(T& out) // 阻塞等待任务
{ // WaitPop 函数体开始
std::unique_lock<std::mutex> lock(mutex_); // 使用 unique_lock 配合条件变量
condition_.wait(lock, [this]() { return stopped_ || !queue_.empty(); }); // 队列非空或停止时才继续执行
if (stopped_ && queue_.empty()) // 如果已经停止并且没有剩余任务
{ // if 代码块开始
return false; // 返回 false,让消费者线程退出
} // if 代码块结束
out = std::move(queue_.front()); // 取出队首任务
queue_.pop(); // 从队列移除任务
return true; // 取任务成功
} // WaitPop 函数体结束
void Stop() // 停止任务队列
{ // Stop 函数体开始
{ // 加锁作用域开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁修改 stopped_
stopped_ = true; // 标记队列已停止
} // 加锁作用域结束
condition_.notify_all(); // 唤醒所有等待线程,让它们检查退出条件
} // Stop 函数体结束
bool Empty() const // 判断队列是否为空
{ // Empty 函数体开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁读取队列状态
return queue_.empty(); // 返回队列是否为空
} // Empty 函数体结束
private: // 私有成员开始
mutable std::mutex mutex_; // 保护 queue_ 和 stopped_
std::condition_variable condition_; // 用于阻塞和唤醒消费者
std::queue<T> queue_; // 保存任务的 FIFO 队列
bool stopped_ = false; // 标记队列是否停止
}; // TaskQueue 类结束底层原理
Push 加锁入队,然后 notify_one 唤醒一个消费者。WaitPop 在队列为空时会睡眠,不会空转占 CPU。Stop 会设置停止标志并 notify_all,让所有卡在 WaitPop 的线程醒来并退出。
面试加分点
condition_variable.wait 要带谓词,因为它可能虚假唤醒。 任务取出来后再执行,执行任务时不要一直持有队列锁。 游戏里它常用于线程池、日志系统、网络消息分发、资源加载任务队列。
实现一个事件分发器
标准答案
事件分发器的核心是:模块订阅某类事件,事件发生时由分发器找到对应监听者并逐个通知。它常用于 UI 通知、任务系统、成就系统、战斗日志、模块解耦。
代码实现
c
#include <algorithm> // 使用 std::remove_if 删除监听者
#include <cstddef> // 使用 std::size_t 作为监听者 id
#include <functional> // 使用 std::function 保存回调函数
#include <typeindex> // 使用 std::type_index 区分事件类型
#include <unordered_map> // 使用 std::unordered_map 保存事件类型到监听列表的映射
#include <vector> // 使用 std::vector 保存同类型事件的监听者列表
class EventDispatcher // 定义事件分发器
{ // 类体开始
public: // 公有接口开始
struct Handle // 定义订阅句柄,用于取消订阅
{ // Handle 结构体开始
std::type_index type = std::type_index(typeid(void)); // 保存事件类型
std::size_t id = 0; // 保存监听者唯一 id
bool IsValid() const { return id != 0; } // id 不为 0 表示句柄有效
}; // Handle 结构体结束
template <typename Event> // 订阅某一种事件类型
Handle Subscribe(std::function<void(const Event&)> callback) // 传入事件回调
{ // Subscribe 函数体开始
std::type_index type(typeid(Event)); // 获取事件类型
std::size_t id = nextId_++; // 生成唯一监听者 id
auto wrapper = [callback = std::move(callback)](const void* eventPtr) { callback(*static_cast<const Event*>(eventPtr)); }; // 把强类型回调包装成通用回调
listeners_[type].push_back(Listener{id, std::move(wrapper)}); // 把监听者加入对应事件类型列表
return Handle{type, id}; // 返回句柄,方便之后取消订阅
} // Subscribe 函数体结束
bool Unsubscribe(const Handle& handle) // 根据句柄取消订阅
{ // Unsubscribe 函数体开始
if (!handle.IsValid()) { return false; } // 无效句柄直接返回失败
auto it = listeners_.find(handle.type); // 找到对应事件类型的监听列表
if (it == listeners_.end()) { return false; } // 没找到说明没有这类监听者
auto& list = it->second; // 引用监听者列表
std::size_t oldSize = list.size(); // 记录删除前数量
list.erase(std::remove_if(list.begin(), list.end(), [&](const Listener& listener) { return listener.id == handle.id; }), list.end()); // 删除 id 匹配的监听者
bool removed = list.size() != oldSize; // 判断是否真的删掉了监听者
if (list.empty()) { listeners_.erase(it); } // 如果该类型没有监听者了,就清理这个桶
return removed; // 返回是否取消成功
} // Unsubscribe 函数体结束
template <typename Event> // 发布某一种事件
void Emit(const Event& event) // 传入事件数据
{ // Emit 函数体开始
std::type_index type(typeid(Event)); // 获取事件类型
auto it = listeners_.find(type); // 查找对应事件类型的监听列表
if (it == listeners_.end()) { return; } // 没有监听者就直接返回
std::vector<std::function<void(const void*)>> snapshot; // 创建回调快照,避免分发中修改容器
snapshot.reserve(it->second.size()); // 预留空间,减少拷贝开销
for (const Listener& listener : it->second) // 遍历当前监听列表
{ // for 循环体开始
snapshot.push_back(listener.callback); // 把回调复制到快照里
} // for 循环体结束
for (const auto& callback : snapshot) // 按订阅顺序遍历快照
{ // for 循环体开始
callback(&event); // 调用监听者回调
} // for 循环体结束
} // Emit 函数体结束
void Clear() // 清空所有监听者
{ // Clear 函数体开始
listeners_.clear(); // 清空事件表
} // Clear 函数体结束
private: // 私有实现开始
struct Listener // 定义监听者数据
{ // Listener 结构体开始
std::size_t id = 0; // 监听者唯一 id
std::function<void(const void*)> callback; // 通用事件回调
}; // Listener 结构体结束
std::unordered_map<std::type_index, std::vector<Listener>> listeners_; // 事件类型到监听列表的映射
std::size_t nextId_ = 1; // 下一个监听者 id,从 1 开始
}; // EventDispatcher 类结束使用示例
c
struct PlayerDiedEvent { int playerId = 0; }; // 定义玩家死亡事件
EventDispatcher dispatcher; // 创建事件分发器
auto handle = dispatcher.Subscribe<PlayerDiedEvent>([](const PlayerDiedEvent& event) { (void)event; }); // 订阅玩家死亡事件
dispatcher.Emit(PlayerDiedEvent{1001}); // 发布玩家死亡事件
dispatcher.Unsubscribe(handle); // 生命周期结束时取消订阅面试加分点
这个版本用 std::vector 保存监听者,所以同一种事件会按订阅顺序分发。Emit 前复制回调快照,是为了避免回调里取消订阅导致正在遍历的容器失效。
真实项目里还要考虑:是否线程安全、异常是否捕获、是否支持优先级、是否允许事件递归触发、UI 或对象销毁时是否自动取消订阅。
实现一个观察者模式
标准答案
观察者模式的核心是:Subject 保存一组 Observer,Subject 状态变化时调用 Notify,让所有 Observer 收到通知并各自更新。它适合角色属性变化通知 UI、任务状态变化、配置更新通知、战斗日志等场景。
代码实现
c
#include <algorithm> // 使用 std::remove 删除观察者
#include <vector> // 使用 std::vector 保存观察者列表
class IObserver // 定义观察者接口
{ // 接口类开始
public: // 公有接口开始
virtual ~IObserver() = default; // 虚析构,保证通过基类指针删除派生类安全
virtual void OnNotify(int hp) = 0; // 收到通知时调用,示例里传递玩家血量
}; // IObserver 接口结束
class Subject // 定义被观察者
{ // 类体开始
public: // 公有接口开始
void Attach(IObserver* observer) // 添加观察者
{ // Attach 函数体开始
if (observer == nullptr) // 如果传入空指针
{ // if 代码块开始
return; // 不添加空观察者
} // if 代码块结束
observers_.push_back(observer); // 把观察者加入列表
} // Attach 函数体结束
void Detach(IObserver* observer) // 移除观察者
{ // Detach 函数体开始
observers_.erase(std::remove(observers_.begin(), observers_.end(), observer), observers_.end()); // 从列表中删除指定观察者
} // Detach 函数体结束
void SetHp(int hp) // 修改被观察者状态
{ // SetHp 函数体开始
hp_ = hp; // 更新血量
Notify(); // 状态变化后通知所有观察者
} // SetHp 函数体结束
int GetHp() const // 获取当前血量
{ // GetHp 函数体开始
return hp_; // 返回血量
} // GetHp 函数体结束
private: // 私有实现开始
void Notify() // 通知所有观察者
{ // Notify 函数体开始
std::vector<IObserver*> snapshot = observers_; // 复制一份快照,避免通知过程中 Detach 导致遍历失效
for (IObserver* observer : snapshot) // 遍历观察者快照
{ // for 循环体开始
if (observer != nullptr) // 如果观察者指针有效
{ // if 代码块开始
observer->OnNotify(hp_); // 调用观察者回调
} // if 代码块结束
} // for 循环体结束
} // Notify 函数体结束
int hp_ = 100; // 被观察者状态,这里用玩家血量举例
std::vector<IObserver*> observers_; // 保存观察者列表
}; // Subject 类结束
class HealthBar : public IObserver // 定义血条 UI 观察者
{ // HealthBar 类开始
public: // 公有接口开始
void OnNotify(int hp) override // 实现观察者通知接口
{ // OnNotify 函数体开始
lastHp_ = hp; // 收到通知后刷新自己的血量显示数据
} // OnNotify 函数体结束
private: // 私有成员开始
int lastHp_ = 0; // 记录 UI 当前显示的血量
}; // HealthBar 类结束使用示例
c
int main() // 程序入口
{ // main 函数体开始
Subject player; // 创建被观察者,这里代表玩家属性
HealthBar healthBar; // 创建观察者,这里代表血条 UI
player.Attach(&healthBar); // 血条订阅玩家血量变化
player.SetHp(30); // 玩家血量变化,自动通知血条
player.Detach(&healthBar); // 血条销毁或关闭前取消订阅
return 0; // 程序正常结束
} // main 函数体结束面试加分点
观察者模式的好处是 Subject 不需要知道具体有哪些 UI、日志、提示模块,只负责通知接口,模块之间耦合更低。 常见坑是观察者销毁前忘记 Detach,Subject 里留下悬空指针;还有通知过程中观察者列表被修改,所以我这里用快照避免遍历失效。
实现一个状态机模板
标准答案
状态机模板的核心是:把不同状态的行为拆到不同 State 类里,StateMachine 只负责保存当前状态、每帧更新当前状态、按顺序切换状态。 切换顺序一定是:旧状态 OnExit → 替换当前状态 → 新状态 OnEnter。
代码实现
c
#include <memory> // 使用 std::unique_ptr 管理状态对象生命周期
#include <utility> // 使用 std::forward 和 std::move 做参数转发和所有权移动
template <typename Owner> // Owner 表示状态机所属对象,比如角色、怪物、UI 窗口
class IState // 定义状态接口
{ // IState 类体开始
public: // 公有接口开始
virtual ~IState() = default; // 虚析构,保证通过基类指针销毁派生状态安全
virtual void OnEnter(Owner& owner) { (void)owner; } // 进入状态时调用,默认什么都不做
virtual void OnUpdate(Owner& owner, float deltaTime) { (void)owner; (void)deltaTime; } // 每帧更新状态,默认什么都不做
virtual void OnExit(Owner& owner) { (void)owner; } // 退出状态时调用,默认什么都不做
}; // IState 类体结束
template <typename Owner> // 定义状态机模板
class StateMachine // 状态机负责持有和切换当前状态
{ // StateMachine 类体开始
public: // 公有接口开始
explicit StateMachine(Owner& owner) // 构造函数,绑定状态机所属对象
: owner_(owner) // 保存 owner 引用
{ // 构造函数体开始
} // 构造函数体结束
template <typename State, typename... Args> // 切换到某个具体状态类型
void ChangeTo(Args&&... args) // 支持给状态构造函数传参数
{ // ChangeTo 函数体开始
ChangeState(std::make_unique<State>(std::forward<Args>(args)...)); // 创建新状态并交给 ChangeState 切换
} // ChangeTo 函数体结束
void ChangeState(std::unique_ptr<IState<Owner>> nextState) // 切换到一个已经创建好的状态对象
{ // ChangeState 函数体开始
if (currentState_ != nullptr) { currentState_->OnExit(owner_); } // 如果有旧状态,先调用旧状态退出逻辑
currentState_ = std::move(nextState); // 用新状态替换旧状态
if (currentState_ != nullptr) { currentState_->OnEnter(owner_); } // 如果新状态有效,再调用新状态进入逻辑
} // ChangeState 函数体结束
void Update(float deltaTime) // 每帧更新状态机
{ // Update 函数体开始
if (currentState_ != nullptr) { currentState_->OnUpdate(owner_, deltaTime); } // 把更新转发给当前状态
} // Update 函数体结束
IState<Owner>* Current() const // 获取当前状态指针
{ // Current 函数体开始
return currentState_.get(); // 返回当前状态裸指针,不转移所有权
} // Current 函数体结束
private: // 私有成员开始
Owner& owner_; // 状态机所属对象引用
std::unique_ptr<IState<Owner>> currentState_; // 当前状态对象
}; // StateMachine 类体结束使用示例
c
struct Character // 定义角色对象
{ // Character 结构体开始
bool hasInput = false; // 是否有移动输入
float speed = 0.0f; // 当前移动速度
StateMachine<Character> fsm; // 角色持有一个状态机
Character() : fsm(*this) {} // 构造角色时,把自己传给状态机
}; // Character 结构体结束
class MoveState : public IState<Character> // 定义移动状态
{ // MoveState 类体开始
public: // 公有接口开始
void OnEnter(Character& owner) override { owner.speed = 5.0f; } // 进入移动状态时设置移动速度
void OnUpdate(Character& owner, float deltaTime) override { (void)owner; (void)deltaTime; } // 移动状态每帧更新移动逻辑
void OnExit(Character& owner) override { owner.speed = 0.0f; } // 退出移动状态时清空速度
}; // MoveState 类体结束
class IdleState : public IState<Character> // 定义待机状态
{ // IdleState 类体开始
public: // 公有接口开始
void OnEnter(Character& owner) override { owner.speed = 0.0f; } // 进入待机状态时速度为 0
void OnUpdate(Character& owner, float deltaTime) override { (void)deltaTime; if (owner.hasInput) { owner.fsm.ChangeTo<MoveState>(); } } // 如果有输入,就切换到移动状态
void OnExit(Character& owner) override { (void)owner; } // 退出待机状态时这里暂时不需要额外处理
}; // IdleState 类体结束
int main() // 程序入口
{ // main 函数体开始
Character character; // 创建角色
character.fsm.ChangeTo<IdleState>(); // 初始进入待机状态
character.hasInput = true; // 模拟玩家开始输入
character.fsm.Update(0.016f); // 更新状态机,IdleState 会切换到 MoveState
return 0; // 程序正常结束
} // main 函数体结束面试加分点
状态机比一堆 bool 清晰,因为同一时间只允许一个主状态,状态自己的逻辑也更独立。 真实项目里如果状态切换很频繁,可以把状态对象缓存起来,避免频繁 new/delete。如果状态很多,还可以扩展成状态栈、层级状态机、带优先级的打断系统。
实现一个字符串类
标准答案
字符串类核心是:对象里保存 char\* 和长度,构造时申请堆内存,析构时释放;拷贝必须深拷贝,移动可以直接转移指针所有权。
代码实现
c
#include <cstring> // 使用 std::strlen 和 std::memcpy
#include <utility> // 使用 std::swap
class MyString // 定义一个简化版字符串类
{ // 类体开始
public: // 公有接口开始
MyString() : data_(new char[1]{'\0'}), size_(0) {} // 默认构造一个空字符串
MyString(const char* str) // 从 C 字符串构造
: size_(str == nullptr ? 0 : std::strlen(str)) // 如果 str 为空,长度为 0,否则计算字符串长度
, data_(new char[size_ + 1]) // 申请 size + 1 个字符空间,额外 1 个保存 '\0'
{ // 构造函数体开始
if (str != nullptr) { std::memcpy(data_, str, size_); } // 如果传入字符串非空,就复制正文内容
data_[size_] = '\0'; // 手动补上字符串结束符
} // 构造函数体结束
~MyString() // 析构函数
{ // 析构函数体开始
delete[] data_; // 释放堆上的字符数组
} // 析构函数体结束
MyString(const MyString& other) // 拷贝构造函数
: size_(other.size_) // 复制字符串长度
, data_(new char[other.size_ + 1]) // 重新申请一份独立内存
{ // 拷贝构造函数体开始
std::memcpy(data_, other.data_, size_ + 1); // 连同 '\0' 一起复制,完成深拷贝
} // 拷贝构造函数体结束
MyString& operator=(const MyString& other) // 拷贝赋值运算符
{ // 拷贝赋值函数体开始
if (this != &other) // 防止自己给自己赋值
{ // if 代码块开始
MyString temp(other); // 先拷贝出临时对象,保证异常安全
Swap(temp); // 和临时对象交换资源
} // if 代码块结束
return *this; // 返回当前对象
} // 拷贝赋值函数体结束
MyString(MyString&& other) noexcept // 移动构造函数
: data_(other.data_) // 直接接管 other 的字符指针
, size_(other.size_) // 直接接管 other 的长度
{ // 移动构造函数体开始
other.data_ = new char[1]{'\0'}; // 把源对象变成安全的空字符串
other.size_ = 0; // 源对象长度清零
} // 移动构造函数体结束
MyString& operator=(MyString&& other) noexcept // 移动赋值运算符
{ // 移动赋值函数体开始
if (this != &other) // 防止自己移动给自己
{ // if 代码块开始
delete[] data_; // 先释放自己原来的字符数组
data_ = other.data_; // 接管 other 的字符数组
size_ = other.size_; // 接管 other 的长度
other.data_ = new char[1]{'\0'}; // 把 other 置为空字符串
other.size_ = 0; // 清空 other 的长度
} // if 代码块结束
return *this; // 返回当前对象
} // 移动赋值函数体结束
const char* CStr() const // 获取 C 风格字符串
{ // CStr 函数体开始
return data_; // 返回内部字符数组指针
} // CStr 函数体结束
std::size_t Size() const // 获取字符串长度
{ // Size 函数体开始
return size_; // 返回不包含 '\0' 的长度
} // Size 函数体结束
void Swap(MyString& other) noexcept // 交换两个字符串对象
{ // Swap 函数体开始
std::swap(data_, other.data_); // 交换字符数组指针
std::swap(size_, other.size_); // 交换字符串长度
} // Swap 函数体结束
private: // 私有成员开始
char* data_ = nullptr; // 指向堆上的字符数组
std::size_t size_ = 0; // 字符串长度,不包含 '\0'
}; // MyString 类结束底层原理
如果只复制 char*,两个对象会指向同一块堆内存,析构时就可能 delete[] 同一个地址两次。所以拷贝构造和拷贝赋值必须重新申请内存并复制内容,也就是深拷贝。
移动构造和移动赋值则不用复制字符内容,只需要把指针转移过去,再把源对象改成安全空字符串。
实现一个动态数组
标准答案
动态数组的核心是:底层连续内存 + size 已用元素数 + capacity 已申请空间。 当 PushBack 时如果 size == capacity,就申请更大的内存,把旧元素搬过去,再释放旧内存。
代码实现
c
#include <cstddef> // 使用 std::size_t 表示大小和下标
#include <new> // 使用 ::operator new 和 placement new
#include <stdexcept> // 使用 std::out_of_range 做越界检查
#include <utility> // 使用 std::move 和 std::swap
template <typename T> // 定义模板,让动态数组能存任意类型
class DynamicArray // 定义简化版动态数组
{ // 类体开始
public: // 公有接口开始
DynamicArray() = default; // 默认构造一个空数组
explicit DynamicArray(std::size_t capacity) { Reserve(capacity); } // 构造时预留指定容量
~DynamicArray() { Clear(); ::operator delete(data_); } // 析构时先析构元素,再释放原始内存
DynamicArray(const DynamicArray& other) // 拷贝构造函数
{ // 拷贝构造函数体开始
Reserve(other.size_); // 先申请足够容纳 other 元素的内存
for (std::size_t i = 0; i < other.size_; ++i) // 遍历 other 的所有有效元素
{ // for 循环体开始
new (data_ + i) T(other.data_[i]); // 在原始内存上拷贝构造元素
} // for 循环体结束
size_ = other.size_; // 更新当前数组元素数量
} // 拷贝构造函数体结束
DynamicArray(DynamicArray&& other) noexcept // 移动构造函数
: data_(other.data_), size_(other.size_), capacity_(other.capacity_) // 直接接管 other 的内存、大小和容量
{ // 移动构造函数体开始
other.data_ = nullptr; // 把 other 的指针置空,避免析构时重复释放
other.size_ = 0; // 把 other 的元素数量清零
other.capacity_ = 0; // 把 other 的容量清零
} // 移动构造函数体结束
DynamicArray& operator=(const DynamicArray& other) // 拷贝赋值运算符
{ // 拷贝赋值函数体开始
if (this != &other) // 防止自己给自己赋值
{ // if 代码块开始
DynamicArray temp(other); // 先拷贝出临时对象,保证异常安全
Swap(temp); // 和临时对象交换资源
} // if 代码块结束
return *this; // 返回当前对象
} // 拷贝赋值函数体结束
DynamicArray& operator=(DynamicArray&& other) noexcept // 移动赋值运算符
{ // 移动赋值函数体开始
if (this != &other) // 防止自己移动给自己
{ // if 代码块开始
Clear(); // 先析构当前已有元素
::operator delete(data_); // 再释放当前持有的原始内存
data_ = other.data_; // 接管 other 的内存指针
size_ = other.size_; // 接管 other 的元素数量
capacity_ = other.capacity_; // 接管 other 的容量
other.data_ = nullptr; // 把 other 的指针置空
other.size_ = 0; // 把 other 的元素数量清零
other.capacity_ = 0; // 把 other 的容量清零
} // if 代码块结束
return *this; // 返回当前对象
} // 移动赋值函数体结束
void PushBack(const T& value) // 尾部插入一个左值元素
{ // PushBack 函数体开始
EnsureCapacityForOneMore(); // 确保还有空间放新元素
new (data_ + size_) T(value); // 在尾部位置拷贝构造新元素
++size_; // 元素数量加一
} // PushBack 函数体结束
void PushBack(T&& value) // 尾部插入一个右值元素
{ // PushBack 函数体开始
EnsureCapacityForOneMore(); // 确保还有空间放新元素
new (data_ + size_) T(std::move(value)); // 在尾部位置移动构造新元素
++size_; // 元素数量加一
} // PushBack 函数体结束
void PopBack() // 删除尾部元素
{ // PopBack 函数体开始
if (size_ == 0) { return; } // 空数组不能继续删除
--size_; // 先把 size 回退到最后一个元素位置
data_[size_].~T(); // 手动调用最后一个元素的析构函数
} // PopBack 函数体结束
void Reserve(std::size_t newCapacity) // 预留容量
{ // Reserve 函数体开始
if (newCapacity <= capacity_) { return; } // 新容量不更大就不处理
T* newData = static_cast<T*>(::operator new(sizeof(T) * newCapacity)); // 申请一块新的原始内存
for (std::size_t i = 0; i < size_; ++i) // 遍历旧数组已有元素
{ // for 循环体开始
new (newData + i) T(std::move(data_[i])); // 把旧元素移动构造到新内存
} // for 循环体结束
DestroyElements(); // 析构旧内存里的元素
::operator delete(data_); // 释放旧的原始内存
data_ = newData; // 更新内存指针
capacity_ = newCapacity; // 更新容量
} // Reserve 函数体结束
void Clear() // 清空数组
{ // Clear 函数体开始
DestroyElements(); // 析构所有已构造元素
size_ = 0; // 元素数量清零
} // Clear 函数体结束
T& operator[](std::size_t index) { return data_[index]; } // 不做越界检查的下标访问
const T& operator[](std::size_t index) const { return data_[index]; } // const 版本下标访问
T& At(std::size_t index) // 带越界检查的访问
{ // At 函数体开始
if (index >= size_) { throw std::out_of_range("DynamicArray index out of range"); } // 越界就抛异常
return data_[index]; // 返回指定元素引用
} // At 函数体结束
std::size_t Size() const { return size_; } // 返回当前元素数量
std::size_t Capacity() const { return capacity_; } // 返回当前容量
bool Empty() const { return size_ == 0; } // 判断数组是否为空
void Swap(DynamicArray& other) noexcept // 交换两个动态数组
{ // Swap 函数体开始
std::swap(data_, other.data_); // 交换底层内存指针
std::swap(size_, other.size_); // 交换元素数量
std::swap(capacity_, other.capacity_); // 交换容量
} // Swap 函数体结束
private: // 私有实现开始
void EnsureCapacityForOneMore() // 确保能再插入一个元素
{ // EnsureCapacityForOneMore 函数体开始
if (size_ == capacity_) // 如果当前元素数量已经等于容量
{ // if 代码块开始
std::size_t newCapacity = capacity_ == 0 ? 1 : capacity_ * 2; // 空数组扩到 1,否则容量翻倍
Reserve(newCapacity); // 申请更大的空间并搬迁旧元素
} // if 代码块结束
} // EnsureCapacityForOneMore 函数体结束
void DestroyElements() // 析构所有已构造元素
{ // DestroyElements 函数体开始
for (std::size_t i = 0; i < size_; ++i) // 遍历已构造元素
{ // for 循环体开始
data_[i].~T(); // 手动调用元素析构函数
} // for 循环体结束
} // DestroyElements 函数体结束
T* data_ = nullptr; // 指向原始连续内存
std::size_t size_ = 0; // 已构造元素数量
std::size_t capacity_ = 0; // 已申请容量
}; // DynamicArray 类结束底层原理
size 以内是真正构造好的对象,size 到 capacity 之间只是预留内存,不应该当对象使用。 扩容时会重新申请一块更大的连续内存,把旧元素移动过去,再析构旧元素并释放旧内存。所以扩容后,原来的指针、引用、迭代器都可能失效。
复杂度
随机访问:O(1)。 尾部插入:平均 O(1),触发扩容时 O(n)。 中间插入删除:通常 O(n),因为要搬元素。
实现一个哈希表
标准答案
哈希表就是用“数组 + 哈希函数 + 冲突处理”实现的快速查找结构。 核心流程是:key -> hash(key) -> bucket index -> 在对应桶里查找/插入/删除。
平均情况下,哈希表的插入、查找、删除都是 O(1);但如果大量 key 冲突到同一个桶,最坏会退化到 O(n)。
底层原理
哈希表主要有四个关键点:
- 桶数组:底层是一段数组,每个位置叫一个 bucket。
- 哈希函数:把 key 转成整数哈希值。
- 取模定位:
index = hash(key) % bucketCount。 - 冲突处理:不同 key 可能算到同一个桶,常见做法是链地址法。
- 扩容重哈希:元素太多时,装载因子变高,需要扩容并重新分布元素。
装载因子一般这样算:
c
loadFactor = 元素数量 / 桶数量如果超过阈值,比如 0.75,就扩容。
C++ 简化实现
c
#include <functional> // 使用 std::hash 和 std::equal_to。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <list> // 使用 std::list 作为每个桶里的链表。
#include <string> // 使用 std::string 测试字符串 key。
#include <vector> // 使用 std::vector 作为桶数组。
template <typename K, typename V> // 定义一个支持任意 Key 和 Value 类型的哈希表模板。
class HashTable // 定义哈希表类。
{ // 类开始。
private: // 私有区域开始。
struct Entry // 定义桶中保存的节点结构。
{ // 节点结构开始。
K key; // 保存键。
V value; // 保存值。
}; // 节点结构结束。
std::vector<std::list<Entry>> buckets_; // 桶数组,每个桶里是一条链表。
std::size_t size_; // 当前哈希表中元素数量。
float maxLoadFactor_; // 最大装载因子,超过后扩容。
std::hash<K> hasher_; // 默认哈希函数对象。
std::equal_to<K> equals_; // 默认相等比较函数对象。
std::size_t BucketIndex(const K& key) const // 根据 key 计算桶下标。
{ // 函数开始。
return hasher_(key) % buckets_.size(); // 哈希值对桶数量取模得到下标。
} // 函数结束。
float LoadFactor() const // 计算当前装载因子。
{ // 函数开始。
return static_cast<float>(size_) / static_cast<float>(buckets_.size()); // 元素数量除以桶数量。
} // 函数结束。
void Rehash(std::size_t newBucketCount) // 扩容并重新哈希所有元素。
{ // 函数开始。
std::vector<std::list<Entry>> newBuckets(newBucketCount); // 创建新的桶数组。
for (const auto& bucket : buckets_) // 遍历旧桶数组中的每一个桶。
{ // 外层循环开始。
for (const auto& entry : bucket) // 遍历旧桶中的每一个节点。
{ // 内层循环开始。
std::size_t newIndex = hasher_(entry.key) % newBucketCount; // 用新的桶数量重新计算下标。
newBuckets[newIndex].push_back(entry); // 把旧节点放入新的桶中。
} // 内层循环结束。
} // 外层循环结束。
buckets_.swap(newBuckets); // 用新桶数组替换旧桶数组。
} // 函数结束。
void EnsureCapacityForOneMore() // 插入前检查是否需要扩容。
{ // 函数开始。
float nextLoad = static_cast<float>(size_ + 1) / static_cast<float>(buckets_.size()); // 计算插入一个元素后的装载因子。
if (nextLoad > maxLoadFactor_) // 如果插入后会超过最大装载因子。
{ // if 开始。
Rehash(buckets_.size() * 2); // 桶数量扩大为原来的两倍。
} // if 结束。
} // 函数结束。
public: // 公有区域开始。
explicit HashTable(std::size_t bucketCount = 8, float maxLoadFactor = 0.75f) // 构造函数,默认 8 个桶。
: buckets_(bucketCount == 0 ? 8 : bucketCount), // 如果传入 0,就兜底创建 8 个桶。
size_(0), // 初始元素数量为 0。
maxLoadFactor_(maxLoadFactor) // 保存最大装载因子。
{ // 构造函数开始。
} // 构造函数结束。
void Insert(const K& key, const V& value) // 插入或更新键值对。
{ // 函数开始。
EnsureCapacityForOneMore(); // 插入前先检查是否需要扩容。
std::size_t index = BucketIndex(key); // 计算 key 应该落在哪个桶。
for (auto& entry : buckets_[index]) // 遍历该桶中的所有节点。
{ // 循环开始。
if (equals_(entry.key, key)) // 如果找到了相同 key。
{ // if 开始。
entry.value = value; // 更新旧 value。
return; // 更新完成后直接返回。
} // if 结束。
} // 循环结束。
buckets_[index].push_back(Entry{key, value}); // 没找到相同 key,就追加新节点。
++size_; // 元素数量加一。
} // 函数结束。
V* Find(const K& key) // 查找 key 对应的 value。
{ // 函数开始。
std::size_t index = BucketIndex(key); // 计算 key 对应的桶下标。
for (auto& entry : buckets_[index]) // 遍历该桶链表。
{ // 循环开始。
if (equals_(entry.key, key)) // 如果 key 相等。
{ // if 开始。
return &entry.value; // 返回 value 的地址。
} // if 结束。
} // 循环结束。
return nullptr; // 没找到就返回空指针。
} // 函数结束。
bool Erase(const K& key) // 删除指定 key。
{ // 函数开始。
std::size_t index = BucketIndex(key); // 计算 key 对应的桶下标。
auto& bucket = buckets_[index]; // 拿到对应桶的链表引用。
for (auto it = bucket.begin(); it != bucket.end(); ++it) // 遍历桶中的节点。
{ // 循环开始。
if (equals_(it->key, key)) // 如果找到目标 key。
{ // if 开始。
bucket.erase(it); // 从链表中删除该节点。
--size_; // 元素数量减一。
return true; // 删除成功。
} // if 结束。
} // 循环结束。
return false; // 没找到,删除失败。
} // 函数结束。
bool Contains(const K& key) // 判断 key 是否存在。
{ // 函数开始。
return Find(key) != nullptr; // 能找到 value 就说明存在。
} // 函数结束。
std::size_t Size() const // 获取元素数量。
{ // 函数开始。
return size_; // 返回当前元素数量。
} // 函数结束。
std::size_t BucketCount() const // 获取桶数量。
{ // 函数开始。
return buckets_.size(); // 返回桶数组大小。
} // 函数结束。
float CurrentLoadFactor() const // 获取当前装载因子。
{ // 函数开始。
return LoadFactor(); // 返回内部计算的装载因子。
} // 函数结束。
}; // 类结束。
int main() // 程序入口。
{ // main 开始。
HashTable<std::string, int> table; // 创建一个 string 到 int 的哈希表。
table.Insert("apple", 10); // 插入 apple。
table.Insert("banana", 20); // 插入 banana。
table.Insert("apple", 30); // 再次插入 apple,会更新旧值。
int* value = table.Find("apple"); // 查找 apple 对应的 value。
if (value != nullptr) // 如果找到了 apple。
{ // if 开始。
std::cout << "apple = " << *value << std::endl; // 输出 apple 的值。
} // if 结束。
table.Erase("banana"); // 删除 banana。
std::cout << "size = " << table.Size() << std::endl; // 输出当前元素数量。
return 0; // 程序正常结束。
} // main 结束。面试里可以这样补充
这个实现用了链地址法解决哈希冲突,结构比较直观。真正的 std::unordered_map 会更复杂,比如迭代器、分配器、异常安全、移动语义、桶策略、节点复用等都会处理得更完整。
游戏里哈希表很常用,比如 id -> 配置、entityId -> 角色对象、资源路径 -> 资源句柄、事件名 -> 回调列表。但要注意:哈希表快,不代表没有成本;扩容重哈希可能造成瞬时开销,所以性能敏感场景可以提前 reserve 或预估容量。
实现一个二叉搜索树
标准答案
二叉搜索树 BST 是一种“按大小关系组织节点”的树: 对任意节点来说,左子树所有节点都比它小,右子树所有节点都比它大。
所以查找、插入、删除时,不需要遍历整棵树,而是从根节点开始比较:
key < node.key:去左子树key > node.key:去右子树key == node.key:命中节点
普通 BST 平均复杂度是 O(log n),但如果插入顺序已经有序,比如 1,2,3,4,5,它会退化成链表,最坏复杂度变成 O(n)。
C++ 实现
c
#include <functional> // 使用 std::less 作为默认比较器。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <string> // 使用 std::string 作为测试用 key。
template <typename K, typename V> // 定义支持任意 Key 和 Value 的二叉搜索树模板。
class BinarySearchTree // 定义二叉搜索树类。
{ // 类开始。
private: // 私有区域开始。
struct Node // 定义树节点结构。
{ // 节点结构开始。
K key; // 保存节点的 key。
V value; // 保存节点的 value。
Node* left; // 指向左孩子,左孩子 key 更小。
Node* right; // 指向右孩子,右孩子 key 更大。
Node(const K& k, const V& v) // 节点构造函数。
: key(k), // 初始化 key。
value(v), // 初始化 value。
left(nullptr), // 初始左孩子为空。
right(nullptr) // 初始右孩子为空。
{ // 构造函数开始。
} // 构造函数结束。
}; // 节点结构结束。
Node* root_; // 保存整棵树的根节点。
std::size_t size_; // 保存当前节点数量。
std::less<K> less_; // 保存 key 的比较器,默认使用小于比较。
Node* FindMin(Node* node) const // 查找某棵子树中的最小节点。
{ // 函数开始。
while (node != nullptr && node->left != nullptr) // 只要左孩子存在,就继续向左走。
{ // 循环开始。
node = node->left; // 移动到左孩子。
} // 循环结束。
return node; // 返回最左边的节点,也就是最小节点。
} // 函数结束。
Node* Remove(Node* node, const K& key, bool& removed) // 递归删除指定 key。
{ // 函数开始。
if (node == nullptr) // 如果当前子树为空。
{ // if 开始。
return nullptr; // 没找到要删除的节点,直接返回空。
} // if 结束。
if (less_(key, node->key)) // 如果目标 key 比当前节点小。
{ // if 开始。
node->left = Remove(node->left, key, removed); // 去左子树删除,并接回新的左子树根。
} // if 结束。
else if (less_(node->key, key)) // 如果目标 key 比当前节点大。
{ // else if 开始。
node->right = Remove(node->right, key, removed); // 去右子树删除,并接回新的右子树根。
} // else if 结束。
else // 否则说明当前节点就是要删除的节点。
{ // else 开始。
removed = true; // 标记删除成功。
if (node->left == nullptr && node->right == nullptr) // 情况一:当前节点是叶子节点。
{ // if 开始。
delete node; // 直接释放当前节点。
return nullptr; // 父节点对应孩子指针应该变成空。
} // if 结束。
if (node->left == nullptr) // 情况二:当前节点只有右孩子。
{ // if 开始。
Node* rightChild = node->right; // 暂存右孩子。
delete node; // 释放当前节点。
return rightChild; // 让父节点直接接上右孩子。
} // if 结束。
if (node->right == nullptr) // 情况二:当前节点只有左孩子。
{ // if 开始。
Node* leftChild = node->left; // 暂存左孩子。
delete node; // 释放当前节点。
return leftChild; // 让父节点直接接上左孩子。
} // if 结束。
Node* successor = FindMin(node->right); // 情况三:双孩子,找右子树最小节点作为后继。
node->key = successor->key; // 用后继节点的 key 覆盖当前节点。
node->value = successor->value; // 用后继节点的 value 覆盖当前节点。
bool ignored = false; // 删除后继时不影响外层删除结果。
node->right = Remove(node->right, successor->key, ignored); // 从右子树中删除那个后继节点。
} // else 结束。
return node; // 返回当前子树新的根节点。
} // 函数结束。
void Clear(Node* node) // 递归释放整棵树。
{ // 函数开始。
if (node == nullptr) // 如果节点为空。
{ // if 开始。
return; // 空节点不用释放。
} // if 结束。
Clear(node->left); // 先释放左子树。
Clear(node->right); // 再释放右子树。
delete node; // 最后释放当前节点。
} // 函数结束。
void InOrder(Node* node) const // 中序遍历。
{ // 函数开始。
if (node == nullptr) // 如果当前节点为空。
{ // if 开始。
return; // 直接返回。
} // if 结束。
InOrder(node->left); // 先遍历左子树。
std::cout << node->key << ":" << node->value << " "; // 输出当前节点。
InOrder(node->right); // 再遍历右子树。
} // 函数结束。
public: // 公有区域开始。
BinarySearchTree() // 构造函数。
: root_(nullptr), // 初始根节点为空。
size_(0) // 初始节点数量为 0。
{ // 构造函数开始。
} // 构造函数结束。
~BinarySearchTree() // 析构函数。
{ // 析构函数开始。
Clear(root_); // 释放整棵树。
} // 析构函数结束。
void Insert(const K& key, const V& value) // 插入或更新节点。
{ // 函数开始。
if (root_ == nullptr) // 如果当前树为空。
{ // if 开始。
root_ = new Node(key, value); // 创建根节点。
++size_; // 节点数量加一。
return; // 插入完成。
} // if 结束。
Node* current = root_; // 从根节点开始查找插入位置。
while (true) // 持续向下比较。
{ // 循环开始。
if (less_(key, current->key)) // 如果新 key 比当前节点小。
{ // if 开始。
if (current->left == nullptr) // 如果左孩子为空。
{ // if 开始。
current->left = new Node(key, value); // 在左孩子位置创建新节点。
++size_; // 节点数量加一。
return; // 插入完成。
} // if 结束。
current = current->left; // 否则继续去左子树。
} // if 结束。
else if (less_(current->key, key)) // 如果新 key 比当前节点大。
{ // else if 开始。
if (current->right == nullptr) // 如果右孩子为空。
{ // if 开始。
current->right = new Node(key, value); // 在右孩子位置创建新节点。
++size_; // 节点数量加一。
return; // 插入完成。
} // if 结束。
current = current->right; // 否则继续去右子树。
} // else if 结束。
else // 否则说明 key 已经存在。
{ // else 开始。
current->value = value; // 更新旧 value。
return; // 更新完成。
} // else 结束。
} // 循环结束。
} // 函数结束。
V* Find(const K& key) // 查找 key 对应的 value。
{ // 函数开始。
Node* current = root_; // 从根节点开始。
while (current != nullptr) // 只要当前节点不为空就继续查找。
{ // 循环开始。
if (less_(key, current->key)) // 如果目标 key 更小。
{ // if 开始。
current = current->left; // 去左子树查找。
} // if 结束。
else if (less_(current->key, key)) // 如果目标 key 更大。
{ // else if 开始。
current = current->right; // 去右子树查找。
} // else if 结束。
else // 否则说明 key 相等。
{ // else 开始。
return ¤t->value; // 返回 value 地址。
} // else 结束。
} // 循环结束。
return nullptr; // 没找到返回空指针。
} // 函数结束。
bool Erase(const K& key) // 删除指定 key。
{ // 函数开始。
bool removed = false; // 记录是否真的删除成功。
root_ = Remove(root_, key, removed); // 从根节点开始递归删除。
if (removed) // 如果删除成功。
{ // if 开始。
--size_; // 节点数量减一。
} // if 结束。
return removed; // 返回删除结果。
} // 函数结束。
bool Contains(const K& key) // 判断 key 是否存在。
{ // 函数开始。
return Find(key) != nullptr; // 能找到 value 就说明存在。
} // 函数结束。
std::size_t Size() const // 获取节点数量。
{ // 函数开始。
return size_; // 返回节点数量。
} // 函数结束。
void PrintInOrder() const // 打印中序遍历结果。
{ // 函数开始。
InOrder(root_); // 中序遍历整棵树。
std::cout << std::endl; // 输出换行。
} // 函数结束。
}; // 类结束。
int main() // 程序入口。
{ // main 开始。
BinarySearchTree<int, std::string> tree; // 创建 int 到 string 的二叉搜索树。
tree.Insert(50, "root"); // 插入根节点 50。
tree.Insert(30, "left"); // 插入节点 30。
tree.Insert(70, "right"); // 插入节点 70。
tree.Insert(20, "left-left"); // 插入节点 20。
tree.Insert(40, "left-right"); // 插入节点 40。
tree.Insert(60, "right-left"); // 插入节点 60。
tree.Insert(80, "right-right"); // 插入节点 80。
tree.PrintInOrder(); // 中序输出,结果按 key 从小到大。
std::string* value = tree.Find(60); // 查找 key 为 60 的节点。
if (value != nullptr) // 如果查找成功。
{ // if 开始。
std::cout << "find 60 = " << *value << std::endl; // 输出查找结果。
} // if 结束。
tree.Erase(70); // 删除 key 为 70 的节点,这里是双孩子情况。
tree.PrintInOrder(); // 再次中序输出,验证删除后仍然有序。
return 0; // 程序正常结束。
} // main 结束。面试重点
删除节点是 BST 最容易被追问的地方:
- 删除叶子节点:直接删。
- 删除只有一个孩子的节点:父节点接上它的孩子。
- 删除有两个孩子的节点:找“后继节点”,也就是右子树最小节点,用它替换当前节点。
普通 BST 的问题是“不保证平衡”。如果数据插入顺序很差,它会退化成链表。工程里更常用红黑树、AVL 树,比如 C++ 的 std::map 通常就是红黑树思想。
实现一个最小堆
标准答案
最小堆是一种特殊的完全二叉树: 每个父节点都小于等于它的孩子节点,所以堆顶永远是当前最小值。
它通常不用指针建树,而是用数组存:
parent = (i - 1) / 2left = i * 2 + 1right = i * 2 + 2
插入元素时放到数组末尾,然后“上浮”。 删除堆顶时,把最后一个元素放到堆顶,然后“下沉”。
复杂度
Top():O(1)Push():O(log n)Pop():O(log n)- 空间复杂度:
O(n)
C++ 实现
c
#include <functional> // 使用 std::less 作为默认比较器。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <stdexcept> // 使用 std::out_of_range 抛出越界异常。
#include <utility> // 使用 std::swap 交换元素。
#include <vector> // 使用 std::vector 作为堆的底层数组。
template <typename T, typename Compare = std::less<T>> // 定义最小堆模板,默认比较规则是小于。
class MinHeap // 定义最小堆类。
{ // 类开始。
private: // 私有区域开始。
std::vector<T> data_; // 用数组保存完全二叉树的层序遍历结果。
Compare compare_; // 保存比较器,compare_(a, b) 为 true 表示 a 应该排在 b 前面。
std::size_t Parent(std::size_t index) const // 根据当前下标计算父节点下标。
{ // 函数开始。
return (index - 1) / 2; // 完全二叉树数组表示中,父节点公式是 (i - 1) / 2。
} // 函数结束。
std::size_t LeftChild(std::size_t index) const // 根据当前下标计算左孩子下标。
{ // 函数开始。
return index * 2 + 1; // 完全二叉树数组表示中,左孩子公式是 i * 2 + 1。
} // 函数结束。
std::size_t RightChild(std::size_t index) const // 根据当前下标计算右孩子下标。
{ // 函数开始。
return index * 2 + 2; // 完全二叉树数组表示中,右孩子公式是 i * 2 + 2。
} // 函数结束。
void SiftUp(std::size_t index) // 插入后向上调整。
{ // 函数开始。
while (index > 0) // 只要当前节点不是根节点,就可能继续上浮。
{ // 循环开始。
std::size_t parent = Parent(index); // 计算父节点下标。
if (!compare_(data_[index], data_[parent])) // 如果当前节点不比父节点更小,说明堆性质已经满足。
{ // if 开始。
break; // 停止上浮。
} // if 结束。
std::swap(data_[index], data_[parent]); // 当前节点更小,就和父节点交换。
index = parent; // 当前节点位置更新为父节点位置。
} // 循环结束。
} // 函数结束。
void SiftDown(std::size_t index) // 删除堆顶后向下调整。
{ // 函数开始。
while (true) // 持续向下比较,直到堆性质恢复。
{ // 循环开始。
std::size_t left = LeftChild(index); // 计算左孩子下标。
std::size_t right = RightChild(index); // 计算右孩子下标。
std::size_t smallest = index; // 先假设当前节点是最小的。
if (left < data_.size() && compare_(data_[left], data_[smallest])) // 如果左孩子存在,并且左孩子更小。
{ // if 开始。
smallest = left; // 记录当前最小节点为左孩子。
} // if 结束。
if (right < data_.size() && compare_(data_[right], data_[smallest])) // 如果右孩子存在,并且右孩子更小。
{ // if 开始。
smallest = right; // 记录当前最小节点为右孩子。
} // if 结束。
if (smallest == index) // 如果当前节点已经比两个孩子都小。
{ // if 开始。
break; // 停止下沉。
} // if 结束。
std::swap(data_[index], data_[smallest]); // 当前节点比某个孩子大,就和更小的孩子交换。
index = smallest; // 继续从交换后的位置向下调整。
} // 循环结束。
} // 函数结束。
public: // 公有区域开始。
MinHeap() = default; // 使用默认构造函数。
bool Empty() const // 判断堆是否为空。
{ // 函数开始。
return data_.empty(); // 返回底层数组是否为空。
} // 函数结束。
std::size_t Size() const // 获取堆中元素数量。
{ // 函数开始。
return data_.size(); // 返回底层数组大小。
} // 函数结束。
const T& Top() const // 获取堆顶最小元素。
{ // 函数开始。
if (data_.empty()) // 如果堆为空。
{ // if 开始。
throw std::out_of_range("heap is empty"); // 空堆不能取堆顶,抛出异常。
} // if 结束。
return data_[0]; // 数组 0 号位置就是最小堆的堆顶。
} // 函数结束。
void Push(const T& value) // 插入一个元素。
{ // 函数开始。
data_.push_back(value); // 先把新元素放到数组末尾。
SiftUp(data_.size() - 1); // 从末尾开始向上调整。
} // 函数结束。
void Pop() // 删除堆顶最小元素。
{ // 函数开始。
if (data_.empty()) // 如果堆为空。
{ // if 开始。
throw std::out_of_range("heap is empty"); // 空堆不能删除堆顶,抛出异常。
} // if 结束。
data_[0] = data_.back(); // 用最后一个元素覆盖堆顶。
data_.pop_back(); // 删除最后一个元素。
if (!data_.empty()) // 如果删除后堆里还有元素。
{ // if 开始。
SiftDown(0); // 从根节点开始向下调整。
} // if 结束。
} // 函数结束。
}; // 类结束。
int main() // 程序入口。
{ // main 开始。
MinHeap<int> heap; // 创建一个 int 类型的最小堆。
heap.Push(5); // 插入 5。
heap.Push(3); // 插入 3,3 会上浮。
heap.Push(8); // 插入 8。
heap.Push(1); // 插入 1,1 会一路上浮到堆顶。
heap.Push(6); // 插入 6。
while (!heap.Empty()) // 只要堆不为空,就不断取出最小值。
{ // 循环开始。
std::cout << heap.Top() << " "; // 输出当前堆顶,也就是最小值。
heap.Pop(); // 删除当前堆顶。
} // 循环结束。
std::cout << std::endl; // 输出换行。
return 0; // 程序正常结束。
} // main 结束。面试重点
最小堆不是“完全有序”,它只保证“父节点小于等于孩子节点”。 所以堆顶一定最小,但左右子树内部不一定整体有序。
游戏开发里,最小堆常用于:
- A* 寻路的 OpenList
- 定时器管理,最早触发的任务放堆顶
- 优先队列,比如按威胁值、距离、时间排序
- Top K 问题,比如维护当前最小或最大的一批元素
实现一个 LRU Cache
标准答案
LRU Cache 是一种“最近最少使用淘汰缓存”。 当缓存容量满了,要淘汰“最久没有被访问”的数据。
它的经典实现是:
unordered_map:根据 key 快速找到节点,保证查找O(1)list双向链表:维护访问顺序,保证移动节点和删除尾部O(1)- 链表头:最近使用
- 链表尾:最久未使用
所以 Get 和 Put 平均复杂度都是 O(1)。
C++ 实现
c
#include <iostream> // 引入输入输出库,用于测试打印。
#include <list> // 引入双向链表,用于维护最近使用顺序。
#include <string> // 引入字符串类型,用于测试 key。
#include <unordered_map> // 引入哈希表,用于 key 到链表节点的快速映射。
#include <utility> // 引入 pair,用于保存 key 和 value。
template <typename K, typename V> // 定义一个支持任意 Key 和 Value 的 LRU Cache 模板。
class LRUCache // 定义 LRU Cache 类。
{ // 类开始。
private: // 私有区域开始。
using Node = std::pair<K, V>; // 一个链表节点保存一个 key-value 对。
using ListIt = typename std::list<Node>::iterator; // 定义链表迭代器类型,哈希表会保存它。
std::size_t capacity_; // 缓存最大容量。
std::list<Node> items_; // 双向链表,头部是最近使用,尾部是最久未使用。
std::unordered_map<K, ListIt> index_; // 哈希表,key 映射到链表节点位置。
void Touch(ListIt it) // 把某个已存在节点移动到链表头部。
{ // 函数开始。
items_.splice(items_.begin(), items_, it); // splice 可以 O(1) 把节点移动到头部,不会拷贝节点。
} // 函数结束。
public: // 公有区域开始。
explicit LRUCache(std::size_t capacity) // 构造函数,传入缓存容量。
: capacity_(capacity) // 初始化最大容量。
{ // 构造函数开始。
} // 构造函数结束。
bool Get(const K& key, V& outValue) // 根据 key 获取 value,找到返回 true。
{ // 函数开始。
auto found = index_.find(key); // 先在哈希表中查找 key。
if (found == index_.end()) // 如果哈希表中没有这个 key。
{ // if 开始。
return false; // 表示缓存未命中。
} // if 结束。
Touch(found->second); // 命中后说明这个节点刚被访问,要移动到链表头部。
outValue = found->second->second; // 取出链表节点中的 value。
return true; // 表示缓存命中。
} // 函数结束。
void Put(const K& key, const V& value) // 插入或更新缓存。
{ // 函数开始。
if (capacity_ == 0) // 如果容量为 0。
{ // if 开始。
return; // 什么都不缓存,直接返回。
} // if 结束。
auto found = index_.find(key); // 先判断 key 是否已经存在。
if (found != index_.end()) // 如果 key 已经存在。
{ // if 开始。
found->second->second = value; // 更新链表节点中的 value。
Touch(found->second); // 更新后也算访问,要移动到链表头部。
return; // 更新完成后直接返回。
} // if 结束。
items_.emplace_front(key, value); // 新节点插入链表头部,表示最近使用。
index_[key] = items_.begin(); // 在哈希表中记录 key 到新节点的迭代器。
if (items_.size() > capacity_) // 如果插入后超过容量。
{ // if 开始。
const K& oldKey = items_.back().first; // 链表尾部就是最久未使用节点,先取出它的 key。
index_.erase(oldKey); // 从哈希表中删除旧 key。
items_.pop_back(); // 从链表尾部删除最久未使用节点。
} // if 结束。
} // 函数结束。
std::size_t Size() const // 获取当前缓存元素数量。
{ // 函数开始。
return items_.size(); // 返回链表节点数量。
} // 函数结束。
void Print() const // 打印当前缓存顺序。
{ // 函数开始。
for (const auto& item : items_) // 从链表头到链表尾遍历。
{ // 循环开始。
std::cout << "[" << item.first << ":" << item.second << "] "; // 输出当前节点。
} // 循环结束。
std::cout << std::endl; // 输出换行。
} // 函数结束。
}; // 类结束。
int main() // 程序入口。
{ // main 开始。
LRUCache<int, std::string> cache(2); // 创建容量为 2 的 LRU Cache。
cache.Put(1, "A"); // 插入 key 1。
cache.Put(2, "B"); // 插入 key 2。
cache.Print(); // 打印当前顺序,2 比 1 更新。
std::string value; // 准备接收 Get 的结果。
cache.Get(1, value); // 访问 key 1,key 1 会移动到头部。
cache.Print(); // 打印当前顺序,1 变成最新。
cache.Put(3, "C"); // 插入 key 3,容量超限,会淘汰最旧的 key 2。
cache.Print(); // 打印当前顺序,只剩 3 和 1。
return 0; // 程序正常结束。
} // main 结束。底层原理
如果只用哈希表,虽然查找快,但不知道谁最久没访问。 如果只用链表,虽然能维护顺序,但查找 key 需要 O(n)。
所以 LRU 的关键组合是:
- 哈希表解决“快速定位”
- 双向链表解决“快速移动和删除”
- 命中节点后移动到头部
- 插入新节点也放到头部
- 超过容量就删除尾部
面试重点
LRU 常用于资源缓存、图片缓存、配置缓存、寻路结果缓存等。 在游戏客户端里,如果缓存纹理、音频、Prefab 或寻路结果,就要注意容量控制,否则缓存会变成“永不释放的内存增长”。
实现一个计时器管理器
标准答案
计时器管理器的核心不是“每个计时器开一个线程”,而是由主循环每帧调用:
c
timerManager.Update(deltaTime);然后统一推进所有计时器的剩余时间。 到点后执行回调;循环计时器重置时间;一次性计时器触发后标记删除。
C++ 实现
c
#include <algorithm> // 使用 std::remove_if 清理已取消的计时器。
#include <functional> // 使用 std::function 保存计时器回调。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <vector> // 使用 std::vector 保存所有计时器。
class TimerManager // 定义计时器管理器类。
{ // 类开始。
private: // 私有区域开始。
struct Timer // 定义单个计时器的数据结构。
{ // 结构体开始。
int id; // 计时器唯一 ID,用于取消计时器。
float remaining; // 当前剩余触发时间。
float interval; // 循环计时器的触发间隔。
int repeatCount; // 剩余触发次数,-1 表示无限循环。
bool canceled; // 是否已经被取消。
std::function<void()> callback; // 到点后执行的回调函数。
}; // 结构体结束。
std::vector<Timer> timers_; // 保存当前所有计时器。
int nextId_ = 1; // 下一个可用的计时器 ID。
int AddTimer(float delay, float interval, int repeatCount, std::function<void()> callback) // 添加通用计时器。
{ // 函数开始。
Timer timer; // 创建一个新的计时器对象。
timer.id = nextId_++; // 分配唯一 ID。
timer.remaining = delay < 0.0f ? 0.0f : delay; // 负延迟没有意义,所以夹到 0。
timer.interval = interval; // 保存循环间隔。
timer.repeatCount = repeatCount; // 保存触发次数。
timer.canceled = false; // 新计时器默认没有取消。
timer.callback = callback; // 保存回调函数。
timers_.push_back(timer); // 放入计时器列表。
return timer.id; // 返回 ID,外部可用它取消计时器。
} // 函数结束。
public: // 公有区域开始。
int AddOnce(float delay, std::function<void()> callback) // 添加一次性计时器。
{ // 函数开始。
return AddTimer(delay, 0.0f, 1, callback); // 一次性计时器只触发 1 次。
} // 函数结束。
int AddRepeating(float interval, int repeatCount, std::function<void()> callback) // 添加循环计时器。
{ // 函数开始。
if (interval <= 0.0f) // 如果间隔小于等于 0。
{ // if 开始。
return -1; // 返回无效 ID,避免死循环触发。
} // if 结束。
if (repeatCount == 0) // 如果要求触发 0 次。
{ // if 开始。
return -1; // 没有意义,直接返回无效 ID。
} // if 结束。
return AddTimer(interval, interval, repeatCount, callback); // 第一次等待 interval 秒,之后每 interval 秒触发一次。
} // 函数结束。
void Cancel(int id) // 根据 ID 取消计时器。
{ // 函数开始。
for (Timer& timer : timers_) // 遍历所有计时器。
{ // 循环开始。
if (timer.id == id) // 如果找到目标计时器。
{ // if 开始。
timer.canceled = true; // 只打取消标记,不在遍历中直接删除。
return; // 取消完成后返回。
} // if 结束。
} // 循环结束。
} // 函数结束。
void Update(float deltaTime) // 每帧推进计时器。
{ // 函数开始。
if (deltaTime < 0.0f) // 如果传入负时间。
{ // if 开始。
deltaTime = 0.0f; // 负时间没有意义,夹到 0。
} // if 结束。
std::vector<std::function<void()>> callbacks; // 暂存本帧需要触发的回调。
for (Timer& timer : timers_) // 遍历所有计时器。
{ // 循环开始。
if (timer.canceled) // 如果计时器已经取消。
{ // if 开始。
continue; // 跳过这个计时器。
} // if 结束。
timer.remaining -= deltaTime; // 扣减剩余时间。
while (timer.remaining <= 0.0f && !timer.canceled) // 如果已经到点,就可能触发一次或多次。
{ // while 开始。
callbacks.push_back(timer.callback); // 先记录回调,避免回调里修改 timers 导致遍历失效。
if (timer.repeatCount == 1) // 如果这是最后一次触发。
{ // if 开始。
timer.canceled = true; // 标记为结束,后面统一清理。
break; // 跳出当前计时器处理。
} // if 结束。
if (timer.repeatCount > 1) // 如果是有限次数循环。
{ // if 开始。
--timer.repeatCount; // 已经触发一次,所以剩余次数减一。
} // if 结束。
timer.remaining += timer.interval; // 循环计时器重置下一次触发时间。
if (timer.interval <= 0.0f) // 如果循环间隔非法。
{ // if 开始。
timer.canceled = true; // 标记取消,避免无限循环。
break; // 跳出当前计时器处理。
} // if 结束。
} // while 结束。
} // 循环结束。
for (const auto& callback : callbacks) // 遍历本帧收集到的所有回调。
{ // 循环开始。
if (callback) // 如果回调有效。
{ // if 开始。
callback(); // 执行回调。
} // if 结束。
} // 循环结束。
timers_.erase( // 从计时器列表中删除无效计时器。
std::remove_if(timers_.begin(), timers_.end(), [](const Timer& timer) // 把已取消计时器移动到末尾。
{ // lambda 开始。
return timer.canceled; // 返回 true 的元素会被清理。
}), // remove_if 调用结束。
timers_.end()); // erase 真正删除尾部无效元素。
} // 函数结束。
void Clear() // 清空所有计时器。
{ // 函数开始。
timers_.clear(); // 删除所有计时器。
} // 函数结束。
}; // 类结束。
int main() // 程序入口。
{ // main 开始。
TimerManager timerManager; // 创建计时器管理器。
timerManager.AddOnce(1.0f, []() // 添加一个 1 秒后触发的一次性计时器。
{ // lambda 开始。
std::cout << "one shot timer fired" << std::endl; // 输出一次性计时器触发信息。
}); // AddOnce 调用结束。
int repeatId = timerManager.AddRepeating(0.5f, 3, []() // 添加一个每 0.5 秒触发一次、触发 3 次的计时器。
{ // lambda 开始。
std::cout << "repeat timer fired" << std::endl; // 输出循环计时器触发信息。
}); // AddRepeating 调用结束。
for (int frame = 0; frame < 10; ++frame) // 模拟 10 帧游戏循环。
{ // 循环开始。
timerManager.Update(0.2f); // 每帧推进 0.2 秒。
} // 循环结束。
timerManager.Cancel(repeatId); // 演示如何取消计时器。
timerManager.Clear(); // 清理所有计时器。
return 0; // 程序正常结束。
} // main 结束。底层原理
这个版本是“简单扫描版”:
- 每帧遍历所有计时器
- 每个计时器扣减
deltaTime - 到点后收集回调
- 本轮遍历结束后再执行回调
- 最后统一清理被取消或结束的计时器
为什么不在遍历中直接删除? 因为遍历 vector 时删除元素会导致迭代器或引用失效,容易出现跳过元素、访问野数据等问题。
工程注意
简单扫描版每帧复杂度是 O(n),适合计时器数量不太大的业务层,比如技能 CD、Buff Tick、UI 倒计时、延迟销毁。
如果计时器非常多,可以优化成:
- 小根堆:按最近触发时间排序,只检查堆顶
- 时间轮:适合大量固定粒度计时器
- 分组计时器:同类 Tick 合并,减少回调数量
实现生产者消费者
标准答案
生产者消费者模型就是: 生产者线程负责生产数据,消费者线程负责处理数据,中间用一个线程安全队列解耦。
经典结构是:
queue:共享缓冲区mutex:保护队列,避免多个线程同时改condition_variable:队列空或满时让线程睡眠等待Stop():程序退出时唤醒所有等待线程,避免卡死
C++ 实现
c
#include <chrono> // 引入时间库,用于模拟生产和消费耗时。
#include <condition_variable> // 引入条件变量,用于线程等待和唤醒。
#include <iostream> // 引入输入输出库,用于打印测试信息。
#include <mutex> // 引入互斥锁,用于保护共享队列。
#include <queue> // 引入队列容器,用于保存生产出来的数据。
#include <thread> // 引入线程库,用于创建生产者和消费者线程。
template <typename T> // 定义一个支持任意数据类型的阻塞队列模板。
class BlockingQueue // 定义阻塞队列类。
{ // 类开始。
private: // 私有区域开始。
std::queue<T> queue_; // 保存数据的普通队列。
std::size_t capacity_; // 队列最大容量,用于实现有界缓冲区。
bool stopped_; // 是否已经停止,用于让等待线程退出。
mutable std::mutex mutex_; // 互斥锁,用于保护 queue_、capacity_ 和 stopped_。
std::condition_variable notEmpty_; // 队列非空条件变量,用于唤醒消费者。
std::condition_variable notFull_; // 队列未满条件变量,用于唤醒生产者。
public: // 公有区域开始。
explicit BlockingQueue(std::size_t capacity) // 构造函数,传入队列容量。
: capacity_(capacity == 0 ? 1 : capacity), // 如果容量传 0,就兜底设置为 1。
stopped_(false) // 初始状态不是停止。
{ // 构造函数开始。
} // 构造函数结束。
bool Push(const T& item) // 生产者调用,把数据放入队列。
{ // 函数开始。
std::unique_lock<std::mutex> lock(mutex_); // 加锁,准备访问共享队列。
notFull_.wait(lock, [this]() // 如果队列满了,生产者就在这里睡眠等待。
{ // 条件谓词开始。
return stopped_ || queue_.size() < capacity_; // 停止了或队列未满时才允许继续。
}); // wait 结束。
if (stopped_) // 如果队列已经停止。
{ // if 开始。
return false; // 返回 false,表示不再接收新数据。
} // if 结束。
queue_.push(item); // 队列未满,把数据放入队列。
lock.unlock(); // 手动解锁,减少被唤醒线程再次抢锁的等待。
notEmpty_.notify_one(); // 通知一个消费者:队列里有数据了。
return true; // 返回 true,表示入队成功。
} // 函数结束。
bool Pop(T& out) // 消费者调用,从队列取出数据。
{ // 函数开始。
std::unique_lock<std::mutex> lock(mutex_); // 加锁,准备访问共享队列。
notEmpty_.wait(lock, [this]() // 如果队列空了,消费者就在这里睡眠等待。
{ // 条件谓词开始。
return stopped_ || !queue_.empty(); // 停止了或队列非空时才允许继续。
}); // wait 结束。
if (queue_.empty()) // 如果醒来后队列仍然为空。
{ // if 开始。
return false; // 说明已经停止并且没有剩余数据,消费者应该退出。
} // if 结束。
out = queue_.front(); // 取出队头数据。
queue_.pop(); // 从队列中删除队头数据。
lock.unlock(); // 手动解锁,减少生产者被唤醒后的等待。
notFull_.notify_one(); // 通知一个生产者:队列有空位了。
return true; // 返回 true,表示出队成功。
} // 函数结束。
void Stop() // 停止队列,唤醒所有等待线程。
{ // 函数开始。
{ // 创建一个局部作用域,用于控制锁的生命周期。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,准备修改 stopped_。
stopped_ = true; // 设置停止标记。
} // 离开作用域后自动解锁。
notEmpty_.notify_all(); // 唤醒所有等待数据的消费者。
notFull_.notify_all(); // 唤醒所有等待空位的生产者。
} // 函数结束。
bool Empty() const // 判断队列是否为空。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,保证读取 queue_ 是线程安全的。
return queue_.empty(); // 返回队列是否为空。
} // 函数结束。
}; // 类结束。
int main() // 程序入口。
{ // main 开始。
BlockingQueue<int> queue(5); // 创建容量为 5 的阻塞队列。
std::mutex coutMutex; // 创建打印锁,避免多个线程同时打印导致输出混乱。
auto producer = [&queue, &coutMutex](int producerId) // 定义生产者函数。
{ // lambda 开始。
for (int i = 0; i < 5; ++i) // 每个生产者生产 5 个数据。
{ // for 开始。
int item = producerId * 100 + i; // 构造一个数据,方便看出来自哪个生产者。
if (!queue.Push(item)) // 尝试把数据放入队列。
{ // if 开始。
return; // 如果队列停止,就直接退出生产者。
} // if 结束。
{ // 创建局部作用域,用于控制打印锁。
std::lock_guard<std::mutex> lock(coutMutex); // 加锁保护 std::cout。
std::cout << "Producer " << producerId << " push " << item << std::endl; // 打印生产信息。
} // 打印锁释放。
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟生产耗时。
} // for 结束。
}; // producer lambda 结束。
auto consumer = [&queue, &coutMutex](int consumerId) // 定义消费者函数。
{ // lambda 开始。
int item = 0; // 用于接收从队列取出的数据。
while (queue.Pop(item)) // 只要能取到数据,就持续消费。
{ // while 开始。
{ // 创建局部作用域,用于控制打印锁。
std::lock_guard<std::mutex> lock(coutMutex); // 加锁保护 std::cout。
std::cout << "Consumer " << consumerId << " pop " << item << std::endl; // 打印消费信息。
} // 打印锁释放。
std::this_thread::sleep_for(std::chrono::milliseconds(150)); // 模拟消费耗时。
} // while 结束。
{ // 创建局部作用域,用于控制打印锁。
std::lock_guard<std::mutex> lock(coutMutex); // 加锁保护 std::cout。
std::cout << "Consumer " << consumerId << " exit" << std::endl; // 打印消费者退出信息。
} // 打印锁释放。
}; // consumer lambda 结束。
std::thread producer1(producer, 1); // 创建第一个生产者线程。
std::thread producer2(producer, 2); // 创建第二个生产者线程。
std::thread consumer1(consumer, 1); // 创建第一个消费者线程。
std::thread consumer2(consumer, 2); // 创建第二个消费者线程。
producer1.join(); // 等待第一个生产者结束。
producer2.join(); // 等待第二个生产者结束。
queue.Stop(); // 生产者都结束后,通知消费者处理完剩余数据并退出。
consumer1.join(); // 等待第一个消费者结束。
consumer2.join(); // 等待第二个消费者结束。
return 0; // 程序正常结束。
} // main 结束。底层原理
这里最关键的是 condition_variable::wait(lock, predicate)。
它做了三件事:
- 条件不满足时,自动释放锁并让线程睡眠
- 被唤醒后,自动重新加锁
- 重新检查条件,防止虚假唤醒
所以不要写成单纯的:
c
if (queue.empty()) wait();应该用谓词版 wait,否则可能被虚假唤醒后误取空队列。
面试重点
生产者消费者常用于:
- 网络线程收包,逻辑线程处理消息
- IO 线程加载资源,主线程取结果
- 日志线程异步写文件
- 任务系统里 worker thread 消费任务队列
在 Unity 里还要注意:子线程可以做 IO、解压、解析、计算,但不能直接操作大多数 Unity API。常见做法是子线程生产结果,主线程消费者在 Update 里取出并应用。
实现读写锁使用场景
标准答案
读写锁适合“读多写少”的场景。 它允许多个读线程同时进入临界区,但写线程进入时必须独占,其他读线程和写线程都要等待。
在 C++17 里常用:
std::shared_mutex:读写锁本体std::shared_lock<std::shared_mutex>:读锁,共享std::unique_lock<std::shared_mutex>:写锁,独占
典型场景
比如游戏客户端里的配置表缓存、资源索引、排行榜快照:
- 战斗系统频繁读技能配置
- UI 系统频繁读道具配置
- 资源系统频繁读资源索引
- 热更新线程偶尔替换配置
这种场景读远多于写,用普通 mutex 会让所有读线程互相阻塞;用读写锁可以让多个读线程并发读。
C++ 实现
c
#include <iostream> // 引入输入输出库,用于打印测试结果。
#include <optional> // 引入 optional,用于表示查询结果可能不存在。
#include <shared_mutex> // 引入 shared_mutex、shared_lock,用于实现读写锁。
#include <string> // 引入 string,用于保存配置名字。
#include <thread> // 引入 thread,用于演示多线程读取和写入。
#include <unordered_map> // 引入 unordered_map,用于保存配置表缓存。
#include <mutex> // 引入 mutex 相关锁类型,unique_lock 也在这里。
struct Config // 定义一个简单配置结构。
{ // 结构体开始。
int id; // 配置 ID。
std::string name; // 配置名字。
int value; // 配置数值。
}; // 结构体结束。
class ConfigStore // 定义一个线程安全的配置缓存。
{ // 类开始。
private: // 私有区域开始。
std::unordered_map<int, Config> configs_; // 保存所有配置,key 是配置 ID。
mutable std::shared_mutex mutex_; // 读写锁,用 mutable 允许 const 读函数中加锁。
public: // 公有区域开始。
void Upsert(const Config& config) // 新增或更新配置,属于写操作。
{ // 函数开始。
std::unique_lock<std::shared_mutex> lock(mutex_); // 写操作使用独占锁,阻塞其他读写。
configs_[config.id] = config; // 写入或覆盖配置数据。
} // 函数结束。
bool Remove(int id) // 删除配置,属于写操作。
{ // 函数开始。
std::unique_lock<std::shared_mutex> lock(mutex_); // 删除会修改容器,所以使用独占锁。
return configs_.erase(id) > 0; // 删除成功返回 true,否则返回 false。
} // 函数结束。
std::optional<Config> Get(int id) const // 查询配置,属于读操作。
{ // 函数开始。
std::shared_lock<std::shared_mutex> lock(mutex_); // 读操作使用共享锁,多个读线程可同时进入。
auto it = configs_.find(id); // 在哈希表中查找配置。
if (it == configs_.end()) // 如果没有找到配置。
{ // if 开始。
return std::nullopt; // 返回空 optional。
} // if 结束。
return it->second; // 返回配置副本,避免外部拿到内部引用后锁释放产生风险。
} // 函数结束。
std::size_t Size() const // 获取配置数量,属于读操作。
{ // 函数开始。
std::shared_lock<std::shared_mutex> lock(mutex_); // 只读查询使用共享锁。
return configs_.size(); // 返回配置数量。
} // 函数结束。
}; // 类结束。
int main() // 程序入口。
{ // main 开始。
ConfigStore store; // 创建配置缓存。
store.Upsert(Config{1, "Sword", 100}); // 写入一条武器配置。
store.Upsert(Config{2, "Shield", 80}); // 写入一条盾牌配置。
auto reader = [&store](int id) // 定义读线程逻辑。
{ // lambda 开始。
for (int i = 0; i < 3; ++i) // 每个读线程读取 3 次。
{ // for 开始。
std::optional<Config> config = store.Get(id); // 使用读锁读取配置。
if (config.has_value()) // 如果读取到了配置。
{ // if 开始。
std::cout << "read " << config->name << " value=" << config->value << std::endl; // 打印配置内容。
} // if 结束。
} // for 结束。
}; // reader lambda 结束。
auto writer = [&store]() // 定义写线程逻辑。
{ // lambda 开始。
store.Upsert(Config{1, "Sword+", 120}); // 使用写锁更新配置。
std::cout << "config updated" << std::endl; // 打印更新信息。
}; // writer lambda 结束。
std::thread readThread1(reader, 1); // 创建第一个读线程。
std::thread readThread2(reader, 2); // 创建第二个读线程。
std::thread writeThread(writer); // 创建写线程。
readThread1.join(); // 等待第一个读线程结束。
readThread2.join(); // 等待第二个读线程结束。
writeThread.join(); // 等待写线程结束。
std::cout << "config count=" << store.Size() << std::endl; // 输出当前配置数量。
return 0; // 程序正常结束。
} // main 结束。底层原理
普通 mutex 是互斥锁:无论读还是写,同一时间只能有一个线程进入。
读写锁更细:
- 多个读者之间不互斥
- 写者和读者互斥
- 写者和写者互斥
所以它适合“读操作非常多,写操作很少”的系统。
常见坑
读写锁不是万能优化:
- 写很多时,读写锁可能比普通
mutex更慢 - 写锁持有太久,读线程会大量等待
- 读锁里不能偷偷修改共享数据
- 不要在锁里做 IO、资源加载、网络请求、复杂回调
- 返回内部引用要小心,锁释放后对象可能被其他写线程修改或删除
实现双缓冲数据交换
标准答案
双缓冲就是准备两份数据:
front buffer:当前给读者读取的稳定快照back buffer:写者在后台准备的新数据- 写完后执行
Swap,让back变成新的front
这样读者不会读到“写了一半的数据”。 游戏里常见于渲染数据、物理快照、网络状态、日志缓存、UI 列表快照。
C++ 实现
c
#include <array> // 使用 std::array 保存两个缓冲区。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <mutex> // 使用 std::mutex 保护交换过程。
#include <utility> // 使用 std::swap 交换 front 和 back 下标。
#include <vector> // 使用 std::vector 模拟一帧中的多个对象数据。
struct FrameSnapshot // 定义一帧快照数据。
{ // 结构体开始。
int frame; // 当前帧号。
std::vector<int> positions; // 模拟多个对象的位置数据。
}; // 结构体结束。
template <typename T> // 定义支持任意数据类型的双缓冲模板。
class DoubleBuffer // 定义双缓冲类。
{ // 类开始。
private: // 私有区域开始。
std::array<T, 2> buffers_; // 保存两个缓冲区。
int frontIndex_; // 当前正在被读取的缓冲区下标。
int backIndex_; // 当前正在被写入的缓冲区下标。
mutable std::mutex mutex_; // 保护 front/back 下标和提交过程。
public: // 公有区域开始。
explicit DoubleBuffer(const T& initialValue) // 构造函数,传入初始数据。
: buffers_{initialValue, initialValue}, // 两个缓冲区都初始化成同一份初始数据。
frontIndex_(0), // 默认 0 号缓冲区作为 front。
backIndex_(1) // 默认 1 号缓冲区作为 back。
{ // 构造函数开始。
} // 构造函数结束。
T ReadSnapshot() const // 读取当前 front 快照。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,保证读取 front 下标时不会和 swap 冲突。
return buffers_[frontIndex_]; // 返回 front 数据副本,外部不会持有内部引用。
} // 函数结束。
void WriteBack(const T& value) // 写入 back buffer。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,保证写 back 时不会和交换过程冲突。
buffers_[backIndex_] = value; // 把新数据写入 back buffer。
} // 函数结束。
void SwapBuffers() // 交换 front 和 back。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,让交换过程成为一个很短的临界区。
std::swap(frontIndex_, backIndex_); // 交换下标,back 变 front,front 变 back。
} // 函数结束。
void Publish(const T& value) // 写入并提交新快照。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,保证写入和交换是一个整体。
buffers_[backIndex_] = value; // 先把新数据写入 back buffer。
std::swap(frontIndex_, backIndex_); // 再交换 front 和 back,让新数据对读者可见。
} // 函数结束。
}; // 类结束。
int main() // 程序入口。
{ // main 开始。
FrameSnapshot initial{0, {0, 0, 0}}; // 创建初始帧快照。
DoubleBuffer<FrameSnapshot> buffer(initial); // 创建双缓冲对象。
FrameSnapshot renderData = buffer.ReadSnapshot(); // 渲染线程读取当前 front 快照。
std::cout << "read frame = " << renderData.frame << std::endl; // 输出当前读取到的帧号。
FrameSnapshot nextFrame{1, {10, 20, 30}}; // 逻辑线程准备下一帧数据。
buffer.Publish(nextFrame); // 提交下一帧数据,让它成为新的 front。
FrameSnapshot newRenderData = buffer.ReadSnapshot(); // 再次读取 front 快照。
std::cout << "read frame = " << newRenderData.frame << std::endl; // 输出新的帧号。
return 0; // 程序正常结束。
} // main 结束。底层原理
双缓冲解决的是“读写冲突”和“半更新数据”问题。
如果只有一份数据,写线程正在改数组时,读线程可能读到一半旧数据、一半新数据。 双缓冲把读写拆开:
- 读者只读
front - 写者准备
back - 写完后用极短时间交换索引
- 下一帧读者读到完整新快照
工程注意
如果数据很大,不建议在锁里做大量拷贝。更好的做法是先在线程本地构造新快照,再用 Publish 快速移动或交换指针。
如果读者需要长期持有数据,也不要直接返回内部引用,可以返回副本、shared_ptr 快照,或者做三缓冲。
实现一个简单日志系统
标准答案
一个简单日志系统至少要有这几件事:
- 日志级别:
Debug / Info / Warn / Error - 级别过滤:低于当前级别的日志不输出
- 格式化:补上时间、级别、消息
- 输出目标:控制台、文件
- 线程安全:多个线程同时写日志时不能串行混乱
简单版可以同步写;如果日志很多,就要升级成异步队列,避免 IO 卡住主逻辑。
C++ 实现
c
#include <chrono> // 引入时间库,用于生成日志时间戳。
#include <ctime> // 引入 C 时间库,用于把时间戳转成本地时间。
#include <fstream> // 引入文件流,用于把日志写入文件。
#include <iomanip> // 引入格式化库,用于格式化时间。
#include <iostream> // 引入输入输出库,用于输出到控制台。
#include <mutex> // 引入互斥锁,用于保证多线程写日志安全。
#include <sstream> // 引入字符串流,用于拼接日志内容。
#include <string> // 引入字符串类型,用于保存日志消息。
enum class LogLevel // 定义日志级别枚举。
{ // 枚举开始。
Debug = 0, // Debug 级别,通常用于开发调试。
Info = 1, // Info 级别,表示普通运行信息。
Warn = 2, // Warn 级别,表示警告但程序还能继续。
Error = 3 // Error 级别,表示错误或异常情况。
}; // 枚举结束。
class Logger // 定义日志系统类。
{ // 类开始。
private: // 私有区域开始。
LogLevel minLevel_; // 当前最小输出级别,低于它的日志会被过滤。
bool consoleEnabled_; // 是否允许输出到控制台。
std::ofstream file_; // 文件输出流,用于写日志文件。
std::mutex mutex_; // 互斥锁,用于保护控制台和文件输出。
const char* LevelName(LogLevel level) const // 把日志级别转成字符串。
{ // 函数开始。
switch (level) // 根据日志级别做分支。
{ // switch 开始。
case LogLevel::Debug: // 如果是 Debug 级别。
return "Debug"; // 返回 Debug 字符串。
case LogLevel::Info: // 如果是 Info 级别。
return "Info"; // 返回 Info 字符串。
case LogLevel::Warn: // 如果是 Warn 级别。
return "Warn"; // 返回 Warn 字符串。
case LogLevel::Error: // 如果是 Error 级别。
return "Error"; // 返回 Error 字符串。
default: // 如果出现未知级别。
return "Unknown"; // 返回 Unknown 字符串。
} // switch 结束。
} // 函数结束。
std::string NowString() const // 获取当前时间字符串。
{ // 函数开始。
auto now = std::chrono::system_clock::now(); // 获取当前系统时间。
std::time_t time = std::chrono::system_clock::to_time_t(now); // 转成 time_t。
std::tm localTime{}; // 创建本地时间结构体。
#if defined(_WIN32) // Windows 平台使用 localtime_s。
localtime_s(&localTime, &time); // 安全地转换成本地时间。
#else // 非 Windows 平台使用 localtime_r。
localtime_r(&time, &localTime); // 安全地转换成本地时间。
#endif // 平台分支结束。
std::ostringstream oss; // 创建字符串流。
oss << std::put_time(&localTime, "%Y-%m-%d %H:%M:%S"); // 格式化年月日时分秒。
return oss.str(); // 返回时间字符串。
} // 函数结束。
std::string FormatLine(LogLevel level, const std::string& message) const // 格式化一整行日志。
{ // 函数开始。
std::ostringstream oss; // 创建字符串流。
oss << "[" << NowString() << "]"; // 拼接时间部分。
oss << "[" << LevelName(level) << "] "; // 拼接日志级别部分。
oss << message; // 拼接用户传入的消息。
return oss.str(); // 返回完整日志行。
} // 函数结束。
public: // 公有区域开始。
Logger() // 构造函数。
: minLevel_(LogLevel::Debug), // 默认输出 Debug 及以上所有日志。
consoleEnabled_(true) // 默认开启控制台输出。
{ // 构造函数开始。
} // 构造函数结束。
void SetLevel(LogLevel level) // 设置最小输出级别。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,保证多线程设置安全。
minLevel_ = level; // 更新最小日志级别。
} // 函数结束。
void EnableConsole(bool enabled) // 设置是否输出到控制台。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,保证多线程设置安全。
consoleEnabled_ = enabled; // 更新控制台输出开关。
} // 函数结束。
bool OpenFile(const std::string& path) // 打开日志文件。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,避免多个线程同时打开文件。
file_.open(path, std::ios::out | std::ios::app); // 以追加模式打开日志文件。
return file_.is_open(); // 返回文件是否打开成功。
} // 函数结束。
void CloseFile() // 关闭日志文件。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,避免关闭时其他线程正在写。
if (file_.is_open()) // 如果文件已经打开。
{ // if 开始。
file_.close(); // 关闭文件。
} // if 结束。
} // 函数结束。
void Log(LogLevel level, const std::string& message) // 输出一条日志。
{ // 函数开始。
if (static_cast<int>(level) < static_cast<int>(minLevel_)) // 如果日志级别低于当前最小级别。
{ // if 开始。
return; // 直接过滤,不输出。
} // if 结束。
std::string line = FormatLine(level, message); // 格式化日志行。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,保证控制台和文件输出不被其他线程打断。
if (consoleEnabled_) // 如果允许输出到控制台。
{ // if 开始。
std::cout << line << std::endl; // 输出到控制台。
} // if 结束。
if (file_.is_open()) // 如果日志文件已经打开。
{ // if 开始。
file_ << line << std::endl; // 写入日志文件。
file_.flush(); // 立即刷新到文件,简单版更安全但性能略差。
} // if 结束。
} // 函数结束。
void Debug(const std::string& message) // 输出 Debug 日志。
{ // 函数开始。
Log(LogLevel::Debug, message); // 调用通用日志函数。
} // 函数结束。
void Info(const std::string& message) // 输出 Info 日志。
{ // 函数开始。
Log(LogLevel::Info, message); // 调用通用日志函数。
} // 函数结束。
void Warn(const std::string& message) // 输出 Warn 日志。
{ // 函数开始。
Log(LogLevel::Warn, message); // 调用通用日志函数。
} // 函数结束。
void Error(const std::string& message) // 输出 Error 日志。
{ // 函数开始。
Log(LogLevel::Error, message); // 调用通用日志函数。
} // 函数结束。
}; // 类结束。
int main() // 程序入口。
{ // main 开始。
Logger logger; // 创建日志系统对象。
logger.SetLevel(LogLevel::Info); // 设置只输出 Info 及以上级别。
logger.OpenFile("game.log"); // 打开日志文件。
logger.Debug("this debug log will be filtered"); // Debug 低于 Info,会被过滤。
logger.Info("game started"); // 输出普通信息日志。
logger.Warn("asset load is slow"); // 输出警告日志。
logger.Error("network disconnected"); // 输出错误日志。
logger.CloseFile(); // 关闭日志文件。
return 0; // 程序正常结束。
} // main 结束。面试重点
这个版本是同步日志系统,优点是简单、好理解、稳定。 缺点是:如果高频输出日志,字符串拼接和文件 IO 可能卡住主线程。
工程里会继续升级:
- 用异步队列:业务线程只入队,日志线程负责写文件
- 批量写入:减少频繁 flush
- 文件滚动:按大小或日期切分日志文件
- 日志脱敏:不要把 token、手机号、支付信息明文写入日志
- 分类模块:网络、资源、战斗、UI 分不同 tag
实现一个资源句柄管理器
标准答案
资源句柄管理器的核心是:外部不直接保存资源裸指针,而是保存一个轻量 Handle。 这个 Handle 通常包含:
index:定位资源槽位generation:判断这个句柄是不是旧句柄refCount:控制资源生命周期
这样可以避免资源释放后,外部还拿着旧指针继续访问,导致悬空指针问题。
C++ 实现
c
#include <cstdint> // 使用 uint32_t 表示句柄索引和代数。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <limits> // 使用 std::numeric_limits 生成无效索引。
#include <mutex> // 使用 std::mutex 保证管理器线程安全。
#include <optional> // 使用 std::optional 表示槽位里资源可能存在或不存在。
#include <string> // 使用 std::string 演示资源名字。
#include <vector> // 使用 std::vector 保存资源槽位和空闲槽位。
struct ResourceHandle // 定义资源句柄结构。
{ // 结构体开始。
static constexpr uint32_t InvalidIndex = std::numeric_limits<uint32_t>::max(); // 定义无效索引。
uint32_t index = InvalidIndex; // 保存资源槽位下标。
uint32_t generation = 0; // 保存资源槽位代数,用于防止旧句柄误访问。
bool IsValid() const // 判断句柄自身是否看起来有效。
{ // 函数开始。
return index != InvalidIndex; // 只要 index 不是无效索引,就认为句柄格式有效。
} // 函数结束。
}; // 结构体结束。
template <typename T> // 定义一个支持任意资源类型的资源句柄管理器。
class ResourceHandleManager // 定义资源句柄管理器类。
{ // 类开始。
private: // 私有区域开始。
struct Slot // 定义资源槽位。
{ // 结构体开始。
uint32_t generation = 1; // 当前槽位代数,每次释放资源都会递增。
uint32_t refCount = 0; // 当前资源引用计数。
std::optional<T> resource; // 当前槽位中的资源对象。
}; // 结构体结束。
std::vector<Slot> slots_; // 保存所有资源槽位。
std::vector<uint32_t> freeList_; // 保存可复用的空闲槽位下标。
mutable std::mutex mutex_; // 保护 slots_ 和 freeList_ 的互斥锁。
bool IsValidNoLock(const ResourceHandle& handle) const // 在已经加锁的前提下校验句柄。
{ // 函数开始。
if (!handle.IsValid()) // 如果句柄本身无效。
{ // if 开始。
return false; // 返回 false。
} // if 结束。
if (handle.index >= slots_.size()) // 如果下标越界。
{ // if 开始。
return false; // 返回 false。
} // if 结束。
const Slot& slot = slots_[handle.index]; // 取出句柄对应的槽位。
if (!slot.resource.has_value()) // 如果槽位里没有资源。
{ // if 开始。
return false; // 返回 false。
} // if 结束。
return slot.generation == handle.generation; // generation 相等才说明句柄没有过期。
} // 函数结束。
public: // 公有区域开始。
ResourceHandle Add(const T& resource) // 添加资源并返回句柄。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护资源表。
uint32_t index = 0; // 准备保存新资源要放入的槽位下标。
if (!freeList_.empty()) // 如果存在可复用的空闲槽位。
{ // if 开始。
index = freeList_.back(); // 取出一个空闲槽位。
freeList_.pop_back(); // 从空闲列表中移除这个槽位。
} // if 结束。
else // 如果没有空闲槽位。
{ // else 开始。
slots_.push_back(Slot{}); // 创建一个新槽位。
index = static_cast<uint32_t>(slots_.size() - 1); // 新槽位下标就是数组最后一个位置。
} // else 结束。
Slot& slot = slots_[index]; // 取得目标槽位。
slot.resource = resource; // 把资源放入槽位。
slot.refCount = 1; // 新资源初始引用计数为 1。
return ResourceHandle{index, slot.generation}; // 返回包含 index 和 generation 的句柄。
} // 函数结束。
bool AddRef(const ResourceHandle& handle) // 增加资源引用计数。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护资源表。
if (!IsValidNoLock(handle)) // 如果句柄无效或过期。
{ // if 开始。
return false; // 增加引用失败。
} // if 结束。
++slots_[handle.index].refCount; // 引用计数加一。
return true; // 增加引用成功。
} // 函数结束。
bool Release(const ResourceHandle& handle) // 释放一次资源引用。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护资源表。
if (!IsValidNoLock(handle)) // 如果句柄无效或过期。
{ // if 开始。
return false; // 释放失败,避免重复释放或旧句柄释放新资源。
} // if 结束。
Slot& slot = slots_[handle.index]; // 取得目标槽位。
if (slot.refCount > 0) // 如果引用计数大于 0。
{ // if 开始。
--slot.refCount; // 引用计数减一。
} // if 结束。
if (slot.refCount == 0) // 如果没有任何地方再引用资源。
{ // if 开始。
slot.resource.reset(); // 清理资源对象。
++slot.generation; // 增加 generation,让旧句柄失效。
freeList_.push_back(handle.index); // 把槽位放回空闲列表,后续可复用。
} // if 结束。
return true; // 释放成功。
} // 函数结束。
T* Get(const ResourceHandle& handle) // 根据句柄获取资源指针。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护资源表。
if (!IsValidNoLock(handle)) // 如果句柄无效或过期。
{ // if 开始。
return nullptr; // 返回空指针。
} // if 结束。
return &slots_[handle.index].resource.value(); // 返回资源对象地址。
} // 函数结束。
bool IsAlive(const ResourceHandle& handle) const // 判断句柄是否还能访问到资源。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护资源表。
return IsValidNoLock(handle); // 返回句柄校验结果。
} // 函数结束。
uint32_t RefCount(const ResourceHandle& handle) const // 查询资源引用计数。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护资源表。
if (!IsValidNoLock(handle)) // 如果句柄无效或过期。
{ // if 开始。
return 0; // 无效句柄引用计数视为 0。
} // if 结束。
return slots_[handle.index].refCount; // 返回当前引用计数。
} // 函数结束。
}; // 类结束。
struct TextureResource // 定义一个测试用纹理资源。
{ // 结构体开始。
std::string name; // 资源名字。
int width = 0; // 纹理宽度。
int height = 0; // 纹理高度。
}; // 结构体结束。
int main() // 程序入口。
{ // main 开始。
ResourceHandleManager<TextureResource> manager; // 创建纹理资源句柄管理器。
ResourceHandle handle = manager.Add(TextureResource{"Player", 1024, 1024}); // 添加一个资源并拿到句柄。
TextureResource* texture = manager.Get(handle); // 通过句柄访问资源。
if (texture != nullptr) // 如果资源有效。
{ // if 开始。
std::cout << texture->name << " " << texture->width << "x" << texture->height << std::endl; // 输出资源信息。
} // if 结束。
manager.AddRef(handle); // 模拟另一个系统也引用了这个资源。
std::cout << "ref = " << manager.RefCount(handle) << std::endl; // 输出当前引用计数。
manager.Release(handle); // 释放一次引用。
manager.Release(handle); // 再释放一次引用,引用归零后资源被清理。
TextureResource* stale = manager.Get(handle); // 用旧句柄再次访问资源。
if (stale == nullptr) // 如果旧句柄已经失效。
{ // if 开始。
std::cout << "old handle is invalid" << std::endl; // 输出旧句柄失效信息。
} // if 结束。
return 0; // 程序正常结束。
} // main 结束。底层原理
为什么要 generation? 因为槽位会复用。假设旧句柄是 { index = 2, generation = 5 },资源释放后槽位 2 被放回空闲列表。以后新资源可能也放到槽位 2。
如果只看 index,旧句柄会误访问新资源。 所以释放时把 generation++,新资源的句柄变成 { index = 2, generation = 6 },旧句柄再访问时校验失败。
工程注意
Get 返回的指针不要长期缓存。 更安全的工程写法是返回临时访问对象、shared_ptr 快照,或者要求每次使用资源前都重新通过 handle 查询。
实现一个组件注册表
标准答案
组件注册表一般出现在 ECS 或自研引擎对象系统里。 它的作用是:用 Entity ID 找到某个实体身上的组件,并且按组件类型分开管理。
核心思想是:
Entity只是一个 IDComponent只保存数据ComponentRegistry负责组件的添加、查询、删除System通过 Registry 查询自己需要的组件组合
C++ 实现
c
#include <cstdint> // 使用 std::uint32_t 表示 Entity ID。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <memory> // 使用 std::unique_ptr 管理不同组件存储。
#include <typeindex> // 使用 std::type_index 作为组件类型 key。
#include <typeinfo> // 使用 typeid 获取组件类型信息。
#include <unordered_map> // 使用 std::unordered_map 保存组件和存储表。
#include <utility> // 使用 std::move 和 std::forward。
using Entity = std::uint32_t; // Entity 本质只是一个整数 ID。
class IComponentStorage // 定义组件存储的类型擦除基类。
{ // 类开始。
public: // 公有区域开始。
virtual ~IComponentStorage() = default; // 使用虚析构,保证通过基类指针删除子类安全。
virtual void Remove(Entity entity) = 0; // 删除某个实体上的该类型组件。
virtual bool Has(Entity entity) const = 0; // 判断某个实体是否拥有该类型组件。
}; // 类结束。
template <typename T> // 定义某一种组件类型的具体存储。
class ComponentStorage : public IComponentStorage // 组件存储继承类型擦除基类。
{ // 类开始。
private: // 私有区域开始。
std::unordered_map<Entity, T> components_; // 用 Entity ID 映射到具体组件数据。
public: // 公有区域开始。
T& Add(Entity entity, const T& component) // 给实体添加或覆盖组件。
{ // 函数开始。
components_[entity] = component; // 如果不存在就插入,如果存在就覆盖。
return components_[entity]; // 返回组件引用。
} // 函数结束。
T* Get(Entity entity) // 获取某个实体的组件指针。
{ // 函数开始。
auto it = components_.find(entity); // 在组件表里查找实体 ID。
if (it == components_.end()) // 如果没有找到组件。
{ // if 开始。
return nullptr; // 返回空指针。
} // if 结束。
return &it->second; // 返回组件地址。
} // 函数结束。
const T* Get(Entity entity) const // const 版本的组件查询。
{ // 函数开始。
auto it = components_.find(entity); // 在组件表里查找实体 ID。
if (it == components_.end()) // 如果没有找到组件。
{ // if 开始。
return nullptr; // 返回空指针。
} // if 结束。
return &it->second; // 返回组件地址。
} // 函数结束。
void Remove(Entity entity) override // 删除某个实体身上的组件。
{ // 函数开始。
components_.erase(entity); // 从组件表中删除该实体对应的组件。
} // 函数结束。
bool Has(Entity entity) const override // 判断某个实体是否拥有该组件。
{ // 函数开始。
return components_.find(entity) != components_.end(); // 找得到就是拥有该组件。
} // 函数结束。
std::unordered_map<Entity, T>& All() // 获取所有组件数据。
{ // 函数开始。
return components_; // 返回内部组件表引用。
} // 函数结束。
}; // 类结束。
class ComponentRegistry // 定义组件注册表。
{ // 类开始。
private: // 私有区域开始。
std::unordered_map<std::type_index, std::unique_ptr<IComponentStorage>> storages_; // 保存组件类型到组件存储的映射。
template <typename T> // 定义查找组件存储的模板函数。
ComponentStorage<T>* FindStorage() // 查找某个组件类型的存储。
{ // 函数开始。
std::type_index type = std::type_index(typeid(T)); // 获取组件类型 key。
auto it = storages_.find(type); // 在存储表里查找该组件类型。
if (it == storages_.end()) // 如果该组件类型还没有注册。
{ // if 开始。
return nullptr; // 返回空指针。
} // if 结束。
return static_cast<ComponentStorage<T>*>(it->second.get()); // 把基类存储转回具体组件存储。
} // 函数结束。
template <typename T> // 定义 const 查找组件存储的模板函数。
const ComponentStorage<T>* FindStorage() const // const 版本的存储查找。
{ // 函数开始。
std::type_index type = std::type_index(typeid(T)); // 获取组件类型 key。
auto it = storages_.find(type); // 在存储表里查找该组件类型。
if (it == storages_.end()) // 如果该组件类型还没有注册。
{ // if 开始。
return nullptr; // 返回空指针。
} // if 结束。
return static_cast<const ComponentStorage<T>*>(it->second.get()); // 把基类存储转回具体组件存储。
} // 函数结束。
template <typename T> // 定义获取或创建组件存储的模板函数。
ComponentStorage<T>& GetOrCreateStorage() // 获取某个组件类型的存储,不存在就创建。
{ // 函数开始。
std::type_index type = std::type_index(typeid(T)); // 获取组件类型 key。
auto it = storages_.find(type); // 查找该类型是否已有存储。
if (it == storages_.end()) // 如果该类型还没有存储。
{ // if 开始。
auto storage = std::make_unique<ComponentStorage<T>>(); // 创建该组件类型的存储。
ComponentStorage<T>* raw = storage.get(); // 暂存原始指针,方便返回引用。
storages_[type] = std::move(storage); // 把存储放入注册表。
return *raw; // 返回新建存储。
} // if 结束。
return *static_cast<ComponentStorage<T>*>(it->second.get()); // 返回已有存储。
} // 函数结束。
public: // 公有区域开始。
template <typename T> // 定义注册组件类型的模板函数。
void RegisterComponent() // 显式注册某个组件类型。
{ // 函数开始。
GetOrCreateStorage<T>(); // 创建对应组件存储。
} // 函数结束。
template <typename T> // 定义添加组件的模板函数。
T& AddComponent(Entity entity, const T& component) // 给实体添加组件。
{ // 函数开始。
ComponentStorage<T>& storage = GetOrCreateStorage<T>(); // 获取该组件类型的存储。
return storage.Add(entity, component); // 把组件添加到该实体身上。
} // 函数结束。
template <typename T> // 定义获取组件的模板函数。
T* GetComponent(Entity entity) // 获取实体身上的某个组件。
{ // 函数开始。
ComponentStorage<T>* storage = FindStorage<T>(); // 查找该组件类型的存储。
if (storage == nullptr) // 如果该组件类型没有注册。
{ // if 开始。
return nullptr; // 返回空指针。
} // if 结束。
return storage->Get(entity); // 从存储中查询该实体的组件。
} // 函数结束。
template <typename T> // 定义判断组件是否存在的模板函数。
bool HasComponent(Entity entity) const // 判断实体是否拥有某个组件。
{ // 函数开始。
const ComponentStorage<T>* storage = FindStorage<T>(); // 查找该组件类型的存储。
if (storage == nullptr) // 如果该组件类型没有注册。
{ // if 开始。
return false; // 没有存储就一定没有组件。
} // if 结束。
return storage->Has(entity); // 返回实体是否拥有该组件。
} // 函数结束。
template <typename T> // 定义删除组件的模板函数。
void RemoveComponent(Entity entity) // 删除实体身上的某个组件。
{ // 函数开始。
ComponentStorage<T>* storage = FindStorage<T>(); // 查找该组件类型的存储。
if (storage == nullptr) // 如果该组件类型没有注册。
{ // if 开始。
return; // 没有存储就不用删除。
} // if 结束。
storage->Remove(entity); // 删除该实体身上的组件。
} // 函数结束。
void DestroyEntity(Entity entity) // 删除实体身上的所有组件。
{ // 函数开始。
for (auto& pair : storages_) // 遍历所有组件类型存储。
{ // 循环开始。
pair.second->Remove(entity); // 从每个组件存储中删除该实体的数据。
} // 循环结束。
} // 函数结束。
template <typename A, typename B, typename Func> // 定义遍历两个组件组合的模板函数。
void ForEach(Func func) // 遍历同时拥有 A 和 B 两种组件的实体。
{ // 函数开始。
ComponentStorage<A>* storageA = FindStorage<A>(); // 查找 A 组件存储。
ComponentStorage<B>* storageB = FindStorage<B>(); // 查找 B 组件存储。
if (storageA == nullptr || storageB == nullptr) // 如果任意一种组件没有存储。
{ // if 开始。
return; // 没有组合可以遍历。
} // if 结束。
for (auto& pair : storageA->All()) // 遍历拥有 A 组件的实体。
{ // 循环开始。
Entity entity = pair.first; // 取出实体 ID。
A& componentA = pair.second; // 取出 A 组件引用。
B* componentB = storageB->Get(entity); // 查询同一个实体是否也有 B 组件。
if (componentB != nullptr) // 如果该实体也有 B 组件。
{ // if 开始。
func(entity, componentA, *componentB); // 调用系统逻辑。
} // if 结束。
} // 循环结束。
} // 函数结束。
}; // 类结束。
struct Position // 定义位置组件。
{ // 结构体开始。
float x; // X 坐标。
float y; // Y 坐标。
}; // 结构体结束。
struct Velocity // 定义速度组件。
{ // 结构体开始。
float vx; // X 方向速度。
float vy; // Y 方向速度。
}; // 结构体结束。
int main() // 程序入口。
{ // main 开始。
ComponentRegistry registry; // 创建组件注册表。
Entity player = 1001; // 创建一个玩家实体 ID。
registry.AddComponent(player, Position{0.0f, 0.0f}); // 给玩家添加位置组件。
registry.AddComponent(player, Velocity{1.0f, 2.0f}); // 给玩家添加速度组件。
registry.ForEach<Position, Velocity>([](Entity entity, Position& position, Velocity& velocity) // 遍历同时拥有位置和速度的实体。
{ // lambda 开始。
position.x += velocity.vx; // 根据速度更新 X 坐标。
position.y += velocity.vy; // 根据速度更新 Y 坐标。
std::cout << "entity " << entity << " move to " << position.x << "," << position.y << std::endl; // 输出移动结果。
}); // ForEach 调用结束。
registry.RemoveComponent<Velocity>(player); // 删除玩家速度组件。
std::cout << "has velocity = " << registry.HasComponent<Velocity>(player) << std::endl; // 输出是否还有速度组件。
registry.DestroyEntity(player); // 删除玩家身上的所有组件。
return 0; // 程序正常结束。
} // main 结束。底层原理
这个实现用了“类型擦除”:
- 每种组件类型都有一个
ComponentStorage<T> - 注册表里统一保存成
IComponentStorage* - 外部通过模板函数
AddComponent<T>、GetComponent<T>操作具体组件 - 内部用
typeid(T)生成类型 key,找到对应 Storage
简单版用 unordered_map<Entity, Component>,容易理解。 高性能 ECS 通常会改成 SparseSet 或 Archetype,让组件连续存储,提高遍历性能和缓存命中率。
工程注意
组件注册表的边界要清楚:
- Component 只放数据
- System 写逻辑
- Registry 做组织和查询
- Entity 不直接持有组件对象
常见坑是:遍历过程中删除组件会导致迭代器失效。工程里一般会延迟删除,或者先收集待删除列表,遍历结束后统一处理。
实现一个简单 ECS 存储
标准答案
简单 ECS 存储的核心是: Entity 只是一个 ID,组件按类型集中存储,系统遍历组件数组来更新逻辑。
比起 unordered_map<Entity, Component>,更像 ECS 的做法是用 SparseSet:
sparse:从Entity快速找到 dense 下标denseEntities:连续保存拥有该组件的实体denseComponents:连续保存组件数据- 删除时用最后一个元素填洞,保持数组连续
这样 Add / Get / Remove 接近 O(1),遍历组件时也更 cache friendly。
C++ 实现
c
#include <cstdint> // 使用 std::uint32_t 表示实体 ID。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <unordered_map> // 使用 unordered_map 实现 sparse 映射。
#include <utility> // 使用 std::move 移动组件数据。
#include <vector> // 使用 vector 实现 dense 连续数组。
using Entity = std::uint32_t; // Entity 本质只是一个整数 ID。
template <typename T> // 定义某一种组件类型的 SparseSet 存储。
class SparseSet // 定义 SparseSet 类。
{ // 类开始。
private: // 私有区域开始。
std::vector<Entity> entities_; // dense entities,连续保存拥有该组件的实体。
std::vector<T> components_; // dense components,连续保存组件数据。
std::unordered_map<Entity, std::size_t> sparse_; // sparse 映射,从 Entity 找到 dense 下标。
public: // 公有区域开始。
T& Add(Entity entity, const T& component) // 添加或更新某个实体的组件。
{ // 函数开始。
auto it = sparse_.find(entity); // 先查这个实体是否已经有该组件。
if (it != sparse_.end()) // 如果已经存在该组件。
{ // if 开始。
components_[it->second] = component; // 直接覆盖旧组件。
return components_[it->second]; // 返回更新后的组件引用。
} // if 结束。
std::size_t denseIndex = components_.size(); // 新组件要放到 dense 数组尾部。
sparse_[entity] = denseIndex; // 建立 Entity 到 dense 下标的映射。
entities_.push_back(entity); // 把实体 ID 放入 dense entities。
components_.push_back(component); // 把组件数据放入 dense components。
return components_.back(); // 返回新插入的组件引用。
} // 函数结束。
bool Has(Entity entity) const // 判断实体是否拥有该组件。
{ // 函数开始。
return sparse_.find(entity) != sparse_.end(); // sparse 中能找到就说明存在。
} // 函数结束。
T* Get(Entity entity) // 获取实体的组件指针。
{ // 函数开始。
auto it = sparse_.find(entity); // 查询 Entity 对应的 dense 下标。
if (it == sparse_.end()) // 如果没有找到。
{ // if 开始。
return nullptr; // 返回空指针。
} // if 结束。
return &components_[it->second]; // 返回 dense components 中的组件地址。
} // 函数结束。
bool Remove(Entity entity) // 删除实体的组件。
{ // 函数开始。
auto it = sparse_.find(entity); // 查询要删除的实体。
if (it == sparse_.end()) // 如果实体没有该组件。
{ // if 开始。
return false; // 删除失败。
} // if 结束。
std::size_t removedIndex = it->second; // 记录要删除元素在 dense 数组中的位置。
std::size_t lastIndex = components_.size() - 1; // 记录 dense 数组最后一个元素的位置。
Entity lastEntity = entities_[lastIndex]; // 记录最后一个元素对应的实体 ID。
if (removedIndex != lastIndex) // 如果删除的不是最后一个元素。
{ // if 开始。
entities_[removedIndex] = entities_[lastIndex]; // 用最后一个实体填补删除位置。
components_[removedIndex] = std::move(components_[lastIndex]); // 用最后一个组件填补删除位置。
sparse_[lastEntity] = removedIndex; // 更新被移动实体的新 dense 下标。
} // if 结束。
entities_.pop_back(); // 删除 dense entities 尾部旧数据。
components_.pop_back(); // 删除 dense components 尾部旧数据。
sparse_.erase(it); // 删除被移除实体的 sparse 映射。
return true; // 删除成功。
} // 函数结束。
template <typename Func> // 定义遍历函数模板。
void ForEach(Func func) // 遍历所有组件。
{ // 函数开始。
for (std::size_t i = 0; i < components_.size(); ++i) // 按 dense 数组顺序连续遍历。
{ // for 开始。
func(entities_[i], components_[i]); // 把 Entity 和 Component 交给系统逻辑。
} // for 结束。
} // 函数结束。
std::size_t Size() const // 获取当前组件数量。
{ // 函数开始。
return components_.size(); // 返回 dense components 的大小。
} // 函数结束。
}; // 类结束。
struct Position // 定义位置组件。
{ // 结构体开始。
float x; // X 坐标。
float y; // Y 坐标。
}; // 结构体结束。
int main() // 程序入口。
{ // main 开始。
SparseSet<Position> positions; // 创建 Position 组件存储。
positions.Add(1001, Position{0.0f, 0.0f}); // 给实体 1001 添加 Position。
positions.Add(1005, Position{10.0f, 20.0f}); // 给实体 1005 添加 Position。
positions.Add(1010, Position{30.0f, 40.0f}); // 给实体 1010 添加 Position。
positions.ForEach([](Entity entity, Position& position) // 遍历所有 Position 组件。
{ // lambda 开始。
position.x += 1.0f; // 模拟系统更新 X 坐标。
position.y += 1.0f; // 模拟系统更新 Y 坐标。
std::cout << entity << " position = " << position.x << "," << position.y << std::endl; // 输出更新结果。
}); // ForEach 调用结束。
positions.Remove(1005); // 删除实体 1005 的 Position,尾元素会填洞。
std::cout << "size = " << positions.Size() << std::endl; // 输出删除后的组件数量。
return 0; // 程序正常结束。
} // main 结束。底层原理
SparseSet 的关键是“查询和遍历分工”:
- 查询:
sparse[entity]直接找到 dense 下标 - 遍历:直接遍历
components_连续数组 - 删除:把最后一个元素移动到被删位置,保持 dense 连续
代价是删除后顺序不稳定。 如果外部依赖组件顺序,就不能用这种简单 swap-remove,或者要额外维护稳定顺序。
工程注意
这个版本是单组件类型存储。真正 ECS 会给每种组件类型建一个 SparseSet<T>,再由 World / Registry 管理多个 Storage。
多组件查询时,比如移动系统需要 Position + Velocity,常见做法是遍历较小的那个组件集合,再用另一个 Storage 的 Has/Get 判断是否也拥有对应组件。
实现一个消息队列
标准答案
消息队列就是一个“先进先出”的消息缓冲区。 生产者只负责 Post 消息,消费者按顺序 Pop 消息并处理。
在游戏客户端里,它常用于:
- 网络线程收到包,把消息投递给主线程
- 资源加载线程加载完成,把结果投递给逻辑层
- 业务模块之间解耦通信
- 主线程任务派发
C++ 实现
c
#include <condition_variable> // 使用条件变量,让消费者在队列空时睡眠等待。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <mutex> // 使用 std::mutex 保护共享队列。
#include <queue> // 使用 std::queue 保存 FIFO 消息。
#include <string> // 使用 std::string 保存消息负载。
#include <thread> // 使用 std::thread 演示跨线程投递消息。
enum class MessageType // 定义消息类型枚举。
{ // 枚举开始。
Login, // 登录消息。
Move, // 移动消息。
Damage, // 伤害消息。
Quit // 退出消息。
}; // 枚举结束。
struct Message // 定义消息结构。
{ // 结构体开始。
MessageType type; // 消息类型,用于决定如何分发。
int senderId; // 消息发送者 ID。
std::string payload; // 消息负载,简单示例用字符串表示。
}; // 结构体结束。
class MessageQueue // 定义线程安全消息队列。
{ // 类开始。
private: // 私有区域开始。
std::queue<Message> queue_; // FIFO 消息队列。
bool stopped_ = false; // 是否已经停止队列。
mutable std::mutex mutex_; // 互斥锁,保护 queue_ 和 stopped_。
std::condition_variable notEmpty_; // 条件变量,用于通知消费者队列非空或停止。
public: // 公有区域开始。
bool Post(const Message& message) // 投递一条消息。
{ // 函数开始。
{ // 创建局部作用域,控制锁的生命周期。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,准备修改队列。
if (stopped_) // 如果队列已经停止。
{ // if 开始。
return false; // 不再接收新消息。
} // if 结束。
queue_.push(message); // 把消息放入队列尾部。
} // 离开作用域自动解锁。
notEmpty_.notify_one(); // 通知一个等待的消费者:有新消息了。
return true; // 投递成功。
} // 函数结束。
bool TryPop(Message& outMessage) // 非阻塞取消息。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,准备访问队列。
if (queue_.empty()) // 如果队列为空。
{ // if 开始。
return false; // 立即返回 false。
} // if 结束。
outMessage = queue_.front(); // 取出队头消息。
queue_.pop(); // 删除队头消息。
return true; // 取消息成功。
} // 函数结束。
bool WaitPop(Message& outMessage) // 阻塞等待并取消息。
{ // 函数开始。
std::unique_lock<std::mutex> lock(mutex_); // 加锁,并允许条件变量临时释放锁。
notEmpty_.wait(lock, [this]() // 队列为空时等待。
{ // 条件谓词开始。
return stopped_ || !queue_.empty(); // 停止了或队列非空时才继续。
}); // wait 结束。
if (queue_.empty()) // 如果醒来后队列仍为空。
{ // if 开始。
return false; // 说明队列已停止且没有剩余消息。
} // if 结束。
outMessage = queue_.front(); // 取出队头消息。
queue_.pop(); // 删除队头消息。
return true; // 取消息成功。
} // 函数结束。
void Stop() // 停止消息队列。
{ // 函数开始。
{ // 创建局部作用域,控制锁的生命周期。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,准备修改 stopped_。
stopped_ = true; // 设置停止标记。
} // 离开作用域自动解锁。
notEmpty_.notify_all(); // 唤醒所有等待线程,避免程序退出时卡死。
} // 函数结束。
std::size_t Size() const // 获取当前队列长度。
{ // 函数开始。
std::lock_guard<std::mutex> lock(mutex_); // 加锁,保证读取队列长度安全。
return queue_.size(); // 返回队列中的消息数量。
} // 函数结束。
}; // 类结束。
void Dispatch(const Message& message) // 分发并处理消息。
{ // 函数开始。
switch (message.type) // 根据消息类型分发。
{ // switch 开始。
case MessageType::Login: // 如果是登录消息。
std::cout << "Login from " << message.senderId << ": " << message.payload << std::endl; // 处理登录消息。
break; // 跳出 switch。
case MessageType::Move: // 如果是移动消息。
std::cout << "Move from " << message.senderId << ": " << message.payload << std::endl; // 处理移动消息。
break; // 跳出 switch。
case MessageType::Damage: // 如果是伤害消息。
std::cout << "Damage from " << message.senderId << ": " << message.payload << std::endl; // 处理伤害消息。
break; // 跳出 switch。
case MessageType::Quit: // 如果是退出消息。
std::cout << "Quit message" << std::endl; // 处理退出消息。
break; // 跳出 switch。
} // switch 结束。
} // 函数结束。
int main() // 程序入口。
{ // main 开始。
MessageQueue queue; // 创建消息队列。
std::thread producer([&queue]() // 创建生产者线程。
{ // lambda 开始。
queue.Post(Message{MessageType::Login, 1, "player login"}); // 投递登录消息。
queue.Post(Message{MessageType::Move, 1, "x=10,y=20"}); // 投递移动消息。
queue.Post(Message{MessageType::Damage, 2, "hp-30"}); // 投递伤害消息。
queue.Post(Message{MessageType::Quit, 0, "quit"}); // 投递退出消息。
}); // 生产者线程创建结束。
std::thread consumer([&queue]() // 创建消费者线程。
{ // lambda 开始。
Message message; // 创建消息接收变量。
while (queue.WaitPop(message)) // 持续等待并取出消息。
{ // while 开始。
Dispatch(message); // 分发消息。
if (message.type == MessageType::Quit) // 如果收到退出消息。
{ // if 开始。
break; // 跳出消费循环。
} // if 结束。
} // while 结束。
}); // 消费者线程创建结束。
producer.join(); // 等待生产者线程结束。
queue.Stop(); // 停止队列并唤醒消费者。
consumer.join(); // 等待消费者线程结束。
return 0; // 程序正常结束。
} // main 结束。底层原理
消息队列本质上是一个 FIFO 容器:
Post:把消息放到队尾Pop:从队头取出消息mutex:保证多线程读写队列安全condition_variable:避免消费者空转浪费 CPUStop:退出时唤醒所有等待线程
工程注意
在 Unity 或游戏客户端中,子线程通常不能直接操作主线程对象或引擎 API。 所以常见做法是:子线程把结果投递到消息队列,主线程在 Update 中取出并执行。
另外,主线程消费时最好限制每帧处理数量,比如 maxPerFrame,避免某一帧消息太多导致卡顿。
实现一个简单反射注册宏
标准答案
C++ 本身不像 C# 那样有完整的运行时反射。 所以引擎里常见做法是:用宏把“类型名、字段名、字段类型、字段偏移量 offset”注册到一个全局表里。
这样运行时就可以做:
- 编辑器 Inspector 自动绘制字段
- 配置表自动绑定对象
- 序列化和反序列化
- 调试面板查看对象数据
- 脚本系统绑定 C++ 类型
C++ 实现
c
#include <cstddef> // 使用 offsetof 和 std::size_t 计算字段偏移。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <string> // 使用 std::string 保存类型名和字段名。
#include <typeinfo> // 使用 typeid 获取类型信息。
#include <unordered_map> // 使用 unordered_map 保存类型名到 TypeInfo 的映射。
#include <vector> // 使用 vector 保存字段列表。
struct FieldInfo // 定义字段元数据结构。
{ // 结构体开始。
std::string name; // 保存字段名字。
std::string typeName; // 保存字段类型名字。
std::size_t offset; // 保存字段在对象内存中的偏移量。
}; // 结构体结束。
struct TypeInfo // 定义类型元数据结构。
{ // 结构体开始。
std::string name; // 保存类型名字。
std::vector<FieldInfo> fields; // 保存该类型的所有字段元数据。
}; // 结构体结束。
class ReflectionRegistry // 定义全局反射注册表。
{ // 类开始。
private: // 私有区域开始。
std::unordered_map<std::string, TypeInfo> types_; // 保存类型名到 TypeInfo 的映射。
public: // 公有区域开始。
static ReflectionRegistry& Instance() // 获取全局唯一注册表。
{ // 函数开始。
static ReflectionRegistry registry; // 函数局部静态对象,第一次调用时创建。
return registry; // 返回全局注册表引用。
} // 函数结束。
TypeInfo& RegisterType(const std::string& typeName) // 注册一个类型。
{ // 函数开始。
TypeInfo& info = types_[typeName]; // 获取或创建该类型的 TypeInfo。
info.name = typeName; // 保存类型名字。
return info; // 返回类型元数据引用。
} // 函数结束。
void RegisterField(const std::string& typeName, const FieldInfo& field) // 注册一个字段。
{ // 函数开始。
TypeInfo& info = RegisterType(typeName); // 先确保类型已经存在。
info.fields.push_back(field); // 把字段元数据加入字段列表。
} // 函数结束。
const TypeInfo* FindType(const std::string& typeName) const // 按名字查找类型元数据。
{ // 函数开始。
auto it = types_.find(typeName); // 在哈希表中查找类型。
if (it == types_.end()) // 如果没有找到。
{ // if 开始。
return nullptr; // 返回空指针。
} // if 结束。
return &it->second; // 返回 TypeInfo 地址。
} // 函数结束。
}; // 类结束。
template <typename T> // 定义通用类型名函数模板。
const char* TypeName() // 返回类型名字。
{ // 函数开始。
return typeid(T).name(); // 默认使用编译器提供的类型名。
} // 函数结束。
template <> // 特化 int 类型。
const char* TypeName<int>() // 返回 int 的可读类型名。
{ // 函数开始。
return "int"; // 返回 int 字符串。
} // 函数结束。
template <> // 特化 float 类型。
const char* TypeName<float>() // 返回 float 的可读类型名。
{ // 函数开始。
return "float"; // 返回 float 字符串。
} // 函数结束。
template <> // 特化 std::string 类型。
const char* TypeName<std::string>() // 返回 string 的可读类型名。
{ // 函数开始。
return "std::string"; // 返回 std::string 字符串。
} // 函数结束。
template <typename T> // 定义类型自动注册器模板。
struct AutoTypeRegister // 定义类型自动注册结构。
{ // 结构体开始。
explicit AutoTypeRegister(const char* typeName) // 构造时执行类型注册。
{ // 构造函数开始。
ReflectionRegistry::Instance().RegisterType(typeName); // 把类型注册到全局表。
} // 构造函数结束。
}; // 结构体结束。
template <typename Owner, typename Field> // 定义字段自动注册器模板。
struct AutoFieldRegister // 定义字段自动注册结构。
{ // 结构体开始。
AutoFieldRegister(const char* typeName, const char* fieldName, std::size_t offset, const char* fieldTypeName) // 构造时执行字段注册。
{ // 构造函数开始。
ReflectionRegistry::Instance().RegisterField(typeName, FieldInfo{fieldName, fieldTypeName, offset}); // 把字段注册到全局表。
} // 构造函数结束。
}; // 结构体结束。
#define REFLECT_TYPE(Type) static AutoTypeRegister<Type> g_reflect_type_##Type(#Type) // 注册类型宏。
#define REFLECT_FIELD(Type, Field) static AutoFieldRegister<Type, decltype(Type::Field)> g_reflect_field_##Type##_##Field(#Type, #Field, offsetof(Type, Field), TypeName<decltype(Type::Field)>()) // 注册字段宏。
template <typename Object, typename Field> // 定义通过 offset 获取字段的工具函数。
Field& GetFieldByOffset(Object& object, std::size_t offset) // 根据对象和字段偏移拿到字段引用。
{ // 函数开始。
char* base = reinterpret_cast<char*>(&object); // 把对象地址转成字节指针。
return *reinterpret_cast<Field*>(base + offset); // 偏移到字段位置并转回字段引用。
} // 函数结束。
struct Player // 定义一个测试用类型。
{ // 结构体开始。
int hp; // 玩家生命值字段。
float speed; // 玩家速度字段。
std::string name; // 玩家名字字段。
}; // 结构体结束。
REFLECT_TYPE(Player); // 注册 Player 类型。
REFLECT_FIELD(Player, hp); // 注册 Player::hp 字段。
REFLECT_FIELD(Player, speed); // 注册 Player::speed 字段。
REFLECT_FIELD(Player, name); // 注册 Player::name 字段。
int main() // 程序入口。
{ // main 开始。
Player player{100, 6.5f, "Knight"}; // 创建一个 Player 对象。
const TypeInfo* info = ReflectionRegistry::Instance().FindType("Player"); // 从注册表查找 Player 类型信息。
if (info == nullptr) // 如果没有查到类型信息。
{ // if 开始。
return 0; // 直接结束程序。
} // if 结束。
std::cout << "Type: " << info->name << std::endl; // 输出类型名字。
for (const FieldInfo& field : info->fields) // 遍历 Player 的所有字段。
{ // 循环开始。
std::cout << "Field: " << field.name << ", Type: " << field.typeName << ", Offset: " << field.offset << std::endl; // 输出字段元数据。
} // 循环结束。
int& hp = GetFieldByOffset<Player, int>(player, info->fields[0].offset); // 通过 offset 拿到 hp 字段引用。
hp = 80; // 修改 hp 字段。
std::cout << "Player hp = " << player.hp << std::endl; // 输出修改后的 hp。
return 0; // 程序正常结束。
} // main 结束。底层原理
这个反射系统不是“编译器自动知道所有字段”,而是靠宏手动注册。
宏做了几件事:
#Type把类型名变成字符串#Field把字段名变成字符串decltype(Type::Field)拿到字段类型offsetof(Type, Field)拿到字段偏移- 静态注册对象在程序启动时把信息写入
ReflectionRegistry
运行时就可以按名字查询类型和字段。
工程注意
offsetof 主要适合标准布局类型。 如果类里有虚函数、多继承、复杂继承结构,直接靠 offset 读写字段会变危险。
大型引擎一般会配合代码生成工具,比如扫描头文件生成 .generated.cpp,比纯宏更稳定,也能避免字段改名后忘记同步注册。
实现一个类工厂
标准答案
类工厂的作用是:把“类型名”映射到“创建对象的函数”。 调用方只传一个字符串,比如 "FireSkill",工厂就能创建对应派生类对象,并返回基类指针。
它常用于:
- 技能系统:配置表写技能类型名
- Buff 系统:根据 Buff 类型创建对象
- 状态机:根据状态名创建状态类
- AI 行为树:根据节点名创建节点
- 编辑器/脚本系统:按名字动态创建 C++ 对象
C++ 实现
c
#include <functional> // 使用 std::function 保存创建函数。
#include <iostream> // 使用 std::cout 输出测试结果。
#include <memory> // 使用 std::unique_ptr 管理对象生命周期。
#include <string> // 使用 std::string 作为类型名字。
#include <type_traits> // 使用 std::is_base_of 检查继承关系。
#include <unordered_map> // 使用 std::unordered_map 保存名字到创建函数的映射。
template <typename Base> // 定义一个基于 Base 类型的类工厂模板。
class ClassFactory // 定义类工厂。
{ // 类开始。
private: // 私有区域开始。
using Creator = std::function<std::unique_ptr<Base>()>; // 定义创建函数类型。
std::unordered_map<std::string, Creator> creators_; // 保存类型名到创建函数的映射。
ClassFactory() = default; // 构造函数私有化,外部通过 Instance 使用单例。
public: // 公有区域开始。
ClassFactory(const ClassFactory&) = delete; // 禁止拷贝构造,避免多个工厂实例状态不一致。
ClassFactory& operator=(const ClassFactory&) = delete; // 禁止拷贝赋值,避免多个工厂实例状态不一致。
static ClassFactory& Instance() // 获取全局工厂实例。
{ // 函数开始。
static ClassFactory factory; // 函数局部静态对象,第一次调用时创建。
return factory; // 返回工厂引用。
} // 函数结束。
template <typename Derived> // 注册某个派生类。
bool Register(const std::string& name) // 把名字和创建函数注册到工厂。
{ // 函数开始。
static_assert(std::is_base_of<Base, Derived>::value, "Derived must inherit Base"); // 编译期检查 Derived 必须继承 Base。
if (creators_.find(name) != creators_.end()) // 如果该名字已经注册过。
{ // if 开始。
return false; // 返回 false,表示重复注册。
} // if 结束。
creators_[name] = []() // 保存一个无参创建函数。
{ // lambda 开始。
return std::make_unique<Derived>(); // 创建 Derived 对象,并用 unique_ptr<Base> 接住。
}; // lambda 结束。
return true; // 注册成功。
} // 函数结束。
std::unique_ptr<Base> Create(const std::string& name) const // 根据名字创建对象。
{ // 函数开始。
auto it = creators_.find(name); // 在注册表中查找创建函数。
if (it == creators_.end()) // 如果没有找到对应类型。
{ // if 开始。
return nullptr; // 返回空指针,表示创建失败。
} // if 结束。
return it->second(); // 调用创建函数,返回对象。
} // 函数结束。
bool Contains(const std::string& name) const // 判断某个类型名是否已经注册。
{ // 函数开始。
return creators_.find(name) != creators_.end(); // 找得到就说明已经注册。
} // 函数结束。
}; // 类结束。
template <typename BaseType, typename DerivedType> // 定义自动注册辅助函数模板。
bool RegisterClass(const std::string& name) // 注册 DerivedType 到 BaseType 工厂。
{ // 函数开始。
return ClassFactory<BaseType>::Instance().template Register<DerivedType>(name); // 调用对应工厂完成注册。
} // 函数结束。
#define REGISTER_CLASS(BaseType, DerivedType, Name) static bool g_register_##DerivedType = RegisterClass<BaseType, DerivedType>(Name) // 定义自动注册宏。
class Skill // 定义技能基类。
{ // 类开始。
public: // 公有区域开始。
virtual ~Skill() = default; // 虚析构,保证通过基类指针删除派生类安全。
virtual void Cast() = 0; // 定义技能释放接口。
}; // 类结束。
class FireSkill : public Skill // 定义火焰技能。
{ // 类开始。
public: // 公有区域开始。
void Cast() override // 实现技能释放接口。
{ // 函数开始。
std::cout << "cast fire skill" << std::endl; // 输出火焰技能释放信息。
} // 函数结束。
}; // 类结束。
class IceSkill : public Skill // 定义冰霜技能。
{ // 类开始。
public: // 公有区域开始。
void Cast() override // 实现技能释放接口。
{ // 函数开始。
std::cout << "cast ice skill" << std::endl; // 输出冰霜技能释放信息。
} // 函数结束。
}; // 类结束。
REGISTER_CLASS(Skill, FireSkill, "FireSkill"); // 自动注册 FireSkill。
REGISTER_CLASS(Skill, IceSkill, "IceSkill"); // 自动注册 IceSkill。
int main() // 程序入口。
{ // main 开始。
std::unique_ptr<Skill> skillA = ClassFactory<Skill>::Instance().Create("FireSkill"); // 根据名字创建火焰技能。
if (skillA != nullptr) // 如果创建成功。
{ // if 开始。
skillA->Cast(); // 通过基类接口调用派生类逻辑。
} // if 结束。
std::unique_ptr<Skill> skillB = ClassFactory<Skill>::Instance().Create("IceSkill"); // 根据名字创建冰霜技能。
if (skillB != nullptr) // 如果创建成功。
{ // if 开始。
skillB->Cast(); // 通过基类接口调用派生类逻辑。
} // if 结束。
std::unique_ptr<Skill> missing = ClassFactory<Skill>::Instance().Create("HealSkill"); // 尝试创建一个未注册类型。
if (missing == nullptr) // 如果没有创建成功。
{ // if 开始。
std::cout << "class not registered" << std::endl; // 输出未注册提示。
} // if 结束。
return 0; // 程序正常结束。
} // main 结束。底层原理
类工厂本质是一个表:
c
"FireSkill" -> [] { return make_unique<FireSkill>(); }
"IceSkill" -> [] { return make_unique<IceSkill>(); }调用方不用写:
c
if name == "FireSkill" new FireSkill
else if name == "IceSkill" new IceSkill而是统一走:
c
factory.Create(name)这样新增派生类时,只要注册一次,不需要修改中心创建逻辑。
工程注意
宏自动注册虽然方便,但要注意静态初始化和链接裁剪问题。 大型项目里更稳的做法是:启动阶段显式注册,或者用代码生成工具生成注册表。