
Array 中的模板约束、条件 noexcept 与反向迭代器
阅读 stl1weekend 的 Array.hpp 时,有三个值得记录的细节:元素类型没有在模板入口写明全部限制;swap 使用条件 noexcept;反向迭代器保存的底层位置与它访问的元素相差一位。下面的源码片段为便于阅读,将保留标识符改成了普通名称。
类型要求何时生效
Array 的基本结构可以简化为:
template<class T, std::size_t N>
struct Array {
T elements[N]; // 此示意仅讨论 N > 0
T& operator[](std::size_t i) { return elements[i]; }
void fill(const T& value) {
for (std::size_t i = 0; i < N; ++i)
elements[i] = value;
}
};class T 表示 T 是类型模板参数,并不表示只接受类类型,也不代表任何类型都能成功使用。类定义中的 T elements[N] 首先要求 T 能作为数组元素;实际源码还声明了 T*、T& 等类型,它们也会带来各自的要求。例如 void 不能作为数组元素,也不能形成 void&。
这些是类定义对 T 的要求。另一些要求来自具体操作:operator[] 可以返回元素引用,而 fill() 的循环需要执行 elements[i] = value,所以要求元素能够从 const T& 赋值。它们不必在类模板入口统一施加。
例如 std::unique_ptr 可以移动,但不能拷贝:
#include <memory>
Array<std::unique_ptr<int>, 2> a{};
a[0].reset(new int(10));
int x = *a[0];
// a.fill(a[0]); // 错误:unique_ptr 不能从 const unique_ptr& 赋值能够创建和访问 a,不表示 fill() 也能成功调用。对于非删除的普通类模板成员函数,类模板特化被隐式实例化时,通常会实例化成员声明,而不会仅因此实例化成员函数的定义。C++ 工作草案 [temp.inst]/3.1 的原文是:
“the implicit instantiation of the declarations, but not of the definitions, of the non-deleted class member functions”
即:类模板特化的隐式实例化会导致非删除成员函数的声明被隐式实例化,但不会因此实例化其定义。接着,[temp.inst]/4 规定,除条文所述例外外,当上下文要求成员定义存在,或定义的存在会影响程序语义时,才隐式实例化该成员。对于上例,实际调用 a.fill(a[0]) 需要 fill() 的定义,此时其函数体中的赋值对 std::unique_ptr<int> 无效,因而编译失败。
成员声明、异常说明等有各自的实例化时机,不能把上述规则简化为“所有检查都等到调用”。例如 [except.spec]/13 单独列出异常说明何时被视为需要的情形。这里引用的是持续更新的 C++ 工作草案,其条款编号和措辞可能变化。
这种设计保留了不可拷贝元素的其他用途。如果在类的入口就要求 T 可拷贝赋值,连只想访问元素的 Array<std::unique_ptr<int>, 2> 也会被拒绝。相反,将要求写在 fill() 的显式约束中可以改善诊断和泛型代码的可调用性检测,但那是另一种接口设计。
这里的 fill() 需要的是拷贝赋值,并不直接要求拷贝构造。原代码的零长度特化有空的 fill() 函数体,不执行元素赋值,应与上面的非零版本分开看。
条件 noexcept
原实现的交换函数声明为:
void swap(Array& other) noexcept(std::is_nothrow_swappable_v<T>);other 是另一个相同 Array<T, N> 类型对象的引用。noexcept(条件) 中的条件是编译期布尔常量,决定该函数的异常说明:
void f() noexcept(true); // 承诺异常不会传播出函数
void g() noexcept(false); // 允许异常传播,不表示一定会抛异常
void h() noexcept; // 等价于 noexcept(true)std::is_nothrow_swappable_v<T> 判断类型 T 是否满足不抛异常的交换要求。因此,这个声明的意图是:元素交换不抛异常时,数组交换也作出不抛异常的承诺;否则允许异常传播。若异常逃出 noexcept(true) 函数,程序会调用 std::terminate()。
noexcept(条件) 是异常说明,不是可调用性约束。 条件为 false 时函数不会因此从候选函数中消失;函数体里的交换是否有效,仍需另行判断。
noexcept 还有另一种用途:作为运算符查询表达式的异常属性。例如 noexcept(expr) 得到一个编译期布尔值,不执行 expr;而函数声明末尾的 noexcept(条件) 使用这个值规定函数的异常说明。
原代码还有一个值得留意的差异:函数体使用限定调用 std::swap(element, other_element),而 is_nothrow_swappable_v<T> 的交换检查可能通过 ADL 找到自定义 swap。这两种选择可能不一致;编写条件异常说明时,判断所依据的操作应与函数体实际执行的操作一致。
noexcept(条件) 是 C++11 特性;is_nothrow_swappable_v 是 C++17 类型特性,不能把整个示例当作严格的 C++11 代码。
reverse_iterator 保存的位置
反向迭代器是一个迭代器适配器。它保存一个底层正向迭代器,可通过 base() 取出;对反向迭代器解引用时,访问的是底层位置前一个元素。
对于 Array 使用的指针迭代器,可以画成:
正向元素: [ 10 ] [ 20 ] [ 30 ] 末尾后
正向位置: begin() end()
反向位置: rend() ←──────────────────────── rbegin()
rbegin().base() 指向 end()
*rbegin() 访问 30
rend().base() 指向 begin()对于可解引用的反向迭代器 rit,概念上有:
*rit == *std::prev(rit.base())对于数组的指针迭代器,也可以写成 *(rit.base() - 1)。一般双向迭代器不一定支持减整数,因此更通用的理解是复制底层迭代器,先执行 --,再解引用。标准草案对 operator* 的效果正是:
Iterator tmp = current;
return *--tmp;反向迭代器不会为了这次解引用而改变自己保存的 current;被减一的是副本。
这解释了 Array 中两个构造方式:
reverse_iterator rbegin() { return reverse_iterator(end()); }
reverse_iterator rend() { return reverse_iterator(begin()); }rbegin() 保存 end(),解引用时退一位,得到最后一个元素。rend() 保存 begin(),表示反向遍历的结束位置;它不能解引用,否则就需要越过首元素之前的位置。空数组中 begin() == end(),因此 rbegin() == rend(),没有可解引用的反向元素。
对反向迭代器执行 ++rit 时,它沿反向遍历前进,对应的底层迭代器向正向序列的前方后退。以 [10, 20, 30] 为例,依次访问的值是 30、20、10。
参考文献
以上 eel.is 链接指向持续更新的工作草案,具体措辞和接口可能包含 C++11 之后的变化。