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<Foo>(a, b) 模板实例化:T=Foo, Args={int, double} 完美转发:std::forward<int>(a), std::forward<double>(b) new Foo(std::forward<int>(a), std::forward<double>(b)) 用户传入参数 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 调用,既保证了异常安全,又让代码更简洁——这笔买卖,怎么算都划算。