C++11引入了std::unique_ptr和std::shared_ptr来取代不安全的std::auto_ptr。两者都支持自定义删除器来管理非内存资源,如文件句柄或数据库连接。然而,由于其所有权模型和性能要求,它们的架构方法根本不同。
std::unique_ptr实现了独占所有权,并将其删除器作为其类型的一部分存储(第二个模板参数)。如果删除器是有状态的,则它在unique_ptr对象中占用空间,与被管理的指针一起。std::shared_ptr通过在堆上分配控制块实现共享所有权,其中删除器经过类型擦除,与shared_ptr对象分开存储。
这种架构差异导致了不同的大小特征。具有无状态删除器的std::unique_ptr占用的空间与原始指针完全相同,这得益于空基类优化。相反,std::shared_ptr保持固定大小(通常为两个指针),无论删除器的大小或复杂性如何,因为删除器存储在单独分配的控制块中。
#include <memory> #include <cstdio> #include <iostream> struct FileDeleter { void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; struct StatefulDeleter { int flags = 0xDEAD; void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; int main() { // unique_ptr with stateless deleter: size == pointer size (8 bytes on 64-bit) std::unique_ptr<FILE, FileDeleter> up(nullptr); // shared_ptr: constant size (16 bytes) regardless of deleter std::shared_ptr<FILE> sp(nullptr, FileDeleter{}); std::cout << "Unique (stateless): " << sizeof(up) << " bytes "; std::cout << "Shared (any deleter): " << sizeof(sp) << " bytes "; // unique_ptr with stateful deleter: larger size (16 bytes: pointer + int + padding) std::unique_ptr<FILE, StatefulDeleter> up2(nullptr, StatefulDeleter{}); std::shared_ptr<FILE> sp2(nullptr, StatefulDeleter{}); std::cout << "Unique (stateful): " << sizeof(up2) << " bytes "; std::cout << "Shared (stateful): " << sizeof(sp2) << " bytes "; }
一个开发团队需要管理由C API返回的遗留数据库连接句柄(void*)。这些句柄需要通过db_disconnect()而不是delete进行特定的清理。该应用程序在紧密循环中每秒创建数千个句柄,使得内存占用和分配性能至关重要。
考虑的第一个方法是一个自定义RAII包装类ConnectionGuard,它存储句柄并在其析构函数中调用db_disconnect()。优点包括对接口的完全控制和能够添加特定于连接的方法。缺点涉及每种资源类型的显著样板代码,重新发明指针语义,以及与为智能指针设计的标准库算法的不兼容。
第二个解决方案利用std::shared_ptr<void>,并使用捕获断开函数的lambda删除器。优点包括使用标准组件的即时可用性,以及如果需要共享所有权的未来可用性。缺点包括控制块的强制堆分配,不适合高频独特所有权的原子引用计数开销,以及无论句柄的轻量特性如何,固定对象大小为16字节。
第三种方法使用std::unique_ptr<void, decltype(&db_disconnect)>与函数指针删除器,或更好地说是无状态的函数对象。优点包括当使用无状态函数对象时,得益于空基类优化(与原始指针大小8字节相匹配),没有堆分配,并且完美表达了独占所有权语义。缺点包括类型签名的冗长和无法在运行时更改删除器。
团队选择了第三种解决方案,使用无状态函数对象删除器。这个选择完全消除了堆分配,将包装大小减少到8字节,并消除了原子操作开销,同时保持自动清理。
结果是内存使用减少了40%,并且在连接池系统中显著提高了延迟,达到了异常安全性而不妥协性能。
为什么 std::unique_ptr 在使用默认删除器时需要在销毁时提供完整类型,而 std::shared_ptr 则不需要?
答案:std::unique_ptr使用默认删除器时在被管理的指针上调用delete。C++标准要求delete对指向T的指针必须将T定义为完整类型,以调用析构函数并计算归还内存的大小。如果unique_ptr的析构函数在T仅被前向声明的情况下实例化,编译将失败。std::shared_ptr在构造时在控制块中捕获删除器(它知道如何销毁T)。由于删除器经过类型擦除并单独存储,shared_ptr可以在T不完整的情况下晚些时候被销毁。这个区别对于Pimpl(指向实现)习语至关重要:shared_ptr允许在源文件中隐藏实现细节,而unique_ptr要求在实施可见的地方定义完整类型或显式的自定义删除器。
为什么 std::make_unique 不支持自定义删除器,推荐的替代方案是什么?
答案:std::make_unique(在C++14中引入)提供了安全的异常处理分配,但仅返回std::unique_ptr<T>或std::unique_ptr<T[]>,这些使用std::default_delete。该函数无法从参数中推断删除器类型,因为删除器类型必须是unique_ptr模板签名的一部分,工厂函数无法在没有显式模板参数的情况下隐式推断自定义删除器类型。推荐的替代方案是直接构造:std::unique_ptr<T, CustomDeleter>(new T(args), CustomDeleter{...})。这种方法在模板中明确指定了删除器类型,同时允许自定义资源清理逻辑,尽管这需要手动异常处理或仔细的构造顺序来维护异常安全保证。
空基类优化如何影响使用无状态删除器的 std::unique_ptr 内存布局,以及为什么 std::shared_ptr 无法使用?
答案:当删除器是类类型时,std::unique_ptr 继承自其删除器类。如果删除器不包含数据成员(无状态),C++应用空基类优化(EBO),允许空基类子对象占用零字节。因此,sizeof(std::unique_ptr<T, StatelessDeleter>)等于sizeof(T*),实现零开销抽象。std::shared_ptr无法利用EBO,因为它必须支持类型擦除:每个相同T的shared_ptr必须具有相同的大小,而不论删除器如何。因此,shared_ptr将删除器存储在堆分配的控制块中,而不是在shared_ptr对象本身内部。这个设计允许删除器的运行时多态性,但强制进行堆分配,并防止unique_ptr享有的堆栈空间优化。