5C程序设计语言(第2版)

指针与数组

C语言的灵魂

指针数组指针运算指针与数组的关系

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 指针的基本概念

指针是一个变量,它的值是另一个变量的内存地址。

c
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 指针的类型

指针的类型决定了:

解引用时访问多少字节

指针运算时步进多少字节

c
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 空指针

c
int *p = NULL;      // 空指针,不指向任何有效内存

if (p != NULL) {
    printf("%d\n", *p);  // 安全:只在p非空时解引用
}

// NULL通常定义为 ((void*)0)
// 解引用空指针是未定义行为(通常导致段错误)

2.2 指针运算

C语言允许对指针进行加减运算,运算的单位是指针指向类型的大小:

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)为指向首元素的指针。

c
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 数组与指针的区别

虽然数组名可以退化为指针,但数组和指针有本质区别:

c
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 数组作为函数参数

c
// 以下三种声明等价——参数都是 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
}

如果需要传递数组大小:

c
void process(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        // ...
    }
}

int data[100];
process(data, 100);  // 显式传递数组大小

2.4 字符指针与字符串

C语言中的字符串是字符数组,以 \0 结尾:

c
// 字符串字面量
char *s1 = "hello";      // s1指向只读数据段中的 "hello\0"

// 字符数组
char s2[] = "hello";     // s2是栈上的数组,内容为 {'h','e','l','l','o','\0'}

// 区别
// s1[0] = 'H';          // 未定义行为!字符串字面量是只读的
s2[0] = 'H';             // 合法!修改数组元素
c
// 字符串操作函数(<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 指针数组与数组指针

c
// 指针数组:数组的元素是指针
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的第一行

指针数组的典型应用:命令行参数

c
int main(int argc, char *argv[]) {
    // argv 是一个指针数组,每个元素指向一个字符串
    for (int i = 0; i < argc; i++) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }
    return 0;
}

数组指针的典型应用:遍历二维数组

c
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

c
void *malloc(size_t size);  // 分配size字节的未初始化内存
void free(void *ptr);       // 释放之前分配的内存
c
// 分配一个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

c
void *calloc(size_t count, size_t size);  // 分配并初始化为0
void *realloc(void *ptr, size_t new_size); // 调整已分配内存的大小
c
// 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` 的模式:

c
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 常见内存错误

c
// 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 多级指针

指针可以指向指针,形成多级间接引用:

c
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 案例一:动态数组实现

c
#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: 80

3.2 案例二:链表实现

c
#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 -> NULL

3.3 案例三:命令行参数解析

c
#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` 函数:将字符串按照指定分隔符拆分为多个子串。

5/8指针与数
0%