第5章 指针与数组
C语言的灵魂
一、导读
1.1 本章学习目标
第5章是整本《C程序设计语言》最核心的一章。指针是C语言最强大也最令人畏惧的特性——它直接操作内存地址,赋予程序员前所未有的控制力,同时也带来了空指针解引用、内存泄漏、缓冲区溢出等风险。数组是C语言中最基本的数据结构,它与指针有着深刻的内在联系。
本章的核心学习目标包括:
- 理解指针的本质:指针是存储内存地址的变量。
&取地址,*解引用。 - 掌握指针运算:指针的加减运算以指向类型的大小为单位。
- 理解指针与数组的关系:数组名在大多数表达式中退化为指向首元素的指针。
- 掌握指针数组和数组指针:
int *p[10]和int (*p)[10]的区别。 - 学会动态内存管理:
malloc、calloc、realloc、free的正确使用。 - 理解多级指针:指向指针的指针,以及
main函数的参数char **argv。
1.2 为什么本章如此重要
K&R 在第五章倾注了大量心血,因为指针是C语言的灵魂。不理解指针,就无法真正理解C语言。
指针的重要性体现在多个层面:
- 效率:通过指针传递地址而不是复制整个数据结构,可以大幅提高函数调用的效率。
- 灵活性:指针可以指向任何类型的数据,包括函数(函数指针),实现动态绑定和回调机制。
- 数据结构:链表、树、图等动态数据结构都依赖指针来连接节点。
- 系统编程:操作系统、驱动程序、嵌入式系统的开发离不开指针——它们需要直接操作硬件寄存器和内存映射。
数组与指针的关系是C语言设计中最精妙的部分之一。理解 arr[i] 等价于 *(arr + i),不仅能帮助你写出更高效的代码,更能让你理解C语言对内存的抽象方式。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
指针到底是什么?它在内存中是如何表示的?
int *p 中的 * 和 *p 中的 * 含义有什么不同?
为什么数组下标从0开始而不是从1开始?
arr[i] 和 *(arr + i) 真的完全等价吗?i[arr] 合法吗?
为什么 sizeof(arr) 和 sizeof(ptr) 的结果不同,即使 arr 可以当指针用?
malloc 分配的内存和局部变量分配的内存有什么区别?什么时候用哪个?
二、核心概念详解
2.1 指针的基本概念
指针是一个变量,它的值是另一个变量的内存地址。
int x = 10;
int *p = &x; // p存储了x的地址
printf("x = %d\n", x); // 10
printf("&x = %p\n", &x); // x的地址,如 0x7ffd5e8a3b4c
printf("p = %p\n", p); // 与 &x 相同
printf("*p = %d\n", *p); // 10(通过指针访问x的值)两个关键运算符:
&(取地址运算符):获取变量的内存地址*(解引用运算符):访问指针指向的值
注意:* 在声明中(int *p)和解引用中(*p)的含义不同。声明中的 * 表示"这是一个指针",解引用中的 * 表示"取指针指向的值"。
2.1.1 指针的类型
指针的类型决定了:
解引用时访问多少字节
指针运算时步进多少字节
int i = 42;
char c = 'A';
double d = 3.14;
int *pi = &i; // pi + 1 前进4字节(sizeof(int))
char *pc = &c; // pc + 1 前进1字节(sizeof(char))
double *pd = &d; // pd + 1 前进8字节(sizeof(double))2.1.2 空指针
int *p = NULL; // 空指针,不指向任何有效内存
if (p != NULL) {
printf("%d\n", *p); // 安全:只在p非空时解引用
}
// NULL通常定义为 ((void*)0)
// 解引用空指针是未定义行为(通常导致段错误)2.2 指针运算
C语言允许对指针进行加减运算,运算的单位是指针指向类型的大小:
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // p指向arr[0]
printf("%d\n", *p); // 10
printf("%d\n", *(p + 1)); // 20(p前进1个int,即4字节)
printf("%d\n", *(p + 3)); // 40
p += 2; // p现在指向arr[2]
printf("%d\n", *p); // 30指针运算的规则:
p + n:向前移动n个元素p - n:向后移动n个元素p - q:两个指针之间的距离(元素个数),要求指向同一数组p++、p--:移动到下一个/上一个元素- 两个指针可以比较(
<、>、==),但只有指向同一数组时才有意义
2.3 指针与数组的关系
这是C语言中最重要也最容易混淆的概念之一。
核心规则:在大多数表达式中,数组名会退化(decay)为指向首元素的指针。
int arr[5] = {10, 20, 30, 40, 50};
// arr 退化为 &arr[0]
int *p = arr; // 等价于 int *p = &arr[0];
// 下标运算的等价形式
arr[i] 等价于 *(arr + i)
// 甚至 i[arr] 也是合法的(虽然不推荐)
// 因为 arr[i] 定义为 *(arr + i),加法满足交换律2.3.1 数组与指针的区别
虽然数组名可以退化为指针,但数组和指针有本质区别:
int arr[5]; // 数组:分配5个int的连续空间
int *ptr = arr; // 指针:一个存储地址的变量
sizeof(arr); // 20(5 × sizeof(int))
sizeof(ptr); // 8(64位系统上指针的大小)
arr = ptr; // 错误!数组名是常量指针,不能赋值
ptr = arr; // 正确!指针可以指向数组关键区别:
sizeof(arr)返回整个数组的大小;sizeof(ptr)返回指针本身的大小- 数组名是常量(不能赋值);指针是变量(可以重新赋值)
- 数组作为函数参数时退化为指针,
sizeof返回指针大小
2.3.2 数组作为函数参数
// 以下三种声明等价——参数都是 int*
void func(int arr[10]) { ... }
void func(int arr[]) { ... }
void func(int *arr) { ... }
// 在函数内部,sizeof(arr) 返回指针大小,不是数组大小
void print_size(int arr[]) {
printf("%lu\n", sizeof(arr)); // 8(指针大小),不是40
}如果需要传递数组大小:
void process(int arr[], int size) {
for (int i = 0; i < size; i++) {
// ...
}
}
int data[100];
process(data, 100); // 显式传递数组大小2.4 字符指针与字符串
C语言中的字符串是字符数组,以 \0 结尾:
// 字符串字面量
char *s1 = "hello"; // s1指向只读数据段中的 "hello\0"
// 字符数组
char s2[] = "hello"; // s2是栈上的数组,内容为 {'h','e','l','l','o','\0'}
// 区别
// s1[0] = 'H'; // 未定义行为!字符串字面量是只读的
s2[0] = 'H'; // 合法!修改数组元素// 字符串操作函数(<string.h>)
#include <string.h>
char dest[100];
strcpy(dest, "hello"); // 复制字符串
strcat(dest, " world"); // 拼接字符串
int len = strlen(dest); // 长度(不含\0)
int cmp = strcmp(dest, "hello world"); // 比较
// 更安全的版本
strncpy(dest, "hello", sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0'; // 确保以\0结尾2.5 指针数组与数组指针
// 指针数组:数组的元素是指针
int *parr[5]; // 5个int指针的数组
char *names[] = {"Alice", "Bob", "Charlie"}; // 3个char指针
// 数组指针:指针指向一个数组
int (*aptr)[5]; // 指向包含5个int的数组的指针
int arr[3][4];
int (*row_ptr)[4] = arr; // row_ptr指向arr的第一行指针数组的典型应用:命令行参数
int main(int argc, char *argv[]) {
// argv 是一个指针数组,每个元素指向一个字符串
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}数组指针的典型应用:遍历二维数组
int matrix[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
int (*row)[4] = matrix;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 4; j++) {
printf("%3d", row[i][j]); // 等价于 *(*(row + i) + j)
}
printf("\n");
}2.6 动态内存分配
C语言提供了四个标准库函数来动态管理内存(<stdlib.h>):
2.6.1 malloc 和 free
void *malloc(size_t size); // 分配size字节的未初始化内存
void free(void *ptr); // 释放之前分配的内存// 分配一个int
int *p = (int *)malloc(sizeof(int));
if (p == NULL) {
fprintf(stderr, "malloc failed\n");
exit(1);
}
*p = 42;
free(p); // 释放内存
p = NULL; // 避免悬空指针
// 分配一个包含n个int的数组
int n = 100;
int *arr = (int *)malloc(n * sizeof(int));
if (arr == NULL) {
fprintf(stderr, "malloc failed\n");
exit(1);
}
for (int i = 0; i < n; i++) {
arr[i] = i;
}
free(arr);
arr = NULL;2.6.2 calloc 和 realloc
void *calloc(size_t count, size_t size); // 分配并初始化为0
void *realloc(void *ptr, size_t new_size); // 调整已分配内存的大小// calloc:分配并清零
int *arr = (int *)calloc(100, sizeof(int)); // 100个int,全部初始化为0
// realloc:调整大小
arr = (int *)realloc(arr, 200 * sizeof(int)); // 扩展到200个int
// 注意:realloc可能移动内存,原来的指针可能失效
// 如果realloc失败,返回NULL,但原来的内存不会被释放安全使用 `realloc` 的模式:
int *new_arr = (int *)realloc(arr, new_size * sizeof(int));
if (new_arr == NULL) {
// 处理错误,arr仍然有效
free(arr);
exit(1);
}
arr = new_arr;2.6.3 常见内存错误
// 1. 内存泄漏:分配了但没有释放
void leak(void) {
int *p = (int *)malloc(sizeof(int));
*p = 42;
return; // p丢失了,内存泄漏!
}
// 2. 悬空指针:释放后继续使用
int *p = (int *)malloc(sizeof(int));
free(p);
*p = 42; // 未定义行为!
// 3. 重复释放
free(p);
free(p); // 未定义行为!
// 4. 缓冲区溢出
int *arr = (int *)malloc(5 * sizeof(int));
arr[10] = 42; // 越界写入!
// 5. 释放非堆内存
int x = 42;
free(&x); // 未定义行为!只能释放malloc/calloc/realloc分配的内存2.7 多级指针
指针可以指向指针,形成多级间接引用:
int x = 10;
int *p = &x; // 一级指针
int **pp = &p; // 二级指针
printf("%d\n", **pp); // 10
// 典型应用:函数需要修改指针本身
void allocate_array(int **arr, int size) {
*arr = (int *)malloc(size * sizeof(int));
}
int main(void) {
int *my_arr = NULL;
allocate_array(&my_arr, 10);
// my_arr 现在指向分配的内存
free(my_arr);
return 0;
}三、实战案例
3.1 案例一:动态数组实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int *data;
int size;
int capacity;
} DynamicArray;
DynamicArray *da_create(int initial_capacity) {
DynamicArray *da = (DynamicArray *)malloc(sizeof(DynamicArray));
da->data = (int *)malloc(initial_capacity * sizeof(int));
da->size = 0;
da->capacity = initial_capacity;
return da;
}
void da_push(DynamicArray *da, int value) {
if (da->size >= da->capacity) {
da->capacity *= 2;
da->data = (int *)realloc(da->data, da->capacity * sizeof(int));
}
da->data[da->size++] = value;
}
int da_pop(DynamicArray *da) {
if (da->size == 0) {
fprintf(stderr, "Error: pop from empty array\n");
exit(1);
}
return da->data[--da->size];
}
void da_free(DynamicArray *da) {
free(da->data);
free(da);
}
int main(void) {
DynamicArray *da = da_create(4);
for (int i = 0; i < 10; i++) {
da_push(da, i * 10);
}
printf("Array: ");
for (int i = 0; i < da->size; i++) {
printf("%d ", da->data[i]);
}
printf("\n");
printf("Popped: %d\n", da_pop(da));
printf("Popped: %d\n", da_pop(da));
da_free(da);
return 0;
}运行结果:
Array: 0 10 20 30 40 50 60 70 80 90
Popped: 90
Popped: 803.2 案例二:链表实现
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node;
Node *create_node(int data) {
Node *node = (Node *)malloc(sizeof(Node));
node->data = data;
node->next = NULL;
return node;
}
void push_front(Node **head, int data) {
Node *node = create_node(data);
node->next = *head;
*head = node;
}
void print_list(Node *head) {
Node *p = head;
while (p != NULL) {
printf("%d -> ", p->data);
p = p->next;
}
printf("NULL\n");
}
void free_list(Node *head) {
Node *p = head;
while (p != NULL) {
Node *next = p->next;
free(p);
p = next;
}
}
Node *reverse_list(Node *head) {
Node *prev = NULL;
Node *curr = head;
while (curr != NULL) {
Node *next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}
return prev;
}
int main(void) {
Node *list = NULL;
for (int i = 1; i <= 5; i++) {
push_front(&list, i);
}
printf("Original: ");
print_list(list);
list = reverse_list(list);
printf("Reversed: ");
print_list(list);
free_list(list);
return 0;
}运行结果:
Original: 5 -> 4 -> 3 -> 2 -> 1 -> NULL
Reversed: 1 -> 2 -> 3 -> 4 -> 5 -> NULL3.3 案例三:命令行参数解析
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
int verbose = 0;
int count = 1;
const char *filename = NULL;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-v") == 0) {
verbose = 1;
} else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) {
count = atoi(argv[++i]);
} else if (argv[i][0] != '-') {
filename = argv[i];
} else {
fprintf(stderr, "Unknown option: %s\n", argv[i]);
return 1;
}
}
if (filename == NULL) {
fprintf(stderr, "Usage: %s [-v] [-n count] filename\n", argv[0]);
return 1;
}
if (verbose) {
printf("File: %s, Count: %d\n", filename, count);
}
for (int i = 0; i < count; i++) {
printf("Processing %s (iteration %d)\n", filename, i + 1);
}
return 0;
}四、本章小结
第5章深入讲解了C语言最核心的特性——指针与数组:
指针本质:指针是存储内存地址的变量。& 取地址,* 解引用。指针的类型决定了指针运算的步进大小和解引用时访问的字节数。
指针运算:指针加减以指向类型的大小为单位。两个指向同一数组的指针相减得到元素个数。
指针与数组:数组名在大多数表达式中退化为指向首元素的指针。arr[i] 等价于 *(arr + i)。但数组和指针有本质区别:sizeof、赋值行为不同。
动态内存:malloc/calloc 分配堆内存,free 释放。必须避免内存泄漏、悬空指针、重复释放和缓冲区溢出。
多级指针:指针可以指向指针。main 函数的 argv 参数就是一个二级指针(指针数组)。
指针是C语言的灵魂。掌握了指针,你就掌握了C语言最强大的武器——但请记住,强大的力量伴随着巨大的责任。下一章将学习结构体——C语言中构建复杂数据结构的工具。
五、思考与练习
思考题
int *p[5] 和 int (*p)[5] 有什么区别?它们分别在什么场景下使用?
为什么 arr[i] 等价于 *(arr + i)?i[arr] 为什么也合法?
malloc(0) 的行为是什么?返回的指针可以解引用吗?
为什么函数参数中的数组声明 int arr[] 等价于 int *arr?
什么是悬空指针?如何避免?
编程练习
实现 `memcpy` 函数:将一块内存的内容复制到另一块内存。注意处理内存重叠的情况(memmove)。
实现一个双向链表:支持在头部、尾部、指定位置插入和删除节点。
编写一个程序:使用动态数组读取一个文件的所有行,存储在一个 char ** 结构中。
实现 `strtok` 函数:将字符串按照指定分隔符拆分为多个子串。