C++ 数组、循环与符号难题的解决方案

在课程《无符号整数及其应避免的原因》中,我们指出:通常更倾向于使用有符号类型来存储数量,因为无符号类型的行为常常出人意料。然而,在课程 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

但随后便出现未定义行为:可能打印垃圾值,也可能导致程序崩溃。

问题有二:

  1. 循环条件 index >= 0 对于无符号类型恒为真,因此循环永不终止。
  2. 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_typeoperator[] 亦以 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:使用带符号循环变量

尽管与标准库容器交互稍麻烦,但使用带符号循环变量与我们其余代码的最佳实践保持一致(倾向于用带符号值存储数量)。一致性越高,整体错误越少。

若采用带符号循环变量,需解决三问题:

  1. 选用哪种带符号类型?
  2. 如何以带符号类型获取数组长度?
  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《迭代器简介》

若仅用索引变量遍历数组,请优先使用无索引方案。

最佳实践
尽可能避免用整型值进行数组索引。

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

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

公众号二维码

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