函数接口¶
strcpy / strncpy | cppreference
Abstract
部分技巧与注意事项¶
-
读库函数时先看原型: 参数叫什么,
const标在哪, 返回什么类型. 这几项往往已经说明「谁提供内存 / 谁只读 / 谁拿结果」. 猜不准的细节 (能不能重叠, 失败时返回什么) 再去翻文档对应段落, 无需把整篇 Man Page 从头读到尾. -
指针参数按数据流动方向分成三类. 传入 (
const T *): 调用者填好数据, 函数只读. 传出 (T *): 调用者先准备好一块空间, 函数往里写, 返回后调用者再读. Value-result (类型也常是T *): 调用者先写入初值, 函数读完再改写同一块, 文档会说明「先读后写」. 后两种在原型上可能长得一样, 角色只能靠文档区分, 不能只靠类型硬猜. -
堆内存要成对处理: 谁
malloc(或提供alloc_xxx), 谁就应提供对应的free/free_xxx.free(p)之后p里的地址值不会自动更新, 调用者应立刻写p = NULL, 避免之后误当成仍有效去解引用. -
有些函数返回指向自己内部静态缓冲区的指针 (多次调用共用同一块). 不要在同一个表达式里调用两次再同时使用两个返回值, 例如
printf("%s %s", f(0), f(1)): 后一次调用会覆盖前一次写入的内容, 两个指针实际指向同一块已被改写的缓冲. -
printf这类带...的可变参数没有编译期类型检查. 后面有几个实参, 各是什么类型, 全靠格式串 (或文档规定的哨兵如NULL) 与调用者对齐. 对不上就会触发未定义行为.
接口即契约¶
调用前, 调用者提供约定条件; 返回时, 实现者履行约定义务. 契约首先写在函数接口上: 名字合理, 类型准确, 调用者单看原型就能猜用法. 接口表达不了的语义 (例如 src 与 dest 不可重叠, n 截断后可能无 '\0') 就回过头看文档, Man Page 是范本.
函数接口与指针结合后, 用法就多了, 常见用法大致有这几类:
-
改调用方的对象: 传对象地址
T *, 函数里写*p. 例如scanf("%d", &n), 或自己写的swap(int *a, int *b). -
只读传入大块数据:
const T *, 避免拷贝整份结构体 / 字符串, 又向调用者保证不改内容. 例如strlen(const char *s),strcpy的src. -
往调用方准备好的缓冲区里填结果: 仍是
T *, 但是传出角色. 例如strcpy的dest,strftime的输出缓冲. -
改调用方手里的指针本身 (让它指向别处, 或指向新
malloc的块): 形参升成T **, 实参传&p. 例如链表头插入add_to_front(node_t **head, ...), 或getaddrinfo(..., struct addrinfo **res). -
用返回值交出指针: 返回
T *. 可能指向静态缓冲 (localtime), 也可能指向新堆块 (malloc,strdup). 两种原型长得一样, 寿命与是否要free必须看文档. -
把「一段行为」当作参数: 函数指针做成回调, 常配
void *用户数据. 例如qsort的比较函数, GUI / 信号里的注册回调.
分析时重点分清「改的是形参副本还是调用方的对象 / 指针」.
从库函数看接口设计¶
strcpy / strncpy¶
char *strcpy(char *restrict dest, const char *restrict src);
char *strncpy(char *restrict dest, const char *restrict src, size_t n);
-
dest是char *(可写) → 传出缓冲区;src是const char *→ 传入只读串. -
实现者不知道缓冲区长度: 不越界是调用者的责任.
src必须'\0'结尾;dest必须够大. -
strncpy的n限制最多拷n字节.src长度>= n时不一定写入结尾'\0'. 需要 C 字符串时调用后应自行保证终止, 例如dest[n ? n - 1 : 0] = '\0'(并先确认n > 0). -
返回
dest便于把调用嵌进表达式, 例如puts(strcpy(buf, "hi")), 并不提供新信息. -
缓冲区溢出属严重漏洞. 新代码优先考虑带长度的安全接口 (如
snprintf拼串), 并始终核对文档.
Man Page 的用法
以 strcpy(3) 为例(页名常写作 func_name(n), 括号里的 n 表示库函数章节), 可以按如下顺序阅读, 无需每次都通读全文:
-
SYNOPSIS: 要
#include哪个头文件, 原型长什么样. 参数名往往带下划线强调 (如dest,src), 便于在正文里用搜索跳到对应说明. -
先猜接口, 再补文档: 看到
strcpy的函数接口原型, 不难猜到dest指代 Destination (char *, 可写),src指代 Source (const char *, 只读), 这样很容易就可以猜出函数的功能是把src指向的串拷贝到dest指向的空间.strncpy多出来的n单靠类型猜不出含义, 才需要往下读 DESCRIPTION / NOTES. -
DESCRIPTION / NOTES / BUGS: 字段则用于阐明接口说不清的契约. 以
strcpy为例:-
strcpy会连同结尾'\0'一起拷; 但实现者不知道长度, dest 够大, src 以'\0'结尾, 且两块内存不重叠, 都是调用者的责任 -
strncpy最多拷n字节;src长度>= n时 dest 可能没有结尾'\0'(页里常标 Warning). 需要 C 字符串时要自己补终止符, 且注意n == 0时不要写buf[n - 1] -
BUGS 会强调
strcpy更容易缓冲区溢出: 越界当时未必崩, 可能在函数返回时才段错误, 甚至被利用
-
-
RETURN VALUE: 二者都返回
dest. 信息量不大, 主要为了能嵌进表达式, 如printf("%s\n", strcpy(buf, "hello")). -
CONFORMING TO: 标明属于 C 标准还是 POSIX. 例如
strcpy是 C 库函数; 以后见到的write(2)是 POSIX / 系统调用 (章节号2).
malloc / free¶
void *malloc(size_t size); /* 失败返回 NULL; 成功返回对齐后的堆块首地址 */
void free(void *ptr); /* free(NULL) 合法; 不可对同一指针 free 两次 */
-
返回
void *: 实现者不知道用户要存什么类型. -
free(p)不能把调用方的p改成NULL(形参只是副本). 调用者应free(p); p = NULL;. -
嵌套分配先释放内层再释放外层. 丢失唯一指向堆块的指针会造成内存泄漏.
初学时可以把 malloc / free 想成使用环形链表串起来的空闲块: 每块开头有头节点, 内含 next (指向下一空闲头) 与 size (本块总长度); 分配时从某空闲块截一段 (含新头节点), 返回头后面的地址, 新头不进 freelist; free 把头节点重新链回环, 相邻空闲尽量合并; 空间不够就 brk/sbrk 抬高 Break:
真实 libc 远复杂于此, 但用这个模型足以理解野指针, 越界砸坏 next/size, 以及「free 不立刻把内存还给内核」等问题.
传入, 传出与 Value-result¶
| 角色 | 典型类型 | 调用者 | 实现者 |
|---|---|---|---|
| 传入 | const T * | 分配并填好数据后传入 | 只读 *p |
| 传出 | T * | 分配空间, 调用后读取 | 写入 *p |
| Value-result | T * | 先填初值, 调用后再读 | 先读后改 |
typedef struct {
int number;
char msg[20];
} unit_t;
void set_unit(unit_t *p) /* 传出 */
{
if (p == NULL)
return;
p->number = 3;
strcpy(p->msg, "Hello World!");
}
int main(void)
{
unit_t u;
set_unit(&u); /* 调用者提供对象的地址 */
printf("%d %s\n", u.number, u.msg);
}
const 写在指针形参上才有「实现者不改对象」的契约含义. void f(const int x) 里的 const 只约束形参副本, 对调用者几乎无信息.
系统调用里 NULL 常有特殊语义: 传入表示默认属性, 传出表示调用者不要结果. 必须写进文档. 传出与 Value-result 的原型可以完全相同, 靠文档区分.
两层指针的参数¶
要改的是调用方的指针变量本身 (让它指向别处, 或指向新分配的堆块), 形参类型为 T **, 实参传 &p.
传出已有对象的地址¶
static const char *msg[] = {
"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"
};
void get_a_day(const char **pp)
{
static int i = 0;
*pp = msg[i % 7];
i++;
}
const char *day = NULL;
get_a_day(&day); /* day 被改成指向静态字符串 */
这里 const 约束的是最终字符只读 (**pp 不可写), 不是「参数只能传入」. 函数仍可通过 *pp = ... 改调用方的指针.
在函数内分配并传出¶
void alloc_unit(unit_t **pp)
{
unit_t *p = malloc(sizeof(unit_t));
if (p == NULL) { /* 处理失败 */ return; }
p->number = 3;
p->msg = malloc(20);
strcpy(p->msg, "Hello World!");
*pp = p; /* 写回调用方的 unit_t * */
}
void free_unit(unit_t *p)
{
free(p->msg);
free(p);
}
为何不能写成 void alloc_unit(unit_t *p)? 形参 p 是调用方指针的值副本. p = malloc(...) 只改副本, 调用方的指针仍是旧值 (常为 NULL), 且堆块泄漏.
两种「传出指针」接口长得一样: (1) 指向静态或已有动态对象; (2) 函数内 malloc 再传出. 文档必须写清是哪一种, 以及谁负责 free.
返回值是指针¶
返回指针同样分两类, 且接口可以长得一样, 必须靠文档区分.
| 模式 | 示例 | 调用者义务 |
|---|---|---|
| 指向静态 / 已有存储 | localtime, 下面的 get_a_day | 搞清寿命与是否可重入; 勿假设多次调用得到独立缓冲 |
| 指向新堆块 | malloc, strdup 类 | 保存返回值并在适当时 free |
静态缓冲区¶
char *get_a_day(int idx)
{
static char buf[20];
strcpy(buf, msg[idx]);
return buf;
}
printf("%s %s\n", get_a_day(0), get_a_day(1));
/* 常见结果是 Sunday Sunday, 而不是 Sunday Monday */
两次调用复用同一 buf. 后一次覆盖前一次内容; 两个返回值是同一地址. 实参求值顺序是实现定义, 但无论左右谁先算, 「两个结果共享一块可变缓冲」这一事实不变.
堆分配并返回¶
unit_t *alloc_unit(void)
{
unit_t *p = malloc(sizeof(unit_t));
/* 初始化... */
return p; /* 返回一层指针即可: 返回值本身会写进调用方的变量 */
}
unit_t *p = alloc_unit();
/* 使用后 */ free_unit(p); p = NULL;
对比: 通过参数交回新指针需要 T **; 通过返回值交回只需要 T *. 原因仍是值传递: 返回值会用来初始化 / 赋值给调用方的指针变量, 而形参赋值不会.
回调函数¶
参数是函数指针时, 调用者把「一段行为」交给实现者, 由实现者在适当时机调用, 称为回调 (callback). 典型: qsort, bsearch.
typedef void (*callback_t)(void *);
void repeat_three_times(callback_t f, void *para)
{
f(para);
f(para);
f(para);
}
void say_hello(void *str)
{
printf("Hello %s\n", (const char *)str);
}
repeat_three_times(say_hello, "Guys");
-
回调形参类型由调用者解释; 对实现者只是
void *, 原样转交. -
同步回调: 调用者 → 库函数 → 再进入调用者提供的函数 (如比较函数).
-
异步回调: 先注册 (
sigaction, 线程入口, GUI 按钮), 事件发生时再调.
泛型查找最大值时, 库只操作 void * 数组并调用比较回调.
返回函数指针 (高阶函数) 在 C 中少见, 语法上允许 func()();.
可变参数¶
原型以 ... 结尾, 且 (C23 前) 前面至少一个具名参数. 实现侧用 <stdarg.h>:
#include <stdarg.h>
void myprintf(const char *format, ...)
{
va_list ap;
char c;
va_start(ap, format);
while ((c = *format++)) {
switch (c) {
case 'c': {
/* char 经 ... 时提升为 int */
char ch = (char)va_arg(ap, int);
putchar(ch);
break;
}
case 's': {
char *p = va_arg(ap, char *);
fputs(p, stdout);
break;
}
default:
putchar(c);
}
}
va_end(ap);
}
要点:
-
va_start(ap, last_named)把游标放到具名参数之后;va_arg(ap, T)按类型取出并前进; 返回前必须va_end. -
传入
...的整数小类型会发生 default argument promotions:char/short→int,float→double. 因此取char实参时写va_arg(ap, int). -
实现者按格式串或约定取数, 无法验证调用者是否传对. 类型或个数不匹配是调用者的责任.
-
也可用哨兵判定结束, 例如
execl以NULL结尾; 文档必须写明, 调用者忘记哨兵则无法由实现方挽救.
历史上一种 va_list 实现就是 char * 游标, 按对齐步长在参数槽上移动. 现代 ABI (寄存器传参) 更复杂, 但「游标 + 按约定解释」的心智模型仍然够用. 上面的演示逐步走了一遍游标前进过程, 并附「调用者传错类型」的对照.
模式速查¶
| 需求 | 常见接口形态 |
|---|---|
| 只读输入大对象 | const T * |
| 填调用者提供的对象 | T * 传出 |
| 改调用者的指针 / 交回新堆块指针 | T ** 或返回 T * |
| 多返回值且其一是错误码 | 返回 int, 其余用传出参数 |
| 算法与类型解耦 | 函数指针回调 + void * |
| 参数个数不定 | ... + 格式串或哨兵 |
