章节目录

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

本节阅读量:

上一课介绍了运算符[],它用于按数组下标访问元素。

本课将研究访问数组元素的其他方法,以及获取容器类长度(容器类中当前元素数量)的几种不同方法。

但在此之前,需要讨论C++设计者犯过的一个错误,以及它如何影响C++标准库中的所有容器类。


容器长度问题

先从一个断言开始:用于获取数组元素的下标值类型,应该与用于存储数组长度的数据类型匹配。这样才能索引尽可能长的数组中的所有元素。

正如Bjarne Stroustrup回忆的那样,当C++标准库中的容器类被设计时(大约1997年),设计者必须选择长度(和数组下标)是有符号的还是无符号的。当时的选择是无符号。

当时给出的理由是:标准库数组类型的下标不能为负;使用无符号类型可以多出一位,从而支持更长的数组(在机器字长只有16位的年代,这一点很重要);并且检查下标范围时只需要一个条件检查,而不是两个条件检查(因为不需要检查索引是否小于0)。

现在回头看,这通常被认为是一个错误选择。我们现在知道,由于隐式转换规则,尝试用无符号值来强制非负并不起作用(因为负的有符号整数只会隐式转换为非常大的无符号整数,从而产生无意义的结果)。在32位或64位系统上,通常也不缺那一位符号位(因为您很可能不会创建包含20亿个以上元素的数组),并且常用的运算符[]本来也不进行范围检查。

前面讨论无符号整数以及为什么要避免它们时,我们解释过为什么应优先使用有符号值保存数量。也提到过,混合有符号值和无符号值会导致意外行为。因此,标准库容器类对长度(和索引)使用无符号值是有问题的,因为这让我们在使用这些类型时无法避开无符号值。

就目前而言,我们只能承受这个选择以及它造成的不必要复杂性。


回顾:符号转换是窄化转换(constexpr除外)

继续之前,先快速回顾一下符号转换、列表初始化和constexpr初始值设定项,因为本章会大量讨论这些内容。

符号转换被认为是窄化转换,因为有符号类型和无符号类型都不能保存对方类型范围内的所有值。当这种转换会在运行时执行时,编译器会在不允许窄化转换的上下文中报错(例如列表初始化),并且在执行这种转换的其他上下文中可能发出警告。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>

void foo(unsigned int)
{
}

int main()
{
    int s { 5 };
    
    [[maybe_unused]] unsigned int u { s }; // 编译失败: 列表初始化不允许窄化转换
    foo(s);                                // 可能有 warning: 拷贝初始化允许窄化转换

    return 0;
}

在上面的示例中,变量u的初始化会产生编译错误,因为列表初始化不允许窄化转换。对foo()的调用执行复制初始化,而复制初始化确实允许窄化转换。根据编译器对符号转换警告的严格程度,这可能生成警告,也可能不会。例如,在这种情况下,当使用编译器标志 -Wsign-conversion 时,GCC和Clang都会产生警告。

然而,如果要进行符号转换的值是constexpr,并且可以转换为目标类型中的等效值,则该符号转换不被视为窄化转换。这是因为编译器可以保证转换是安全的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#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: s 是 constexpr 并且可以安全转换, 不是窄化转换

    return 0;
}

在这种情况下,由于s是constexpr,并且要转换的值(5)可以表示为无符号值,因此该转换不被认为是窄化转换,可以隐式执行而不会出现问题。

这种非窄化constexpr转换(从constexpr int到constexpr std::size_t)将会经常用到。


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

在学习Typedef和类型别名时,我们提到类型定义和类型别名通常用于需要额外类型名称的情况。例如,std::size_t是某种大型无符号整型的typedef,通常是unsigned long或unsigned long long。

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

您通常会在文档和编译器警告/错误消息中看到size_type。例如,std::vector的 size() 成员函数文档指出,size()返回一个类型为size_type的值。

size_type几乎总是std::size_t的别名,但在极少数情况下可以被覆盖为其他类型。

访问容器类的size_type成员时,必须使用容器类的完整模板化名称来限定其作用域。例如,std::vector<int>::size_type。


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

可以使用size()成员函数查看容器类对象的长度(该函数以无符号的 size_type 返回长度):

1
2
3
4
5
6
7
8
9
#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;
}

这将打印:

1
length: 5

std::string和std::string_view同时具有length()和size()成员函数(它们执行相同操作),而std::vector(以及C++中的大多数其他容器类型)只有size()。现在您应该能理解,为什么容器长度有时会被含糊地称为大小。

在C++17中,还可以使用std::size()非成员函数(对于容器类,该函数就是调用size()成员函数)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#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;
}

如果想使用上述任一方法将长度存储在有符号类型变量中,可能会导致有符号/无符号转换警告或错误。这里最简单的做法是用static_cast将结果转换为所需类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <iostream>
#include <vector>

int main()
{
    std::vector prime { 2, 3, 5, 7, 11 };
    int length { static_cast<int>(prime.size()) }; // static_cast 返回值为 int
    std::cout << "length: " << length ;

    return 0;
}

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

C++20引入了std::ssize() 非成员函数,该函数将长度作为大型有符号整数类型返回(通常为std::ptrdiff_t,它通常用作std::size_t的有符号对应类型):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#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类型可能小于std::ssize()返回的有符号类型,如果要将长度赋给int变量,则应将结果static_cast为int(否则可能收到窄化转换警告或错误):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };
    int length { static_cast<int>(std::ssize(prime)) }; // static_cast 结果转换为 int
    std::cout << "length: " << length;

    return 0;
}

或者,可以使用auto让编译器推断适合该变量的正确有符号类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#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;
}

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

在上一课中,介绍了下标运算符(运算符[]):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#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;
}

运算符[]不执行边界检查。后面的部分会进一步讨论这一点。


使用at()成员函数访问数组元素执行运行时边界检查

数组容器类支持另一种访问方法。at() 成员函数会进行运行时边界检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <iostream>
#include <vector>

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

    std::cout << prime.at(3); // 打印下标为3的位置中存储的值 (7)
    std::cout << prime.at(9); // 无效索引 (抛出异常)

    return 0;
}

在上面的例子中,对prime.at(3)的调用会检查索引3是否有效。由于它有效,函数会返回数组元素3的引用,然后可以打印该值。然而,对prime.at(9)的调用会失败(在运行时),因为9不是该数组的有效索引。函数不会返回引用,而是生成一个错误来终止程序。

因为它对每次调用都进行运行时边界检查,所以at()比运算符[]慢(但更安全)。尽管不太安全,但通常仍使用操作符[]而不是at(),因为我们通常会先检查索引是否在数组长度范围内,再进行访问,而不是一开始就尝试使用无效索引。


使用constexpr有符号int索引访问std::vector

当使用constexpr(有符号)int索引std::vector时,可以让编译器隐式地将其转换为std::size_t,而不会进行窄化转换:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <iostream>
#include <vector>

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

    std::cout << prime[3] << '\n';     // okay: 3 从 int 转换为 std::size_t, 不是窄化转换
 
    constexpr int index { 3 };         // constexpr
    std::cout << prime[index] << '\n'; // okay: constexpr 索引隐式转换为 std::size_t, 不是窄化转换
   
    return 0;
}

使用非constexpr值索引std::vector

用于索引数组的下标可以不是常量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#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[] 参数是 std::size_t, 无需转换
   
    return 0;
}

然而,根据最佳实践,我们通常希望避免使用无符号类型来保存数量。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <iostream>
#include <vector>

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

    int index { 3 };                   // non-constexpr
    std::cout << prime[index] << '\n'; // 可能会有 warning: index 隐式转换为 std::size_t, 窄化转换
   
    return 0;
}

在本例中,索引是一个非constexpr的有符号int。std::vector的运算符[]的下标类型为size_type(std::size_t的别名)。因此,当调用prime[index]时,有符号int必须转换为std::size_t。

这样的转换本不应该危险(因为std::vector的索引应该是非负的,并且非负有符号值可以安全地转换为无符号值)。但当它在运行时执行时,会被认为是窄化转换,编译器可能生成警告,提示这是一个不安全的转换。

由于数组下标非常常见,并且每个这样的转换都可能生成警告,因此编译日志很容易被无关警告淹没。或者,如果启用了“将警告视为错误”,编译将会失败。

有许多方法可以避免此问题(例如,每次索引数组时,用static_cast将int转换为std::size_t),但没有一种方法特别方便。它们都不可避免地会以某种方式让代码变得混乱或复杂。

在这种情况下,最简单的做法是使用类型为std::size_t的变量作为索引,并且除了索引用途之外,不要将该变量用于其他事情。


16.1 std::vector和列表构造函数简介

上一节

16.3 传递std::vector

下一节