C程序培训中的指针学习:理解概念与动手实践

站长资讯1小时前发布 jdkjadmin
0 00
C程序培训中的指针学习:理解概念与动手实践

C程序培训中的指针学习:理解概念与动手实践

指针是 C 语言中最重要、也最容易让初学者困惑的概念之一。它不仅用于访问内存,还关系到数组处理、函数间共享数据、动态分配内存以及许多系统接口。学会指针,不是背下“指针就是地址”这句话就够了;还要弄清地址指向什么对象、对象在什么时候有效、可以访问多大范围,以及谁负责释放资源。

本文从实际代码入手,逐步介绍指针的基本操作、常见用途和边界规则。示例均可保存为 C 源文件编译运行。建议使用支持 C11 的编译器,例如 GCC 或 Clang,并开启警告:

gcc -std=c11 -Wall -Wextra -Wpedantic -g pointer_demo.c -o pointer_demo

调试阶段还可以使用地址和未定义行为检测工具。支持相关选项的 GCC 或 Clang 可这样编译:

gcc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined pointer_demo.c -o pointer_demo

检测工具能帮助发现部分越界、释放后使用等问题,但不能证明程序没有错误;编译器、平台和运行环境的支持也可能不同。

一、从变量地址理解指针

普通变量保存一个值;指针变量保存一个地址。使用取地址运算符 & 可以取得对象的地址,使用解引用运算符 * 可以通过指针访问它所指向的对象。

#include <stdio.h>

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

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

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

    return 0;
}

声明中的 int *p 表示 p 是“指向 int 的指针”。p = &score 让它指向变量 score;*p 则访问 score 本身。通过 *p = 92 修改的不是指针地址,而是指向的整数对象。

星号在声明和表达式中的含义需要区分:int *p 是声明指针;在表达式 *p 中,星号表示解引用。声明多个变量时也要留意,int *a, b; 中只有 a 是指针,b 是普通整数。若想避免误读,可以分开声明。

二、空指针、初始化与基本检查

一个指针若暂时没有指向可用对象,应初始化为空指针。C 中可以用 NULL 表示空指针。空指针不能解引用;解引用空指针会导致未定义行为,程序可能崩溃,也可能表现出其他不可预测的结果。

#include <stdio.h>
#include <stddef.h>

int main(void)
{
    int *p = NULL;

    if (p == NULL) {
        puts("p does not point to an object");
    }

    return 0;
}

检查指针是否为空,只能确认它不是空指针,不能证明它指向的对象仍然有效。一个非空指针也可能已经悬空、越界,或指向已经结束生命周期的对象。C 程序不会自动跟踪这些状态,必须通过设计和代码约定保证。

三、指针作为函数参数:让函数修改调用者的数据

C 的函数参数按值传递。调用函数时,形参得到实参值的副本。若需要让函数修改调用者的变量,可以传入该变量的地址,再通过指针访问对象。

#include <stdio.h>

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

    *value = 0;
}

int main(void)
{
    int count = 12;

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

    return 0;
}

这里函数接收的是指针值的副本,但这份副本仍指向调用者的 count,所以解引用后可以修改原变量。空指针检查适用于接口允许空指针的情形;若函数约定参数必须有效,调用者就应遵守契约,函数内部的检查也不能代替正确传参。

指针参数常用于一次返回多个结果。例如,函数既要计算商,也要计算余数,可以通过两个输出参数传回结果:

#include <stdio.h>
#include <stdbool.h>

bool divide_int(int a, int b, int *quotient, int *remainder)
{
    if (b == 0 || quotient == NULL || remainder == NULL) {
        return false;
    }

    *quotient = a / b;
    *remainder = a % b;
    return true;
}

int main(void)
{
    int q;
    int r;

    if (divide_int(17, 5, &q, &r)) {
        printf("quotient = %d, remainder = %d\n", q, r);
    } else {
        puts("invalid arguments");
    }

    return 0;
}

示例用返回值表示操作是否成功,用指针参数输出结果。它明确处理了除数为零和空指针。对于有符号整数,除以零是未定义行为;此外,INT_MIN / -1 在无法用 int 表示结果的平台上也可能产生未定义行为。真实程序若接受任意整数输入,应根据类型范围补充检查。

四、指针与数组:相似但不是同一种东西

数组是一段连续存储的元素;指针是保存地址的变量。两者相关,但不能简单说成“数组就是指针”。在大多数表达式中,数组名会转换为指向首元素的指针;但数组本身仍有固定的元素数量和存储空间。数组名也不能像普通指针变量那样重新赋值。

#include <stdio.h>

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

    printf("first = %d\n", *p);
    printf("third = %d\n", *(p + 2));

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

    return 0;
}

表达式 p + 2 并不是把地址简单加 2 个字节,而是前进两个 int 元素的位置。指针加减的单位由所指向类型决定。对数组中的指针进行运算时,只允许得到同一数组内某个元素的指针,或得到“尾后”位置;尾后位置可以用于比较和循环结束判断,但不能解引用。

下面是一个常用的数组求和函数。由于函数参数中的数组形式会调整为指针形式,函数仅凭 int values[] 不知道数组有多长,所以长度必须另外传入。

#include <stdio.h>
#include <stddef.h>

long sum_values(const int values[], size_t length)
{
    long sum = 0;

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

    return sum;
}

int main(void)
{
    int data[] = { 3, 5, 8, 13 };
    size_t length = sizeof data / sizeof data[0];

    printf("sum = %ld\n", sum_values(data, length));
    return 0;
}

函数参数里的 const int values[] 等价于指向常量整数的指针参数:函数不应通过这个参数修改元素。这里的 const 修饰所指向的元素,不表示指针本身不可改写。另一个容易忽略的边界是:在函数内部,sizeof values 得到的是指针大小,而不是调用者数组的总大小。因此数组长度通常要作为单独参数传入。

五、字符串与字符指针

C 字符串以空字符 '\0' 结束。字符串字面量可用于初始化指向字符的指针,但程序不应尝试修改字符串字面量;修改它的行为未定义。若需要修改内容,应使用字符数组,并确保数组容量足够。

#include <stdio.h>

int main(void)
{
    const char *message = "hello";
    char editable[] = "hello";

    printf("%s\n", message);

    editable[0] = 'H';
    printf("%s\n", editable);

    return 0;
}

const char *message 表示不能通过 message 修改它指向的字符。将字符串字面量赋给 char * 在 C 中允许通过类型转换表现出来,但之后若通过该指针写入,行为未定义;用 const char * 表达只读意图更清楚。字符数组 editable 则包含可修改的副本。

处理不可信输入时,不要把“有一个字符指针”误当成“知道缓冲区容量”。例如,使用 fgets 时应传入实际数组大小,并在后续处理时考虑输入是否被截断、是否包含换行符,以及字符串是否以空字符结束。

#include <stdio.h>
#include <string.h>

int main(void)
{
    char buffer[32];

    if (fgets(buffer, sizeof buffer, stdin) == NULL) {
        return 1;
    }

    buffer[strcspn(buffer, "\n")] = '\0';
    printf("input: %s\n", buffer);

    return 0;
}

fgets 最多读取参数指定的容量减一的字符,并在成功读取后补上终止空字符。上例去掉可能存在的换行符。若输入超过缓冲区,剩余内容仍可能留在输入流中,应用程序应按需要决定如何处理。

六、动态内存:申请、使用和释放成对管理

局部变量和数组通常在其所在作用域内具有自动存储期;当动态大小或对象需要跨越当前作用域使用时,可以考虑使用 malloc 等分配函数。成功分配的内存需要由程序负责释放,通常用 free。使用动态内存时,必须明确记录容量、所有权和释放时机。

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

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

    if (items == NULL) {
        fputs("allocation failed\n", stderr);
        return 1;
    }

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

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

    free(items);
    items = NULL;

    return 0;
}

malloc 返回未初始化的存储空间,成功时得到指向所分配区域起始位置的指针,失败时返回空指针。用 sizeof *items 表达元素大小,可以减少类型改变后遗漏修改的风险。示例中的长度很小,但通用程序还必须防止 count * sizeof *items 发生 size_t 乘法溢出;溢出可能导致申请空间比预期少,之后写入越界。可以先验证 count <= SIZE_MAX / sizeof *items,再计算字节数。

每块动态内存应恰好释放一次。重复释放同一地址、释放非动态分配得到的指针,或释放后继续访问,都会导致未定义行为。把指针设为 NULL 可减少同一变量再次误用的机会,但它不会修复其他副本:如果还有别的指针保存着旧地址,那些副本仍然是悬空指针。

七、理解指针类型和 const 的位置

指针类型决定了通过它访问对象时使用的类型,也决定了指针运算的步长。int * 指向整数,double * 指向双精度浮点数。不要为了绕过类型检查,随意把一种对象的地址转换成另一种指针类型后解引用;这可能违反对齐要求或 C 的有效类型规则。

void * 是通用对象指针,可以保存对象指针的值,并可在 C 中与其他对象指针相互转换。但它没有具体指向类型,不能直接解引用,也不能对其做标准 C 的指针算术。需要知道对象类型和有效范围之后,才能转换成恰当类型使用。函数指针与对象指针不是同一类指针,不能假设它们可以互换。

const 放置位置会改变含义:

const int *p;        /* 不能通过 p 修改所指向的 int */
int *const q = NULL; /* q 本身不可改指向,所指 int 可修改 */
const int *const r = NULL; /* 指针和所指对象都不能经由 r 修改 */

第一种情况下,指针本身可以指向别的整数;第二种情况下,指针初始化后不能改指向。实际声明是否合理,还要考虑初始化值和对象生命周期。const 是类型约束,不等于内存保护机制,也不保证对象在程序其他路径中不会改变。

八、数组与指针参数的边界如何表达

C 的普通指针本身不携带对象长度。函数拿到一个 int *,不能仅靠这个值判断它后面有多少个有效元素。因此,接口应同时传递长度、容量或其他足以确定范围的信息,并明确哪些参数允许为空、哪些参数必须有效。

有些 C 函数参数声明可以使用 static 表达调用约定,例如:

void process(size_t n, int values[static n]);

在这种参数声明中,调用者需要保证参数提供的数组访问范围至少包含 n 个元素;它不会把数组自动变成带长度的对象,也不会在运行时替函数验证边界。不要把这种写法误解为自动安全检查。若接口的空指针、零长度和有效元素数量规则不清楚,应在函数文档和调用处明确说明,并在合适的位置实施检查。

九、常见错误及其后果

1. 未初始化指针就解引用

未初始化的自动指针包含不确定值。它不一定为空,也不一定指向可访问内存。应在声明时初始化,或在保证先赋有效地址后再使用。

2. 访问数组边界之外

长度为 n 的数组,有效下标是 0 到 n - 1。尾后指针允许用于比较和循环终止,但不能读取或写入。越界读写会造成未定义行为,影响可能延迟出现,不一定立即崩溃。

3. 返回局部变量的地址

函数返回后,普通局部变量的生命周期结束。返回它的地址会得到悬空指针。需要返回数据时,可由调用者提供输出缓冲区、使用调用者管理的动态内存,或返回适合按值传递的结构体。

4. 释放后继续使用

free(p) 之后,不能再通过 p 访问原对象。即使地址看起来没有变化,内存也可能已经被重新分配给其他用途。

5. 只检查非空,不检查容量和生命周期

非空检查不能确认指针所指对象存在、足够大或仍然有效。必须结合接口约定维护这些条件。

6. 混淆指针与指针所指的值

p = &x 改变指针保存的地址;*p = x 修改该地址所指对象的内容。看懂赋值号两侧的类型,能避免许多初级错误。

十、实践顺序与安全习惯

学习时可以按以下顺序练习:先声明指针并指向有效变量;再通过函数参数修改变量;接着遍历数组并传递长度;最后学习动态内存管理。每个阶段都应亲自修改程序,观察编译器警告和运行结果,而不是只看代码。

  • 给指针明确的类型,并在暂时无目标时初始化为 NULL。
  • 访问数组时同时维护元素数量,使用小于长度的循环条件。
  • 把对象的生命周期、指针的所有权和释放责任写进接口约定。
  • 对输入容量、整数运算溢出和失败路径做检查;检查应针对具体风险,而不是只做表面上的非空判断。
  • 需要只读访问时用 const 表达意图,但不要把它当成越界或内存安全机制。
  • 启用编译器警告,配合调试器和运行时检测工具;它们是辅助,不替代正确的边界设计。

指针提供了对内存的直接访问能力,也把许多责任交给了程序员。真正理解指针,关键不在于记住星号和取地址符号,而在于每次使用时都能回答几个问题:这个地址指向什么类型的对象?对象是否仍在生命周期内?允许访问的范围有多大?这段内存由谁负责释放?当这些问题都能在代码和接口中得到明确答案,指针就不再只是难以捉摸的地址,而是可以有边界地使用的编程工具。

© 版权声明

相关文章