浏览知识库目录

C++

手写对象池

在内存池之上管理 T 的构造与析构,提供异常安全 acquire/release 和 RAII 归还句柄。

手写对象池

在内存池之上管理 T 的构造与析构,提供异常安全 acquire/release 和 RAII 归还句柄。

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


一、学习目标

  • 正确开始和结束 T 生命周期
  • 构造失败时自动归还槽位
  • 用 unique_ptr 风格句柄防止遗忘 release

二、前置条件

完成内存池篇,熟悉 placement new、destroy_at 与自定义 deleter。

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

三、问题与设计选择

先从固定块池获得槽位,再 construct_at;若构造抛异常立即归还。句柄 deleter 先 destroy 再返还内存。

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


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

已借出槽位中恰有一个活着的 T;空闲槽位中没有 T;每个句柄最多归还一次。

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


五、核心实现

template<class... Args>
handle acquire(Args&&... args) {
    void* slot = memory_.allocate_block();
    try {
        T* value = std::construct_at(
            static_cast<T*>(slot), std::forward<Args>(args)...);
        return handle(value, deleter{this});
    } catch (...) {
        memory_.deallocate_block(slot);
        throw;
    }
}

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


六、完整教学实现

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

namespace oc::handmade {

template<class T>
class object_pool {
    fixed_block_pool memory_{sizeof(T), alignof(T)};
public:
    struct deleter {
        object_pool* owner{};
        void operator()(T* value) const noexcept {
            if (!value) return;
            std::destroy_at(value);
            owner->memory_.deallocate_block(value);
        }
    };
    using handle = std::unique_ptr<T, deleter>;
    template<class... Args>
    handle acquire(Args&&... args) {
        void* slot = memory_.allocate_block();
        try {
            T* value = std::construct_at(
                static_cast<T*>(slot), std::forward<Args>(args)...);
            return handle(value, deleter{this});
        } catch (...) {
            memory_.deallocate_block(slot);
            throw;
        }
    }
    std::size_t outstanding() const { return memory_.outstanding(); }
};

}  // namespace oc::handmade

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


七、使用示例与输出

预期输出或状态:

构造三个 Session 后 outstanding 为 3;句柄离开作用域后对象析构且计数回到 0。

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


八、复杂度与失效规则

操作 复杂度 说明
acquire O(1)+构造 失败自动回滚
release 析构+O(1) 不得重复
容量扩展 按页 地址稳定
析构 要求无借出 调试断言

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


九、异常安全与资源管理

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

十、常见错误

1. 只归还内存不调用析构

只归还内存不调用析构会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。

2. 构造异常泄漏槽位

构造异常泄漏槽位会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。

3. 池先于仍存活的句柄析构

池先于仍存活的句柄析构会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。


十一、面试追问

  1. 对象池适合所有对象吗?
  2. 如何处理不同大小派生类?
  3. RAII 句柄怎样表达池的生命周期关系?

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


十二、练习与自测

  1. 增加 shared handle 讨论
  2. 测试构造函数抛异常
  3. 为过对齐类型编写测试

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


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


上一篇:手写内存池 | 下一篇:手写有界阻塞队列