3. 返回值检查

好,咱们接着聊错误处理。这一章我要讲的是最基础、也是最容易被忽视的一招——返回值检查。

你想想看,C语言不像Java有try-catch,也不像Python有异常机制。C语言怎么告诉你“我出错了”?说白了,就是靠返回值。函数执行完了,给你一个数字,或者一个指针,告诉你结果怎么样。你要是没接住这个信号,那后面的事就不好说了。

3.1 函数返回值约定:C语言的“潜规则”

C语言里,函数返回值的含义其实有一套约定俗成的规矩。我个人习惯把它们分成三类:

  • 成功/失败指示器:返回0表示成功,非0表示失败。比如很多标准库函数就是这么干的。
  • 数据值:返回计算结果本身。比如getchar()返回读到的字符。
  • 指针:返回内存地址。成功时返回有效指针,失败时返回NULL。

嗯,这里要注意:同一个函数,返回值只能表达一种语义。你不能让一个函数既返回错误码,又返回计算结果。否则调用方会疯掉的。

核心原则:函数返回值的含义必须清晰、唯一、文档化。调用方看一眼函数签名,就应该知道怎么处理返回值。

3.2 检查标准库函数返回值:别偷懒

我在项目中遇到过最典型的bug,就是有人不检查fopen()的返回值。直接拿着文件指针就开始读写,结果程序崩溃了还不知道为什么。

标准库函数几乎都有返回值,而且大部分都会用返回值告诉你执行结果。我列几个常见的:

函数 成功返回值 失败返回值 错误信息
fopen() 文件指针 NULL errno
malloc() 分配的内存指针 NULL errno (ENOMEM)
fread() 实际读取的字节数 小于请求的字节数 feof()/ferror()
printf() 输出的字符数 负数 errno
scanf() 成功匹配的项数 EOF 或 0 errno

你看,每个函数都有自己的一套返回值规则。不检查?那就是在赌运气。

避坑指南:我曾经接手过一个项目,里面有个模块用了上百次malloc(),但没有一次检查返回值。结果在内存紧张的环境下,程序莫名其妙地崩溃。后来我花了整整两天,才把所有malloc()的返回值检查补上。从那以后,我写代码的第一反应就是:任何可能失败的函数,返回值必须检查。

正确的做法是这样的:

FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
    fprintf(stderr, "打开文件失败: %s\n", strerror(errno));
    return -1;
}

char *buffer = (char *)malloc(1024);
if (buffer == NULL) {
    fprintf(stderr, "内存分配失败\n");
    fclose(fp);
    return -1;
}

看到了吗?每一步都要检查。而且失败时要记得释放已经分配的资源——这就是后面要讲的资源管理。

3.3 自定义函数的错误返回设计

写自己的函数时,怎么设计返回值?我建议遵循一个原则:让调用方用起来舒服。

举个例子,假设你要写一个除法函数:

// 不好的设计:用返回值传结果,用参数传错误
int divide_bad(int a, int b, int *result) {
    if (b == 0) {
        return -1;  // 返回错误码
    }
    *result = a / b;
    return 0;       // 返回成功
}

// 好的设计:用返回值传错误,用参数传结果
int divide_good(int a, int b, int *result) {
    if (result == NULL) {
        return -1;  // 参数检查
    }
    if (b == 0) {
        return -2;  // 除零错误
    }
    *result = a / b;
    return 0;       // 成功
}

我个人习惯用返回值表示状态,用指针参数传出结果。这样调用方可以这样写:

int result;
int ret = divide_good(10, 0, &result);
if (ret != 0) {
    // 处理错误
    printf("除法失败,错误码: %d\n", ret);
} else {
    printf("结果是: %d\n", result);
}

你看,逻辑是不是很清晰?

3.4 错误码枚举:给错误一个名字

直接返回数字有个问题——没人记得住-1、-2、-3分别代表什么。所以我们需要枚举。

枚举说白了就是给数字起个名字。比如:

typedef enum {
    ERR_SUCCESS = 0,      // 成功
    ERR_NULL_POINTER = -1, // 空指针
    ERR_DIVIDE_BY_ZERO = -2, // 除零
    ERR_OUT_OF_MEMORY = -3,  // 内存不足
    ERR_FILE_NOT_FOUND = -4, // 文件未找到
    ERR_INVALID_PARAM = -5   // 无效参数
} ErrorCode;

然后你的函数就可以这样写:

ErrorCode read_config(const char *filename, Config *config) {
    if (filename == NULL || config == NULL) {
        return ERR_NULL_POINTER;
    }
    
    FILE *fp = fopen(filename, "r");
    if (fp == NULL) {
        return ERR_FILE_NOT_FOUND;
    }
    
    // ... 读取配置 ...
    
    fclose(fp);
    return ERR_SUCCESS;
}

调用方看到ERR_FILE_NOT_FOUND,一眼就知道是文件没找到。比看到-4强多了,对吧?

小技巧:我习惯在枚举里把成功码放在最前面,值为0。这样if (func())就可以判断是否失败——因为成功时返回0,在C语言里0表示false。当然,这取决于你的个人风格,但保持一致性很重要。

最后,我画了一张图,帮你理清返回值检查的整个流程:

返回值检查核心流程 调用函数 检查返回值 成功 (0/NULL) 继续正常流程 失败 (非0/NULL) 记录错误信息 (errno/日志) 返回/退出/重试 核心思想:每次函数调用后,必须检查返回值,并根据结果决定后续流程

这张图其实就讲了一件事:调用函数 → 检查返回值 → 成功继续,失败处理。就这么简单,但能做到的人真不多。

好了,这一章就到这里。记住:返回值检查是C语言错误处理的第一道防线。别偷懒,别侥幸,该检查的一个都不能少。