C语言最佳实践
上QQ阅读APP看书,第一时间看更新

1.5.2 错误处理及集中返回

如前所述,Linux内核的编码风格要求所有的函数应在末尾提供统一的出口,因此我们在Linux内核的源代码中看到goto语句被频繁使用。实际上,除了Linux内核,其他基于C语言的开源软件也在使用这一经验性约定写法。

为了直观感受这种写法的优势,我们来看看程序清单1.3中的代码。

程序清单1.3 一个哈希表的创建函数

struct pchash_table *pchash_table_new(size_t size,
        pchash_copy_key_fn copy_key, pchash_free_key_fn free_key,
        pchash_copy_val_fn copy_val, pchash_free_val_fn free_val,
        pchash_hash_fn hash_fn, pchash_equal_fn equal_fn)
{
    struct pchash_table *t;
 
    if (size == 0)
        size = PCHASH_DEFAULT_SIZE;
 
    t = (struct pchash_table *)calloc(1, sizeof(struct pchash_table));
    if (!t)
        return NULL;
 
    t->count = 0;
    t->size = size;
    t->table = (struct pchash_entry *)calloc(size, sizeof(struct pchash_entry));
    if (!t->table) {
        free(t);
        return NULL;
    }
 
    t->copy_key = copy_key;
    t->free_key = free_key;
    t->copy_val = copy_val;
    t->free_val = free_val;
    t->hash_fn = hash_fn;
    t->equal_fn = equal_fn;
 
    for (size_t i = 0; i < size; i++)
        t->table[i].key = PCHASH_EMPTY;
 
    if (do_other_initialization(t)) {
        free(t->table);
        free(t);
        return NULL;
    }
 
    return t;
}

上述代码实现了一个用来创建哈希表的函数pchash_table_new()。在这个函数中,我们需要执行两次内存分配,一次用于分配哈希表本身,另一次用于分配保存各个哈希项的数组。另外,该函数还调用了一次do_other_initialization()函数,以执行一次额外的初始化操作。如果第二次内存分配失败,或者额外的初始化操作失败,则需要释放已分配的内存并返回NULL表示失败。可以想象,我们还需要执行其他更多的初始化操作,当后续的任何一次初始化操作失败时,我们就需要不厌其烦地在返回NULL之前调用free()函数来释放前面已经分配的内存,否则就会造成内存泄漏。

要想优雅地处理上述情形,可按如下代码(为节省版面,我们略去了部分代码)所示使用goto语句,如此便能起到化腐朽为神奇的效果:

struct pchash_table *pchash_table_new(...)
{
    struct pchash_table *t = NULL;
 
    ...
    t = (struct pchash_table *)calloc(1, sizeof(struct pchash_table));
    if (!t)
        goto failed;
 
    ...
    t->table = (struct pchash_entry *)calloc(size, sizeof(struct pchash_entry));
    if (!t->table) {
        goto failed;
    }
 
    ...
 
    if (do_other_initialization(t)) {
        goto failed;
    }
 
    return t;
 
failed:
    if (t) {
        if (t->table)
            free(t->table);
        free(t);
    }
 
    return NULL;
}

以上写法带来的好处显而易见:将函数中多个初始化操作失败时的处理统一集中到函数末尾,减少了return语句出现的次数,方便了代码的维护。

还有一个技巧,我们可以通过定义多个goto语句的目标标签(label),让以上代码变得更加简洁:

struct pchash_table *pchash_table_new(...)
{
    struct pchash_table *t = NULL;
 
    ...
    t = (struct pchash_table *)calloc(1, sizeof(struct pchash_table));
    if (!t)
        goto failed;
 
    ...
    t->table = (struct pchash_entry *)calloc(size, sizeof(struct pchash_entry));
    if (!t->table) {
        goto failed_table;
    }
 
    ...
 
    if (do_other_initialization(t)) {
        goto failed_init;
    }
 
    return t;
 
failed_init:
    free(t->table);
 
failed_table:
    free(t);
 
failed:
    return NULL;
}

以上写法带来的好处是,调用free()函数时不再需要作额外的判断。

在实践中,我们还可能遇到一种写法,就是在进行错误处理时避免使用有争议的goto语句,例如:

struct pchash_table *pchash_table_new(...)
{
    struct pchash_table *t = NULL;
 
    do {
        t = (struct pchash_table *)calloc(1, sizeof(struct pchash_table));
        if (!t)
            break;
 
        ...
        t->table = (struct pchash_entry *)calloc(size,
                sizeof(struct pchash_entry));
        if (!t->table) {
            break;
        }
 
        ...
 
        if (do_other_initialization(t)) {
            break;
        }
 
        return t;
    } while (0);
 
    if (t) {
        if (t->table)
            free(t->table);
        free(t);
    }
 
    return NULL;
}

本质上,上述写法利用了do - while (0)单次循环,因为我们可以使用break语句跳出这一循环,从而避免goto语句的使用。

但笔者并不建议使用这种写法,原因有二。

(1)大部分人看到do语句的第一反应是循环。在看到while (0)语句之前,很少有人会想到这段代码本质上不是循环,从而影响代码的可读性。

(2)这种写法额外增加了一次不必要的缩进。这一方面会让代码从感官上变得更为复杂,另一方面则会出现因为坚守“80列”这条红线而不得不绕行的情形。

需要说明的是,在定义宏时,我们经常使用do - while (0)单次循环,尤其是当一个宏由多条语句组成时:

#define FOO(x)                  \
    do {                        \
        if (a == 5)             \
            do_this(b, c);      \
    } while (0)