C 教程:指针是什么,怎么写才不容易出错

站长资讯23分钟前发布 jdkjadmin
0 00
C 教程:指针是什么,怎么写才不容易出错

C 教程:指针是什么,怎么写才不容易出错

指针是 C 语言里很实用、也很容易误用的工具。它可以让函数访问调用者的数据,可以遍历数组,也可以管理运行时申请的内存。问题在于,C 通常不会替你检查指针是否有效、是否越界,以及它指向的对象是否还存在。写指针代码时,理解“它指向什么、能访问多大范围、有效到什么时候”,比背语法更重要。

本文从地址和解引用讲起,逐步介绍数组、函数参数、动态内存与指针运算,并给出可以直接编译运行的示例。示例以常见的 C11 编译器为目标;命令中的 cc 可以替换成你使用的编译器。

1. 指针是什么

程序运行时,变量通常占据一段内存。指针对象保存一个地址,这个地址可以指向某种类型的对象。例如,int * 是“指向 int 的指针类型”,而不是一个整数变量本身。

#include <stdio.h>

int main(void)
{
    int score = 90;
    int *p = &score;

    printf("score = %d\n", score);
    printf("*p = %d\n", *p);

    *p = 95;
    printf("score after update = %d\n", score);

    return 0;
}

这里的 &score 是取地址运算,得到 score 的地址;p 保存这个地址;*p 是解引用,表示访问 p 指向的对象。因此,通过 *p 修改数据,也就修改了 score。

声明中的星号属于声明符的一部分。下面这种写法容易让人误以为两个变量都是指针:

int* a, b;

实际上,a 是 int 指针,b 是普通 int。为了减少误读,很多人选择每个声明单独写一行:

int *a;
int b;

2. 初始化和空指针

指针在使用前应当有明确的初始值。若暂时没有可指向的对象,可以初始化为空指针:

int *p = NULL;

NULL 是空指针常量,表示它不指向可供程序访问的对象。解引用空指针是未定义行为,可能崩溃,也可能表现出其他不可预测的结果。检查指针是否为空,并不能证明它非空时一定有效;它也可能已经悬垂、越界,或不再指向存活对象。

if (p != NULL) {
    /* 这里只能说明 p 不是空指针,
       不能单凭这个检查证明 *p 一定可以访问。 */
}

不要把整数 0、空指针和“地址为零的对象”混为一谈。C 语言保证空指针比较结果不等于指向任何对象或函数的有效指针;空指针的具体机器表示由实现决定,不要求一定是所有位都为零。使用 NULL 或适当的指针初始化表达式即可,不要自行构造所谓的空地址表示。

3. 指针作为函数参数:让函数访问调用者的数据

C 的函数参数按值传递。把一个普通变量传给函数,函数得到的是它的副本,直接修改副本不会改变调用者的变量。如果传入变量的地址,函数仍然是按值接收参数,只不过这份参数值是一个指针;通过指针访问对象,才可以修改调用者的数据。

#include <stdio.h>

static void set_to_zero(int *value)
{
    if (value == NULL) {
        return;
    }

    *value = 0;
}

int main(void)
{
    int count = 12;

    set_to_zero(&count);
    printf("%d\n", count);

    return 0;
}

这段程序输出 0。空指针检查是对接口的一种防御,但它不解决所有有效性问题:调用者如果传入悬垂指针,函数依然无法安全访问它。因此,调用双方必须约定指针所指对象在调用期间存活,并且至少有足够的可访问空间。

如果函数需要处理多个元素,仅有指针还不够,通常还要传入元素个数:

#include <stddef.h>

static long sum_ints(const int *values, size_t count)
{
    long sum = 0;

    if (count == 0) {
        return 0;
    }
    if (values == NULL) {
        return 0; /* 本示例将非零长度配空指针视为无效输入 */
    }

    for (size_t i = 0; i < count; ++i) {
        sum += values[i];
    }

    return sum;
}

const int *values 表示函数不通过这个指针修改元素;它并不保证对象在程序其他位置绝不会被修改。示例采用了一个明确的接口约定:长度为零时不访问指针;长度非零时要求指针指向至少 count 个可读的 int。C 不会自动验证这个条件,调用者必须保证它成立。实际项目中,遇到无效参数时也可以选择返回错误码,而不是像示例一样返回零;要根据接口设计区分“有效的零结果”和“参数错误”。

4. 数组与指针有关,但不是同一种东西

数组是一组连续排列的元素;指针是保存地址的对象。两者类型和行为不同,不过数组表达式在许多上下文中会转换为指向首元素的指针,这就是常说的“数组退化”。例如,函数参数写成 int values[] 时,实际调整为指针参数,函数无法仅靠这个参数得知原数组长度。

#include <stdio.h>

static void print_values(const int values[], size_t count)
{
    for (size_t i = 0; i < count; ++i) {
        printf("%d\n", values[i]);
    }
}

int main(void)
{
    int numbers[] = { 4, 8, 15, 16, 23, 42 };
    size_t count = sizeof numbers / sizeof numbers[0];

    print_values(numbers, count);
    return 0;
}

在 main 中,sizeof numbers 得到整个数组占用的字节数,除以单个元素的字节数可得到元素个数。这个技巧只适用于仍然是数组的表达式;在函数参数中,values 已经是指针,sizeof values 得到的是指针对象的大小,而不是数组长度。

指针和数组还存在另一个重要差别:数组名不能像普通指针变量那样重新赋值,而指针变量可以改为指向别处。不要假设指针的字节数与 int、long 或数组长度有固定关系。对象指针大小受平台与实现影响,使用 sizeof 取得当前实现中的实际大小。

5. 指针运算的范围

对指向数组元素的指针加减整数,运算单位是元素,而不是字节。若 p 指向一个 int,p + 1 指向下一个 int 元素的位置。

#include <stdio.h>

int main(void)
{
    int values[] = { 10, 20, 30 };
    int *p = values;

    printf("%d\n", *p);       /* 10 */
    printf("%d\n", *(p + 1)); /* 20 */

    return 0;
}

指针运算有严格边界:指针加减后,结果必须仍指向同一个数组对象中的元素,或恰好指向该数组最后一个元素之后的位置。这个“末尾之后”的指针可以用于比较、循环终止等用途,但不能解引用。不能再往后跨出这一格,也不能对它读写。

int values[] = { 10, 20, 30 };
int *begin = values;
int *end = values + 3; /* 指向数组末尾之后,不可解引用 */

for (int *p = begin; p != end; ++p) {
    /* 此处 p 指向数组中的有效元素 */
}

对不属于同一个数组对象(或其末尾之后位置)的指针做相减,行为未定义;比较不同对象的指针也有标准规定的限制,不应把指针大小或地址排序当作通用的对象顺序。需要计算元素个数时,可以对同一数组中的两个适当指针相减,结果类型是 ptrdiff_t,定义在 <stddef.h> 中。

越界访问并不总会立即报错。它可能覆盖其他数据,也可能暂时看似正常,最终导致难以定位的问题。循环条件应与实际元素个数对应,切勿把“程序没崩溃”当作没有越界的证据。

6. 字符串和字符指针

C 风格字符串是以空字符 '\0' 结尾的字符序列。字符串字面量具有静态存储期,但尝试修改字符串字面量的内容会产生未定义行为;应通过指向 const char 的指针访问它。

#include <stdio.h>

int main(void)
{
    const char *message = "hello";
    printf("%s\n", message);

    return 0;
}

如果要修改字符,应使用可写数组,并确保数组空间包含末尾的空字符:

#include <stdio.h>

int main(void)
{
    char word[] = "cat";
    word[0] = 'b';

    printf("%s\n", word);
    return 0;
}

这里数组至少需要四个字符位置:三个可见字符加结尾的 '\0'。直接把未经终止的字符缓冲区传给 printf("%s", ...) 等字符串函数,可能导致函数越过缓冲区继续读取。使用字符串函数时,要确认输入已正确终止,并在目标容量有限时选择能明确控制长度的接口,同时处理截断和错误情况。

7. 动态内存:指针负责访问,调用者负责生命周期

当对象大小在编译时不确定,或需要让对象超出创建它的函数继续存活时,可以使用动态内存分配。malloc 分配指定字节数,成功时返回指向该空间起始位置的指针,失败时返回空指针;返回空间未初始化。free 释放通过内存分配函数获得、且尚未释放的空间。

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    size_t count = 5;
    int *values = malloc(count * sizeof *values);

    if (values == NULL) {
        fprintf(stderr, "内存分配失败\n");
        return 1;
    }

    for (size_t i = 0; i < count; ++i) {
        values[i] = (int)(i * 10);
    }

    for (size_t i = 0; i < count; ++i) {
        printf("%d\n", values[i]);
    }

    free(values);
    values = NULL;

    return 0;
}

sizeof *values 以所指元素类型计算需要的字节数。C 中不需要把 malloc 的返回值强制转换成 int *;添加转换既不提升安全性,也可能掩盖未正确包含 <stdlib.h> 等问题。示例在分配成功后初始化每个元素,因为 malloc 不会把内容清零。

如果计算 count * sizeof *values 时发生整数溢出,传给分配函数的字节数可能小于预期,从而造成后续越界。对于来自输入或其他不可信来源的长度,应在乘法前检查范围:

#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>

size_t count = /* 已验证的元素数量 */;
if (count > SIZE_MAX / sizeof(int)) {
    /* 数量过大,不能安全计算所需字节数 */
} else {
    int *values = malloc(count * sizeof *values);
    /* 检查 values,并在适当时释放 */
}

这里的代码片段需要在实际程序中为“数量过大”和分配失败定义恰当处理方式。是否限制最大元素数,还取决于程序资源预算和接口要求。仅仅检查乘法不溢出,并不代表分配一定成功或输入长度符合业务限制。

若使用 realloc 调整大小,应先把返回值存入临时指针。失败时,原有分配仍然有效;直接覆盖原指针会丢失它,使内存无法再释放。

int *tmp = realloc(values, new_count * sizeof *values);
if (tmp == NULL) {
    /* values 仍然指向原分配,可按程序策略继续或释放 */
} else {
    values = tmp;
    /* 若变大,新增加的部分尚未初始化 */
}

这段写法仍要求事先检查 new_count * sizeof *values 不溢出。若 new_count 为零,realloc 的行为在不同版本的 C 标准和实现间存在需要特别处理的差异;为了代码可移植且容易审查,建议对零长度单独设计逻辑,不把它当作普通扩容操作。

8. 生命周期、悬垂指针与重复释放

指针只有在所指对象仍然存活时才能用于访问。返回局部变量地址是典型错误:函数返回后,局部对象的生命周期结束,地址不能再用于读取或写入。

int *bad_function(void)
{
    int local = 7;
    return &local; /* 错误:local 离开作用域后不再存活 */
}

编译器可能对此给出警告,应当认真处理。可行的设计包括由调用者提供存储空间、返回一个值,或在需要动态生命周期时由明确的一方分配并负责释放。不能通过改变变量类型或强制转换,延长一个局部变量的生命周期。

动态内存释放后,所有指向该内存的指针都不能再解引用。把负责释放的那个指针设为 NULL,可以避免通过它再次误用,但不会自动清除程序其他位置保存的副本。

free(values);
values = NULL;

/* 如果另一个指针 alias 也曾指向同一块内存,
   把 values 设为 NULL 不会让 alias 自动失效。 */

重复释放同一块内存、释放非分配函数返回的地址、释放已经失效的地址,都是未定义行为。一个清晰的所有权约定很重要:哪些函数负责分配,哪些函数负责释放,返回的指针由谁拥有,指针参数只是借用还是所有权转移,都应在接口中说清楚。

9. const 与指针声明

const 放在不同位置,约束的对象不同。下面两种声明含义不一样:

const int *p;       /* 不能通过 p 修改所指 int;p 可改指向 */
int *const q = NULL; /* q 本身不能改指向;所指 int 可通过 q 修改 */

第一种常用于只读取数组或对象的函数参数;第二种是不可重新赋值的指针变量,实际声明时必须在合适的位置初始化。还可以同时约束指针和所指对象:

const int *const fixed = /* 一个有效的 int 对象地址 */;

const 表达的是通过该访问路径能否修改对象,不是内存保护机制,也不表示数据不可被程序其他路径修改。不要随意去掉 const 后写入对象;如果原对象本身定义为常量,修改它会产生未定义行为。

10. void 指针与函数指针

void * 是通用对象指针,可以保存指向对象的地址,并可在 C 中与对象指针之间进行适当转换。它不携带所指对象的类型信息,不能直接解引用;访问前需要先转换为正确的对象指针类型。它不能被视为通用函数指针,ISO C 不保证对象指针和函数指针可以互相转换或用同一种方式存储。

例如,通用排序接口常使用 void * 传递元素地址,再通过比较函数按实际元素类型处理。这样的接口依赖调用者遵守类型和长度约定,类型信息不会由 void * 自动保留。

函数指针则用于保存函数的地址,常见用途是回调和操作表。它的声明语法与对象指针不同,调用时必须符合函数类型。需要函数回调时,应使用相应的函数指针类型,不要把函数地址塞进 void * 再假设它可移植。

11. 常见错误及排查方法

  • 未初始化就解引用:局部指针变量如果没有初始化,其值不确定。声明时就设为有效地址或 NULL,并在使用前建立清晰的赋值路径。
  • 空指针解引用:可能由分配失败、查找失败或接口返回空值导致。检查返回值,并明确空值代表的含义。
  • 数组越界:指针运算不会自动做边界检查。将指针和长度一同传递,循环条件严格使用长度。
  • 访问已结束生命周期的对象:不要返回局部变量地址;不要在释放动态内存后继续使用旧地址。
  • 重复释放或错误释放:只释放尚未释放、且确由兼容分配函数获得的地址。通过所有权约定避免多个位置都认为自己负责释放。
  • 把地址当普通整数:指针与整数不是可任意互换的类型。地址转换受实现和标准规则限制,不要为了“方便”把地址存进整数变量。
  • 误以为指针能记住数组长度:指针只提供地址信息,不附带可访问元素数量。长度需要通过其他参数或数据结构传递。
  • 字符串缺少结尾空字符:可能导致读取超出缓冲区。分配或声明空间时为 '\0' 留出位置,并确认字符串操作不超过容量。

编译阶段就可以发现一部分问题。开发时尽量启用警告,例如:

cc -std=c11 -Wall -Wextra -Wpedantic -g example.c -o example

部分编译器和平台还支持地址检查器等运行时工具,例如 Clang 或 GCC 的 AddressSanitizer。可在支持的环境中尝试:

cc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined example.c -o example
./example

检查器能帮助发现某些越界、释放后使用等问题,但不能证明程序没有错误,也不能替代对标准规则、输入范围和对象生命周期的审查。某些错误在特定运行中没有被工具发现,并不代表行为已变得安全。

12. 什么时候适合使用指针

指针尤其适用于以下场景:函数需要修改调用者的对象;函数需要接收数组或缓冲区及其长度;数据量运行时才确定,需要动态内存;数据结构需要通过链接关系组织节点;需要实现回调或与底层接口交互。

如果只需要传递一个小型整数或结构体的只读副本,直接按值传递往往更简单。不要为了“显得底层”而把所有参数都改成指针。使用指针会引入有效性、别名、生命周期和所有权问题,只有当接口确实需要这些能力时才值得承担。

写指针代码时,可以在每个关键位置问四个问题:

  1. 这个指针当前指向什么对象?
  2. 从这里开始允许访问多少个元素或多少字节?
  3. 所指对象的生命周期何时结束?
  4. 谁负责分配、修改和释放这份资源?

这些问题都有明确答案,指针通常就容易控制。反过来,如果代码无法说明一个指针为什么有效、长度从哪里来、何时失效,就应先补全接口约定,再继续编写读写操作。指针本身并不危险;缺少边界和生命周期约束,才是大多数指针错误的来源。

© 版权声明

相关文章