深入理解指针(二):const、野指针、assert与传址调用
指针是C语言的灵魂,也是无数初学者的噩梦。上一篇我们聊了指针的基础概念,这一篇我们深入四个关键话题:const修饰指针、野指针、assert断言,以及指针的使用和传址调用。掌握这些,你的指针功力至少提升一个档次。

目录
- 一、const修饰指针
- [1. const 在 * 左边](#1. const 在 * 左边)
- [2. const 在 * 右边](#2. const 在 * 右边)
- [3. 两边都有 const](#3. 两边都有 const)
- [4. 为什么要用 const 修饰指针?](#4. 为什么要用 const 修饰指针?)
- 二、野指针
- [2.1 野指针的常见成因](#2.1 野指针的常见成因)
- [2.2 如何规避野指针?](#2.2 如何规避野指针?)
- 三、assert断言
- [1. assert 是什么?](#1. assert 是什么?)
- [2. 为什么需要 assert?](#2. 为什么需要 assert?)
- [3. 在指针中的典型应用](#3. 在指针中的典型应用)
- [4. assert 的使用原则](#4. assert 的使用原则)
- [5. 补充:C11 的静态断言](#5. 补充:C11 的静态断言)
- 四、指针的使用和传址调用
- [4.1 经典例子:交换两个数](#4.1 经典例子:交换两个数)
- [4.2 通过指针返回多个值](#4.2 通过指针返回多个值)
- [4.3 数组传参的本质](#4.3 数组传参的本质)
- 总结
一、const修饰指针
const 是C语言中的常量修饰符,当它和指针结合时,位置不同,含义天差地别。很多人被 const int *p、int * const p、const int * const p 绕晕,其实记住一句话就行:
左定值,右定向。
const 在 * 左边,修饰的是指针指向的内容,内容不可改;
const 在 * 右边,修饰的是指针本身,指针指向不可改。
1. const 在 * 左边
c
#include <stdio.h>
int main(void)
{
int a = 10;
int b = 20;
const int *p = &a; /* 等价于 int const *p */
// *p = 30; /* 错误:不能通过 p 修改 a 的值 */
printf("初始: *p = %d\n", *p);
p = &b; /* 正确:p 本身可以指向别处 */
printf("改变指向后: *p = %d\n", *p);
return 0;
}

此时 p 指向的内容是只读的,但 p 自己可以改变指向。
2. const 在 * 右边
c
#include <stdio.h>
int main(void)
{
int a = 10;
int b = 20;
int * const p = &a;
*p = 30; /* 正确:可以修改 a 的值 */
// p = &b; /* 错误:p 本身是常量,不能改变指向 */
printf("a = %d, *p = %d\n", a, *p);
return 0;
}
此时 p 是一个常量指针,必须在定义时初始化,之后不能指向别处,但可以通过它修改所指内容。
3. 两边都有 const
c
#include <stdio.h>
int main(void)
{
int a = 10;
const int * const p = &a;
// *p = 20; /* 错误:内容不可改 */
// p = &b; /* 错误:指向不可改 */
printf("*p = %d\n", *p);
return 0;
}

既不能改内容,也不能改指向,完全"锁死"。
4. 为什么要用 const 修饰指针?
· 提高代码可读性:看到 const int *p,调用者就知道函数不会修改 p 指向的数据。
· 安全性:防止无意中修改不该修改的数据。
· 接口约定:在函数参数中常用,比如 void print(const char *str),表明函数只读字符串。
小技巧:const 离变量名近,修饰的就是变量名;离类型近,修饰的就是类型。多写多练,自然就记住了。
二、野指针
野指针(Wild Pointer)是指向"不可知"内存位置的指针。它不像空指针那样有明确的值(NULL),而是随机指向某块内存,解引用它会导致未定义行为------可能崩溃,可能篡改关键数据,甚至看起来"正常运行"但埋下隐患。
2.1野指针的常见成因
- 指针未初始化
c
int *p; // p是野指针,指向随机地址
*p = 10; // 危险!可能写入非法内存
- 指针越界访问
c
int arr[5] = {0};
int *p = arr;
for (int i = 0; i <= 5; i++) {
*(p + i) = i; // 当i=5时越界,p+5已超出数组范围
}
- 指针指向的空间已释放
c
int *p = (int *)malloc(sizeof(int));
free(p);
// p现在成了野指针,再解引用就是灾难
*p = 20; // 错误
- 返回局部变量的地址
c
int *func() {
int a = 10;
return &a; // a是局部变量,函数结束后栈帧销毁,返回的地址无效
}
2.2如何规避野指针?
(1)初始化时置 NULL
指针在定义时就赋初值,不知道指向哪里就先置 NULL,用之前再赋值。
c
#include <stdio.h>
int main(void)
{
int a = 100;
int *p = NULL; /* 明确初始化为空指针 */
if (p != NULL) {
*p = 10; /* 不会执行 */
}
printf("初始 p = %p (NULL)\n", (void *)p);
p = &a; /* 真正需要时再指向有效内存 */
if (p != NULL) {
*p = 10;
printf("赋值后 *p = %d, a = %d\n", *p, a);
}
return 0;
}

(2)使用前检查有效性
尤其是作为函数参数传入的指针,一定要先判断:
c
#include <stdio.h>
void print_array(int *arr, int len)
{
if (arr == NULL || len <= 0) {
printf("[print_array] 无效输入,直接返回\n");
return; /* 无效输入直接返回,避免解引用 */
}
printf("[print_array] ");
for (int i = 0; i < len; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main(void)
{
int a[3] = {1, 2, 3};
print_array(a, 3); /* 正常 */
print_array(NULL, 3); /* 不会崩溃,直接返回 */
return 0;
}

(3)小心数组越界
循环边界要严格,< 不要写成 <=:
c
#include <stdio.h>
int main(void)
{
int arr[5] = {0};
int *p = arr;
/* 错误写法:i <= 5 会访问 arr[5],越界 */
// for (int i = 0; i <= 5; i++) { *(p + i) = i; }
/* 正确写法 */
for (int i = 0; i < 5; i++) {
*(p + i) = i + 1;
}
printf("数组内容: ");
for (int i = 0; i < 5; i++) {
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}

(4)free 之后立即置 NULL
动态内存释放后,指针变量本身不会自动变成 NULL,它依然保留原来的地址,此时再解引用就是野指针:
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *p = (int *)malloc(sizeof(int) * 10);
if (p == NULL) {
printf("malloc 失败\n");
return 1;
}
*p = 100;
printf("使用中 *p = %d\n", *p);
free(p);
p = NULL; /* 关键一步:置空,避免野指针 */
if (p != NULL) {
*p = 200; /* 不会执行 */
}
printf("free 后 p = %p (已置 NULL,安全)\n", (void *)p);
return 0;
}

(5)不要返回局部变量的地址
局部变量存放在栈上,函数返回后栈帧销毁,地址不再有效。需要返回数据时,用 malloc 动态分配,或者让调用者传入缓冲区:
c
#include <stdio.h>
#include <stdlib.h>
/* 错误示例(不要打开):
int *bad_func(void) {
int a = 10;
return &a; // a 随函数结束而销毁,返回的指针是野指针
}
*/
/* 正确方式一:动态分配,由调用者负责释放 */
int *func1(void)
{
int *p = (int *)malloc(sizeof(int));
if (p != NULL) {
*p = 10;
}
return p; /* 调用者用完需要 free */
}
/* 正确方式二:传入缓冲区,由调用者提供存储空间 */
void func2(int *out)
{
if (out != NULL) {
*out = 10; /* 直接写入调用者提供的地址 */
}
}
int main(void)
{
/* 方式一 */
int *p1 = func1();
if (p1 != NULL) {
printf("方式一 malloc: *p1 = %d\n", *p1);
free(p1);
p1 = NULL; /* 释放后置空 */
}
/* 方式二 */
int val = 0;
func2(&val);
printf("方式二 传缓冲区: val = %d\n", val);
return 0;
}

(6)局部指针作用域尽量小
c
#include <stdio.h>
#include <stdlib.h>
void process(void)
{
int *p = NULL; /* 定义时置 NULL */
p = (int *)malloc(sizeof(int) * 5);
if (p == NULL) {
return; /* 分配失败处理 */
}
for (int i = 0; i < 5; i++) {
p[i] = i;
}
printf("局部指针内容: ");
for (int i = 0; i < 5; i++) {
printf("%d ", p[i]);
}
printf("\n");
free(p);
p = NULL; /* 释放后置空,作用域结束前清理干净 */
}
int main(void)
{
process();
return 0;
}

野指针就像一颗定时炸弹,养成好习惯,把它扼杀在摇篮里。
三、assert断言
1. assert 是什么?
assert 是C标准库中的宏,定义在 <assert.h> 中。它不是普通函数,而是一个"运行时断言"工具。它接收一个表达式:
· 如果表达式为真(非0),程序继续执行,什么都不做;
· 如果表达式为假(0),程序会立即终止,并向标准错误输出诊断信息,通常包括:失败的表达式、源文件名、行号。
例如:
c
#include <stdio.h>
#include <assert.h>
int divide(int a, int b)
{
assert(b != 0); /* 如果 b 为 0,程序在这里终止并报错 */
return a / b;
}
int main(void)
{
printf("10 / 2 = %d\n", divide(10, 2));
/* 取消下面注释,会看到 assert 触发终止程序 */
// printf("10 / 0 = %d\n", divide(10, 0));
return 0;
}

当 b == 0 时,你会看到类似这样的错误信息:
text
Assertion failed: b != 0, file test.c, line 5

然后程序调用 abort() 终止。这就是 assert 的核心机制:在错误发生的第一现场停下,而不是等到后面数据被破坏后再崩溃。
2. 为什么需要 assert?
在开发阶段,bug 越早暴露越好。很多指针错误、非法参数、逻辑不变量被破坏,如果不检查,程序可能继续运行,然后在一段毫不相关的代码处崩溃,调试难度极大。
assert 的作用就是:把"本不该发生"的情况变成"立刻可见的失败"。
例如,一个函数要求指针不能为空、长度必须大于0,这些属于函数的前置条件。用 assert 检查,可以快速定位调用者传错参数的问题。
3. 在指针中的典型应用
c
#include <stdio.h>
#include <assert.h>
void print_array(int *arr, int len)
{
assert(arr != NULL); /* 确保指针有效 */
assert(len > 0); /* 确保长度合理 */
printf("[print_array] ");
for (int i = 0; i < len; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main(void)
{
int a[5] = {1, 2, 3, 4, 5};
print_array(a, 5);
/* 取消下面注释会触发 assert */
// print_array(NULL, 5); /* Assertion failed: arr != NULL */
return 0;
}

这里用 assert 表达的是:调用 print_array 时,arr 不应该为 NULL,len 不应该小于等于 0。如果违反了,说明程序逻辑有问题,应该立即修复。
4. assert 的使用原则
- 只检查"绝不应该发生"的情况
比如内部不变量、函数前置条件、后置条件。不要用它处理用户输入错误、文件打开失败、网络断开、malloc 失败等可预期错误。- 不要用于错误恢复
assert 失败会直接终止程序,它不能像 if 那样让程序优雅处理并继续运行。需要恢复的错误,必须用 if 判断并处理。- 不要有副作用
像 assert(i++ > 0) 这种写法非常危险。因为在发布版本中,assert 可能被完全移除,i++ 就不会执行,导致逻辑不一致。- 发布版本可能禁用 assert
如果定义了 NDEBUG 宏(通常在 Release 版本中),assert 会被替换成空操作。因此,不能依赖 assert 来完成必须执行的逻辑。
c
#define NDEBUG
#include <assert.h>
- 一个 assert 只检查一个条件
这样失败时信息更明确,便于定位问题。- assert 不是错误处理,而是调试工具
它用于捕捉程序员自己的错误,而不是用户或外部环境造成的错误。
5. 补充:C11 的静态断言
C11 提供了 _Static_assert,用于编译期检查。例如:
c
_Static_assert(sizeof(int) == 4, "int must be 4 bytes");
如果条件不满足,编译就会失败。它适合检查类型大小、常量表达式等,和运行时的 assert 互补。
合理使用 assert,能让bug在开发阶段就暴露出来,而不是等到上线后才发现。
四、指针的使用和传址调用
C语言函数参数默认是传值调用,也就是说,形参是实参的一份临时拷贝。在函数内修改形参,不会影响外面的实参。
4.1经典例子:交换两个数
错误版(传值):
c
void swap(int x, int y) {
int temp = x;
x = y;
y = temp;
}
int main() {
int a = 10, b = 20;
swap(a, b);
printf("a=%d, b=%d\n", a, b); // 输出 a=10, b=20,交换失败
return 0;
}
正确版(传址):
c
void swap(int *px, int *py) {
int temp = *px;
*px = *py;
*py = temp;
}
int main() {
int a = 10, b = 20;
swap(&a, &b);
printf("a=%d, b=%d\n", a, b); // 输出 a=20, b=10,成功
return 0;
}
原理:传址调用时,函数接收到的是变量的地址,通过 * 解引用就能直接操作原变量。
(对比传值与传址):
c
#include <stdio.h>
/* 错误版:传值,交换失败 */
void swap_wrong(int x, int y)
{
int temp = x;
x = y;
y = temp;
}
/* 正确版:传址,交换成功 */
void swap_right(int *px, int *py)
{
int temp = *px;
*px = *py;
*py = temp;
}
int main(void)
{
int a = 10, b = 20;
swap_wrong(a, b);
printf("[传值调用] a = %d, b = %d (未交换)\n", a, b);
swap_right(&a, &b);
printf("[传址调用] a = %d, b = %d (已交换)\n", a, b);
return 0;
}

4.2通过指针返回多个值
函数只能有一个返回值,但通过指针参数,可以"返回"多个结果:
c
#include <stdio.h>
#include <assert.h>
void find_min_max(int arr[], int len, int *min, int *max)
{
assert(arr != NULL && min != NULL && max != NULL);
assert(len > 0);
*min = *max = arr[0];
for (int i = 1; i < len; i++) {
if (arr[i] < *min) *min = arr[i];
if (arr[i] > *max) *max = arr[i];
}
}
int main(void)
{
int arr[] = {3, 1, 7, 2, 9, 4};
int len = sizeof(arr) / sizeof(arr[0]);
int min = 0, max = 0;
find_min_max(arr, len, &min, &max);
printf("min = %d, max = %d\n", min, max);
return 0;
}

4.3数组传参的本质
数组名在传参时会"退化"为指向首元素的指针:
c
#include <stdio.h>
void func(int arr[])
{
/* 这里 arr 等价于 int *arr */
printf("[函数内] sizeof(arr) = %zu 字节(指针大小)\n", sizeof(arr));
printf("[函数内] sizeof(int *) = %zu 字节\n", sizeof(int *));
}
int main(void)
{
int arr[10] = {0};
printf("[main 中] sizeof(arr) = %zu 字节(真正的数组大小)\n", sizeof(arr));
func(arr);
return 0;
}

所以在函数内部无法用 sizeof 求数组长度,必须额外传入长度参数。
使用指针参数的注意事项
· 调用前确保传入的地址有效。
· 在函数内用 assert 或 if 检查指针是否为 NULL。
· 明确指针是输入、输出还是输入输出,必要时加 const 保护输入数据。
总结
核心要点:
const修饰指针 左定值,右定向;提高安全性与可读性
野指针 未初始化、越界、释放后未置NULL、返回局部变量地址;初始化置NULL,free后置NULL
assert断言 运行时检查,Debug有效,不能替代错误处理,不要有副作用
传址调用 通过指针修改外部变量,实现交换、返回多值;数组传参退化为指针
指针是一把双刃剑,用好了效率极高,用不好程序崩溃。理解 const、避开野指针、善用 assert、掌握传址调用,是写出健壮C代码的必经之路。
下一篇我们聊聊指针与数组的微妙关系。敬请期待!