15. 工厂函数中的完美转发:make_unique, make_shared 的内部实现
工厂函数,说白了就是帮你“造对象”的函数。你给它原材料,它给你返回一个包装好的成品。C++标准库里的 std::make_unique 和 std::make_shared 就是最典型的两个工厂函数。
我个人习惯,只要不是必须用 new 的场景,一律用 make_xxx。为什么?因为安全、高效,而且代码写起来清爽。但你真的理解它们内部是怎么工作的吗?尤其是“完美转发”在里头扮演了什么角色?
15.1 为什么需要工厂函数?
先看一个最朴素的写法:
std::unique_ptr<Widget> p(new Widget(1, "hello"));
这代码有什么问题?嗯,有两个小毛病:
- 重复写类型:
Widget出现了两次,万一类型名很长,看着就烦。 - 异常不安全:如果
new Widget(...)成功了,但unique_ptr的构造函数还没执行,此时抛出异常,内存就泄漏了。
用 make_unique 就清爽多了:
auto p = std::make_unique<Widget>(1, "hello");
类型只写一次,而且异常安全。那它是怎么把参数传进去的?
15.2 make_unique 的内部实现
我直接给你看一个简化版的 make_unique 实现,你就明白了:
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
这里的关键就两点:
- 可变参数模板
typename... Args:可以接受任意数量、任意类型的参数。 - 万能引用
Args&&... args:每个参数都能保持自己的左值/右值属性。 - std::forward:把参数原样转发给
T的构造函数。
举个例子:
auto u = std::make_unique<std::string>(5, 'A');
这里 5 和 'A' 都是右值,std::forward 会把它们作为右值转发给 std::string 的构造函数 string(size_t, char)。完美!
make_unique 在 C++14 才进入标准库。C++11 里没有,很多人自己手写了一个。我当年在项目中就自己实现过,后来升级到 C++14 才换成标准版本。
15.3 make_shared 的内部实现
make_shared 比 make_unique 稍微复杂一点,因为它要管理引用计数。简化版实现如下:
template<typename T, typename... Args>
std::shared_ptr<T> make_shared(Args&&... args) {
// 一次性分配控制块和对象内存
auto p = new detail::shared_ptr_control_block<T>(
std::forward<Args>(args)...
);
return std::shared_ptr<T>(p);
}
注意,真正的标准库实现会更复杂,但核心逻辑不变:
- 一次性分配一块内存,同时容纳 控制块(引用计数、删除器等)和 对象本身。
- 通过完美转发把参数传给对象的构造函数。
- 返回的
shared_ptr直接指向这块内存中的对象部分。
这样做的好处是什么?
- 减少一次内存分配:如果用
shared_ptr<T>(new T(...)),会先new T分配对象内存,再new控制块内存,总共两次。而make_shared只分配一次。 - 更好的局部性:对象和控制块在连续内存中,缓存命中率更高。
make_shared 也有一个坑。因为对象和控制块在同一块内存上,只有当所有 shared_ptr 和所有 weak_ptr 都销毁后,这块内存才会释放。如果你有 weak_ptr 长期持有,对象的内存可能迟迟无法回收。我曾经在某个长连接服务里踩过这个坑,后来改用 shared_ptr(new T(...)) 才解决。
15.4 完美转发在工厂函数中的角色
你想想看,如果没有完美转发,工厂函数会变成什么样?
假设我们只能用左值引用:
template<typename T, typename Arg>
std::unique_ptr<T> make_unique(Arg& arg) {
return std::unique_ptr<T>(new T(arg));
}
那你就没法传右值了:
auto u = make_unique<std::string>("hello"); // 编译错误!"hello" 是右值
如果改成 const Arg&,倒是能接受右值了,但参数永远变成 const 左值引用,没法触发移动语义:
std::string s = "hello";
auto u = make_unique<std::string>(std::move(s)); // s 不会被移动,而是被拷贝
这显然不是我们想要的。完美转发 + 万能引用,正好解决了这个问题:
- 左值传进来,保持左值属性,走拷贝构造。
- 右值传进来,保持右值属性,走移动构造。
15.5 知识体系图
下面这张图帮你理清工厂函数、完美转发、移动语义之间的关系:
15.6 实际项目中的避坑指南
我在项目中遇到过几次跟工厂函数相关的坑,分享给你:
- 自定义删除器不能用 make_shared:
make_shared不支持自定义删除器。如果你需要特殊的内存管理,老老实实用shared_ptr(new T(...), deleter)。 - 数组类型用 make_unique 要小心:C++17 之前,
make_unique<T[]>不支持传参初始化元素。C++17 之后可以用make_unique<T[]>(size),但每个元素是值初始化的。 - 大对象慎用 make_shared:因为控制块和对象在同一块内存,如果对象很大,而
weak_ptr生命周期很长,内存占用会比你想象的大。我曾经在缓存系统里吃过这个亏。 - 不要对 make_unique 的结果调用 .get() 再手动 delete:这会导致 double free。嗯,听起来很蠢,但我真见过有人这么干。
make_unique 和 make_shared,除非你有明确的理由不用。它们不仅代码更简洁,而且性能更好、更安全。完美转发让这一切变得可能。
好了,关于工厂函数中的完美转发,就聊到这里。你只要记住一句话:完美转发是工厂函数的灵魂,它让参数传递变得透明且高效。下次你写自己的工厂函数时,别忘了用上 std::forward。