如何编写指针函数:用示例理解返回指针的函数

如何编写指针函数:用示例理解返回指针的函数

如何编写指针函数:用示例理解返回指针的函数

函数除了返回整数、浮点数或结构体,也可以返回指针。返回指针的函数常用于查找数组中的元素、提供字符串或缓冲区、动态创建对象,以及让调用者访问一块由其他代码管理的数据。

不过,指针只记录一个地址,并不携带完整的对象信息:它本身不说明地址是否有效、对象有多大、由谁负责释放,也不保证对象还活着。因此,写这类函数时,关键不只是声明语法,还要明确返回地址指向什么、有效到什么时候,以及调用者能否修改或释放它。

本文以 C11 为例。示例可保存为 .c 文件,用支持 C11 的编译器编译,例如:cc -std=c11 -Wall -Wextra -pedantic example.c -o example。部分示例各自包含完整的 main 函数,不能把它们原样放进同一个文件;可分别编译运行。

一、什么是返回指针的函数

例如下面的函数声明表示:find_value 接收一个指向 int 的指针和一个元素个数,返回一个指向 int 的指针:

int *find_value(int *array, size_t length, int target);

声明中的 int * 是返回类型,表示返回值是“指向 int 的指针”。它不是“返回指针的变量”,也不表示函数会自动分配内存。

返回类型也可以包含限定符。例如,const int * 表示返回的指针可用于读取一个 int,但不能通过这个指针修改该对象:

const int *find_value_readonly(const int *array, size_t length, int target);

需要区分的是,函数指针与返回指针的函数不是一回事。int *f(void) 表示函数 f 返回 int *;而 int (*f)(void) 表示 f 是一个指向函数的指针。括号会改变声明的含义。

二、最常见用途:返回数组元素的地址

查找函数可以返回找到的元素地址;如果没有找到,则返回空指针。调用者拿到地址后,可以读取元素,也可以按接口约定修改它。

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

int *find_value(int *array, size_t length, int target)
{
    if (array == NULL) {
        return NULL;
    }

    for (size_t i = 0; i < length; ++i) {
        if (array[i] == target) {
            return &array[i];
        }
    }

    return NULL;
}

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

    int *result = find_value(values, count, 15);

    if (result != NULL) {
        printf("找到:%d\\n", *result);
        *result = 99;
        printf("修改后:%d\\n", values[2]);
    } else {
        puts("没有找到");
    }

    return 0;
}

这里返回的是调用者数组中某个元素的地址,不是新建的整数。函数返回后,数组仍由 main 中的代码管理,所以指针在该数组的生命周期内有效。main 通过检查 result != NULL 再解引用,避免在查找失败时访问无效地址。

示例对空指针做了检查,但这并不意味着函数能检查任意非空指针是否有效。C 语言无法仅凭一个指针判断它是否悬空、是否指向足够大的数组,也不能验证调用者传入的长度是否真实。调用契约仍要求:当 length 大于零时,array 必须指向至少 length 个可读写的 int 对象;若不需要修改元素,可将参数和返回类型改为 const int *。

当 length 为零时,上面的函数不会访问数组元素,即使 array 为 NULL 也会安全返回。长度和指针之间的约定应在接口文档中写清楚,而不应期待函数能推断数组边界。

三、最重要的边界:对象的生命周期

函数返回的指针必须指向函数返回后仍然存在的对象,或者明确地指向由调用者负责管理的资源。下面的写法是错误的:

int *bad_function(void)
{
    int local = 10;
    return &local;  /* 错误:local 在函数返回时生命周期结束 */
}

local 是自动存储期的局部变量。函数返回后,它的生命周期结束;返回的地址随即变成悬空指针。之后解引用该指针会导致未定义行为。编译器通常会对此类代码发出警告,但不能依赖警告来保障正确性。

局部数组也一样,不能通过返回数组首元素地址来延长数组寿命:

int *bad_array(void)
{
    int temporary[3] = {1, 2, 3};
    return temporary;  /* 错误:temporary 已结束生命周期 */
}

正确方案取决于需求:让调用者提供缓冲区;返回静态存储期对象;或者动态分配对象并约定由调用者释放。不要为了让地址“看起来可用”而返回局部变量或局部数组的地址。

四、调用者提供缓冲区:边界明确,所有权简单

如果函数的工作是生成一段数据,让调用者传入目标缓冲区通常更直接。函数可以返回缓冲区地址以便串接调用,也可以返回状态码;无论采用哪种形式,都应明确缓冲区容量以及写入失败时的行为。

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

char *write_greeting(char *buffer, size_t capacity)
{
    static const char text[] = "hello";
    const size_t needed = sizeof text; /* 包含结尾的 '\0' */

    if (buffer == NULL || capacity < needed) {
        return NULL;
    }

    for (size_t i = 0; i < needed; ++i) {
        buffer[i] = text[i];
    }

    return buffer;
}

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

    if (write_greeting(buffer, sizeof buffer) == NULL) {
        puts("缓冲区不足");
        return 1;
    }

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

示例通过容量参数限制写入范围,并把字符串结尾的空字符也计算在内。容量不足时不写入数据并返回 NULL。调用者负责提供至少 capacity 字节的可写空间;仅传入一个非空指针并不能证明它指向足够大的缓冲区。

生产代码中也可以使用 snprintf 等有容量参数的接口,但仍需检查返回值并确认输出是否完整。容量参数只能在调用者传入的值可信、且与真实缓冲区大小相符时提供帮助,不能自动发现不匹配的参数。

五、动态分配:返回新对象并约定释放责任

当对象需要在函数返回后继续存在,而调用者又无法事先提供存储空间时,可以动态分配内存。一个常见接口是:成功时返回新分配的指针,失败时返回 NULL,并约定调用者使用 free 释放。

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

int *make_int_array(size_t count)
{
    if (count == 0) {
        return NULL;
    }

    /* 防止 count * sizeof(int) 发生尺寸溢出 */
    if (count > SIZE_MAX / sizeof(int)) {
        return NULL;
    }

    return malloc(count * sizeof(int));
}

int main(void)
{
    const size_t count = 5;
    int *values = make_int_array(count);

    if (values == NULL) {
        fputs("分配失败或请求尺寸无效\\n", stderr);
        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%c", values[i], i + 1 == count ? '\\n' : ' ');
    }

    free(values);
    return 0;
}

这里的 count 是函数的请求参数,并不意味着函数能确认调用者最终会访问多少元素。调用者必须按照申请时的数量访问,不能越界;成功后也必须恰当释放。示例用 SIZE_MAX / sizeof(int) 检查乘法上界,避免字节数计算溢出。实际项目还应根据应用需要设置合理的最大分配量,防止异常大的输入消耗过多资源。

也可以用 calloc(count, sizeof(int)) 申请并将分配的字节初始化为零,但应同样检查尺寸和分配失败。无论使用 malloc 还是 calloc,都不能返回已释放的内存,也不应在释放后继续使用旧指针。free(NULL) 是允许的;对已经释放过的非空指针再次调用 free 则不安全。

“由调用者释放”只是常见约定,不是指针类型自带的功能。函数接口应清楚说明:谁负责释放、用什么释放、能否修改对象,以及指针有效到什么时候。若对象需要专用清理函数,应提供对应的释放接口,而不要求调用者猜测。

六、返回静态对象:简单,但通常共享同一份数据

函数也可以返回静态存储期对象的地址。它不会在函数返回时消失,但调用者得到的往往是共享状态,后续调用可能覆盖先前结果。

const char *status_text(int code)
{
    switch (code) {
    case 0:
        return "成功";
    case 1:
        return "输入无效";
    default:
        return "未知状态";
    }
}

这些字符串字面量具有静态存储期,返回的指针在程序运行期间有效。返回类型中的 const 表示调用者不应通过该指针修改字符。试图修改字符串字面量指向的内容会导致未定义行为。

另一种静态对象是函数内部的静态缓冲区。它也不会随函数返回而消失,但每次调用可能重写内容;多个调用者看到的是同一块存储。若并发调用或嵌套调用可能发生,还要考虑数据竞争、重入和同步问题。需要独立结果时,调用者提供缓冲区或动态分配通常更合适。

七、const 的两种位置

在 C 中,const int *p 与 int *const p 的含义不同:

  • const int *p:p 可以改为指向别处,但不能通过 p 修改它指向的整数。
  • int *const p:p 自身不能改为指向别处,但可以通过它修改所指向的整数。
  • const int *const p:指针和所指向的整数都不能通过 p 修改。

函数的返回类型也遵循同样规则。常见的只读返回类型是 const int *。但 const 只是类型层面的访问限制,不会延长对象生命周期,也不能证明指针非空、内存有效或数组长度足够。

八、返回指针与返回值、输出参数如何选择

如果只需返回一个小型数值,直接按值返回通常最简单。若要表达“找到的对象在哪里”,返回指针能让调用者访问原对象。若要返回多个结果或填充大块数据,可考虑输出参数,避免额外分配,也便于调用者控制存储位置。

例如,查找函数可以返回指针,也可以返回是否找到的状态,并通过输出参数写入索引。两种接口各有用途:指针接口便于直接访问元素;索引接口不暴露元素地址,且更容易用特殊值表示失败,但调用者仍需检查索引范围。选型应依据对象所有权、数据是否需要修改、错误处理方式和接口使用习惯,而不是认为返回指针总是更高效。

九、参数限制:函数能检查什么,不能检查什么

返回指针函数常接收指针、长度、容量、索引或分配尺寸。对这些参数的限制要区分“能在函数内验证的条件”和“必须由调用者保证的条件”。

  • 函数可以检查指针是否为 NULL,但非空不代表它有效。
  • 函数可以检查长度是否超过某个应用上限,也可以检查乘法是否可能溢出,但无法一般性地验证该长度对应的真实数组大小。
  • 函数可以拒绝不支持的索引或尺寸;调用者仍须遵守接口对对象类型、可读写性和生命周期的约定。
  • 若指针参数允许为空,应明确空指针代表什么;若不允许,应在函数内部处理或在接口契约中说明。
  • 限制请求尺寸有助于避免不必要的资源消耗,但上限应依据应用需求制定,不能用一个固定数字假装适用于所有场景。

这些规则尤其重要,因为 C 函数通常接收不到数组实际长度。把数组传入函数时,参数往往会退化为指针;sizeof array 在调用者的数组表达式中可以得到整个数组的字节数,但在接收指针的函数内部,sizeof array 得到的是指针本身的大小。需要遍历的接口通常应显式接收元素个数或容量。

十、常见错误及其后果

1. 返回局部变量的地址

局部自动变量在函数返回后结束生命周期。返回其地址会产生悬空指针,不能通过“马上使用”或“当前测试能跑”来证明安全。

2. 忘记检查空指针

查找失败和内存分配失败常通过 NULL 表示。解引用前应检查结果,并确保所有返回路径都遵循一致的约定。

3. 把数组长度和字节数混为一谈

malloc 的参数是字节数,而数组访问通常使用元素个数。计算字节数时要检查乘法溢出;使用缓冲区时还要确认容量单位是字节还是元素。

4. 越界访问

有有效指针不等于有权访问任意位置。访问数组时索引必须落在实际对象边界内;传入的长度错误会使循环越界。C 语言不会自动阻止这种访问。

5. 释放后继续使用或重复释放

动态内存被释放后,所有指向该区域的别名都不再可用。把其中一个指针设为 NULL 不能自动清除其他副本。设计接口时应尽量让所有权清晰,减少不必要的别名。

6. 返回指向静态缓冲区的指针,却假设结果彼此独立

静态缓冲区会被多次调用共享。保存第一次调用返回的指针,再调用函数,原内容可能已被覆盖。需要可重入或并发安全的接口时,应避免未同步的共享可变缓冲区。

7. 把只读误认为安全

const 能限制通过某个表达式进行的修改,但不能防止其他指针修改同一对象,也不能修复悬空指针、越界访问或错误的对象所有权。

十一、编写接口时的实用清单

实现和调用返回指针的函数前,可以逐项确认:

  • 返回指针可能为空吗?为空表示什么?
  • 指针指向谁管理的对象:调用者对象、静态对象,还是动态分配对象?
  • 对象在什么条件下仍然有效?是否会被后续调用覆盖?
  • 调用者能否修改对象?是否应使用 const?
  • 动态分配的内存由谁释放,释放函数是什么?
  • 参数的单位是什么:字节、元素数还是容量?尺寸计算是否可能溢出?
  • 访问边界由谁保证?长度与实际对象大小是否一致?
  • 接口是否会被并发调用,是否共享可变状态?

结语

返回指针适合表达“某个对象的位置”,但地址本身并不等同于对象的所有权、长度或有效性。查找数组元素时,返回数组内部元素的地址并明确要求调用者保证数组有效;需要长期保存的新对象时,动态分配并写清释放责任;生成结果时,调用者提供缓冲区往往能让容量和所有权更清楚。

养成先定义生命周期与所有权、再写指针类型和错误处理的习惯,通常比单纯记住 类型 * 的声明语法更重要。只要接口把空值、边界、可修改性和释放责任说清楚,返回指针就能成为清晰而实用的 C 语言工具。

© 版权声明

相关文章