29. 如何实现一个通用的 make_unique 函数(类似 std::make_unique)?
说实话,std::make_unique 是 C++14 才正式加入标准库的。但在 C++11 时代,我们很多人都在自己手写这个工具函数。我自己在 2013 年重构一个老项目时,就亲手写过一版——那时候项目里到处都是裸 new,看得我头皮发麻。
今天我们就来聊聊,怎么实现一个通用的 make_unique。你想想看,它本质上就是个工厂函数,帮我们完成「分配内存 + 构造对象 + 包装成 unique_ptr」这三步。
核心思路:完美转发是关键
要实现通用的 make_unique,核心就两点:
- 可变参数模板:支持任意数量和类型的构造函数参数
- 完美转发:把参数原封不动地传递给构造函数
说白了,就是要把用户传进来的参数,不丢失类型信息地转发给 new 表达式。嗯,这里要注意,如果参数是左值,就传左值引用;如果是右值,就传右值引用——这正是完美转发干的事。
核心公式:make_unique<T>(args...) = unique_ptr<T>(new T(std::forward<Args>(args)...))
基础实现:单对象版本
我们先写一个最基础的版本,只处理单个对象,不处理数组:
#include <memory>
#include <utility>
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique_ptr<T>(
new T(std::forward<Args>(args)...)
);
}
这段代码看着简单,但背后有门道。我刚开始写的时候,犯过一个低级错误——忘了写 std::forward,结果所有参数都被当成左值传递,导致某些只能接受右值引用的构造函数编译失败。那次排查花了我半小时,后来我养成了一个习惯:看到可变参数模板,第一反应就是加 forward。
数组版本:支持 new T[n]
单对象版本够用吗?不够。C++ 标准库的 std::make_unique 还支持数组形式。比如 auto p = std::make_unique<int[]>(10); 会分配 10 个 int 的数组。
数组版本有个特殊之处:你不能在 new 数组时传构造参数。数组元素是默认初始化的。所以实现起来反而更简单:
template<typename T>
std::unique_ptr<T> make_unique(std::size_t size) {
// 这里用 std::remove_extent 去掉 [],得到元素类型
using U = std::remove_extent_t<T>;
return std::unique_ptr<T>(new U[size]());
}
注意我加了 () 在 new U[size]() 后面。这表示值初始化,数组元素会被清零。如果不加,就是默认初始化,元素值未定义。我个人习惯总是加上,避免踩坑。
完整实现:SFINAE 与重载
现在我们把单对象版本和数组版本组合起来。这里要用到 std::enable_if 来做 SFINAE,让编译器根据 T 的类型选择正确的重载:
#include <memory>
#include <utility>
#include <type_traits>
// 版本1:非数组类型
template<typename T, typename... Args>
std::enable_if_t<!std::is_array_v<T>, std::unique_ptr<T>>
make_unique(Args&&... args) {
return std::unique_ptr<T>(
new T(std::forward<Args>(args)...)
);
}
// 版本2:未知边界数组(如 int[])
template<typename T>
std::enable_if_t<std::is_array_v<T> && std::extent_v<T> == 0, std::unique_ptr<T>>
make_unique(std::size_t size) {
using U = std::remove_extent_t<T>;
return std::unique_ptr<T>(new U[size]());
}
// 版本3:已知边界数组(如 int[10])—— 禁止使用
template<typename T, typename... Args>
std::enable_if_t<std::extent_v<T> != 0, void>
make_unique(Args&&...) = delete;
这里我做了三件事:
- 版本1:处理普通类型,比如
make_unique<Foo>(1, 2) - 版本2:处理未知边界的数组,比如
make_unique<int[]>(10) - 版本3:处理已知边界的数组,比如
make_unique<int[10]>()——直接 delete 掉,因为这种用法没有意义
注意:版本3的 delete 声明很重要。如果不加,用户写 make_unique<int[10]>() 可能会匹配到版本1,然后编译出奇怪的错误。明确 delete 掉,编译器会给出清晰的提示。
为什么不直接用 std::make_unique?
你可能会问:既然 C++14 已经有了标准实现,为什么还要自己写?
嗯,有几个场景:
- C++11 项目:还在用 C++11 标准,标准库没有
std::make_unique - 定制化需求:比如你想在分配时打日志、统计内存、或者使用自定义分配器
- 教学目的:理解内部机制,写起来心里有底
我曾经在一个嵌入式项目里,因为标准库不支持 make_unique,就自己手写了一个带内存池的版本。那感觉就像自己造轮子,但造完之后对 unique_ptr 的理解深了一个层次。
SVG:make_unique 的调用流程
下面这张图展示了 make_unique<Foo>(a, b) 的完整调用链路:
避坑指南:我踩过的几个坑
写 make_unique 时,有几个细节容易翻车:
- 忘记处理数组版本:用户写
make_unique<int[]>(5)时,如果只定义了单对象版本,编译器会报错。我曾经在一个库中漏掉了数组重载,结果用户反馈说「为什么我的 int 数组分配不了?」——嗯,那次我连夜补上了。 - 没有 delete 已知边界数组:
make_unique<int[10]>()这种写法应该直接禁止。标准库就是这么做的,我们也应该跟上。 - 忘记值初始化:数组版本中
new U[size]()的括号不能省。不写的话,内置类型的数组元素是未初始化的,用起来就是定时炸弹。
小技巧:如果你在 C++11 环境下工作,可以把上面的代码封装到一个头文件里,命名为 make_unique.h。然后在项目里统一包含。这样既不用升级编译器,又能享受 make_unique 的便利。
总结
实现一个通用的 make_unique,本质上就是「可变参数模板 + 完美转发 + SFINAE 重载」的组合拳。它不算复杂,但每个细节都值得推敲。我自己在项目中用过不下几十次,每次看到 std::forward 那行代码,都会想起当年踩过的坑。
说白了,make_unique 的价值不在于它有多难写,而在于它帮我们养成了「不用裸 new」的好习惯。你想想看,一个 make_unique 调用,既保证了异常安全,又让代码更简洁——这笔买卖,怎么算都划算。