
从 UniquePtr 理解模板与动态数组
阅读 stl1weekend 的 UniquePtr.hpp 时,数组偏特化有一个容易产生疑问的地方:偏特化继承的是 UniquePtr<T, Deleter>,为什么最终仍然用数组删除器?顺着这个问题,还可以理解类型约束和数组初始化的几个细节。
数组偏特化和删除器
源代码中与类型推导有关的声明如下:
template <class _Tp, class _Deleter = DefaultDeleter<_Tp>>
struct UniquePtr { /* ... */ };
template <class _Tp, class _Deleter>
struct UniquePtr<_Tp[], _Deleter> : UniquePtr<_Tp, _Deleter> {
using UniquePtr<_Tp, _Deleter>::UniquePtr;
// ...
};当使用 UniquePtr<int[]> 时,可以按下面的步骤理解其含义:
// 1. 主模板的默认模板实参使用完整的第一个实参 int[]
UniquePtr<int[], DefaultDeleter<int[]>>
// 2. 匹配偏特化 UniquePtr<_Tp[], _Deleter>
// _Tp = int
// _Deleter = DefaultDeleter<int[]>
// 3. 代入偏特化的基类
UniquePtr<int, DefaultDeleter<int[]>>偏特化中的 _Tp 是元素类型,但 _Deleter 已经是针对完整类型 int[] 得到的默认删除器。基类显式接收了该删除器,所以不会按照基类的第一个参数 int 重新取默认值。用户显式指定删除器时,偏特化也会原样接收指定的类型。
这个解释可以拆成两条标准规则:C++ 工作草案 [temp.param]/17 定义默认模板实参;[temp.spec.partial.match]/1–2 说明需要实例化时,用特化的模板实参与偏特化的实参模式匹配,能推导并满足约束时才匹配;尤其是 [temp.spec.partial.match]/4 明确指出,引用类模板特化时实参列表必须匹配主模板参数列表,而偏特化的模板实参从主模板的实参推导。此处“先补全、再匹配”是解释规则结果的顺序,并非对编译器内部实现流程的要求。链接是持续更新的工作草案,段落编号可能变化。
可以用编译器观察结果。下面的示例是独立验证代码,保留了源代码的模板关系:
#include <type_traits>
template<class T> struct DefaultDeleter {};
template<class T, class D = DefaultDeleter<T>> struct Ptr {};
template<class T, class D>
struct Ptr<T[], D> : Ptr<T, D> {
using element = T;
using deleter = D;
};
static_assert(std::is_same_v<Ptr<int[]>, Ptr<int[], DefaultDeleter<int[]>>>);
static_assert(std::is_same_v<Ptr<int[]>::element, int>);
static_assert(std::is_same_v<Ptr<int[]>::deleter, DefaultDeleter<int[]>>);
static_assert(std::is_base_of_v<Ptr<int, DefaultDeleter<int[]>>, Ptr<int[]>>);使用 GCC 的 C++20 模式编译这些断言可以通过。实验验证了这个例子的结果,规则依据仍是上面的标准条文。
源代码同时为 DefaultDeleter<T> 和 DefaultDeleter<T[]> 提供不同实现:前者调用 delete p,后者调用 delete[] p。动态数组由 new T[n] 创建时,需要数组形式的删除表达式;因此数组类型信息必须保留在删除器中。
SFINAE 与转换构造函数
源码将约束放在转换构造函数的模板参数中:
template <class _Up, class _UDeleter,
class = std::enable_if_t<std::is_convertible_v<_Up *, _Tp *>>>
UniquePtr(UniquePtr<_Up, _UDeleter> &&__that) noexcept;当 _Up* 可以隐式转换为 _Tp* 时,std::enable_if_t<true> 是 void,该候选可以继续参与重载决议。例如,公开继承下的 Derived* 通常可以转换为 Base*。反过来,Base* 通常不能隐式转换为 Derived*,此时 std::enable_if_t<false> 没有成员类型 type。
由于这个替换失败发生在函数模板声明的直接上下文,SFINAE 使该函数模板特化从候选中移除。相比之下,如果把同样的检查放进函数体中的 static_assert,候选可能已经被选中,随后实例化函数体才报错。替换触发的深层模板实例化副作用不一定属于直接上下文,也可能产生硬错误。
C++20 可以用约束写出同一意图:
template<class U, class UDeleter>
requires std::is_convertible_v<U*, T*>
UniquePtr(UniquePtr<U, UDeleter>&& other);源码以指针可转换性为主要条件,这是教学实现;标准库 unique_ptr 还需要考虑删除器等更多条件。源码的转换构造和移动操作也没有相应转移删除器状态,不能假定它已完整支持有状态删除器。
工厂函数中的非类型模板参数
makeUniqueForOverwrite 的单对象重载使用了另一种 SFINAE 写法:
template <class _Tp,
std::enable_if_t<!std::is_unbounded_array_v<_Tp>, int> = 0>
UniquePtr<_Tp> makeUniqueForOverwrite();第二个模板参数没有写名字。它是非类型模板参数:std::enable_if_t<条件, int> 决定它的类型,= 0 是这个模板参数的默认实参,与函数参数或返回值无关。可以给它起名来观察语法(名字仅用于说明):
template<class T, std::enable_if_t<!std::is_unbounded_array_v<T>, int> Enable = 0>
void example();std::enable_if_t<B, int> 在 B 为 true 时表示 int;在 B 为 false 时没有可用的 type,替换失败。具体推导如下:
_Tp = int:is_unbounded_array_v<int>为false,取反为true;第二个参数变为int = 0,该重载可以参与后续的重载决议。_Tp = int[]:is_unbounded_array_v<int[]>为true,取反为false;enable_if_t<false, int>无法形成类型,这个重载因替换失败被排除。源码的数组重载使用不取反的条件,并接收长度参数。_Tp = int[3]:它是有界数组,所以is_unbounded_array_v<int[3]>为false,取反后条件成立。条件成立只表明这一约束没有排除重载,不保证函数体对于有界数组有效。
int 是 enable_if_t 在条件成立时产生的类型;末尾的 0 则是该非类型参数的值。它们不表示数组长度,也不是函数实参。= 0 让调用方通常只写 makeUniqueForOverwrite<int>(),无需显式提供第二个模板实参。
这里用 int 而不是省略 enable_if_t 的第二个参数,是因为默认类型为 void,而 void 不能用作这种非类型模板参数的类型。这与前面转换构造函数中的 class = std::enable_if_t<条件> 不同:前者声明非类型参数,后者声明类型参数,条件为真时 void 是合法的类型实参。C++20 可将对应条件直接写成 requires (!std::is_unbounded_array_v<T>)。
add_lvalue_reference_t 与 void
源码的解引用操作返回类型是:
std::add_lvalue_reference_t<_Tp> operator*() const noexcept {
return *_M_p;
}对普通对象类型,std::add_lvalue_reference_t<int> 是 int&。但 void& 不是合法类型,而 std::add_lvalue_reference_t<void> 的结果是 void。因此类型变换可以避免类模板声明仅因试图形成 void& 就立即无效。
static_assert(std::is_same_v<std::add_lvalue_reference_t<int>, int&>);
static_assert(std::is_same_v<std::add_lvalue_reference_t<void>, void>);这只解决了返回类型的形成问题,并不让 void* 可以解引用。源代码的函数体仍然含有 *_M_p:如果对 UniquePtr<void, 自定义删除器> 实际使用解引用,仍会失败。默认删除器也不能作为 void* 的通用有效释放方式。引用类型则遵循引用折叠规则,例如 add_lvalue_reference_t<int&&> 得到 int&。
makeUniqueForOverwrite 与初始化
四个工厂函数的差异集中在 new 表达式的括号:
| 调用 | 分配表达式 | 初始化 |
|---|---|---|
makeUnique<int>() | new int() | 值初始化,得到 0 |
makeUniqueForOverwrite<int>() | new int | 默认初始化,不得到确定的初始值 |
makeUnique<int[]>(n) | new int[n]() | 各元素值初始化,得到 0 |
makeUniqueForOverwrite<int[]>(n) | new int[n] | 各元素默认初始化,不得到确定的初始值 |
数组版本通过 std::remove_extent_t<_Tp> 从 int[] 得到元素类型 int,再用运行时传入的 n 分配数组。std::is_unbounded_array_v 用于区分无界数组和其他类型。
ForOverwrite 适合分配后先写入、再读取的场景:对于标量及其数组,它可以避免把即将覆盖的数据先值初始化。必须确保要读取的元素已经写入;实际性能收益依赖编译器和程序。对于类类型,默认初始化仍可能调用默认构造函数,ForOverwrite 并不意味着跳过类的构造。
标准库的 std::make_unique_for_overwrite 自 C++20 提供。这里还有一个实现边界:源码用 !std::is_unbounded_array_v<T> 选择普通版本,它对 int[3] 也为真;重载被选中并不保证函数体对有界数组合法。标准库则专门禁止 make_unique 的有界数组形式。
为什么动态数组接口使用 T[]
UniquePtr<int[]> 表示数组元素类型是 int,但数组长度没有编码进智能指针的类型:
std::size_t n = /* 运行时确定 */;
UniquePtr<int[]> p(new int[n]);不管运行时的 n 是多少,这里的类型都是 UniquePtr<int[]>。new[] 返回指向首元素的指针;此实现不保存长度,需要使用者另行管理。
有界数组同样可以用于模板。int[3] 和 int[4] 是不同的类型,可以借用引用来推导编译期长度:
template<class T, std::size_t N>
void inspect(T (&array)[N]) {
// N 是类型中记录的长度
}因此,T[] 适合长度在运行时给出的动态数组接口;T[N] 适合需要把编译期长度放进类型的场景。模板并不要求数组必须无界。