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

如何编写指针函数:用示例理解返回指针的函数
函数除了返回整数、浮点数或结构体,也可以返回指针。返回指针的函数常用于查找数组中的元素、提供字符串或缓冲区、动态创建对象,以及让调用者访问一块由其他代码管理的数据。
不过,指针只记录一个地址,并不携带完整的对象信息:它本身不说明地址是否有效、对象有多大、由谁负责释放,也不保证对象还活着。因此,写这类函数时,关键不只是声明语法,还要明确返回地址指向什么、有效到什么时候,以及调用者能否修改或释放它。
本文以 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 语言工具。





