
C语言程序设计教程:用实例掌握指针、数组和结构体
指针、数组和结构体是 C 程序组织数据的基础:数组适合存放一组同类型元素,指针用于表示对象的位置并传递对象,结构体则把相关字段组合成一个整体。它们经常一起出现,例如用结构体数组保存记录,再通过指针遍历或修改记录。
这几种机制也容易引入错误。越界访问、悬空指针、错误的字符串处理和不匹配的内存释放,都可能导致未定义行为。本文使用 C11 标准,逐步说明基本用法、适用场景和关键边界。示例可分别保存为 C 源文件编译运行:
cc -std=c11 -Wall -Wextra -Wpedantic example.c -o example
这些警告选项有助于发现部分问题,但不能证明程序没有越界或内存错误。调试时还可以使用支持 AddressSanitizer 的编译器,例如:
cc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined example.c -o example
一、指针是什么:保存对象的地址
指针变量保存的是对象的地址。指针类型决定了通过它访问对象时,编译器按什么类型解释内存,以及指针加一时跨过多少字节。
#include <stdio.h>
int main(void)
{
int value = 42;
int *p = &value;
printf("value = %d\n", value);
printf("*p = %d\n", *p);
*p = 100;
printf("修改后 value = %d\n", value);
return 0;
}
&value 是取地址运算,得到 value 的地址;*p 是解引用运算,访问该地址所指向的 int 对象。修改 *p 等同于修改 value。
声明中的星号属于类型声明的一部分。例如 int *p 表示 p 是指向 int 的指针。要注意,int *p, q; 中只有 p 是指针,q 是普通整数。为减少误读,复杂声明通常分开写。
空指针与有效指针
还没有指向有效对象的指针,可以初始化为空指针:
int *p = NULL;
空指针不能解引用。使用前应确认它指向有效对象,例如:
if (p != NULL) {
printf("%d\n", *p);
}
检查非空只是必要条件之一,并不能证明指针有效。它仍可能指向已经结束生命周期的对象、已释放的内存或错误地址。对这类指针解引用仍然是未定义行为。
指针作为函数参数
C 的函数参数按值传递。传入指针时,函数收到的是地址值的副本,但可以通过这份地址访问并修改原对象。下面的函数交换两个整数:
#include <stdio.h>
void swap_int(int *a, int *b)
{
if (a == NULL || b == NULL) {
return;
}
int temp = *a;
*a = *b;
*b = temp;
}
int main(void)
{
int x = 3;
int y = 8;
swap_int(&x, &y);
printf("x = %d, y = %d\n", x, y);
return 0;
}
这里两个参数必须指向可读写的 int 对象。空指针检查不能验证任意非空地址是否合法;调用者仍须保证指针指向有效对象。若函数不应修改对象,可以声明为 const int *p,表示函数不能通过 p 修改所指向的整数。
二、数组:连续存放的同类型元素
数组适合表示长度固定的一组同类型数据。数组下标从零开始,长度为 n 的数组合法下标是 0 到 n - 1。C 不会自动检查下标范围,越界读写属于未定义行为。
#include <stdio.h>
int main(void)
{
int scores[4] = {88, 91, 76, 95};
for (size_t i = 0; i < 4; ++i) {
printf("%d\n", scores[i]);
}
return 0;
}
size_t 是用于表示对象大小和数组下标等场景的无符号整数类型,定义在 <stddef.h> 等标准头文件中。实际程序可把数组长度保存为变量,避免在多个地方重复填写:
#include <stddef.h>
int scores[4] = {88, 91, 76, 95};
size_t count = sizeof scores / sizeof scores[0];
这个计算方式只在 scores 仍是数组时成立。它不能用于已经退化为指针的函数参数。
数组传给函数时发生什么
数组作为函数参数时,形参会被调整为指针类型。以下两种写法作为函数形参时等价:
int sum(const int a[], size_t n);
int sum(const int *a, size_t n);
函数无法仅凭 a 得知原数组有多长,因此通常要把元素数量作为另一个参数传入。一个完整示例:
#include <stddef.h>
#include <stdio.h>
long sum(const int *a, size_t n)
{
long total = 0;
if (a == NULL && n != 0) {
return 0;
}
for (size_t i = 0; i < n; ++i) {
total += a[i];
}
return total;
}
int main(void)
{
int values[] = {10, 20, 30};
size_t n = sizeof values / sizeof values[0];
printf("sum = %ld\n", sum(values, n));
return 0;
}
这个示例约定:n 为零时不读取数组,因此允许 a 为空;当 n 非零时,调用者必须提供至少含有 n 个可读整数的有效数组。函数里的空指针检查并不能替代长度和有效性的保证。生产代码中,如果空指针代表错误,最好另外返回错误状态,而不是把错误静默解释成总和为零。
数组名与指针的区别
在大多数表达式中,数组名会转换为指向首元素的指针,但数组和指针不是同一种对象。例如:
int a[5];
int *p = a;
a 是含有五个 int 元素的数组,p 是一个指针变量。sizeof a 得到整个数组占用的字节数;sizeof p 得到指针本身占用的字节数,两者通常不同,且指针大小取决于实现。
数组名在多数表达式中会转换为首元素地址,但有重要例外,例如作为 sizeof 的操作数时不会发生这种转换。传入函数后,形参是指针,因而函数内部的 sizeof a 计算的是指针大小,而不是原数组大小。
指针运算的范围
对指向数组元素的指针做加减运算,步长按元素类型计算。若 p 指向 a[i],那么 p + 1 指向下一个 int 元素,而不是简单地前进一个字节。
指针可以指向数组内的元素,也可以指向数组尾后位置。例如,若数组有五个元素,a + 5 可以用于表示尾后位置,但不能解引用。指针运算只能在同一数组对象及其尾后位置范围内进行;把任意地址当作数组指针运算,或解引用尾后指针,都是错误的。
下面的遍历方式与下标访问等价:
#include <stdio.h>
int main(void)
{
int a[] = {4, 7, 9};
int *end = a + sizeof a / sizeof a[0];
for (int *p = a; p != end; ++p) {
printf("%d\n", *p);
}
return 0;
}
多维数组
二维数组在内存中按行连续存放。函数接收二维数组时,除第一维外,后续维度通常必须明确,因为编译器需要据此计算元素地址:
#include <stddef.h>
#include <stdio.h>
void print_matrix(size_t rows, const int matrix[][3])
{
for (size_t r = 0; r < rows; ++r) {
for (size_t c = 0; c < 3; ++c) {
printf("%d%c", matrix[r][c], c == 2 ? '\n' : ' ');
}
}
}
int main(void)
{
const int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
print_matrix(2, matrix);
return 0;
}
这里的形参类型相当于指向“含三个 int 的数组”的指针,不能把它随意替换为 int **。int ** 通常表示指向 int * 的指针,内存布局与连续的二维数组不同。
三、字符串:以空字符结尾的字符数组
C 字符串不是独立的内置类型,而是以 '\0' 结尾的字符序列。存储可见长度为 n 的字符串,至少需要 n + 1 个字符空间来容纳结尾空字符。
#include <stdio.h>
#include <string.h>
int main(void)
{
char name[16] = "Ada";
printf("内容:%s\n", name);
printf("字符长度:%zu\n", strlen(name));
printf("数组容量:%zu\n", sizeof name);
return 0;
}
strlen 返回空字符之前的字符数,不包含结尾的 '\0'。它会持续读取直到找到空字符,因此传入未正确终止的字符数组可能造成越界读取。
下面的输入代码限制读取的字符数,给结尾空字符留出空间:
#include <stdio.h>
#include <string.h>
int main(void)
{
char name[16];
printf("请输入姓名:");
if (fgets(name, sizeof name, stdin) == NULL) {
return 1;
}
size_t len = strlen(name);
if (len > 0 && name[len - 1] == '\n') {
name[len - 1] = '\0';
}
printf("你好,%s\n", name);
return 0;
}
fgets 最多读取 sizeof name - 1 个字符,并在成功读取后补上空字符。若输入行长于缓冲区,换行符可能没有被读入,剩余内容仍留在输入流中;需要完整读取长输入的程序应进一步处理剩余字符,或使用支持动态扩展的输入策略。不要把 gets 用于输入,它已从标准 C 中移除,原因之一就是无法限制写入长度。
格式化输出也要匹配类型。size_t 应使用 %zu 输出;用不匹配的格式说明符可能产生未定义行为。
四、结构体:把相关数据组织在一起
结构体适合表示具有多个字段的记录,例如坐标、日期或用户资料。不同字段可以有不同类型:
#include <stdio.h>
struct Point {
double x;
double y;
};
int main(void)
{
struct Point p = {2.5, 4.0};
printf("(%.1f, %.1f)\n", p.x, p.y);
return 0;
}
用点号访问结构体变量的成员。若变量是指向结构体的指针,则可用箭头运算符访问成员:
struct Point p = {2.5, 4.0};
struct Point *ptr = &p;
ptr->x = 3.0;
ptr->x 等价于 (*ptr).x。括号不可省略:*ptr.x 会按不同的运算优先级解释,并不是同一个表达式。
结构体数组与函数参数
当需要保存多个同类记录时,可以使用结构体数组。把数组传给函数后,同样需要显式传递元素数量:
#include <stddef.h>
#include <stdio.h>
struct Student {
int id;
double score;
};
void print_students(const struct Student *students, size_t count)
{
if (students == NULL && count != 0) {
return;
}
for (size_t i = 0; i < count; ++i) {
printf("学号:%d,成绩:%.1f\n",
students[i].id, students[i].score);
}
}
int main(void)
{
const struct Student students[] = {
{1001, 87.5},
{1002, 92.0}
};
size_t count = sizeof students / sizeof students[0];
print_students(students, count);
return 0;
}
参数中的 const 表示该函数不会通过这个指针修改数组元素。它不表示对象在程序的任何地方都绝对不可修改,而是限制这次访问路径。调用者仍须保证传入的指针与数量匹配。
结构体成员在内存中可能包含填充字节,以满足对齐要求。因此,sizeof(struct Student) 不一定等于所有成员大小的简单相加。这也是不能轻率地把结构体内存直接当作跨平台文件格式或网络数据格式的原因。
五、动态内存与指针所有权
数组长度在编译时确定时,局部数组通常更简单。需要在运行时决定容量,或需要让对象的生命周期超出当前函数时,可以考虑动态分配。动态内存必须明确由谁负责释放。
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
size_t n = 5;
if (n > SIZE_MAX / sizeof(int)) {
fprintf(stderr, "申请长度过大\n");
return 1;
}
int *values = malloc(n * sizeof *values);
if (values == NULL) {
fprintf(stderr, "内存分配失败\n");
return 1;
}
for (size_t i = 0; i < n; ++i) {
values[i] = (int)(i * 10);
}
for (size_t i = 0; i < n; ++i) {
printf("%d\n", values[i]);
}
free(values);
values = NULL;
return 0;
}
malloc 接收以字节为单位的大小,成功时返回一块未初始化内存的起始地址,失败时返回空指针。示例使用 sizeof *values,这样即使指针目标类型调整,也不必重复写类型名。乘法前的边界检查用于避免 n * sizeof(int) 发生无符号整数溢出;若长度来自外部输入,还必须先验证它符合程序业务规则和可接受的资源限制。
每次成功分配的内存都应在适当时机释放。释放后,原指针不再可用于访问该内存。把它设为 NULL 可以减少本变量被意外重复使用的风险,但不会清除其他指向同一内存的别名。
free 只能用于尚未释放、且由兼容的动态分配函数返回的地址;不能释放局部数组、全局变量或字符串字面量。对同一块内存重复释放,或释放非法地址,都属于未定义行为。
六、常见错误与边界检查
1. 未初始化指针
局部指针若未初始化,其值不确定。不能假定它自然为空或指向可用内存。声明时应初始化为有效对象地址或 NULL,随后再按实际状态使用。
2. 下标越界或解引用尾后指针
数组下标和指针运算都不会自动做边界检查。处理外部长度时,应先验证长度,再访问对应元素。对于长度为零的数组视图,不要解引用其指针;即使循环次数为零,也应避免在循环外提前读取元素。
3. 函数返回局部变量地址
普通局部变量的生命周期在函数返回时结束。返回它的地址会留下悬空指针。需要把结果带出函数时,可以由调用者提供输出对象、返回结构体值,或按明确的所有权约定动态分配内存。
4. 误以为指针自带长度
指针本身不记录数组长度。函数接口常用“指针加元素数”或“指针加容量”表达可访问范围。接口设计要说明长度单位、是否允许空指针、函数是否修改内容,以及由谁负责内存释放。
5. 用字符串函数写入容量不足的缓冲区
复制或拼接字符串前必须确认目标容量足够,包括结尾空字符。很多库函数的行为取决于输入是否以空字符结尾,且“看起来有长度限制”不代表在所有情况下都能保证目标安全。应优先设计接口,使缓冲区容量明确,并对输入长度做检查。
6. 将指针转换成整数或随意转换类型
不同指针类型有不同的访问要求。随意强制转换可能造成错误的对齐或类型访问问题;把指针转成普通整数保存也不具备通用可移植性。只有在接口和平台明确规定时,才应进行这类转换。
七、如何选择:数组、指针还是结构体
- 数据项数量固定且类型相同:优先考虑数组,例如固定大小的坐标序列或缓冲区。
- 需要让函数访问或修改调用者的数据:通过指针传入,并同时约定长度、可写性和生命周期。
- 需要把不同类型但语义相关的字段组合起来:使用结构体,例如把编号和成绩组成一条记录。
- 数据数量运行时才确定:评估动态分配是否必要,并规定分配与释放责任;若使用动态数组,容量和元素数量要区分清楚。
指针是表达地址和访问关系的工具,不是自动安全的内存管理机制。数组提供连续元素布局,但 C 不替程序检查边界;结构体提供字段组织方式,但不自动定义文件或网络格式。把长度、有效期、可写性和所有权写进接口约定,再配合编译器警告、边界检查和动态检测工具,才能让这些基础特性在实际程序中可靠地协同工作。







