函数接口¶
strcpy / strncpy | cppreference
Abstract
-
读库函数时先看原型: 参数叫什么,
const标在哪, 返回什么类型. 这几项往往已经说明「谁提供内存 / 谁只读 / 谁拿结果」. 猜不准的细节 (能不能重叠, 失败时返回什么) 再去翻文档对应段落, 无需把整篇 Man Page 从头读到尾. -
指针使接口变灵活. 同一指针参数
T *按数据流动方向可以分为以下三类:-
传入 (
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).
简易的函数接口实现¶
strcpy 简易实现¶
对照strcpy的接口文档.
把 src (含结尾 '\0') 拷贝到调用者提供的 dest, 并返回原先的 dest (拷贝过程中写指针会前进, 若不先保存就无法返回原地址).
若不考虑可读性, 可以把赋值表达式直接当作 while 的条件, 函数体就只需要三行代码:
/**
* @brief `strcpy` 简易实现. 将 `src` 复制到 `dest` 中, 并返回 `dest` 的指针.
*
* @param dest 目标字符串的指针.
* @param src 源字符串的指针.
* @return `dest` 的指针, 便于链式调用.
* @note 最简写法可将函数体压成三行: 先保存返回用的 dest, 再用
* `while ((*dest++ = *src++) != '\0');` 边拷边前进 —— 赋值表达式的值就是
* 刚写入的字节, 写到 '\0' 时条件为假, 循环结束, 因而无需再单独补结尾.
*/
char *strcpy_sim(char *dest, const char *src)
{
char *ret = dest;
while (*src != '\0') {
*dest++ = *src++;
}
*dest = '\0';
// /* In simply */
// char* ret = dest;
// while ((*dest++ = *src++) != '\0');
return ret;
}
(*dest++ = *src++) 这个赋值表达式的值就是刚写入的字节, 当写入 '\0' 时条件为假, 循环结束, 因而无需再单独写结尾.
shrink_space¶
给定一个接口标准:
char *shrink_space(char *dest, const char *src, size_t n);
语义如下:
-
把
src里一段或多段连续空白 (' '/'\t'/'\n'/'\r') 压缩成恰好一个空格写入dest; -
非空白原样拷贝;
-
最多写
n字节, 若结果不足n则剩余位置填'\0'; -
若恰好写满
n字节则不保证以'\0'结尾. -
返回原
dest.
仔细观察一下可以发现其实和 strncpy 的语义类似, 只是多了个处理空白段的逻辑.
实现上读写进度必须分开: src 扫描源串, 下标 i (或另一指针) 推进 dest. 遇到空白先整段吞掉, 再决定是否写入一个 ' '.
空白判定要写成每次带入当前字符再求值的形式 (如宏或内联函数), 不能使用单个 bool 变量缓存(单变量缓存相当于只在进入函数时求值一次, 之后不会更新), 否则 src 前进后条件不更新, 内层循环会死循环或漏掉后面的空白段. 这里使用宏定义来判断空白:
#define IS_SPACE(c) ((c) == ' ' || (c) == '\t' || (c) == '\n' || (c) == '\r')
完整实现如下:
/**
* @brief 将 `src` 中连续空白压缩后写入 `dest`, 接口语义类似 `strncpy`.
*
* @note 这里的空白采用的是宏定义. 空白的求值必须采用「每次带入当前字符再求值」的
* 形式 (宏或内联函数), 不能使用一个一次性的 bool 变量缓存起来(`int is_space = ...`):
* 后者在 src 前进后不会更新, 会导致内层 while 死循环, 或漏掉串中间的空白段.
*
* @param dest 调用者提供的缓冲区, 至少 `n` 字节; 函数会改写其内容.
* @param src 以 '\0' 结尾的源字符串; 只读.
* @param n 最多写入 `dest` 的字节数. 若压缩结果不足 `n` 字节, 剩余位置
* 填 '\0' (同 `strncpy`); 若恰好写满 `n` 字节, 不保证以 '\0' 结尾.
* @return 原先的 `dest`, 便于当作表达式链式使用.
*
* @note 调用者须保证: `dest` 足够大、`src` 合法以 '\0' 结尾、二者指向的
* 内存不重叠. 读写不越界是调用者的责任, 与 `strcpy` / `strncpy` 相同.
*/
char *shrink_space(char *dest, const char *src, size_t n)
{
size_t i = 0;
while (*src && i < n) {
if (IS_SPACE(*src)) {
while (IS_SPACE(*src)) {
src++;
}
if (*src != '\0') {
dest[i++] = ' ';
}
} else {
dest[i++] = *src++;
}
}
while (i < n) {
dest[i++] = '\0';
}
return dest;
}
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 不立刻把内存还给内核」等问题.
内存泄漏¶
内存泄漏 (Memory Leak) 是指堆上通过 malloc 等内存操作接口申请到的块, 在进程退出前再也无法 free 的现象. 常见原因是丢掉了唯一指向该块的指针, 或分配与释放不成对.
出现泄漏时往往不引起段错误, 且不会立刻出现程序崩溃等异常现象. 短生命周期程序退出时 OS 会收回整个地址空间, 问题被掩盖. 长跑进程 (如服务器进程, 循环里反复分配) 则会慢慢占满内存, 变慢甚至触发操作系统的保护机制.
下面就是一个典型的会引起内存泄露的代码示例:
void foo(void)
{
char *p = malloc(10);
/* 用完却忘记 free; 返回后局部指针 p 消失, 地址丢失 */
}
Example
内存泄露在C程序中是个致命但很容易犯的错误. 每次 malloc 成功后没有对应的 free, 指针直接丢弃或不再使用, 堆上的分配的内存块就会一直占着.
值得注意的是, 由于 Linux 通常会开启 memory overcommit, 只 malloc 不写页时, 虚拟地址空间很容易一直分配成功. 这也是操作系统为了应对内存过量分配的一种机制.
因此在示例实现上, 对整块 memset (或其它写入) 才会真正提交物理页, 才更容易看到 malloc 返回 NULL, 或进程被 OOM killer 杀掉, 两种结局都是内存过量分配时可能会观察到的现象:
enum { CHUNK = 1 << 20 }; /* 1 << 20 == 2^20 字节 (1 MiB) */
size_t mem_consume(void)
{
size_t total = 0;
for (;;) {
void *p = malloc(CHUNK);
if (p == NULL)
return total; /* 先判失败, 再谈累计 */
memset(p, 0, CHUNK); /* 触碰页面, 逼出真实占用 */
total += CHUNK;
if ((total / CHUNK) % 64 == 0)
printf("allocated %zu MiB\n", total >> 20);
/* 故意不 free: 教学上的可控泄漏 */
}
}
下面是被操作系统OOM killer杀掉的示例:
真实的业务代码里应避免泄漏, 谁 malloc 谁提供成对的 free, 出错提前返回时也要释放已申请成功的块; 可用 Valgrind 等工具辅助排查.
传入, 传出与 Value-result¶
如果函数接口有指针参数, 既可以把指针所指向的数据传给函数使用, 称为传入参数; 也可以由函数填充指针所指的内存空间, 传回给调用者使用, 称为传出参数(如 strcpy 的 src 参数是传入参数,dest 参数是传出参数); 有些函数的指针参数同时担当了这两种角色(如select(2)的fd_set *参数), 既是传入参数又是传出参数,这称为Value-result 参数.
| 角色 | 典型类型 | 调用者 | 实现者 |
|---|---|---|---|
| 传入 | const T * | 分配并将数据保存到指针指向的内存中后再调用函数 由于有 const修饰, 调用者无需考虑指针参数指向的内存是否会被修改 | 规定指针参数的类型并读取其指向的内存空间 |
| 传出 | T * | 分配空间, 调用函数并读取 | 规定类型并填充指针所指的内存空间 |
| 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 的约束对象
const 写在指针形参上才有「实现者不改对象」的契约含义. 因此诸如 void f(const int x) 里的 const 只约束形参副本, 对调用者几乎无信息.
系统调用里 NULL 常有特殊语义: 传入表示默认属性, 传出表示调用者不要结果. 必须写进文档. 传出与 Value-result 的原型可能完全相同, 因此需要靠文档区分.
两层指针的参数¶
两层指针也是指针, 同样可以表示传入参数、传出参数或者Value-result参数, 只不过该参数所指的内存空间应该解释成一个指针变量.
这里要改的就是调用方的指针变量本身 (让它指向别处, 或指向新分配的堆块), 形参类型为 T **, 实参传 &p.
以接口原型 void alloc_unit(unit_t **pp) 和 void free_unit(unit_t *p) 为例, 可以利用双重指针在函数中分配内存:
| 调用者 | 实现者 |
|---|---|
1. 分配 pp 所指的指针变量的内存空间2. 调用 alloc_unit 分配内存3. 读取 *pp 指向的指针变量, 通过后者使用 alloc_unit 分配的内存4. 调用 free_unit 释放内存空间 | 1. 规定指针参数的类型 unit_t **2. alloc_unit 分配 unit_t *的内存并写入 *pp所指向的指针变量3. free_unit 释放 p 所指的 unit_t 内存块 |
需要注意的是, 若双重指针参数是传出的, 那么有两种情况:
传出已有对象的地址¶
第一种情况, 传出的指针指向静态内存, 或者指向已分配的动态内存(比如指向某个链表的节点).
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), 且堆块泄漏.
这里不能看出两种情况的传出的函数原型仍然是一致的, 文档同样需要写清是哪一种, 以及谁负责 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 *.
原因很简单, 仍然是函数调用时的值传递特性, 返回值可以用来初始化 / 赋值给调用方的指针变量, 而形参赋值不会:
-
形参
T *p: 传入的是指针的值副本. 函数里p = malloc(...)只改副本, 调用方变量不变. -
返回值
T *: 函数把新地址作为结果交出去; 由调用方赋值(或初始化)p = alloc_unit(), 所以调用方的指针会被更新.
回调函数¶
参数是函数指针时, 调用者把「一段行为」交给实现者, 由实现者在适当时机调用, 称为回调 (callback). libc中的qsort, bsearch就是典型的回调函数.
以原型 void func(void (*f)(void *), void *p);为例:
| 调用者 | 实现者 |
|---|---|
| 1. 提供一个回调函数, 再提供一个准备传给回调函数的参数 2. 把回调函数传给参数 f,把准备传给回调函数的参数按 void * 类型传给参数 p | 在适当的时候根据调用者传来的函数指针 f 调用回调函数,将调用者传来的参数 p 转交给回调函数,即调用 f(p) |
这里的 void (*f)(void *) 就可以看作是回调函数指针的类型, 等同于下面的 callback_t.
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()();.
回调函数的思想
回调的思想很简单, 本质是把「一段行为」交给别人, 由对方在合适时机再调用你 (When something happens, do something). 回调的重点在回(back)上, 把术语拆开来看: 调是调用(call), 回是回头再调你(back) —— 控制权先交给库 / 框架, 对方在合适时机再转回来调用你提供的函数. 以排序为例, 库 / 框架负责流程 (排序, 遍历, 等事件), 调用者只填「碰到元素怎么比」, 「事件来了做什么」.
在C中, 函数不是一等值, 因此需要通过函数指针 (如 void (*f)(void *) / callback_t)来传递调用信息, 额外上下文常再跟一个 void *, 由调用者解释、实现者原样转交.
JavaScript中, map / filter / modify(data, fn) 也是把函数当参数. 差别在载体: JS 函数可直接传递并靠闭包带数据, C 用指针 + void *. qsort 的比较回调与 array.sort(compareFn) 同属一类设计.
同步与异步方面, 同步是调用者进库后再被调回来 (比较函数); 异步是先注册, 事件发生时再调用 (信号, GUI). 思想不变, 只是「回调函数的调用时机」不同.
回调应用示例¶
回顾一下折半插入排序, 其流程中包含一个查找插入位置的步骤, 这个步骤就可以基于回调函数进行实现. 结合上面阐述的回调函数的思想, 就是排序流程固定, 查找行为注入.
/**
* @brief 折半「插入点」查找的回调类型.
*
* 指向的函数应在有序区间 array[lo..hi] 上查找 key 应插入的下标,
* 而不是「找到则下标、找不到则 -1」的精确查找.
*
* @param array 有序数组 (只读).
* @param lo 查找区间左端 (含).
* @param hi 查找区间右端 (含).
* @param key 待插入关键字.
* @return 插入下标 pos, 使得将 key 放在 pos 后 [lo..hi] 仍有序.
*
* @note 回调指针类型应与函数接口一致.
*/
typedef int (*bsearch_fn)(const int *array, int lo, int hi, int key);
/**
* @brief 在有序区间 array[lo..hi] 上折半查找 key 的插入位置.
*
* @param array 有序数组 (只读).
* @param lo 左端下标 (含).
* @param hi 右端下标 (含).
* @param key 待插入关键字.
* @return 插入下标. 循环结束时的 lo; 相等元素插到右侧, 排序稳定.
*
* @note 用 `array[mid] <= key` 时 lo 右移, 故相等键不会插到已有相等元素左边.
*/
int binary_search_insert(const int *array, int lo, int hi, int key)
{
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (array[mid] <= key)
lo = mid + 1;
else
hi = mid - 1;
}
return lo;
}
/**
* @brief 折半插入排序: 通过回调在有序区中定位插入点.
*
* @param bsearch 折半查找插入点的函数指针, 类型见 `bsearch_fn`.
* @param array 哨兵数组: array[1..n] 为待排序数据, array[0] 作暂存, 调用者须预留.
* @param n 元素个数 (有效下标 1..n).
*
* @note 排序流程固定, 「如何找插入点」交给回调, 体现回调思想:
* 库负责外层循环与搬移, 调用者只提供查找行为.
*/
void binary_insert_sort(bsearch_fn bsearch, int *array, int n)
{
int i, j, pos;
for (i = 2; i <= n; i++) {
array[0] = array[i];
/* 回调获取插入位置 */
pos = bsearch(array, 1, i - 1, array[0]);
/* 执行插入并后移元素 */
for (j = i - 1; j >= pos; j--)
array[j + 1] = array[j];
array[pos] = array[0];
}
}
-
数组采用哨兵下标:
a[1..n]存数据,a[0]每轮暂存待插元素, 搬移循环无需额外判界. -
回调返回的是插入位置, 不是「找到 / 没找到」: 折半循环
while (lo <= hi)收空后,lo恰好停在「第一个比 key 大的元素」处; 用array[mid] <= key时相等键被越过, 新元素插在同值右侧, 排序稳定. 若照抄「找不到返回 -1」的精确查找, 插入排序会拿不到可用下标. -
回调类型必须与被指向函数一致:
bsearch_fn与binary_search_insert的参数、返回值逐一对应; 类型不匹配的函数指针调用是未定义行为. -
分工与
qsort同构: 排序主体负责取元素、搬移、插入; 「怎么找位置」由调用者通过函数指针提供. 换成线性查找回调, 排序主体一行不用改.
可变参数¶
原型以 ... 结尾, 且 (C23 前) 前面至少一个具名参数. 实现侧用 <stdarg.h>:
#include <stdarg.h>
void printf_sim(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 * |
| 参数个数不定 | ... + 格式串或哨兵 |

