在课程《无符号整数及其应避免的原因》中,我们指出:通常更倾向于使用有符号类型来存储数量,因为无符号类型的行为常常出人意料。然而,在课程 16.3《std::vector 与无符号长度及下标问题》中,我们又讨论了 std::vector
(以及其他容器类)为何采用无符号整型 std::size_t
来表示长度和下标。
这就引出了如下问题示例:
#include <iostream>
#include <vector>
template <typename T>
void printReverse(const std::vector<T>& arr)
{
for (std::size_t index{ arr.size() - 1 }; index >= 0; --index) // index 为无符号
{
std::cout << arr[index] << ' ';
}
std::cout << '\n';
}
int main()
{
std::vector arr{ 4, 6, 7, 3, 8, 2, 1, 9 };
printReverse(arr);
return 0;
}
这段代码一开始确实能倒序打印数组:
9 1 2 8 3 7 6 4
但随后便出现未定义行为:可能打印垃圾值,也可能导致程序崩溃。
问题有二:
- 循环条件
index >= 0
对于无符号类型恒为真,因此循环永不终止。 - 当
index
为 0 时再自减,会回绕成一个巨大的正数,下一次迭代用它作下标将导致越界访问,产生未定义行为。若 vector 为空,同样会触发此问题。
尽管存在诸多变通方案,但这类问题极易成为 bug 的温床。
若改用带符号类型作为循环变量,可更轻易地规避上述风险,但又会带来新的挑战。以下示例即采用带符号下标:
#include <iostream>
#include <vector>
template <typename T>
void printReverse(const std::vector<T>& arr)
{
for (int index{ static_cast<int>(arr.size()) - 1 }; index >= 0; --index) // index 为有符号
{
std::cout << arr[static_cast<std::size_t>(index)] << ' ';
}
std::cout << '\n';
}
int main()
{
std::vector arr{ 4, 6, 7, 3, 8, 2, 1, 9 };
printReverse(arr);
return 0;
}
虽然这段代码行为正确,却因两处 static_cast
而显得杂乱,尤其是 arr[static_cast<std::size_t>(index)]
可读性极差。我们用可读性换取了安全性。
再看一个使用带符号下标的例子:
#include <iostream>
#include <vector>
// 计算 std::vector 平均值的函数模板
template <typename T>
T calculateAverage(const std::vector<T>& arr)
{
int length{ static_cast<int>(arr.size()) };
T average{ 0 };
for (int index{ 0 }; index < length; ++index)
average += arr[static_cast<std::size_t>(index)];
average /= length;
return average;
}
int main()
{
std::vector testScore1 { 84, 92, 76, 81, 56 };
std::cout << "The class 1 average is: " << calculateAverage(testScore1) << '\n';
return 0;
}
代码中遍布的 static_cast
严重影响了整洁度。
那么,我们到底该怎么做?此领域并无完美答案。
下文将按“从差到好”的顺序列出可行方案。你在阅读他人代码时,很可能会全部遇到。
作者注
尽管我们以 std::vector
为例讨论,但所有标准库容器(如 std::array
)均同理,面临相同困境。以下内容适用于任何此类容器。
选项 1:关闭符号转换警告
你可能疑惑为何符号转换警告默认关闭——本话题正是关键原因之一。每当我们用带符号下标访问标准库容器,都会产生符号转换警告,很快便淹没编译日志,掩盖真正需要关注的警告。
一种“解决”方式,是直接关闭这些警告。
这最简单,却不推荐,因为也会屏蔽掉真正可能导致 bug 的符号转换警告。
选项 2:使用无符号循环变量
许多开发者认为,既然标准库容器使用无符号下标,我们也应如此。此立场完全合理,只需额外留意避免符号/无符号混用。若可行,应避免把循环变量用于除下标之外的任何用途。
若采用此方案,应选用哪种无符号类型?
在课程 16.3 中我们指出,标准库容器定义了嵌套 typedef size_type
,专用于长度与下标。size()
返回 size_type
,operator[]
亦以 size_type
作下标,因此使用 size_type
技术上最一致且安全(即使极罕见情况下 size_type
并非 size_t
亦然)。示例:
#include <iostream>
#include <vector>
int main()
{
std::vector arr { 1, 2, 3, 4, 5 };
for (std::vector<int>::size_type index { 0 }; index < arr.size(); ++index)
std::cout << arr[index] << ' ';
return 0;
}
然而,size_type
的缺陷在于它是嵌套类型,需在名称前添加完整模板限定(如 std::vector<int>::size_type
),既冗长又难读,且随容器及元素类型变化。
在函数模板中,可用 T
代替模板实参,但仍需前置 typename
:
#include <iostream>
#include <vector>
template <typename T>
void printArray(const std::vector<T>& arr)
{
// 依赖类型需加 typename
for (typename std::vector<T>::size_type index { 0 }; index < arr.size(); ++index)
std::cout << arr[index] << ' ';
}
int main()
{
std::vector arr { 9, 7, 5, 3, 1 };
printArray(arr);
return 0;
}
若忘记写 typename
,编译器会提醒。
高级读者
任何依赖于含模板参数之类型的名称称为依赖名。依赖名用作类型时必须加 typename
。
有时你会看到用类型别名简化循环:
using arrayi = std::vector<int>;
for (arrayi::size_type index { 0 }; index < arr.size(); ++index)
更通用的做法是让编译器推断数组类型,可用 decltype
:
// arr 为非引用类型
for (decltype(arr)::size_type index { 0 }; index < arr.size(); ++index)
若 arr
为引用类型(如传参引用),需先移除引用:
template <typename T>
void printArray(const std::vector<T>& arr)
{
for (typename std::remove_reference_t<decltype(arr)>::size_type index { 0 }; index < arr.size(); ++index)
std::cout << arr[index] << ' ';
}
可惜这既不简洁也难记。
由于 size_type
几乎总是 size_t
的别名,许多程序员直接改用 std::size_t
:
for (std::size_t index { 0 }; index < arr.size(); ++index)
除非使用自定义分配器(通常不会),我们认为这是可接受的折中。
选项 3:使用带符号循环变量
尽管与标准库容器交互稍麻烦,但使用带符号循环变量与我们其余代码的最佳实践保持一致(倾向于用带符号值存储数量)。一致性越高,整体错误越少。
若采用带符号循环变量,需解决三问题:
- 选用哪种带符号类型?
- 如何以带符号类型获取数组长度?
- 如何把带符号循环变量转换为无符号下标?
选用带符号类型
除非处理极大数组,使用 int
通常足够(尤其在 int
为 4 字节的平台)。int
是我们默认的带符号整型,若无特殊理由无需更换。
若处理极大数组,或想更防御性,可使用 std::ptrdiff_t
(常与 std::size_t
配对)。因其名字怪异,可自定义别名:
using Index = std::ptrdiff_t;
for (Index index { 0 }; index < static_cast<Index>(arr.size()); ++index)
自定义别名未来亦有益:若标准库推出专用带符号下标类型,只需修改 Index
即可。
若可从初始化器推导类型,可用 auto
:
for (auto index { static_cast<std::ptrdiff_t>(arr.size()) - 1 }; index >= 0; --index)
C++23 起,可用后缀 Z
定义与 std::size_t
对应的带符号字面量:
for (auto index { 0Z }; index < static_cast<std::ptrdiff_t>(arr.size()); ++index)
以带符号类型获取数组长度
C++20 之前
最佳做法是把 size()
或 std::size()
的返回值 static_cast
为带符号类型:
#include <iostream>
#include <vector>
using Index = std::ptrdiff_t;
int main()
{
std::vector arr{ 9, 7, 5, 3, 1 };
for (auto index { static_cast<Index>(arr.size()) - 1 }; index >= 0; --index)
std::cout << arr[static_cast<std::size_t>(index)] << ' ';
return 0;
}
这样,无符号长度转为带符号后,循环条件两侧均为带符号类型,且带符号下标不会回绕。
缺点:循环体变得冗长。可把长度移出循环:
auto length { static_cast<Index>(arr.size()) };
for (auto index { length - 1 }; index >= 0; --index)
std::cout << arr[static_cast<std::size_t>(index)] << ' ';
C++20 起使用 std::ssize()
C++20 引入 std::ssize()
,返回带符号长度(通常为 ptrdiff_t
):
#include <iostream>
#include <vector>
int main()
{
std::vector arr{ 9, 7, 5, 3, 1 };
for (auto index { std::ssize(arr) - 1 }; index >= 0; --index)
std::cout << arr[static_cast<std::size_t>(index)] << ' ';
return 0;
}
把带符号循环变量转为无符号下标
带符号循环变量每次下标都会触发隐式符号转换警告,故需显式转换。
最直白做法是在每一处使用 static_cast
转为无符号下标,但会使数组索引可读性变差。
可封装一个短名转换函数:
#include <iostream>
#include <type_traits>
#include <vector>
using Index = std::ptrdiff_t;
// 把整型值转为 std::size_t
template <typename T>
constexpr std::size_t toUZ(T value)
{
static_assert(std::is_integral<T>() || std::is_enum<T>());
return static_cast<std::size_t>(value);
}
int main()
{
std::vector arr{ 9, 7, 5, 3, 1 };
auto length { static_cast<Index>(arr.size()) };
for (auto index{ length - 1 }; index >= 0; --index)
std::cout << arr[toUZ(index)] << ' ';
return 0;
}
这样即可用 arr[toUZ(index)]
下标,兼顾可读性。
使用自定义视图
之前我们指出,std::string
拥有字符串数据,而 std::string_view
只是对别处字符串的视图。std::string_view
的一大优点是能以统一接口查看多种字符串类型。
虽然无法修改标准库容器以接受带符号下标,但可自定义视图类来“查看”标准库容器,并按需设计接口。
以下示例定义了 SignedArrayView
,可查看任何支持下标的容器,并提供:
- 用带符号整型调用
operator[]
- 以带符号整型返回容器长度(
std::ssize()
仅 C++20 可用)
SignedArrayView.h
:
#ifndef SIGNED_ARRAY_VIEW_H
#define SIGNED_ARRAY_VIEW_H
#include <cstddef>
template <typename T>
class SignedArrayView // 需 C++17
{
private:
T& m_array;
public:
using Index = std::ptrdiff_t;
SignedArrayView(T& array) : m_array{ array } {}
constexpr auto& operator[](Index index) { return m_array[static_cast<typename T::size_type>(index)]; }
constexpr const auto& operator[](Index index) const { return m_array[static_cast<typename T::size_type>(index)]; }
constexpr auto ssize() const { return static_cast<Index>(m_array.size()); }
};
#endif
main.cpp
:
#include <iostream>
#include <vector>
#include "SignedArrayView.h"
int main()
{
std::vector arr{ 9, 7, 5, 3, 1 };
SignedArrayView sarr{ arr }; // 创建带符号视图
for (auto index{ sarr.ssize() - 1 }; index >= 0; --index)
std::cout << sarr[index] << ' ';
return 0;
}
直接索引底层 C 风格数组
课程 16.3 提到,可调用 data()
取得底层 C 风格数组并以其下标访问。C 风格数组允许带符号或无符号下标,从而避免符号转换问题:
int main()
{
std::vector arr{ 9, 7, 5, 3, 1 };
auto length { static_cast<Index>(arr.size()) };
for (auto index{ length - 1 }; index >= 0; --index)
std::cout << arr.data()[index] << ' ';
}
我们认为这是索引方案中的最佳选择:
- 可使用带符号循环变量与下标。
- 无需自定义类型或别名。
data()
带来的可读性损失极小。- 优化后无性能损失。
唯一理智的选择:彻底避免整数下标!
上述方案各有缺点,难以互相推荐。然而,有一个远比它们理智的选择:彻底避免用整型值进行数组索引。
C++ 提供了多种无需下标即可遍历数组的方法;没有下标,便不会遇到符号/无符号转换问题。
两种常见无下标遍历方法:
- 基于范围的 for 循环
- 迭代器
相关内容
- 范围 for 循环见下一课 16.8《基于范围的 for 循环(for-each)》
- 迭代器见后续课程 18.2《迭代器简介》
若仅用索引变量遍历数组,请优先使用无索引方案。
最佳实践
尽可能避免用整型值进行数组索引。