16-7 数组、循环与符号挑战解决方案

在第4.5节——无符号整数及其使用禁忌中,我们指出通常更倾向于使用有符号值来存储数量,因为无符号值可能产生出人意料的行为。然而在第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 is unsigned
    {
        std::cout << arr[index] << ' ';
    }

    std::cout << '\n';
}

int main()
{
    std::vector arr{ 4, 6, 7, 3, 8, 2, 1, 9 };

    printReverse(arr);

    return 0;
}

该代码首先将数组倒序打印出来:

image

然后就会出现未定义行为。它可能会输出垃圾值,或者导致应用程序崩溃。

这里存在两个问题。首先,只要索引值大于等于0(换言之,只要索引值为正),循环就会持续执行——当索引值为无符号类型时,这个条件永远成立。因此循环永远不会终止。

其次,当索引值为0时进行递减操作,会导致其溢出为超大正值,而下一次迭代又将以此值作为数组索引。这种越界索引将引发未定义行为。若向量为空时同样会出现此问题。

虽然存在多种解决此类具体问题的方案,但这类问题本身就是漏洞的温床。

使用带符号类型作为循环变量虽能更轻松规避此类问题,但又会带来新的挑战。以下是采用带符号索引的同类问题示例:

#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 is signed
    {
        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;
}

image

虽然此版本能正常运行,但由于新增了两个静态转换,代码显得杂乱无章。其中arr[static_cast<std::size_t>(index)]尤其难以阅读。在此情况下,我们以可读性大幅下降为代价换取了安全性提升。

以下是使用有符号索引的另一个示例:

#include <iostream>
#include <vector>

// Function template to calculate the average value in a 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;
}

image

代码中充斥着静态强制类型转换,这实在令人头疼。

那么我们该怎么办?这个领域并不存在理想的解决方案。

这里存在多种可行方案,我们将按从最差到最佳的顺序进行介绍。在他人编写的代码中,你很可能会遇到所有这些方案。

作者注:
虽然我们将以std::vector为例展开讨论,但所有标准库容器(如std::array)的工作原理相似且面临相同挑战。下文讨论内容同样适用于这些容器。


禁用有符号/无符号转换警告

若您曾疑惑为何有符号/无符号转换警告常默认禁用,此问题正是关键原因之一。每当我们使用有符号索引访问标准库容器时,系统都会生成符号转换警告。这将迅速使编译日志充斥冗余警告,淹没真正有价值的警告信息。

因此避免大量符号转换警告的简易方法就是直接禁用这些警告。

虽然这是最直接的解决方案,但我们并不推荐此做法——它同时会屏蔽那些可能导致缺陷的有效符号转换警告。


使用无符号循环变量

许多开发者认为,既然标准库数组类型设计时就使用了无符号索引,那么我们也应该使用无符号索引!这种观点完全合理。我们只需格外小心,避免在操作过程中出现有符号/无符号类型不匹配的问题。如果可能,请避免将索引循环变量用于索引操作之外的任何用途。

若决定采用此方案,实际应选用哪种无符号类型?

在第16.3节——std::vector与无符号长度及下标问题中,我们提到标准库容器类定义了嵌套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;
}

image

然而,使用size_type存在一个主要缺点:由于它是嵌套类型,使用时必须显式地在名称前添加容器的完整模板名称(即必须输入std::vector::size_type而非简写的std::size_type)。这不仅需要大量输入,难以阅读,还会因容器和元素类型不同而变化。

在函数模板内部使用时,我们可以使用 T 作为模板参数。但同样需要在类型前添加 typename 关键字:

#include <iostream>
#include <vector>

template <typename T>
void printArray(const std::vector<T>& arr)
{
	// typename keyword prefix required for dependent type
	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;
}

image

若忘记使用typename关键字,编译器通常会提示添加该关键字。

进阶读者须知:
任何依赖于包含模板参数的类型的名称均称为依赖名称dependent name。依赖名称必须添加typename关键字前缀才能作为类型使用。

在上例中,std::vector 是带模板参数的类型,因此嵌套类型 std::vector::size_type 属于依赖名称,必须添加 typename 前缀才能作为类型使用。

有时会看到数组类型被别名化以提升循环可读性:

using arrayi = std::vector<int>;
for (arrayi::size_type index { 0 }; index < arr.size(); ++index)

更通用的解决方案是让编译器自动获取数组类型对象的类型,从而无需显式指定容器类型或模板参数。为此,我们可以使用decltype关键字,该关键字返回其参数的类型。

// arr is some non-reference type
for (decltype(arr)::size_type index { 0 }; index < arr.size(); ++index) // decltype(arr) resolves to std::vector<int>

然而,如果 arr 是引用类型(例如按引用传递的数组),上述方法将失效。我们需要先从 arr 中移除引用:

template <typename T>
void printArray(const std::vector<T>& arr)
{
	// arr can be a reference or non-reference type
	for (typename std::remove_reference_t<decltype(arr)>::size_type index { 0 }; index < arr.size(); ++index)
		std::cout << arr[index] << ' ';
}

遗憾的是,这已不再简洁易记。

由于 size_type 几乎总是 size_t 的 typedef,许多程序员干脆跳过 size_type,直接使用更易记忆和输入的 std::size_t:

for (std::size_t index { 0 }; index < arr.size(); ++index)

除非您使用的是自定义分配器(而您很可能没有),否则我们认为这是合理的方法。


使用带符号循环变量

尽管这会使标准库容器类型的操作稍显复杂,但使用带符号循环变量与我们代码中其他部分的最佳实践(即优先使用带符号值表示数量)保持一致。而我们越能始终如一地贯彻最佳实践,整体错误率就越低。

若要使用带符号循环变量,需解决以下三个问题:

  • 应选用何种带符号类型?
  • 如何获取数组长度的带符号值?
  • 如何将带符号循环变量转换为无符号索引?

我们应该使用哪种有符号类型?

这里有三种(有时四种)不错的选择。

  1. 除非处理非常大的数组,否则使用 int 通常足够(尤其在 int 为 4 字节的架构上)。当我们不特别关注类型时,int 是默认的有符号整数类型,而这里也没有理由另作选择。
  2. 若处理超大数组,或希望更稳妥些,可选用名称奇特的std::ptrdiff_t。该类型别名常作为std::size_t的有符号对应版本使用。
  3. 鉴于std::ptrdiff_t名称特殊,另一种好方法是为索引定义自定义类型别名:
using Index = std::ptrdiff_t;

// Sample loop using index
for (Index index{ 0 }; index < static_cast<Index>(arr.size()); ++index)

我们将在下一节展示一个完整的示例。

定义自定义类型别名还具有潜在的未来优势:若C++标准库日后发布专用于有符号索引的类型,届时只需修改Index使其别名指向该类型,或直接将Index替换为该类型名称即可。

  1. 当循环变量的类型可由初始化表达式推导时,可使用auto让编译器自动推断类型:
for (auto index{ static_cast<std::ptrdiff_t>(arr.size())-1 }; index >= 0; --index)

在 C++23 中,可以使用 Z 后缀定义一种与 std::size_t 对应的有符号类型(可能是 std::ptrdiff_t)的字面量:

for (auto index{ 0Z }; index < static_cast<std::ptrdiff_t>(arr.size()); ++index)

获取数组长度作为有符号值

  1. 在C++20之前,最佳方案是将size()成员函数或std::size()的返回值静态转换为有符号类型:
#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;
}

这样,arr.size()返回的无符号值将被转换为有符号类型,使得我们的比较运算符拥有两个有符号操作数。由于有符号索引在变为负数时不会溢出,我们避免了使用无符号索引时遇到的环绕问题。

此方法的缺点是会使循环代码变得冗杂,降低可读性。我们可通过将长度计算移出循环来解决这个问题:

#include <iostream>
#include <vector>

using Index = std::ptrdiff_t;

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[static_cast<std::size_t>(index)] << ' ';

    return 0;
}
  1. 在 C++20 中,使用 std::ssize():

若需更多证据证明 C++ 设计者现已认可带符号索引的正确性,不妨关注 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::ssize introduced in C++20
        std::cout << arr[static_cast<std::size_t>(index)] << ' ';

    return 0;
}

将有符号循环变量转换为无符号索引

一旦使用有符号循环变量,每当尝试将其作为索引使用时,就会触发隐式符号转换警告。因此我们需要在需要将其用作索引的位置,将有符号循环变量转换为无符号值。

  1. 最直接的方法是通过静态强制转换将有符号循环变量转换为无符号索引。前例中已展示此方法。但遗憾的是,这种转换需在数组索引的每个位置重复执行,导致数组索引难以阅读。

  2. 使用带简短名称的转换函数:

#include <iostream>
#include <type_traits> // for std::is_integral and std::is_enum
#include <vector>

using Index = std::ptrdiff_t;

// Helper function to convert `value` into an object of type std::size_t
// UZ is the suffix for literals of type std::size_t.
template <typename T>
constexpr std::size_t toUZ(T value)
{
    // make sure T is an integral type
    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()) };  // in C++20, prefer std::ssize()
    for (auto index{ length-1 }; index >= 0; --index)
        std::cout << arr[toUZ(index)] << ' '; // use toUZ() to avoid sign conversion warning

    return 0;
}

image

在上例中,我们创建了一个名为 toUZ() 的函数,用于将整数值转换为 std::size_t 类型的值。这使得我们可以使用 arr[toUZ(index)] 这种可读性较高的方式对数组进行索引。

  1. 使用自定义视图

在之前的课程中,我们讨论过std::string如何拥有字符串,而std::string_view则是对其他位置字符串的视图。std::string_view的一个精妙之处在于它能查看不同类型的字符串(C风格字符串常量、std::string以及其他std::string_view),同时为我们提供一致的接口。

虽然无法修改标准库容器以接受带符号整数索引,但我们可以创建自定义视图类来“查看”标准库容器类。通过这种方式,我们可以定义自己的接口以实现任意功能。

在下面的示例中,我们定义了一个自定义视图类,它可以查看任何支持索引的标准库容器。我们的接口将实现两项功能:

  • 允许使用带符号整数类型的operator[]访问元素。
  • 获取容器长度并以带符号整数类型返回(因 std::ssize() 仅在 C++20 中可用)。

该实现通过运算符重载(详见第 13.5 节 I/O 运算符重载导论 )实现 operator[]。使用时无需了解 SignedArrayView 的具体实现细节。

SignedArrayView.h:

#ifndef SIGNED_ARRAY_VIEW_H
#define SIGNED_ARRAY_VIEW_H

#include <cstddef> // for std::size_t and std::ptrdiff_t

// SignedArrayView provides a view into a container that supports indexing
// allowing us to work with these types using signed indices
template <typename T>
class SignedArrayView // requires C++17
{
private:
    T& m_array;

public:
    using Index = std::ptrdiff_t;

    SignedArrayView(T& array)
        : m_array{ array } {}

    // Overload operator[] to take a signed index
    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 }; // Create a signed view of our std::vector

    for (auto index{ sarr.ssize() - 1 }; index >= 0; --index)
        std::cout << sarr[index] << ' '; // index using a signed type

    return 0;
}

image


改用底层C风格数组索引

第16.3节——std::vector与无符号长度及下标问题中,我们提到:与其直接索引标准库容器,不如调用data()成员函数并对其进行索引。由于data()返回的是C风格数组形式的数组数据,而C风格数组支持带符号值和无符号值的索引操作,因此可避免符号转换问题。

int main()
{
    std::vector arr{ 9, 7, 5, 3, 1 };

    auto length { static_cast<Index>(arr.size()) };  // in C++20, prefer std::ssize()
    for (auto index{ length - 1 }; index >= 0; --index)
        std::cout << arr.data()[index] << ' ';       // use data() to avoid sign conversion warning

    return 0;
}

我们认为此方法是索引选项中最优的方案:

  • 可使用带符号的循环变量和索引。
  • 无需定义任何自定义类型或类型别名。
  • 使用 data() 对可读性的影响不大。
  • 在优化后的代码中不应存在性能损失。

唯一明智的选择:彻底避免索引!

上述所有方案都各有弊端,因此难以推荐某一种方法优于其他方案。然而,存在一种远比其他方案更明智的选择:彻底避免使用整数值进行索引。

C++提供了多种完全不依赖索引的数组遍历方法。既然没有索引,自然就不会遇到所有这些有符号/无符号转换问题。

两种常见的无索引数组遍历方法是基于范围的for循环和迭代器。

相关内容:
范围 for 循环将在下一课讲解(16.8 -- 基于范围的 for 循环(for-each))。
迭代器将在后续课程 18.2 -- 迭代器入门 中介绍。

若仅为遍历数组而使用索引变量,请优先采用无索引方法。

最佳实践:
尽可能避免使用整数值进行数组索引操作。

posted @ 2026-01-06 15:32  游翔  阅读(20)  评论(0)    收藏  举报