1. 为什么C++14需要补上std::make_unique
在C++11引入智能指针后,开发者们逐渐习惯了使用std::unique_ptr和std::shared_ptr来管理内存资源。但很快人们发现一个奇怪的现象:标准库提供了std::make_shared来创建shared_ptr,却没有对应的make_unique来创建unique_ptr。
这个遗漏并非有意为之,而是标准制定过程中的一个疏忽。C++14标准委员会很快意识到了这个问题,并决定将其补齐。但make_unique的引入绝不仅仅是为了API的对称美观,它实际上解决了一个非常关键的内存安全问题。
提示:在C++11中,直接使用new创建unique_ptr可能会导致内存泄漏,特别是在函数参数传递的场景下。
2. 参数求值顺序带来的内存泄漏风险
2.1 一个危险的例子
考虑以下代码示例:
cpp复制void processResource(std::unique_ptr<Resource> res, int priority) {
// 使用资源
}
int calculatePriority() {
// 可能抛出异常的计算
throw std::runtime_error("Calculation failed");
}
// 不安全的调用方式
processResource(std::unique_ptr<Resource>(new Resource()), calculatePriority());
这段代码在C++11中存在着潜在的内存泄漏风险。问题出在函数参数的求值顺序上。
2.2 编译器可能生成的代码序列
在C++17之前,编译器对函数参数的求值顺序没有严格规定。对于上面的函数调用,编译器可能生成以下执行序列:
- new Resource() - 分配内存并构造对象
- calculatePriority() - 计算优先级(可能抛出异常)
- 构造unique_ptr
如果calculatePriority()在第2步抛出异常,那么第1步分配的Resource对象将永远无法被释放,因为unique_ptr还没有接管这个指针。
2.3 异常安全的重要性
在资源管理方面,异常安全是一个经常被忽视但极其重要的问题。现代C++的一个核心原则就是通过RAII(资源获取即初始化)来确保资源的安全管理。而上述情况恰恰违背了这一原则。
3. std::make_unique的解决方案
3.1 安全的调用方式
使用std::make_unique可以完全避免上述问题:
cpp复制// 安全的调用方式
processResource(std::make_unique<Resource>(), calculatePriority());
这种写法将资源分配和智能指针构造合并为一个原子操作,无论calculatePriority()是否抛出异常,都不会造成内存泄漏。
3.2 make_unique的实现原理
std::make_unique的实现其实相当简单,它利用了C++11的变参模板和完美转发:
cpp复制template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
这个实现确保了:
- 内存分配和对象构造是原子的
- 构造参数被完美转发给T的构造函数
- 整个过程是异常安全的
3.3 对数组的支持
std::make_unique还支持动态数组的创建:
cpp复制// 创建包含10个元素的int数组
auto arr = std::make_unique<int[]>(10);
// 可以安全地使用数组
arr[0] = 42;
数组版本会在适当的时候调用delete[]来释放内存,完全遵循RAII原则。
4. 工程实践中的最佳实践
4.1 现代C++内存管理准则
在现代C++开发中,应该遵循以下内存管理准则:
- 优先使用智能指针而非裸指针
- 需要独占所有权时使用unique_ptr
- 需要共享所有权时使用shared_ptr
- 总是使用make_unique和make_shared来创建智能指针
- 尽量避免直接使用new和delete
4.2 代码可读性提升
使用make_unique还能显著提高代码的可读性:
cpp复制// 传统方式 - 类型名重复
std::unique_ptr<Widget> ptr(new Widget());
// 现代方式 - 更简洁
auto ptr = std::make_unique<Widget>();
这种方式不仅减少了代码量,还使得重构更加容易(只需修改一处类型名)。
4.3 性能考虑
虽然make_unique的主要优势在于安全性,但它也有一些性能上的好处:
- 减少了代码冗余
- 编译器有更多优化机会
- 避免了显式的new操作,减少了出错的可能性
5. 常见问题与解决方案
5.1 自定义删除器的情况
make_unique不支持直接指定自定义删除器。如果需要自定义删除器,仍然需要使用unique_ptr的构造函数:
cpp复制auto deleter = [](FILE* f) { fclose(f); };
std::unique_ptr<FILE, decltype(deleter)> fp(fopen("file.txt", "r"), deleter);
5.2 私有构造函数的情况
如果类构造函数是私有的,make_unique也无法直接使用。这时可以考虑以下几种方案:
- 使用友元声明
- 提供静态工厂方法
- 使用std::unique_ptr的构造函数
5.3 与旧代码的兼容性
在需要与旧代码或C接口交互时,可以使用get()方法获取裸指针:
cpp复制void legacyFunction(Resource* res);
auto ptr = std::make_unique<Resource>();
legacyFunction(ptr.get());
但要注意确保legacyFunction不会尝试删除这个指针。
6. C++17对求值顺序的改进
C++17对函数参数的求值顺序做出了更严格的规定,要求每个参数的值计算和副作用在下一个参数开始前完成。这意味着:
cpp复制processResource(std::unique_ptr<Resource>(new Resource()), calculatePriority());
在C++17中,上述代码不再有内存泄漏的风险,因为new Resource()和unique_ptr构造必须作为一个整体在calculatePriority()之前完成。
尽管如此,仍然推荐使用make_unique,因为:
- 代码更简洁
- 兼容C++14及更早标准
- 遵循DRY原则
- 意图更明确
7. 实际项目中的应用建议
在实际项目中应用make_unique时,建议:
- 在团队中建立统一的代码规范,要求使用make_unique
- 在代码审查中检查裸new的使用
- 对于遗留代码,逐步替换为make_unique
- 在文档中明确内存管理策略
对于大型项目,可以考虑使用静态分析工具来检测不安全的new使用。
8. 性能与异常安全的权衡
虽然make_shared因为可以将控制块和对象分配在连续内存中而有一定的性能优势,但make_unique在这方面并没有特别的优势。不过,它带来的异常安全性提升远远超过了任何微小的性能考虑。
在绝大多数情况下,应该优先考虑代码的安全性和可维护性,而不是微小的性能差异。现代编译器的优化能力已经足够强大,能够很好地处理make_unique生成的代码。