在上一课 —— std::vector 简介与列表构造函数中,我们介绍了 operator[],可用于通过下标访问数组元素。
本课将介绍其他访问数组元素的方法,以及获取容器类长度(即容器当前所含元素个数)的若干方式。
但在展开之前,必须先讨论 C++ 设计者犯下的一个重大失误——这一失误影响了整个 C++ 标准库中所有容器类的设计。
容器长度符号问题
首先给出一个论断:用于数组下标的数据类型,应当与用于存储数组长度的数据类型保持一致。唯有如此,才能索引到最长可能数组中的所有元素,而又不越界。
据 Bjarne Stroustrup 回忆,在设计 C++ 标准库容器类时(约 1997 年),设计者必须决定长度(及数组下标)采用有符号还是无符号类型,最终选择了无符号。
当时的理由包括:
- 标准库数组类型的下标不应为负数;
- 使用无符号类型能多利用一位,从而支持更大长度的数组(在 16 位时代尤为重要);
- 范围检查只需一次条件判断(无需再检查下标是否小于 0)。
然而事后看来,这一选择普遍被认为是错误的。我们现在认识到:
- 用无符号值试图强制非负性并不奏效,因为负的有符号整数会隐式转换为一个巨大的无符号整数,导致垃圾结果;
- 在 32 位或 64 位系统上,额外的那一位范围几乎用不上(极少创建超过 20 亿个元素的数组);
- 常用的 operator[] 本身并不进行范围检查。
在课程无符号整数及其应避免的原因中,我们讨论了为何倾向于使用有符号值来保存数量,并指出混用有符号与无符号值易导致意料之外的行为。标准库容器类却恰恰采用了无符号值来表示长度(及下标),这使得使用这些类型时无法完全避免无符号值,从而带来不必要的复杂性。
回顾:符号转换属于收缩转换,但 constexpr 除外
继续之前,先简要回顾课程 —— 收缩转换、列表初始化与 constexpr 初始化器中有关符号转换(有符号与无符号整型间的转换)的内容,因为本章会频繁涉及。
符号转换被视为收缩转换:有符号或无符号类型均无法完全包含对方类型的全部取值范围。若该转换在运行时发生,则在禁止收缩转换的语境(如列表初始化)中,编译器会报错;在其他语境中,编译器可能仅给出警告。
示例:
#include <iostream>
void foo(unsigned int)
{
}
int main()
{
int s { 5 };
[[maybe_unused]] unsigned int u { s }; // 编译错误:列表初始化禁止收缩转换
foo(s); // 可能警告:复制初始化允许收缩转换
return 0;
}
上述代码中,变量 u 的初始化因收缩转换而触发编译错误;调用 foo(s) 则通过复制初始化完成,允许收缩转换,是否产生警告取决于编译器对符号转换警告的严格程度。例如,在 GCC 与 Clang 中启用 -Wsign-conversion
后将产生警告。
然而,若待转换值为 constexpr,且能安全地转换为对应类型的等效值,则该符号转换不被视为收缩转换,因为编译器可保证转换安全,否则中止编译。
#include <iostream>
void foo(unsigned int)
{
}
int main()
{
constexpr int s { 5 }; // 现为 constexpr
[[maybe_unused]] unsigned int u { s }; // OK:s 为 constexpr,可安全转换,非收缩转换
foo(s); // OK:同上
return 0;
}
上述示例中,由于 s 为 constexpr,且值 5 能被无符号类型表示,该转换不被视为收缩转换,可安全隐式完成。
这种非收缩的 constexpr 转换(从 constexpr int 到 constexpr std::size_t)将在后续频繁使用。
std::vector
的长度与下标类型为 size_type
在课程 —— typedef 与类型别名中,我们曾提到:当某类型可能随实现而异时,常使用 typedef 或类型别名为其命名。例如,std::size_t 通常是对某种较大无符号整型(unsigned long 或 unsigned long long)的 typedef。
每个标准库容器类都定义了一个嵌套 typedef 成员,名为 size_type(亦写作 T::size_type),作为该容器长度(及下标,若支持下标)的别名。
在文档或编译器警告/报错信息中,常可见到 size_type。例如,std::vector::size() 的文档即指出其返回类型为 size_type。
相关内容参见课 —— 嵌套类型(成员类型)。
size_type 几乎总是 std::size_t 的别名,但极少数情况下可被覆盖为其他类型。
要点
size_type 是标准库容器类中定义的嵌套 typedef,用作容器长度(及下标)的类型。
size_type 默认为 std::size_t,且几乎不会被更改,故可合理认为 size_type 即 std::size_t。
进阶阅读
除 std::array 外,所有标准库容器均使用 std::allocator 分配内存。对于这些容器,T::size_type 派生自所用分配器的 size_type。由于 std::allocator 最多可分配 std::size_t 字节,std::allocator
仅当使用自定义分配器且其 T::size_type 非 std::size_t 时,容器的 T::size_type 才会不同。这种情况罕见且通常只在特定应用中出现,故一般可认定 T::size_type 即 std::size_t,除非应用明确使用了此类自定义分配器(若如此,开发者自会知晓)。
访问容器类的 size_type 成员时,必须使用完全模板化的容器类名进行作用域限定,例如 std::vector
使用 size()
成员函数或 std::size()
获取 std::vector
长度
可通过容器对象的 size() 成员函数查询其长度(返回无符号 size_type):
#include <iostream>
#include <vector>
int main()
{
std::vector prime { 2, 3, 5, 7, 11 };
std::cout << "length: " << prime.size() << '\n'; // 返回类型为 size_type(std::size_t 的别名)
return 0;
}
输出:
length: 5
与 std::string 和 std::string_view 同时拥有 length() 与 size()(二者功能相同)不同,std::vector(以及大多数其他容器)仅提供 size()。至此,你也理解了为何容器长度有时会被含糊地称作 size。
C++17 起,亦可用 std::size() 非成员函数(对容器类而言,其内部直接调用 size() 成员函数):
#include <iostream>
#include <vector>
int main()
{
std::vector prime { 2, 3, 5, 7, 11 };
std::cout << "length: " << std::size(prime); // C++17,返回类型为 size_type(std::size_t 的别名)
return 0;
}
进阶阅读
由于 std::size() 亦可用于未退化的 C 风格数组,故在编写可同时接受容器类或未退化 C 风格数组的函数模板时,此方法更受青睐。
相关讨论见课程 —— C 风格数组退化。
若需将上述任一方法返回的长度存入有符号类型变量,通常会触发符号/无符号转换警告或错误。最简单的做法是使用 static_cast 将结果转换为所需类型:
#include <iostream>
#include <vector>
int main()
{
std::vector prime { 2, 3, 5, 7, 11 };
int length { static_cast<int>(prime.size()) }; // 显式转换为 int
std::cout << "length: " << length;
return 0;
}
使用 C++20 的 std::ssize()
获取带符号长度
C++20 引入了 std::ssize() 非成员函数,其返回较大有符号整型(通常为 std::ptrdiff_t,即与 std::size_t 对应的有符号类型):
#include <iostream>
#include <vector>
int main()
{
std::vector prime{ 2, 3, 5, 7, 11 };
std::cout << "length: " << std::ssize(prime); // C++20,返回带符号整型
return 0;
}
这是三者中唯一返回有符号类型的函数。
若需以此方法获取长度并赋给 int 变量,需注意:int 可能比 std::ssize() 返回的有符号类型窄,故应显式 static_cast 为 int,以避免收缩转换警告或错误:
#include <iostream>
#include <vector>
int main()
{
std::vector prime{ 2, 3, 5, 7, 11 };
int length { static_cast<int>(std::ssize(prime)) }; // 显式转换为 int
std::cout << "length: " << length;
return 0;
}
亦可使用 auto 让编译器推导正确的有符号类型:
#include <iostream>
#include <vector>
int main()
{
std::vector prime{ 2, 3, 5, 7, 11 };
auto length { std::ssize(prime) }; // 使用 auto 推导 std::ssize() 返回的有符号类型
std::cout << "length: " << length;
return 0;
}
使用 operator[]
访问数组元素时不进行边界检查
上一课我们介绍了下标运算符(operator[]):
#include <iostream>
#include <vector>
int main()
{
std::vector prime{ 2, 3, 5, 7, 11 };
std::cout << prime[3]; // 打印下标 3 的元素值(7)
std::cout << prime[9]; // 无效下标(未定义行为)
return 0;
}
operator[] 不做边界检查。operator[] 的下标可以为非 const。稍后将进一步讨论。
使用 at()
成员函数访问数组元素并进行运行时边界检查
数组容器类还支持另一种访问方式。at() 成员函数可在运行时进行边界检查:
#include <iostream>
#include <vector>
int main()
{
std::vector prime{ 2, 3, 5, 7, 11 };
std::cout << prime.at(3); // 打印下标 3 的元素值
std::cout << prime.at(9); // 无效下标(抛出异常)
return 0;
}
上述示例中,prime.at(3) 先检查下标 3 是否有效,由于有效,返回对数组元素 3 的引用并打印。然而 prime.at(9) 因下标无效而失败(运行时),at() 会抛出一个异常,终止程序。
进阶阅读
当 at() 遇到越界下标时,会抛出 std::out_of_range 异常。若未处理该异常,程序将终止。异常及其处理方式将在第 27 章讨论。
与 operator[] 类似,at() 的下标亦可为非 const。
由于每次调用都进行运行时边界检查,at() 比 operator[] 慢,但更安全。尽管 operator[] 安全性较低,实践中仍更常使用,原因在于最好在索引前完成边界检查,从而避免使用无效下标。
以 constexpr signed int
索引 std::vector
若用 constexpr(有符号)int 索引 std::vector,可让编译器隐式将其转换为 std::size_t,且不构成收缩转换:
#include <iostream>
#include <vector>
int main()
{
std::vector prime{ 2, 3, 5, 7, 11 };
std::cout << prime[3] << '\n'; // OK:3 从 int 转换为 std::size_t,非收缩转换
constexpr int index { 3 }; // constexpr
std::cout << prime[index] << '\n'; // OK:constexpr 下标隐式转换为 std::size_t,非收缩转换
return 0;
}
以非 constexpr
值索引 std::vector
用于数组下标的值可以为非 const:
#include <iostream>
#include <vector>
int main()
{
std::vector prime{ 2, 3, 5, 7, 11 };
std::size_t index { 3 }; // 非 constexpr
std::cout << prime[index] << '\n'; // operator[] 期望 std::size_t 类型下标,无需转换
return 0;
}
然而,依循最佳实践(课程 —— 无符号整数及其应避免的原因),我们一般应避免使用无符号类型保存数量。
当我们的下标为非 constexpr 有符号值时,就会遇到问题:
#include <iostream>
#include <vector>
int main()
{
std::vector prime{ 2, 3, 5, 7, 11 };
int index { 3 }; // 非 constexpr
std::cout << prime[index] << '\n'; // 可能警告:index 隐式转换为 std::size_t,属于收缩转换
return 0;
}
本例中,index 为非 constexpr 有符号 int,而 std::vector 的 operator[] 下标类型为 size_type(std::size_t 的别名)。因此调用 prime[index] 时,需将有符号 int 转换为 std::size_t。
此类转换通常不应带来危险(因 std::vector 的下标理应非负,非负有符号值可安全转换为无符号值)。但在运行时,该转换被视为收缩转换,编译器会发出不安全转换警告(若未出现警告,建议调整警告设置以启用)。
由于数组下标非常常见,每次转换都产生警告,会迅速淹没编译日志,甚至若启用“将警告视为错误”,将直接导致编译失败。
避免此问题的方法很多(如每次下标时将 int static_cast 为 std::size_t),但都会以某种方式污染或复杂化代码。最简单的做法是以 std::size_t 类型的变量作为下标,且该变量仅用于下标,从而避免任何非 constexpr 转换。
提示
另一种可行方案是:不直接对 std::vector 下标,而是对其 data() 成员函数的返回值进行下标:
#include <iostream>
#include <vector>
int main()
{
std::vector prime{ 2, 3, 5, 7, 11 };
int index { 3 }; // 非 constexpr 有符号值
std::cout << prime.data()[index] << '\n'; // OK:无符号转换警告
return 0;
}
底层实现中,std::vector 将其元素存储于 C 风格数组。data() 成员函数返回指向该底层数组的指针,可直接对其使用下标。由于 C 风格数组允许使用有符号或无符号类型下标,因此不会触发符号转换警告。C 风格数组相关内容参见课程 —— C 风格数组简介 与 C 风格数组退化。
作者注
我们将在课程 16.7 —— 数组、循环与符号挑战解决方案中讨论更多应对此类下标问题的方案。
小测验
问题 1
用以下值初始化一个 std::vector:‘h’, ’e’, ’l’, ’l’, ‘o’。随后打印数组长度(使用 std::size()),并用下标运算符与 at() 成员函数分别打印下标 1 的元素值。
程序应输出:
The array has 5 elements.
ee
问题 2
a) 什么是 size_type?其用途为何?
b) size_type 默认对应什么类型?是有符号还是无符号?
c) 哪些获取容器长度的函数返回 size_type?