章节目录

C样式数组退化

本节阅读量:

C数组传递挑战

C语言的设计者遇到了一个问题。考虑以下简单程序:

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

void print(int val)
{
    std::cout << val;
}

int main()
{
    int x { 5 };
    print(x);

    return 0;
}

调用print(x)时,x(5)的值被复制到参数val。在函数体中,val(5)值被打印到控制台。因为x的复制成本很低,所以这里没有问题。

现在考虑以下类似的程序,它使用1000个元素C样式的int数组,而不是单个int:

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

void printElementZero(int arr[1000])
{
    std::cout << arr[0]; // 打印数组的第一个元素
}

int main()
{
    int x[1000] { 5 };   // 定义有1000个元素的数组, x[0] 初始化为 5
    printElementZero(x);

    return 0;
}

该程序可以编译,并将期望值(5)打印到控制台。

虽然本例中的代码与前一个示例中的代码类似,但它的工作方式与您可能期望的略有不同(我们将在下面解释这一点)。这是由于C设计人员针对两个主要挑战提出的解决方案。

首先,每次调用函数时复制1000个元素数组的成本很高(如果元素的复制很昂贵,则成本更高),因此希望避免这种情况。但如何做呢?C没有引用,因此按引用传递来避免复制函数参数不是一个选项。

其次,希望能够编写单个函数,该函数可以接受不同长度的数组参数。理想情况下,上面示例中的printElementZero()函数应该可以用任意长度的数组参数调用(因为元素0保证存在)。我们不想为每个可能的数组长度编写不同的函数,我们想将其用作参数。但如何做呢?C没有语法来指定“任意长度”数组,也不支持模板,一个长度的数组也不能转换为另一个长度(大概是因为这样做将涉及制作昂贵的副本)。

C语言的设计者提出了一个聪明的解决方案(由于兼容性原因由C++继承),可以解决这两个问题:

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

void printElementZero(int arr[1000]) // 并不制作拷贝
{
    std::cout << arr[0]; // 打印数组的第一个元素
}

int main()
{
    int x[7] { 5 };      // 定义有7个元素的数组
    printElementZero(x); // 无论如何能运行!

    return 0;
}

不知怎的,上面的示例将一个含7个元素的数组传递给一个需要1000元素数组的函数,而没有进行任何复制。在本课中,我们将探索这是如何工作的。

我们还将看看为什么C设计者选择的解决方案是危险的,并且不适合在现代C++中使用。

但首先,我们需要涵盖两个子主题。


数组到指针转换(数组退化)

在大多数情况下,当在表达式中使用C样式数组时,数组将隐式转换为指向元素类型的指针,并用第一个元素的地址(索引为0)初始化。通俗地说,这称为数组退化。

您可以在以下程序中看到这一点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <iomanip> // for std::boolalpha
#include <iostream>

int main()
{
    int arr[5]{ 9, 7, 5, 3, 1 }; // 数组中的元素是 int 类型

    // 首先,我们来验证数组退化成了 int* 指针

    auto ptr{ arr }; // 求值造成退化, 类型推导应该为 int*
    std::cout << std::boolalpha << (typeid(ptr) == typeid(int*)) << '\n'; // 如果 ptr 类型是 int*,则打印 true

    // 接下来,验证数组的值,等于数组第0个元素的地址

    std::cout << std::boolalpha << (&arr[0] == ptr) << '\n';

    return 0;
}

在作者的机器上,打印了:

1
2
true
true

数组退化到的指针没有什么特别之处。它是一个普通指针,保存第一个元素的地址。

类似地,常量数组(例如,const int arr[5])退化为指向常量的指针(const int*)。

因为在大多数情况下,C样式数组会退化为指针,所以认为数组是指针,这是一种常见的错误。事实并非如此。数组对象是元素序列,而指针对象仅保存地址。

数组和退化数组的类型信息不同。在上面的示例中,数组arr的类型为int[5],而退化数组的类型为int*。值得注意的是,数组类型int[5]包含长度信息,而退化数组指针类型int*不包含长度信息。


访问C样式数组实际上将运算符[]应用于退化的指针

由于C样式数组在求值时退化为指针,因此当C样式数组被访问时,下标实际上在退化的数组指针上操作:

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

int main()
{
    const int arr[] { 9, 7, 5, 3, 1 };
    std::cout << arr[2]; // 访问退化了的数组 索引为2的元素, 打印 5

    return 0;
}

我们也可以直接在指针上使用运算符[]。如果该指针保存第一个元素的地址,则结果将相同:

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

int main()
{
    const int arr[] { 9, 7, 5, 3, 1 };
    
    const int* ptr{ arr };  // 显式使用指针
    std::cout << ptr[2];    // 使用 ptr 去获取 索引为2的元素, 打印 5

    return 0;
}

稍后我们将看到这在哪里是方便的,并在下一课中更深入地研究这实际上是如何工作的(以及当指针持有第一个元素的地址以外的内容时会发生什么)。


数组退化解决了C样式数组传递问题

数组退化解决了我们在本课顶部遇到的两个挑战。

当传递C样式数组作为参数时,数组退化为指针,并且保存数组第一个元素地址的指针是传递给函数的。因此,尽管看起来像是通过值传递C样式数组,但实际上是通过地址传递它!这就是如何避免复制C样式数组参数的方法。

现在考虑具有相同元素类型但不同长度的两个不同数组(例如int[5]和int[7])。这些是不同的类型,彼此不兼容。然而,它们都将退化为相同的指针类型(例如int*)。他们退化的版本是可以互换的!从类型中删除长度信息允许我们传递不同长度的数组,而不会出现类型不匹配。

在下面的示例中,我们将演示两件事:

  1. 我们可以将不同长度的数组传递给单个函数(因为两者都退化为相同的指针类型)。
  2. 接收数组的函数参数可以是数组元素类型的(常量)指针。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>

void printElementZero(const int* arr) // 按常量指针传递
{
    std::cout << arr[0];
}

int main()
{
    const int prime[] { 2, 3, 5, 7, 11 };
    const int squares[] { 1, 4, 9, 25, 36, 49, 64, 81 };

    printElementZero(prime);   // prime 退化为 const int* 指针
    printElementZero(squares); // squares 退化为 const int* 指针

    return 0;
}

此示例工作正常,并打印:

1
2
2
1

在main()中,当我们调用printElementZero(prime)时,prime数组从const int[5]类型的数组退化到const int*类型的指针,该指针保存prime的第一个元素的地址。类似地,当我们调用printElementZero(squares)时,squares从const int[8]类型的数组退化到const int*类型的指针,该指针保存squares的第一个元素的地址。这些const int*类型的指针实际上是作为参数传递给函数的。

由于我们传递的是const int类型的指针,因此printElementZero()函数需要具有相同指针类型的参数(cont int)。

在这个函数中,我们用指针下标以访问所选数组元素。

由于C样式数组是通过地址传递的,因此该函数可以直接访问传入的数组(而不是副本),并可以修改其元素。因此,如果函数不打算修改数组元素,则最好确保函数参数是常量。


C样式数组函数参数语法

将函数参数声明为int* arr的一个问题是,arr应该是值数组的指针,而不是单个整数的指针,这一点并不明显。因此,在传递C样式数组时,最好使用声明形式int arr[]:

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

void printElementZero(const int arr[]) // 会被当做 const int*
{
    std::cout << arr[0];
}

int main()
{
    const int prime[] { 2, 3, 5, 7, 11 };
    const int squares[] { 1, 4, 9, 25, 36, 49, 64, 81 };

    printElementZero(prime);  // prime 退化为 const int* 指针
    printElementZero(squares); // squares 退化为 const int* 指针

    return 0;
}

该程序的行为与前一个程序相同,因为编译器将解释函数参数const int arr[],与const int*相同。然而,这具有与调用者一致的优点,即arr预期是退化的C样式数组,而不是指向单个值的指针。请注意,方括号之间不需要长度信息(因为它无论如何都不使用)。如果提供了长度,它将被忽略。

使用此语法的缺点是,它使arr退化的迹象不那么明显(而指针语法则非常清楚),因此您需要格外小心,不要对退化的数组执行任何不符合预期的操作(我们稍后将介绍其中的一些)。


数组退化问题

尽管数组退化是一种聪明的解决方案,可以确保不同长度的C样式数组可以传递给函数,而无需制作昂贵的副本,但数组长度信息的丢失使得很容易发生几种类型的错误。

首先,sizeof()将为数组和退化数组返回不同的值:

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

void printArraySize(int arr[])
{
    std::cout << sizeof(arr) << '\n'; // 打印 4 (假设 32-bit 的地址)
}

int main()
{
    int arr[]{ 3, 2, 1 };

    std::cout << sizeof(arr) << '\n'; // 打印 12 (假设 int 是 4 byte)

    printArraySize(arr);

    return 0;
}

这意味着在C样式数组上使用sizeof()可能是危险的,因为您必须确保仅在可以访问实际数组对象而不是退化的数组或指针时才使用它。

在上一课中,我们提到sizeof(arr)/sizeof(*arr)在历史上被用作获取C样式数组大小的方式。这种trick的方式是危险的,因为如果arr已经退化,sizeof(arr)将返回指针的大小而不是数组的大小,从而产生错误的数组长度,很可能导致程序故障。

幸运的是,如果传递了指针值,C++17的更好的替代std::size()(和C++20的std::ssize())将无法编译:

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

int printArrayLength(int arr[])
{
    std::cout << std::size(arr) << '\n'; // 编译失败: std::size() 不能接受指针
}

int main()
{
    int arr[]{ 3, 2, 1 };

    std::cout << std::size(arr) << '\n'; // 打印 3

    printArrayLength(arr);

    return 0;
}

第二,也是最重要的一点,数组退化可能会使重构(将长函数分解为更短、更模块化的函数)变得困难。当相同的代码使用退化数组时,与非退化数组一起工作的代码可能无法编译(或者更糟,可能会无提示地正常运行)。

第三,没有长度信息带来了几个编程挑战。如果没有长度信息,则传入数组的长度是不同的。用户可以很容易地传入比预期短的数组(甚至是指向单个值的指针),当用无效索引访问它们时,这将导致未定义的行为。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

void printElement2(int arr[])
{
    // 如何保证起码有三个元素呢?
    std::cout << arr[2] << '\n';
}

int main()
{
    int a[]{ 3, 2, 1 };
    printElement2(a);  // ok

    int b[]{ 7, 6 };
    printElement2(b);  // 可以编译,但是产生未定义的行为

    int c{ 9 };
    printElement2(&c); // 可以编译,但是产生未定义的行为

    return 0;
}

在遍历数组时,没有数组长度也会带来挑战——我们如何知道何时到达末尾?

这些问题有解决方案,但这些解决方案增加了程序的复杂性和脆弱性。


解决数组长度问题

在历史上,程序员通过两种方法之一来解决缺少数组长度信息的问题。

首先,我们可以将数组和数组长度作为单独的参数传入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <cassert>
#include <iostream>

void printElement2(const int arr[], int length)
{
    assert(length > 2 && "printElement2: Array too short"); // 不能通过 static_assert 来检查 length

    std::cout << arr[2] << '\n';
}

int main()
{
    constexpr int a[]{ 3, 2, 1 };
    printElement2(a, static_cast<int>(std::size(a)));  // ok

    constexpr int b[]{ 7, 6 };
    printElement2(b, static_cast<int>(std::size(b)));  // 会触发 assert

    return 0;
}

然而,这仍然存在一些问题:

  1. 调用者需要确保数组和数组长度成对——如果传入了错误的长度值,函数仍然会发生故障。
  2. 如果您使用的是std::size()或函数,该函数将长度返回为std::size_t,则可能存在符号转换问题。
  3. 仅在运行时遇到assert时断言触发。如果我们的测试路径没有覆盖对函数的所有调用,则存在将程序发送给客户的风险,该程序将在客户执行我们没有明确测试的操作时断言。在现代C++中,我们希望使用static_assert来对constexpr数组的数组长度进行编译时验证,但没有简单的方法可以做到这一点(因为函数参数不能是constexpr.即使在constexpr或consteval函数中!)。
  4. 此方法仅在进行显式函数调用时有效。如果函数调用是隐式的(例如,我们正在调用一个操作符,将数组作为操作数),则没有机会传入长度。

其次,如果存在语义上无效的元素值(例如,学生分数不能为-1),我们可以使用该值的元素来标记数组的末尾。这样,可以通过计算数组的开始和该终止元素之间存在多少个元素来计算数组的长度。数组也可以通过从开始迭代直到到达终止元素来遍历。这个方法的好处是,它甚至可以与隐式函数调用一起工作。

但这种方法也存在一些问题:

  1. 如果终止元素不存在,遍历将直接走到数组的末尾,导致未定义的行为。
  2. 遍历数组的函数需要对终止元素进行特殊处理(例如,C样式的字符串打印函数需要知道不打印终止元素)。
  3. 实际数组长度与语义有效元素的数量不匹配。如果使用了错误的长度,则可能会“处理”语义无效的终止元素。
  4. 这种方法仅在存在语义无效的值时有效,但通常情况并非如此。

在大多数情况下,应避免使用C样式数组

由于非标准传递语义(使用传递地址而不是传递值)以及与退化数组丢失其长度信息相关的风险,C样式的数组通常已不受欢迎。我们建议尽可能避免它们。


那么,C风格的数组何时用于现代C++?

在现代C++中,C样式数组通常用于两种情况:

  1. 存储全局constexpr (或static局部) 程序数据。因为这样的数组可以在程序的任何地方访问,不需要传递,因此避免了退化的问题。定义C样式的数组比std::array更加容易一些,更重要的是,访问这样的数组不会有符号转换问题。
  2. 当函数或类想要处理非 constexpr的C样式字符串时(std::string_view需要进行转换)。这里有两个可能得原因:首先,从非constexpr的C样式字符串,转成std::string_view需要获取对应的字符串长度。如果该函数位于性能敏感的位置,并且长度数据不需要(例如,自从字符串遍历到尾巴就完事),那么避免转换非常有用。其次,如果函数或类调用其它需要C样式字符串的函数,那么转换成std::string_view,还需要再转换回去。

17.6 C样式数组简介

上一节

17.8 指针运算和下标

下一节