Skip to content

函数接口

函数接口 | Linux C编程一站式学习

strcpy / strncpy | cppreference

malloc / free | cppreference

Variadic arguments | cppreference

qsort | cppreference

Abstract

  • 函数接口是调用者与实现者之间的契约表面: 函数名, 参数类型与角色, 返回值语义. Man Page等文档补充接口写不下的约束 (缓冲区大小, 是否可重叠, 谁负责 free).

  • 指针使接口变灵活: 同一 T * 可以是传入, 传出, 或 Value-result. 要改调用方手里的指针本身, 形参升一级成 T **. 返回指针时必须写清所指对象的寿命 (静态缓冲 vs 堆分配).

部分技巧与注意事项

  • 读库函数时先看原型: 参数叫什么, 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 是范本.

函数接口与指针结合后, 用法就多了, 常见用法大致有这几类:

  1. 改调用方的对象: 传对象地址 T *, 函数里写 *p. 例如 scanf("%d", &n), 或自己写的 swap(int *a, int *b).

  2. 只读传入大块数据: const T *, 避免拷贝整份结构体 / 字符串, 又向调用者保证不改内容. 例如 strlen(const char *s), strcpy 的 src.

  3. 往调用方准备好的缓冲区里填结果: 仍是 T *, 但是传出角色. 例如 strcpy 的 dest, strftime 的输出缓冲.

  4. 改调用方手里的指针本身 (让它指向别处, 或指向新 malloc 的块): 形参升成 T **, 实参传 &p. 例如链表头插入 add_to_front(node_t **head, ...), 或 getaddrinfo(..., struct addrinfo **res).

  5. 用返回值交出指针: 返回 T *. 可能指向静态缓冲 (localtime), 也可能指向新堆块 (malloc, strdup). 两种原型长得一样, 寿命与是否要 free 必须看文档.

  6. 把「一段行为」当作参数: 函数指针做成回调, 常配 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 表示库函数章节), 可以按如下顺序阅读, 无需每次都通读全文:

  1. SYNOPSIS: 要 #include 哪个头文件, 原型长什么样. 参数名往往带下划线强调 (如 dest, src), 便于在正文里用搜索跳到对应说明.

  2. 先猜接口, 再补文档: 看到 strcpy 的函数接口原型, 不难猜到 dest 指代 Destination (char *, 可写), src 指代 Source (const char *, 只读), 这样很容易就可以猜出函数的功能是把 src 指向的串拷贝到 dest 指向的空间. strncpy 多出来的 n 单靠类型猜不出含义, 才需要往下读 DESCRIPTION / NOTES.

  3. DESCRIPTION / NOTES / BUGS: 字段则用于阐明接口说不清的契约. 以 strcpy 为例:

    • strcpy 会连同结尾 '\0' 一起拷; 但实现者不知道长度, dest 够大, src 以 '\0' 结尾, 且两块内存不重叠, 都是调用者的责任

    • strncpy 最多拷 n 字节; src 长度 >= n 时 dest 可能没有结尾 '\0' (页里常标 Warning). 需要 C 字符串时要自己补终止符, 且注意 n == 0 时不要写 buf[n - 1]

    • BUGS 会强调 strcpy 更容易缓冲区溢出: 越界当时未必崩, 可能在函数返回时才段错误, 甚至被利用

  4. RETURN VALUE: 二者都返回 dest. 信息量不大, 主要为了能嵌进表达式, 如 printf("%s\n", strcpy(buf, "hello")).

  5. 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);
}

要点:

  1. va_start(ap, last_named) 把游标放到具名参数之后; va_arg(ap, T) 按类型取出并前进; 返回前必须 va_end.

  2. 传入 ... 的整数小类型会发生 default argument promotions: char / short → int, float → double. 因此取 char 实参时写 va_arg(ap, int).

  3. 实现者按格式串或约定取数, 无法验证调用者是否传对. 类型或个数不匹配是调用者的责任.

  4. 也可用哨兵判定结束, 例如 execl 以 NULL 结尾; 文档必须写明, 调用者忘记哨兵则无法由实现方挽救.

历史上一种 va_list 实现就是 char * 游标, 按对齐步长在参数槽上移动. 现代 ABI (寄存器传参) 更复杂, 但「游标 + 按约定解释」的心智模型仍然够用. 上面的演示逐步走了一遍游标前进过程, 并附「调用者传错类型」的对照.

模式速查

需求 常见接口形态
只读输入大对象 const T *
填调用者提供的对象 T * 传出
改调用者的指针 / 交回新堆块指针 T ** 或返回 T *
多返回值且其一是错误码 返回 int, 其余用传出参数
算法与类型解耦 函数指针回调 + void *
参数个数不定 ... + 格式串或哨兵