C程序中的数组与指针共生关系

C程序中的数组与指针共生关系

在C语言的编程世界中,处理大量同类型的数据是程序员不可避免的任务。无论是统计日降雨量、管理库存,还是记录客户交易,我们都需要一种高效的方式来组织这些相关数据。数组,作为相同数据类型元素的集合,正是为此而生。然而,要真正掌握C语言的精髓,就必须深入理解数组背后更本质的概念------指针。这篇文章将深度剖析数组与指针之间密不可分、甚至可以说是一体两面的共生关系,揭示C语言高效和强大的根源。

数组:数据的有序集合

创建数组时,我们必须明确告知编译器两件事:元素的类型和数量。编译器会据此在内存中开辟一块连续的空间。访问数组中的元素,我们通过下标(或称索引)来实现,这个编号从0开始。

初始化与声明

初始化数组最直接的方式是提供一个值的列表,用花括号 {} 包围。

c 复制代码
/* day_mon1.c -- 打印每个月的天数 */
#include <stdio.h>
#define MONTHS 12

int main(void)
{
    // 使用花括号和逗号分隔的列表来初始化数组
    const int days[MONTHS] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
    int index;

    for (index = 0; index < MONTHS; index++)
        printf("Month %2d has %2d days.\n", index + 1, days[index]);

    return 0;
}

代码描述 :此程序定义了一个包含12个整数的常量数组 days,用于存储每个月的天数。const 关键字确保这个数组在程序运行期间不会被意外修改,这是一种良好的编程实践,用于保护关键数据。for 循环遍历数组,通过下标 days[index] 访问每个元素并打印出来。

C语言在初始化方面提供了相当的灵活性。如果初始化列表中的项数少于数组的元素个数,编译器会自动将剩余的元素初始化为0。这是一个非常重要的特性,可以避免未初始化数组中存在的"垃圾值"。

c 复制代码
/* some_data.c -- 部分初始化数组 */
#include <stdio.h>
#define SIZE 4
int main(void)
{
    int some_data[SIZE] = { 1492, 1066 }; // 只提供了2个初始值
    int i;
    printf("%2s%14s\n", "i", "some_data[i]");
    for (i = 0; i < SIZE; i++)
        printf("%2d%14d\n", i, some_data[i]);
    return 0;
}

代码描述some_data 数组的大小为4,但只初始化了前两个元素。运行此程序,你会发现 some_data[2]some_data[3] 的值都是0,这是编译器自动完成的。

C99标准引入了"指定初始化器",允许我们初始化指定的数组元素,而不必按顺序进行。

c 复制代码
// designate.c -- 使用指定初始化器
#include <stdio.h>
#define MONTHS 12
int main(void)
{
    int days[MONTHS] = { 31, 28, [4] = 31, 30, 31, [1] = 29 };
    int i;
    for (i = 0; i < MONTHS; i++)
        printf("%d %d\n", i + 1, days[i]);
    return 0;
}

代码描述:这段代码展示了指定初始化器的两个重要特性:

  1. 顺序填充[4] = 31 初始化了第5个元素(下标为4)后,紧随其后的 3031 会被用来初始化后续的元素,即 days[5]days[6]
  2. 最终赋值有效days[1](下标为1的元素)最初被初始化为 28,但随后又被 [1] = 29 重新赋值,因此最终它的值是 29。未被显式初始化的元素,如 days[2]days[3],会被自动初始化为0。

指针:访问数据的另一种途径

指针提供了一种以符号形式使用内存地址的方法。它与数组的关系,是C语言强大与高效的根源之一。最核心的概念是:数组名是该数组首元素的地址

这意味着,如果 flizny 是一个数组,那么 flizny&flizny[0] 在值上是完全等价的。这个地址是一个常量,不能被修改。但我们可以将它赋值给一个指针变量,然后通过操作指针来访问数组。

c 复制代码
// pnt_add.c -- 指针地址
#include <stdio.h>
#define SIZE 4
int main(void)
{
    short dates[SIZE];
    double bills[SIZE];
    short * pti;
    double * ptf;

    pti = dates; // 把数组地址赋给指针
    ptf = bills;
    printf("%23s %15s\n", "short", "double");
    for (int index = 0; index < SIZE; index++)
        printf("pointers + %d: %10p %10p\n", index, pti + index, ptf + index);
    return 0;
}

代码描述 :该程序声明了一个 short 型指针 pti 和一个 double 型指针 ptf,并分别将 datesbills 数组的地址赋给它们。循环打印 指针 + 整数 的结果。你会发现 pti 每加1,其地址值增加2(sizeof(short));而 ptf 每加1,地址值增加8(sizeof(double))。这揭示了指针运算的本质:在C中,指针加1,指的是增加一个存储单元。对数组而言,这意味着地址会移动到下一个元素的地址,而不是下一个字节的地址

这种关系使得数组表示法和指针表示法可以互换。C语言标准明确定义 ar[n] 的意思就是 *(ar + n)

c 复制代码
/* day_mon3.c -- 使用指针表示法 */
#include <stdio.h>
#define MONTHS 12
int main(void)
{
    int days[MONTHS] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
    int index;

    for (index = 0; index < MONTHS; index++)
        // *(days + index) 与 days[index] 相同
        printf("Month %2d has %d days.\n", index + 1, *(days + index));
    
    return 0;
}

代码描述 :这个程序使用 *(days + index) 来获取数组元素的值,其中 days + index 计算出第 index 个元素的地址,* 运算符则解引用该地址以获取存储的值。其功能和输出与使用 days[index] 的版本完全一致,有力地证明了二者的等效性。

协同工作:函数中的数组与指针

将数组传递给函数时,这种指针与数组的等价性变得至关重要。你无法将整个数组作为参数传递给函数,因为这涉及到庞大的数据拷贝,效率极低。实际上传递的,仅仅是数组的地址------一个指向其首元素的指针。

因此,一个接收数组的函数,其形参实际上是一个指针。int *arint ar[] 这两种写法在作为函数形参时是完全等价的。

c 复制代码
// sum_arr1.c -- 数组元素之和
#include <stdio.h>
#define SIZE 10
int sum(int ar[], int n);

int main(void)
{
    int marbles[SIZE] = { 20, 10, 5, 39, 4, 16, 19, 26, 31, 20 };
    printf("The size of marbles is %zd bytes.\n", sizeof marbles);
    sum(marbles, SIZE);
    return 0;
}

int sum(int ar[], int n)
{
    printf("The size of ar is %zd bytes.\n", sizeof ar);
    // ... sum calculation ...
}

代码描述 :此代码的关键在于 sizeof 运算符的结果。在 main 函数中,sizeof marbles 会返回 10 * sizeof(int),即40字节(假设 int 为4字节)。然而,在 sum 函数内部,sizeof ar 返回的是指针的大小(在64位系统上通常是8字节),而不是整个数组的大小。这清晰地证明了传递给函数的是地址,而非数组实体。

为了保护传入函数的数据不被意外修改,我们可以使用 const 关键字。

c 复制代码
// arf.c -- 处理数组的函数
void show_array(const double ar[], int n);
void mult_array(double ar[], int n, double mult);

代码描述 :在 show_array 函数中,ar 被声明为 const,这意味着函数内部任何试图修改 ar 指向的数据(如 ar[i] = 0;)的操作都会导致编译错误。而 mult_array 函数需要修改数组内容,因此其参数 ar 没有 const 限定。

深入多维数组

对于二维数组,例如 int zippo[4][2],我们可以将其理解为"数组的数组"。这里的指针关系变得更加微妙:zippo 是指向"包含2个int的数组"的指针,而 zippo[0] 是指向 int 的指针。

c 复制代码
// zippo1.c -- zippo的相关信息
#include <stdio.h>
int main(void)
{
    int zippo[4][2] = { { 2, 4 }, { 6, 8 }, { 1, 3 }, { 5, 7 } };

    printf("      zippo = %p,   zippo + 1 = %p\n", zippo, zippo + 1);
    printf("   zippo[0] = %p, zippo[0] + 1 = %p\n", zippo[0], zippo[0] + 1);
    printf("     *zippo = %p,   *zippo + 1 = %p\n", *zippo, *zippo + 1);
    printf("      **zippo = %d\n", **zippo);
    printf("    zippo[2][1] = %d\n", zippo[2][1]);
    printf("*(*(zippo+2)+1) = %d\n", *(*(zippo + 2) + 1));
    return 0;
}

代码描述:这段代码是理解多维数组与指针关系的关键。

  • zippozippo[0] 的地址值相同,但 zippo + 1 的地址偏移了8字节(一行的大小),而 zippo[0] + 1 只偏移了4字节(一个元素的大小)。
  • *zippo 解引用了行指针,其结果是第一行的地址,所以 *zippozippo[0] 的值相同。
  • **zippo 是对行指针解引用再对元素指针解引用,最终得到第一个元素 zippo[0][0] 的值。
  • *(*(zippo+2)+1) 通过指针运算精确地定位到了 zippo[2][1]

要声明一个能指向 zippo 这种二维数组的指针,必须使用 int (* pz)[2]; 语法。在将二维数组传递给函数时,这个概念尤为重要,函数的形参必须能匹配传入的实参类型,且必须指明除第一维之外的所有维度的大小。

c 复制代码
// array2d.c -- 处理二维数组的函数
#define ROWS 3
#define COLS 4
void sum_cols(int ar[][COLS], int rows); // COLS是必须的

int main(void) {
    int junk[ROWS][COLS] = { ... };
    sum_cols(junk, ROWS);
}

代码描述sum_cols 函数的声明 int ar[][COLS] 告诉编译器 ar 是一个指针,它指向一个包含 COLS(即4)个 int 元素的数组。编译器需要 COLS 这个信息来正确计算 ar[r][c] 的内存地址。

C99/C11 的现代特性

变长数组 (VLA)

C99标准引入了变长数组,允许使用变量来定义数组的维度,极大地增强了代码的通用性。

c 复制代码
// vararr2d.c -- 使用变长数组的函数
#include <stdio.h>
int sum2d(int rows, int cols, int ar[rows][cols]); // VLA作为函数形参

int main(void)
{
    int junk[3][4] = { ... };
    int varr[3][10]; // VLA声明
    // ...
    sum2d(3, 4, junk);
    sum2d(3, 10, varr);
    return 0;
}

代码描述 :通过VLA,sum2d 函数现在可以处理任意行列的二维 int 数组。注意,在函数原型和定义中,维度变量 rowscols 必须在数组 ar 之前声明。

复合字面量

C99的另一个创新是复合字面量,它允许创建匿名的、临时的数组常量,常用于函数调用。

c 复制代码
// flc.c -- 有趣的常量
#include <stdio.h>
int sum(const int ar[], int n);
int sum2d(const int ar[][4], int rows);

int main(void)
{
    int total1, total2, total3;
    int *pt1 = (int[2]){10, 20}; // 创建匿名数组并存储地址
    total1 = sum(pt1, 2);
    total2 = sum2d((int[2][4]){{1,2,3,-9},{4,5,6,-8}}, 2); // 直接传递匿名二维数组
    total3 = sum((int[]){4,4,4,5,5,5}, 6); // 省略大小的匿名数组
    // ...
    return 0;
}

代码描述 :该代码展示了复合字面量的三种用法。pt1 指向一个在代码中直接定义的匿名数组。sum2dsum 的调用则直接将复合字面量作为实参传递,避免了预先声明变量的需要,使代码更加紧凑。


附录:精选代码解读

1. ptr_ops.c --- 指针操作的权威展示

这个程序是理解指针所有基本操作的绝佳范例。让我们逐一分析。

c 复制代码
// ptr_ops.c -- 指针操作
#include <stdio.h>
int main(void)
{
    int urn[5] = {100, 200, 300, 400, 500};
    int * ptr1, *ptr2, *ptr3;

    ptr1 = urn;      // 操作1: 赋值
    ptr2 = &urn[2];  // 也是赋值

    // 打印初始状态
    printf("ptr1 = %p, *ptr1 =%d, &ptr1 = %p\n", ptr1, *ptr1, &ptr1);

    ptr3 = ptr1 + 4; // 操作2: 指针加法
    printf("ptr1 + 4 = %p, *(ptr1 + 4) = %d\n", ptr1 + 4, *(ptr1 + 4));

    ptr1++;          // 操作3: 递增指针
    printf("ptr1 after ptr1++: %p, *ptr1 = %d\n", ptr1, *ptr1);

    ptr2--;          // 操作4: 递减指针
    printf("ptr2 after ptr2--: %p, *ptr2 = %d\n", ptr2, *ptr2);

    // 恢复ptr1和ptr2
    --ptr1;
    ++ptr2;

    printf("ptr2 - ptr1 = %td\n", ptr2 - ptr1); // 操作5: 指针求差

    printf("ptr3 - 2 = %p\n", ptr3 - 2);        // 操作6: 指针减整数
    
    return 0;
}

深度解读:

  • 赋值 : ptr1 = urn; 将数组 urn 的首地址赋给 ptr1ptr2 = &urn[2]; 将第3个元素的地址赋给 ptr2
  • 解引用与取址 : *ptr1 得到 ptr1 指向地址的值(100)。&ptr1 得到指针变量 ptr1 自身的内存地址,它与 urn 的地址是完全不同的。
  • 指针加法 : ptr1 + 4 计算的是 ptr1 的地址加上 4 * sizeof(int)。结果是指向 urn[4] 的地址。*(ptr1 + 4) 自然就是 urn[4] 的值(500)。
  • 递增/递减 : ptr1++ 是一个副作用操作,它修改了 ptr1 自身的值,使其指向下一个元素 urn[1]ptr2-- 同理,使其从指向 urn[2] 变为指向 urn[1]
  • 指针求差 : ptr2 - ptr1 计算的是两个地址之间相隔多少个元素 。在恢复后,ptr1 指向 urn[0]ptr2 指向 urn[2],它们之间相隔2个 int 元素,所以结果是2,而不是地址的字节差。
  • 指针减整数 : ptr3 - 2 与指针加法类似,计算结果是一个新的地址,即 ptr3 的地址减去 2 * sizeof(int)。因为 ptr3 指向 urn[4],所以结果是 urn[2] 的地址。
2. zippo 数组 --- *(*(zippo+2)+1) 的逐步解析

这个表达式是理解多维数组指针表示法的试金石。让我们以 int zippo[4][2] 为例,一步步拆解它如何等价于 zippo[2][1]

  1. zippo:

    • 含义: 二维数组的名称。
    • 类型 : int (*)[2],即一个指向"包含2个int的数组"的指针。
    • : 整个数组的起始地址,数值上等于 &zippo[0]
  2. zippo + 2:

    • 含义: 对指向行的指针进行算术运算。
    • 计算 : zippo 的地址值加上 2 * sizeof(int[2])sizeof(int[2]) 是8字节(假设int为4字节),所以地址增加了16字节。
    • 结果 : &zippo[2],即第3行的起始地址。
  3. *(zippo + 2):

    • 含义: 解引用行指针。
    • 计算 : 获取 &zippo[2] 地址处存储的值。由于 zippo + 2 是一个指向数组的指针,解引用它得到的是那个数组本身。在C中,数组名即其首元素地址。
    • 结果 : zippo[2],它是一个一维数组。其值等于 &zippo[2][0],即第3行第1个元素的地址。
    • 类型 : int *,即一个指向 int 的指针。
  4. *(zippo + 2) + 1:

    • 含义: 对指向元素的指针进行算术运算。
    • 计算 : &zippo[2][0] 的地址值加上 1 * sizeof(int),即4字节。
    • 结果 : &zippo[2][1],即第3行第2个元素的地址。
  5. *(*(zippo + 2) + 1):

    • 含义: 解引用最终的元素指针。
    • 计算 : 获取 &zippo[2][1] 地址处存储的值。
    • 结果 : zippo[2][1] 的值。
相关推荐
而后笑面对3 小时前
力扣2025.10.19每日一题
算法·leetcode·职场和发展
我星期八休息3 小时前
C++智能指针全面解析:原理、使用场景与最佳实践
java·大数据·开发语言·jvm·c++·人工智能·python
shuair3 小时前
mysql8支持远程访问 -mysql5.7支持远程访问
linux·mysql
来生硬件工程师3 小时前
【STM32笔记】:P04 断言的使用
c语言·笔记·stm32·单片机·嵌入式硬件·硬件架构·硬件设计
大猫会长3 小时前
docker安装php+apache
java·开发语言
·白小白3 小时前
力扣(LeetCode) ——11.盛水最多的容器(C++)
c++·算法·leetcode
道之极万物灭3 小时前
Go小工具合集
开发语言·后端·golang
梵得儿SHI4 小时前
Java 反射机制深度剖析:性能与安全性的那些坑
java·开发语言·安全·反射·动态代理·性能·反射机制
fsnine4 小时前
Python图形化界面——pyqt5教程
开发语言·python·qt