C 风格数组退化

C 风格数组的传参难题

C 语言的设计者曾遇到一个棘手问题。观察以下简单程序:

#include <iostream>

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

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

    return 0;
}

当调用 print(x) 时,实参 x 的值 5 被复制到形参 val 中;随后函数体内打印 val 的值。由于复制单个 int 成本极低,这一方式并无问题。

再看一个相似的例子,它使用包含 1000 个 int 的 C 风格数组:

#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 设计者为解决两大挑战而提出的方案所致:

  1. 每次函数调用都复制含 1000 个元素的数组代价极高(若元素本身复制开销更大则更糟),因此必须避免复制。然而 C 语言没有引用,无法使用按引用传递来规避复制。
  2. 我们希望编写一个函数即可接受不同长度的数组。理想情况下,上述 printElementZero() 应能处理任意长度的数组(只要存在第 0 个元素)。我们不想为每一种可能的数组长度都重载一个函数。然而,C 语法既无法表达“任意长度”数组,也不支持模板,更不可能把一种长度的数组转换为另一种长度(否则需要昂贵的复制)。

C 语言设计者提出了一项巧妙方案(C++ 为兼容性而继承):

#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 风格数组出现在表达式中时,数组会隐式转换为指向其元素类型的指针,该指针初始化为首元素地址。这一过程俗称数组退化(array decay,简称 decay)。

下列程序演示了该现象:

#include <iomanip> // std::boolalpha
#include <iostream>

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

    // 证明 arr 退化为 int* 指针
    auto ptr{ arr }; // 求值导致退化,类型推导为 int*
    std::cout << std::boolalpha << (typeid(ptr) == typeid(int*)) << '\n'; // 若 ptr 类型为 int* 则输出 true

    // 证明指针存储首元素地址
    std::cout << std::boolalpha << (&arr[0] == ptr) << '\n';

    return 0;
}

在作者机器上输出:

true
true

退化得到的指针并无特殊之处,它仅是一个普通指针,指向首元素。
同理,const 数组(如 const int arr[5])退化为指向常量元素的指针(const int*)。

提示
在 C++ 中,以下少数场景数组不会退化:

  • 作为 sizeof()typeid() 的实参
  • 使用一元 operator& 取数组地址
  • 作为类类型成员
  • 按引用传递

由于 C 风格数组在大多数场景都会退化为指针,常见误解是“数组即指针”。事实并非如此:数组对象是一段连续元素,而指针对象仅保存地址。

数组类型与退化后指针类型的信息不同。上述示例中,arr 类型为 int[5],包含长度信息;退化后类型为 int*,长度信息丢失。

关键洞察
退化后的数组指针不知道所指数组长度。“退化”一词即指长度类型信息的丢失。

下标运算实际作用在退化指针上

由于表达式求值时数组先退化为指针,下标运算实际上作用在退化后的指针上:

#include <iostream>

int main()
{
    const int arr[] { 9, 7, 5, 3, 1 };
    std::cout << arr[2]; // 对退化指针取下标 2,输出 5
    return 0;
}

也可直接对指针使用 operator[],若该指针恰好指向首元素,结果一致:

#include <iostream>

int main()
{
    const int arr[] { 9, 7, 5, 3, 1 };

    const int* ptr{ arr }; // arr 退化为指针
    std::cout << ptr[2];   // 对 ptr 取下标 2,输出 5
    return 0;
}

稍后我们将看到其便利性,并在下一章《指针运算与下标》深入探讨其机制。

数组退化解决了传参难题

数组退化一次性解决了前述两大挑战:

  1. 传参时数组退化为指针,实际传递的是首元素地址。看似按值传递,实则按地址传递,从而避免了复制。
  2. 同元素类型但不同长度的数组(如 int[5]int[7])虽为不同类型,却退化为同一指针类型(如 int*)。长度信息丢失,使得不同长度数组可互操作。

以下示例演示两点:

  • 不同长度数组可传给同一函数(因退化后指针类型相同)
  • 函数形参可声明为(const)元素指针类型
#include <iostream>

void printElementZero(const int* arr) // 按 const 地址传递
{
    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;
}

输出:

2
1

由于按地址传递,函数直接访问原数组(而非副本),故若函数无意修改元素,形参应声明为 const

C 风格数组形参的语法

将形参声明为 int* arr 时,无法直观表明其指向数组而非单个整数。因此,推荐采用数组语法 int arr[]

#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);
    printElementZero(squares);

    return 0;
}

两者行为一致,但数组语法更清晰地表明预期实参为退化后的 C 风格数组。方括号中无需(亦不会)使用长度信息;若提供长度,编译器直接忽略。

最佳实践
期望接收 C 风格数组的函数形参应使用数组语法(如 int arr[]),而非指针语法(如 int* arr)。

缺点:该语法弱化了退化的事实(指针语法则更明显),因此需格外注意退化后不可用的操作。

数组退化的风险

尽管退化方案巧妙,但丢失长度信息极易引发错误:

  1. sizeof 行为突变
    sizeof() 对数组与退化指针返回不同结果:

    #include <iostream>
    
    void printArraySize(int arr[])
    {
        std::cout << sizeof(arr) << '\n'; // 输出 4(假设 32 位地址)
    }
    
    int main()
    {
        int arr[]{ 3, 2, 1 };
        std::cout << sizeof(arr) << '\n'; // 输出 12(假设 4 字节 int)
        printArraySize(arr);
        return 0;
    }
    

    因此,对 C 风格数组使用 sizeof() 需确保操作对象为实际数组,而非退化指针或指针变量。

    历史惯用的 sizeof(arr)/sizeof(*arr) 求长度极易因退化而失效:退化后 sizeof(arr) 变为指针大小,计算结果错误,程序可能崩溃。
    C++17 的 std::size()(及 C++20 的 std::ssize())在退化场景会直接编译失败,避免隐患:

    #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;
    }
    
  2. 重构风险
    将长函数拆分为短小而模块化的函数时,原先用非退化数组可正常工作的代码,改用退化数组后可能无法编译,甚至可能静默产生错误行为。

  3. 缺少长度信息导致程序性难题
    无法轻易校验数组长度。用户可能传入短于预期的数组,甚至单个元素的指针,下标越界引发未定义行为:

    #include <iostream>
    
    void printElement2(int arr[])
    {
        // 如何保证 arr 至少有 3 个元素?
        std::cout << arr[2] << '\n';
    }
    
    int main()
    {
        int a[]{ 3, 2, 1 };
        printElement2(a);  // 正常
    
        int b[]{ 7, 6 };
        printElement2(b);  // 编译通过但导致 UB
    
        int c{ 9 };
        printElement2(&c); // 编译通过但导致 UB
    
        return 0;
    }
    

    遍历数组时,亦无法知道何时到达末尾。

    虽有解决方案,但均增加复杂性与脆弱性。

绕过长度缺失的历史做法

历史上,程序员采用两种方法弥补长度缺失:

  1. 同时传递长度
    将数组及其长度作为独立实参:

    #include <cassert>
    #include <iostream>
    
    void printElement2(const int arr[], int length)
    {
        assert(length > 2 && "printElement2: Array too short"); // 无法在编译期 static_assert
        std::cout << arr[2] << '\n';
    }
    
    int main()
    {
        constexpr int a[]{ 3, 2, 1 };
        printElement2(a, static_cast<int>(std::size(a))); // 正确
    
        constexpr int b[]{ 7, 6 };
        printElement2(b, static_cast<int>(std::size(b))); // 触发断言
        return 0;
    }
    

    但仍存在以下问题:

    • 调用者需确保数组与长度配对;传错长度,函数仍会出错。
    • 使用 std::size() 返回 std::size_t,可能产生符号转换警告。
    • 运行时断言仅在执行到该分支时触发;测试未覆盖的路径可能在用户端触发断言。现代 C++ 倾向用 static_assert 在编译期校验 constexpr 数组长度,但函数形参无法在编译期使用(即使函数为 constexpr/consteval)。
    • 仅适用于显式函数调用;隐式操作(如重载运算符)无法额外传递长度。
  2. 哨兵值终止
    若存在语义无效的元素值(如测试分数 -1),可用该值作为数组终止标志。通过计数到终止元素即可得到长度,并可遍历至终止符为止。优点是即使隐式函数调用亦适用。

    关键洞察
    C 风格字符串(本身是 C 风格数组)使用空字符终止,以便在退化后仍可遍历。

    但该方法同样存在问题:

    • 若漏掉终止符,遍历将越界,导致未定义行为。
    • 遍历函数需特殊处理终止符(如打印 C 风格字符串时需跳过空字符)。
    • 实际长度与有效元素数不符;若使用错误长度,可能处理到终止符本身。
    • 并非总有语义无效值可用。

现代 C++ 应尽量避免 C 风格数组

由于非标准传参语义(按地址而非按值)及退化导致长度信息丢失所带来的风险,C 风格数组已逐渐被弃用。建议尽可能避免使用。

最佳实践
凡可行之处,避免使用 C 风格数组。

  • 只读字符串:优先使用 std::string_view
  • 可修改字符串:优先使用 std::string
  • 非常量表达式非全局数组:优先使用 std::array
  • 非常量表达式数组:优先使用 std::vector
  • 全局 constexpr 数组:可使用 C 风格数组(稍后说明)。

附注
C++ 可通过引用传递数组,此时数组实参不发生退化(但引用本身求值时仍会退化)。然而,稍有遗漏即会导致退化,且数组引用形参长度固定,只能处理特定长度的数组。若需处理不同长度,则必须使用函数模板。既然要同时修复这两点,不如直接使用 std::array

现代 C++ 中 C 风格数组的合理用途

现代 C++ 中,C 风格数组通常仅用于以下两种场景:

  1. 存储 constexpr 全局(或 constexpr static 局部)程序数据
    由于可在程序各处直接访问,无需传参,自然避免退化问题。语法上定义 C 风格数组也略简洁;更重要的是,对其下标操作不会产生标准库容器常见的符号转换警告。

  2. 作为函数或类的形参,直接处理非 constexpr C 风格字符串
    而非强制转换为 std::string_view。原因有二:

    • 性能:非 constexpr C 风格字符串转换为 std::string_view 需遍历求长;若函数位于性能关键路径且无需长度(例如本身就要遍历字符串),避免转换可节省开销。
    • 接口:若函数(或类)内部仍需调用只接受 C 风格字符串的接口,来回转换可能得不偿失(除非确有其他理由使用 std::string_view)。

测验

问题 1
什么是数组退化?为何会成为问题?

问题 2
C 风格字符串(即 C 风格数组)为何使用空字符终止?

问题 3(附加)
为何 C 风格字符串采用空字符终止,而不是要求同时传递退化后的 C 风格字符串和显式长度?

附加问题 2:
即使 C++ 想强制要求显式传递长度,为何也无法实现?

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

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

公众号二维码

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