vector与无符号长度和下标问题
本节阅读量:上一课介绍了运算符[],它用于按数组下标访问元素。
本课将研究访问数组元素的其他方法,以及获取容器类长度(容器类中当前元素数量)的几种不同方法。
但在此之前,需要讨论C++设计者犯过的一个错误,以及它如何影响C++标准库中的所有容器类。
容器长度问题
先从一个断言开始:用于获取数组元素的下标值类型,应该与用于存储数组长度的数据类型匹配。这样才能索引尽可能长的数组中的所有元素。
正如Bjarne Stroustrup回忆的那样,当C++标准库中的容器类被设计时(大约1997年),设计者必须选择长度(和数组下标)是有符号的还是无符号的。当时的选择是无符号。
当时给出的理由是:标准库数组类型的下标不能为负;使用无符号类型可以多出一位,从而支持更长的数组(在机器字长只有16位的年代,这一点很重要);并且检查下标范围时只需要一个条件检查,而不是两个条件检查(因为不需要检查索引是否小于0)。
现在回头看,这通常被认为是一个错误选择。我们现在知道,由于隐式转换规则,尝试用无符号值来强制非负并不起作用(因为负的有符号整数只会隐式转换为非常大的无符号整数,从而产生无意义的结果)。在32位或64位系统上,通常也不缺那一位符号位(因为您很可能不会创建包含20亿个以上元素的数组),并且常用的运算符[]本来也不进行范围检查。
前面讨论无符号整数以及为什么要避免它们时,我们解释过为什么应优先使用有符号值保存数量。也提到过,混合有符号值和无符号值会导致意外行为。因此,标准库容器类对长度(和索引)使用无符号值是有问题的,因为这让我们在使用这些类型时无法避开无符号值。
就目前而言,我们只能承受这个选择以及它造成的不必要复杂性。
回顾:符号转换是窄化转换(constexpr除外)
继续之前,先快速回顾一下符号转换、列表初始化和constexpr初始值设定项,因为本章会大量讨论这些内容。
符号转换被认为是窄化转换,因为有符号类型和无符号类型都不能保存对方类型范围内的所有值。当这种转换会在运行时执行时,编译器会在不允许窄化转换的上下文中报错(例如列表初始化),并且在执行这种转换的其他上下文中可能发出警告。
例如:
|
|
在上面的示例中,变量u的初始化会产生编译错误,因为列表初始化不允许窄化转换。对foo()的调用执行复制初始化,而复制初始化确实允许窄化转换。根据编译器对符号转换警告的严格程度,这可能生成警告,也可能不会。例如,在这种情况下,当使用编译器标志 -Wsign-conversion 时,GCC和Clang都会产生警告。
然而,如果要进行符号转换的值是constexpr,并且可以转换为目标类型中的等效值,则该符号转换不被视为窄化转换。这是因为编译器可以保证转换是安全的。
|
|
在这种情况下,由于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_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默认为std::size_t。
只有在自定义分配器的T::size_type被定义为std::size_t之外的类型时,容器的T::size_type才会不是std::size_t。这很少见,并且是单个程序独立决定的行为,因此通常可以安全地假设T::size_type就是std::size_t。除非您的应用程序正在使用自定义分配器(如果是这样,您通常会知道)。
使用size()成员函数或std::size()获取std::vector的长度
可以使用size()成员函数查看容器类对象的长度(该函数以无符号的 size_type 返回长度):
|
|
这将打印:
|
|
std::string和std::string_view同时具有length()和size()成员函数(它们执行相同操作),而std::vector(以及C++中的大多数其他容器类型)只有size()。现在您应该能理解,为什么容器长度有时会被含糊地称为大小。
在C++17中,还可以使用std::size()非成员函数(对于容器类,该函数就是调用size()成员函数)。
|
|
如果想使用上述任一方法将长度存储在有符号类型变量中,可能会导致有符号/无符号转换警告或错误。这里最简单的做法是用static_cast将结果转换为所需类型:
|
|
使用std::ssize() (C++20)获取std::vector的长度
C++20引入了std::ssize() 非成员函数,该函数将长度作为大型有符号整数类型返回(通常为std::ptrdiff_t,它通常用作std::size_t的有符号对应类型):
|
|
这是三个函数中唯一一个将长度返回为有符号类型的函数。
如果要使用此方法将长度存储在有符号类型的变量中,则有两个选项。
首先,由于int类型可能小于std::ssize()返回的有符号类型,如果要将长度赋给int变量,则应将结果static_cast为int(否则可能收到窄化转换警告或错误):
|
|
或者,可以使用auto让编译器推断适合该变量的正确有符号类型:
|
|
使用运算符[]访问数组元素不进行边界检查
在上一课中,介绍了下标运算符(运算符[]):
|
|
运算符[]不执行边界检查。后面的部分会进一步讨论这一点。
使用at()成员函数访问数组元素执行运行时边界检查
数组容器类支持另一种访问方法。at() 成员函数会进行运行时边界检查:
|
|
在上面的例子中,对prime.at(3)的调用会检查索引3是否有效。由于它有效,函数会返回数组元素3的引用,然后可以打印该值。然而,对prime.at(9)的调用会失败(在运行时),因为9不是该数组的有效索引。函数不会返回引用,而是生成一个错误来终止程序。
因为它对每次调用都进行运行时边界检查,所以at()比运算符[]慢(但更安全)。尽管不太安全,但通常仍使用操作符[]而不是at(),因为我们通常会先检查索引是否在数组长度范围内,再进行访问,而不是一开始就尝试使用无效索引。
对于高级读者
当at()成员函数遇到越界索引时,它实际上抛出类型为std::out_of_range的异常。如果不处理异常,程序将被终止。
使用constexpr有符号int索引访问std::vector
当使用constexpr(有符号)int索引std::vector时,可以让编译器隐式地将其转换为std::size_t,而不会进行窄化转换:
|
|
使用非constexpr值索引std::vector
用于索引数组的下标可以不是常量:
|
|
然而,根据最佳实践,我们通常希望避免使用无符号类型来保存数量。
当下标是非constexpr有符号值时,会遇到问题:
|
|
在本例中,索引是一个非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的变量作为索引,并且除了索引用途之外,不要将该变量用于其他事情。