16. 指针与数组的亲密关系

上一篇我们初识指针,知道了数组名就是首元素地址,arr[i] 就是 *(arr+i)。但如果你就此认为“数组和指针就是一回事”,那可就埋下隐患了——它们有微妙而重要的差别,而且围绕它们还衍生出几个让无数初学者抓狂的概念:指针数组数组指针二级指针

今天我们就来彻底理清这些关系。顺便,你会真正理解 C 语言字符串的本质,以及 main 函数那两个神秘参数 argcargv 到底是怎么回事。


一、数组名 ≠ 指针:三条铁律

数组名在绝大多数表达式中会被自动转换成指向首元素的指针,但这条规则有三个重要的例外。记住这三个例外,你就能分清什么时候数组是数组,什么时候它会变成指针。

例外 1:sizeof(数组名) 得到整个数组的大小

int arr[10];
printf("%zu\n", sizeof(arr));       // 40(假设 int 4 字节)
printf("%zu\n", sizeof(&arr[0]));   // 8(指针大小,64位系统)

如果 arr 真是一个指针,sizeof(arr) 应该是指针大小。但它不是,它返回整个数组的字节数。这是区分数组和指针最直接的方法。

例外 2:&数组名 得到指向整个数组的指针

int arr[5];
int *p1 = arr;       // 指向 int 的指针,类型 int*
int (*p2)[5] = &arr; // 指向“5个int组成的数组”的指针,类型 int(*)[5]

p2 就是所谓的数组指针,它的类型不是 int*,而是 int(*)[5]p2+1 会跳过整个数组(20 字节),而不是一个 int。这个区别我们稍后详细展开。

例外 3:用字符串字面量初始化字符数组时

char str[] = "hello";   // 在栈上创建一个数组,内容是 "hello" 的副本
char *ptr = "hello";    // ptr 指向存储在只读数据区的字符串字面量

这是两种完全不同的东西:前者是可修改的字符数组,后者是指向只读字符串的指针。这也是字符串的经典陷阱,我们马上会讲。


二、指针数组 vs 数组指针:别被名字绕晕

这是 C 语言最经典的混淆点。记住一条规则:从右往左读[] 的优先级高于 *,括号可以改变结合顺序。

指针数组:int *p[10]

读法:p 是一个数组,有 10 个元素,每个元素是 int*(指向 int 的指针)。

int a = 1, b = 2, c = 3;
int *ptr_array[3] = {&a, &b, &c};  // 一个存了三个指针的数组

for (int i = 0; i < 3; i++) {
    printf("%d ", *ptr_array[i]);   // 输出 1 2 3
}

指针数组最常见的应用,就是存储多个字符串:

char *fruits[] = {"apple", "banana", "cherry"};
// fruits[0] 是一个 char*,指向字符串 "apple" 的首字符

这比用二维 char 数组更灵活——每个字符串的长度可以不同,不用浪费空间。

数组指针:int (*p)[10]

读法:p 是一个指针,它指向一个“有 10 个 int 的数组”。

int arr[10];
int (*p)[10] = &arr;   // p 指向 arr 整个数组

// 访问元素:
(*p)[3] = 100;         // 等价于 arr[3] = 100;

为什么需要括号?因为 [] 优先级高于 *。没有括号的 int *p[10] 是“装指针的数组”,有括号的 int (*p)[10] 是“指数组的针”。

数组指针最常用于处理二维数组。当你把二维数组传给函数时,形参也可以写成数组指针:

void print_matrix(int (*mat)[4], int rows) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < 4; j++) {
            printf("%d ", mat[i][j]);
        }
        printf("\n");
    }
}

int main(void) {
    int matrix[3][4] = {
        {1,2,3,4}, {5,6,7,8}, {9,10,11,12}
    };
    print_matrix(matrix, 3);
    return 0;
}

matrix 作为参数传递时退化为指向首元素(一行)的指针,那一行是 4 个 int 的数组,所以类型恰好是 int(*)[4]。这就是为什么二维数组形参必须写列数——编译器需要知道每一行多长。

快速对照表

写法本质解释
int *p[10]数组10 个 int* 组成的数组
int (*p)[10]指针指向“10个int数组”的指针
int *p(int)函数函数名为 p,返回 int*(今天不讲)
int (*p)(int)指针指向“接收int返回int”的函数的指针(以后讲)

记忆口诀:最后一步看符号——最后解析为数组就是数组,最后解析为指针就是指针。


三、字符串与指针:字符数组 vs 字符指针

在 C 语言里,字符串并不是一种独立的类型。它就是'\0' 结尾的字符数组。但对字符串的存储方式有两种截然不同的形式,这个区别一定要分清楚。

形式一:字符数组(可修改)

char str[] = "hello";
str[0] = 'H';       // 合法!数组内容可以修改
printf("%s\n", str);  // Hello

str 是一个数组,在栈上分配了 6 个字节(h e l l o \0)。"hello" 的每个字符被复制到了这个数组里。你是这个内存的主人,可以随意改。

形式二:字符指针(指向只读字符串字面量)

char *ptr = "hello";
ptr[0] = 'H';       // 危险!未定义行为,通常会崩溃

这里 ptr 是一个指针,指向字符串字面量 "hello"。这个字面量存储在只读数据区.rodata),操作系统不允许你修改它。试图修改要么崩溃(段错误),要么什么都没发生(但这是未定义行为,千万不要依赖)。

正确做法:如果你声明了一个指向字符串字面量的指针,并且以后不会修改它,用 const 保护起来:

const char *ptr = "hello";   // ptr[0] = 'H' 直接编译报错

形式三:用指针遍历字符串

不管字符串是以数组还是字面量形式存在,你都可以用指针来遍历它:

char str[] = "hello";
char *p = str;
while (*p != '\0') {
    printf("%c ", *p);
    p++;
}
// 输出 h e l l o

标准库的 strlen 本质上就是这么干的:

size_t my_strlen(const char *s) {
    const char *p = s;
    while (*p) p++;    // *p 为 '\0' 时退出
    return p - s;      // 两个指针相减得到元素个数
}

四、命令行参数:argcargv

也许你一直好奇 main 函数的另一种写法:

int main(int argc, char *argv[]) {
    // ...
}

现在我们可以理解它了。

  • argc(argument count):命令行参数的个数,包含程序名本身。
  • argv(argument vector):一个指针数组,每个元素是一个 char*,指向一个参数字符串。

char *argv[] 等价于 char **argv(数组作为形参退化为指针,指针数组退化为二级指针)。我们先直观理解,二级指针下一节细说。

假如你的程序叫 echo,运行命令:

./echo hello world 123

那么:

  • argc = 4
  • argv[0] = "./echo"
  • argv[1] = "hello"
  • argv[2] = "world"
  • argv[3] = "123"
  • argv[4] = NULL(标准保证最后一个指针是 NULL)

一个简单程序打印所有参数:

#include <stdio.h>

int main(int argc, char *argv[]) {
    printf("参数个数:%d\n", argc);
    for (int i = 0; i < argc; i++) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }
    return 0;
}

argv 的图形化理解:

argv -> [0] -> "./echo\0"
        [1] -> "hello\0"
        [2] -> "world\0"
        [3] -> "123\0"
        [4] -> NULL

argv 本身是一个 char**,它指向一个由 char* 指针组成的数组,每个 char* 又指向一个实际的字符串。


五、二级指针初识

如果指针是一个变量,存着另一个变量的地址,那二级指针就是一个变量,存着另一个指针的地址。

int a = 10;
int *p = &a;      // p 指向 a
int **pp = &p;    // pp 指向 p

printf("%d\n", **pp);  // **pp = *(*pp) = *p = a = 10

**pp 就是 a。二级指针最常见的用途:

  1. 在函数里修改一级指针本身(比如动态分配内存并传出)
  2. 操作指针数组(比如 argv 就是 char**

一个函数示例:修改传入的指针,让它指向新分配的内存(后面动态内存会讲,这里先感性认知):

void allocate(int **ptr) {
    *ptr = malloc(sizeof(int));   // 修改 ptr 指向的那个指针
    **ptr = 100;
}

int main(void) {
    int *p = NULL;
    allocate(&p);    // 传 p 的地址
    printf("%d\n", *p);  // 100
    free(p);
    return 0;
}

在二维动态数组的分配中,二级指针也是核心角色,这个我们在动态内存部分会专门展开。


六、如何读懂复杂声明:右左法则

当声明混有指针、数组、函数时,比如 int *(*p[10])(int),怎么读懂?可以按“右左法则”:

  1. 从变量名开始。
  2. 先往右看,遇到 [ ]( ) 就读出来。
  3. 再往左看,遇到 * 就读“指针”。
  4. 遇到括号就跳进去,重复上述过程。

char *argv[] 为例:

  • argv 是……先右看,[] 说明是数组。
  • 再左看,* 说明是指针 → argv 是一个指针数组。
  • 再左看,char 说明每个元素指向字符。

int (*p)[10] 为例:

  • p 遇到括号,先处理括号内。左看 *p 是指针。
  • 括号外右看 [10],指向有 10 个元素的数组。
  • 左看 int,数组元素是 int。
  • 结论:p 是指向有 10 个 int 的数组的指针。

这个法则很实用,以后遇到复杂的声明多试几次就熟了。


七、常见错误与陷阱

1. 把指针数组和数组指针搞反

int (*ptr)[5];    // 指针,指向5个int的数组
int *ptr[5];      // 数组,有5个int*

2. 试图修改字符串字面量

char *s = "hello";
s[0] = 'H';       // 未定义行为,崩溃或无效

const char * 或字符数组。

3. 对函数参数中的数组用 sizeof

void func(int arr[]) {
    printf("%zu\n", sizeof(arr));  // 总是指针大小,不是数组大小
}

4. 二级指针解引用层级混乱

int a = 5;
int *p = &a;
int **pp = &p;
printf("%d\n", *pp);  // 输出的是 p 的值(即 a 的地址),不是 a

*ppp**pp 才是 a


八、小结

今天我们把指针和数组的关系理清了。核心记住:

  • 数组名在大多数情况下退化为指针,但 sizeof&数组名、字符串字面量初始化是例外。
  • 指针数组(int *p[10])和数组指针(int (*p)[10])是完全不同的东西,读声明的技巧是看优先级和结合方向。
  • 字符串可以用字符数组(可修改)或字符指针(指向字面量,不可修改)来存储。
  • mainargv 是指针数组/二级指针的典型应用。
  • 二级指针是指向指针的指针,用于间接修改指针本身。

现在你对指针已经有了基本的感觉。但指针最灵活也最危险的用途,还在后面——动态内存分配。下一篇我们就进入 mallocfree、堆与栈的广阔天地,让程序能够按需索取内存,而不再受编译时数组大小的束缚。


课后小练习

  1. 分别声明一个指针数组和一个数组指针,并写出它们的类型含义。用 sizeof 验证它们的大小(数组指针是指针大小,指针数组是多个指针大小)。
  2. argv 实现一个简单的 echo 程序,要求将命令行参数逆序输出。
  3. 分析这段代码并修正错误:
    char *msg = "hello";
    msg[0] = 'H';
    printf("%s\n", msg);
    
  4. 写一个函数 void reverse_string(char *str),用指针操作(不用下标)实现字符串的原地反转。
  5. (挑战)解释以下声明的含义,并尝试各写一个简单用例:
    int *arr1[10];
    int (*arr2)[10];
    int **arr3;
    

我们下期见!

代码下载链接: https://pan.quark.cn/s/a4b39357ea24 第 一 章 概述 1-1 简述计算机程序设计语言的发展阶段。 解: 自从计算机诞生以来,程序设计语言经历了从机器语言、汇编语言到高级语言的演变过程,C++语言作为一种面向对象的编程语言,也属于高级语言范畴。 1-2 面向对象的编程语言具备哪些特性? 解: 面向对象的编程语言传统的编程语言有着本质的区别,其设计初衷是为了更直观地模拟现实世界中存在的事物及其相互关系。这类编程语言将客观事物视为具有属性和行为的对象,通过抽象方法提取出同一类对象的共同属性(静态特征)和行为(动态特征),从而构建类。借助类的继承多态机制,能够便捷地实现代码复用,显著缩短软件开发周期,并确保软件风格的一致性。因此,面向对象的编程语言使得程序能够较为准确地反映问题域的本质,软件开发人员可以运用人类惯用的思维模式进行开发工作。C++语言是目前应用最为广泛的面向对象编程语言。 1-3 结构化程序设计方法是什么?这种方法有哪些优势和不足? 解: 结构化程序设计的核心思想是自顶向下、逐步求精;其程序结构按照功能划分为多个基本模块;各模块之间的关联尽可能简化,在功能上保持相对独立性;每个模块内部均由顺序、选择和循环三种基本结构构成;模块化实现的具体途径是利用子程序。结构化程序设计由于采用模块分解功能抽象,自顶向下、分而治之的策略,从而有效地将一个较为复杂的程序系统设计任务分解成许多易于管理和处理的子任务,便于开发维护。 尽管结构化程序设计方法具备诸多优点,但它本质上仍是一种面向过程的程序设计方法,将数据处理数据的操作分离为相互独立的实体。当数据结构发生变化时,所有相关的处理过程都需要进行相应的调整,每一种...
已经博主授权,源码转载自 https://pan.quark.cn/s/a4b39357ea24 【高清晰度壁纸】是一种适用于计算机或移动设备的高解析度图像,通常用于定制用户界面,以增强视觉感受。$4K$分辨率指的是宽度约为$3840$像素,高度约为$2160$像素的显示标准,这种分辨率提供了极为清晰的细节,使得图像在大尺寸屏幕上呈现更为生动和逼真的效果。本压缩文件内含$20$张$4K$高清晰度壁纸,每张均从知名搜索引擎必应及彼岸图网中经过细致挑选。这些壁纸的题材丰富多样,涵盖了自然景观、科幻元素、游戏场景以及人物画像等多个方面,能够满足不同用户的需求。 1. **$125c1aa02ad94869ef055b870a54af560ad1574e144e03-qL6oaN_fw658.gif$**:这可能是一张动态壁纸,由于$gif$格式支持动态效果,或许包含有趣的动画元素,为桌面增添活力。 2. **$204b05b99e9b404aa6436f3c7c03d9c9.jpeg$**:$JPEG$是一种常见的静态图像格式,适合存储高品质照片,可能是一张风景或人物图片。 3. **加拿大班夫国家公园的朱砂湖的星空$4K$壁纸_彼岸图网.jpg**:这张壁纸展现了自然的宏伟,将班夫国家公园的优美湖泊璀璨星空相结合,为用户带来宁静且和谐的视觉体验。 4. **《星球大战堕落秩序(Star Wars Jedi_ Fallen Order)》$4K$游戏壁纸_彼岸图网.jpg**:这是一张基于热门游戏《星球大战:堕落秩序》设计的壁纸,对于游戏爱好者而言极具吸引力,可能包含游戏中的角色或场景。 5. **陈钰琪倚天屠龙记$4K$壁纸_彼岸图网.jpg**:陈钰琪...
源码下载地址: https://pan.quark.cn/s/95927341e579 该方法适用于二进制数值向十进制数值的转化,其中A代表十进制数值,B代表二进制数值。{A,B}序列会执行位移操作,每次左移一位,同时检验A中的每四位数值是否>4,若超过四则进行加三调整,否则维持原状;B的位数决定了左移操作的重复次数。最终,A的数值即为B转换后的十进制表达。此代码示例专注于32位二进制数值向十进制数值的转换。在数字操作领域,二进制十进制之间的相互转换是一项基础性操作。二进制体系(Base-2)采用0和1两种符号来表示数值,而十进制体系(Base-10)则使用0到9这十个符号。在计算机科学范畴内,特别是在硬件描述语言(例如Verilog)的应用中,掌握并执行此类转换显得尤为关键。下文将深入阐述如何借助Verilog代码实现32位二进制数值向十进制数值的转换。 我们必须明确Verilog是一种用于数字系统逻辑设计验证的硬件描述语言。在所提及的代码中,`module b32_o(bdata, odata)`定义了一个名为 `b32_o` 的Verilog模块,该模块接收一个32位输入 `bdata`(二进制数据)并输出一个32位结果 `odata`(十进制数据)。 转换的核心逻辑在于对二进制数值进行逐位解析并依据特定规则实施调整。文中指出,针对每四位分组,我们需评估这四位数值是否大于4(4h4)。若超过四,则执行加三操作,此调整源于二进制的1000相当于十进制的8,故需将此部分值递增至下一位,即加三。该操作会在32位二进制数值的每个四位组上反复执行,总共进行32次。 代码中的 `always @(bdata)` 区块设定了一个触发机制,当 `bdata` 发生变化...
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值