std::vector 与无符号长度和下标问题

在上一课 —— 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::size_type 被定义为 std::size_t,因此 T::size_type 默认为 std::size_t。

仅当使用自定义分配器且其 T::size_type 非 std::size_t 时,容器的 T::size_type 才会不同。这种情况罕见且通常只在特定应用中出现,故一般可认定 T::size_type 即 std::size_t,除非应用明确使用了此类自定义分配器(若如此,开发者自会知晓)。

访问容器类的 size_type 成员时,必须使用完全模板化的容器类名进行作用域限定,例如 std::vector::size_type。

使用 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?

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"