浏览知识库目录

C++

手写 std::queue

基于双端容器实现先进先出适配器,并讨论 front/back、关闭语义与后续阻塞队列的关系。

手写 std::queue

基于双端容器实现先进先出适配器,并讨论 front/back、关闭语义与后续阻塞队列的关系。

本系列代码使用 C++20 和 oc::handmade 命名空间,目标是解释实现机制、复杂度和工程边界,不是替代标准库。普通容器与缓存核心不内置互斥锁;这不代表 lock-free。


一、学习目标

  • 实现 FIFO 适配器
  • 保持接口与底层容器复杂度一致
  • 为有界阻塞队列建立非并发基线

二、前置条件

完成 deque 和 stack 篇。

Linux/macOS:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir build --output-on-failure

Windows PowerShell:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build --config Debug
ctest --test-dir build -C Debug --output-on-failure

三、问题与设计选择

入队调用 push_back,出队调用 pop_front,front 是最早元素,back 是最新元素。

这里刻意保留一条边界:教学实现覆盖构造、复制移动、核心修改、查找和迭代契约,但不复刻标准库全部重载、ABI、constexpr、异构查找或节点句柄。


四、内存布局与核心不变量

若没有删除,第 i 次成功入队的元素一定早于第 i+1 次入队的元素出队。

每个修改操作都按“准备资源 → 构造新状态 → 提交连接或指针 → 清理旧状态”的顺序设计。提交点之前发生异常,应保持原对象可继续使用;无法提供强保证时,会在接口说明中明确基本保证。


五、核心实现

template<class T, class Container = deque<T>>
class queue {
    Container data_;
public:
    T& front() { return data_.front(); }
    T& back() { return data_.back(); }
    void push(T value) { data_.push_back(std::move(value)); }
    void pop() { data_.pop_front(); }
    bool empty() const noexcept { return data_.empty(); }
};

上面先聚焦最容易写错的核心步骤;若本篇对应一个独立组件,下一节给出统一工程中的完整教学实现。代码没有放入 std 命名空间,避免未定义行为和名称冲突。


六、完整教学实现

下面是统一工程中经过 GCC、Clang、GoogleTest 和 Sanitizer 验证的完整组件。它依赖前序文章已经实现的公共类型以及头文件中的标准库 #include

namespace oc::handmade {

template<class T, class Container = deque<T>>
class queue {
    Container data_;
public:
    bool empty() const noexcept { return data_.empty(); }
    std::size_t size() const noexcept { return data_.size(); }
    T& front() { return data_.front(); }
    const T& front() const { return data_.front(); }
    T& back() { return data_.back(); }
    const T& back() const { return data_.back(); }
    void push(const T& value) { data_.push_back(value); }
    void push(T&& value) { data_.push_back(std::move(value)); }
    template<class... Args>
    T& emplace(Args&&... args) { return data_.emplace_back(std::forward<Args>(args)...); }
    void pop() { data_.pop_front(); }
};

}  // namespace oc::handmade

生产级标准库还要处理完整 allocator 传播、全部重载、ABI、调试迭代器和平台特化;这里保留的是能够独立推导核心数据结构的教学边界。


七、使用示例与输出

预期输出或状态:

push 10、20、30 后连续 front/pop 的输出为 10 20 30。

示例必须在文章对应的测试目标中实际编译。涉及顺序的输出只依赖接口明确承诺的顺序;无序容器不会把某次桶顺序写成稳定结果。


八、复杂度与失效规则

操作 复杂度 说明
push/emplace 摊还 O(1) 尾部插入
pop O(1) 头部删除
front/back O(1) 访问两端
size O(1) 转发

复杂度中的 O(1) 若标记为“平均”或“摊还”,不能在面试中省略限定词。任何重新分配、节点删除、rehash 或缓存淘汰都必须单独说明迭代器、引用与指针是否失效。


九、异常安全与资源管理

  • 获取资源后立即交给 RAII 对象或明确记录已构造数量。
  • 用户类型构造、复制、移动、比较器和哈希器都可能抛异常。
  • 只有在所有后续步骤不会失败时才修改不可回滚的链接。
  • 析构、释放和关闭路径不得抛异常。
  • 并发包装通过回调在锁内访问,避免返回保护对象的裸引用。

十、常见错误

1. 用 vector.erase(begin) 导致每次 O(n)

用 vector.erase(begin) 导致每次 O(n)会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。

2. 混淆 back 与下一次出队元素

混淆 back 与下一次出队元素会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。

3. 把普通 queue 当作线程安全队列

把普通 queue 当作线程安全队列会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。


十一、面试追问

  1. 为什么 queue 通常默认基于 deque?
  2. 循环数组队列如何区分空和满?
  3. FIFO 与公平调度是否等价?

回答时先说数据结构不变量,再给复杂度,最后说明异常、迭代器或并发边界,通常比背诵结论更有说服力。


十二、练习与自测

  1. 改用循环缓冲实现底层容器
  2. 增加 try_pop
  3. 写入队出队状态机测试

自测标准:能够不看代码画出内存或节点关系,解释一次成功操作和一次失败回滚,并写出至少一个会击穿错误实现的测试。


十三、官方资料与延伸阅读


上一篇:手写 std::stack | 下一篇:手写 std::priority_queue