16-3 std::vector 与无符号长度及下标问题

在之前的第16.2节——std::vector与list构造函数入门 中,我们介绍了运算符[],它可用于通过下标访问数组元素。

本节将探讨访问数组元素的其他方法,以及获取容器类长度(即容器类当前元素数量)的几种不同方式。

但在深入之前,我们需要先讨论C++设计者犯下的一个重大错误,以及它如何影响C++标准库中的所有容器类。


容器长度符号问题

我们先提出一个断言:用于数组下标的类型应与存储数组长度的类型一致。这样才能确保最长数组的所有元素都能被索引,且不会超出范围。

正如Bjarne Stroustrup所回忆,在设计C++标准库容器类时(约1997年),设计者需抉择长度(及数组下标)采用有符号还是无符号类型。最终他们选择了无符号类型。

当时给出的理由是:标准库数组类型的下标不可能为负值;使用无符号类型可利用额外位实现更长的数组(在16位时代这很重要);且对下标进行范围检查只需一次条件判断而非两次(因无需检查索引是否小于0)。

回顾来看,这通常被认为是个错误的选择。如今我们认识到:利用无符号值强制非负性会因隐式转换规则失效(负有符号整数会隐式转换为大型无符号整数,导致垃圾结果); 在32位或64位系统上通常无需额外位宽(毕竟很少创建超过20亿元素的数组),且常用操作符[]本身也不进行范围检查。

在第4.5节《无符号整数及其禁忌》中,我们已阐述过为何更倾向于使用有符号值存储量值。我们还指出,有符号与无符号值的混用极易引发意外行为。因此标准库容器类将长度(及索引)设为无符号值的做法存在隐患——这使得使用这些类型时无法规避无符号值。

目前我们只能接受这种选择及其带来的不必要复杂性。


回顾:符号转换属于缩窄转换,constexpr 情况除外

在深入探讨前,让我们快速回顾第 10.4 节——缩窄转换、列表初始化与 constexpr 初始化器中关于符号转换(有符号到无符号或反之的整数转换)的内容,因为本章将频繁涉及这些概念。

符号转换被视为窄化转换,因为有符号或无符号类型无法容纳对方类型范围内的所有值。当运行时执行此类转换时,编译器会在禁止窄化转换的上下文中(如列表初始化)报错,而在允许转换的其他上下文中则可能发出警告也可能不发出警告。

例如:

#include <iostream>

void foo(unsigned int)
{
}

int main()
{
    int s { 5 };

    [[maybe_unused]] unsigned int u { s }; // compile error: list initialization disallows narrowing conversion
    foo(s);                                // possible warning: copy initialization allows narrowing conversion

    return 0;
}

image

在上例中,变量 u 的初始化会引发编译错误,因为列表初始化时不允许进行窄化转换。对 foo() 的调用执行的是复制初始化,该操作允许窄化转换,且是否产生警告取决于编译器对符号转换警告的严格程度。例如,当使用编译器标志 -Wsign-conversion 时,GCC 和 Clang 都会在此情况下发出警告。

然而,若待符号转换的值为 constexpr 且可转换为对应类型中的等效值,则该符号转换不被视为窄化转换。这是因为编译器能确保转换安全,否则将终止编译过程。

#include <iostream>

void foo(unsigned int)
{
}

int main()
{
    constexpr int s { 5 };                 // now constexpr
    [[maybe_unused]] unsigned int u { s }; // ok: s is constexpr and can be converted safely, not a narrowing conversion
    foo(s);                                // ok: s is constexpr and can be converted safely, not a narrowing conversion

    return 0;
}

image

在此情况下,由于 s 是 constexpr 类型,且待转换的值 (5) 可表示为无符号值,因此该转换不被视为窄化转换,可隐式执行且不会引发问题。

这种非窄化 constexpr 转换(从 constexpr int 到 constexpr std::size_t)将是我们在后续开发中频繁使用的技术。


std::vector 的长度和索引类型为 size_type

在第 10.7 节——类型别名Typedefs类型别名aliases中,我们提到当需要为可能变化的类型命名时(例如因实现定义而变化),常使用类型别名和类型别名。例如 std::size_t 是某种大型无符号整数类型的 typedef,通常为 unsigned long 或 unsigned long long。

每个标准库容器类都定义了一个名为 size_type 的嵌套 typedef 成员(有时写作 T::size_type),该类型是容器长度(及支持索引时的索引类型)的别名。

在文档和编译器警告/错误信息中常见 size_type 的出现。例如,std::vector 的 size() 成员函数文档中明确指出该函数返回 size_type 类型的值。

相关内容:
第 15.3 课 嵌套类型(成员类型)”中详述了嵌套 typedef 的实现。

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 才会不同于 std::size_t。这种情况极为罕见且需在应用程序层面实现,因此除非您的应用程序使用了此类自定义分配器,否则通常可安全假设 T::size_type 为 std::size_t (若存在此类情况,您必然知晓)。

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


使用size()成员函数或std::size()获取std::vector的长度

我们可以使用size()成员函数(该函数返回长度值,类型为unsigned size_type)来获取容器类对象的长度:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime { 2, 3, 5, 7, 11 };
    std::cout << "length: " << prime.size() << '\n'; // returns length as type `size_type` (alias for `std::size_t`)
    return 0;
}

这将输出:

image

与同时提供 length() 和 size() 成员函数(功能相同)的 std::string 和 std::string_view 不同,std::vector(以及 C++ 中大多数其他容器类型)仅提供 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, returns length as type `size_type` (alias for `std::size_t`)

    return 0;
}

image

对于高级读者:
由于 std::size() 也可用于非衰变的 C 风格数组,因此相较于使用 size() 成员函数,此方法有时更受青睐(尤其在编写可接受容器类或非衰变 C 风格数组参数的函数模板时)。

我们在第 17.8 课——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()) }; // static_cast return value to int
    std::cout << "length: " << length ;

    return 0;
}

image


使用 std::ssize() 获取 std::vector 的长度( C++20 )

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, returns length as a large signed integral type

    return 0;
}

image

这是三个函数中唯一以有符号类型返回长度的函数。

若需使用此方法将长度存储于带符号类型的变量中,可采取以下方案:

首先,由于 int 类型可能小于 std::ssize() 返回的带符号类型,若将长度赋值给 int 变量,应使用 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)) }; // static_cast return value to int
    std::cout << "length: " << length;

    return 0;
}

image

或者,你可以使用 auto 关键字让编译器推断出该变量应使用的正确有符号类型:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };
    auto length { std::ssize(prime) }; // use auto to deduce signed type, as returned by std::ssize()
    std::cout << "length: " << length;

    return 0;
}

image


使用[]运算符访问数组元素时不会进行边界检查

在上节课中,我们介绍了下标运算符(operator[]):

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    std::cout << prime[3];  // print the value of element with index 3 (7)
    std::cout << prime[9]; // invalid index (undefined behavior)

    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); // print the value of element with index 3
    std::cout << prime.at(9); // invalid index (throws exception)

    return 0;
}

image

在上例中,对 prime.at(3) 的调用会检查索引 3 是否有效,由于索引有效,因此返回数组元素 3 的引用。随后我们可以打印该值。然而对 prime.at(9) 的调用在运行时失败,因为 9 不是该数组的有效索引。at() 函数不会返回引用,而是会引发导致程序终止的错误。

面向高级读者:
当 at() 成员函数遇到越界索引时,实际上会抛出类型为 std::out_of_range 的异常。若未处理该异常,程序将终止运行。关于异常及其处理方法,我们将在第 27 章进行详细讲解。

与operator[]类似,传递给at()的索引可以是非const的。

由于每次调用时都会进行运行时边界检查,at()比operator[]更慢(但更安全)。尽管安全性较低,但通常更倾向于使用operator[]而非at(),主要原因在于索引操作前进行边界检查更为妥当——这样能从源头避免使用无效索引的情况。


使用 constexpr 有符号整数索引 std::vector

当使用 constexpr (有符号) 整数索引 std::vector 时,我们可以让编译器将其隐式转换为 std::size_t,而不会发生窄化转换:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    std::cout << prime[3] << '\n';     // okay: 3 converted from int to std::size_t, not a narrowing conversion

    constexpr int index { 3 };         // constexpr
    std::cout << prime[index] << '\n'; // okay: constexpr index implicitly converted to std::size_t, not a narrowing conversion

    return 0;
}

image


使用非constexpr值对std::vector进行索引

用于数组索引的下标可以是非const的:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    std::size_t index { 3 };           // non-constexpr
    std::cout << prime[index] << '\n'; // operator[] expects an index of type std::size_t, no conversion required

    return 0;
}

然而,根据我们的最佳实践(4.5节——无符号整数及其应避免使用的原因),我们通常应避免使用无符号类型来存储数值。

当下标是非constexpr的有符号值时,我们会遇到问题:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    int index { 3 };                   // non-constexpr
    std::cout << prime[index] << '\n'; // possible warning: index implicitly converted to std::size_t, narrowing conversion

    return 0;
}

image

在此示例中,index 是非 const 表达式的有符号整型。std::vector 定义的下标运算符[]的类型为 size_type(std::size_t 的别名)。因此当调用 prime[index] 时,有符号整型必须转换为 std::size_t。

此类转换本不应存在风险(因 std::vector 的索引预期为非负值,而非负有符号值可安全转换为无符号值)。但运行时执行此转换会被视为缩窄转换,编译器应就此发出不安全转换警告(若未发出,请考虑修改警告设置)。

由于数组下标操作频繁,每次转换都会触发警告,这会导致编译日志充斥大量冗余警告。若启用了“将警告视为错误”设置,编译过程甚至会因此中断。

规避此问题的方案众多(例如每次数组索引时都将 int 静态转换为 std::size_t),但所有方案都不可避免地会以某种形式增加代码冗余或复杂度。最简洁的解决方案是直接使用std::size_t类型的变量作为索引,且该变量仅用于索引操作。如此便能从源头避免任何非const转换。

提示:
另一个不错的替代方案是:不要直接对 std::vector 进行索引操作,而是对 data() 成员函数的返回结果进行索引:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    int index { 3 };                          // non-constexpr signed value
    std::cout << prime.data()[index] << '\n'; // okay: no sign conversion warnings

    return 0;
}

在底层实现中,std::vector 将元素存储于 C 风格数组中。data() 成员函数返回指向该底层 C 风格数组的指针,我们可通过索引访问数据。由于 C 风格数组允许使用带符号和无符号类型进行索引,因此不会遇到任何符号转换问题。关于C风格数组的深入探讨,请参阅第17.7节——C风格数组简介 和第17.8节——C风格数组衰变 。

作者注:
我们将在第16.7节——数组、循环与符号挑战解决方案中探讨应对这类索引难题的其他方法。


测验时间

问题 #1

使用以下值初始化一个 std::vector:‘h’,'e',‘l’,'l',‘o’。然后打印数组的长度(使用 std::size())。最后,使用下标运算符和 at() 成员函数打印索引为 1 的元素值。

程序应输出以下内容:

The array has 5 elements.
ee

image

显示答案

#include <iostream>
#include <vector>

int main()
{
    std::vector arr { 'h', 'e', 'l', 'l', 'o' };
    std::cout << "The array has " << std::size(arr) << " elements.\n";
    std::cout << arr[1] << arr.at(1) << '\n';

    return 0;
}

问题 #2

a) 什么是 size_type 类型?它有什么用途?

显示答案

size_type 是一个嵌套的 typedef,它是用于存储标准库容器长度(以及索引,如果支持的话)的类型的别名。

b) size_type 的默认类型是什么?它是带符号还是无符号类型?

显示答案

std::size_t,这是一个无符号类型。

c) 哪些获取容器长度的函数返回 size_type 类型?

显示答案

size() 成员函数和 std::size 均返回 size_type 类型。
posted @ 2026-01-04 23:02  游翔  阅读(23)  评论(0)    收藏  举报