本节阐述的内容从严格意义上讲不属于代码的可读性范畴,而属于代码的可维护性或技巧性范畴。将这些内容置于本节,是因为这些方法或技巧在实践中十分常见,希望读者在看到类似代码的时候不要慌张。
下画线前缀的主要作用是防止命名污染,因此对静态变量或局部变量使用下画线前缀并无意义。在实践中,我们经常对非公开的
extern
类型(也就是全局)的变量或函数使用下画线前缀,例如:
extern size_t __total_mem_use;
为一个变量加上一个或两个下画线符号作为前缀,通常表示这个变量是非公开的外部变量。对于静态变量或局部变量,使用下画线前缀只会给其他程序员造成困扰,因此不建议使用。
另外,在结构体的定义中,可能包括一些隐藏(或者保留内部使用的)成员。对于这些成员,我们也可追加下画线前缀。在下面这个结构体(
pcrdr_msg
)的对外定义中,就包含这样的4个成员:
/** the renderer message structure */ struct pcrdr_msg { unsigned int __refcnt; purc_atom_t __origin; void *__padding1; // reserved for struct list_head void *__padding2; // reserved for struct list_head pcrdr_msg_type type; pcrdr_msg_target target; pcrdr_msg_element_type elementType; pcrdr_msg_data_type dataType; ... };
之所以这样做,是因为我们不希望外部模块访问这4个仅供内部模块使用的成员。在内部模块中,我们可通过另一个结构体(
pcrdr_msg_hdr
)来访问这4个成员,只需要将结构体
pcrdr_msg
的指针强制转换为结构体
pcrdr_msg_hdr
的指针即可:
struct list_head { struct list_head *next; struct list_head *prev; }; /* the header of the struct pcrdr_msg */ struct pcrdr_msg_hdr { atomic_uint refcnt; purc_atom_t origin; struct list_head ln; };
如前所述,Linux内核的编码风格要求所有的函数应在末尾提供统一的出口,因此我们在Linux内核的源代码中看到
goto
语句被频繁使用。实际上,除了Linux内核,其他基于C语言的开源软件也在使用这一经验性约定写法。
为了直观感受这种写法的优势,我们来看看程序清单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)
很多细致的程序员会在每个函数的入口处检查所有传入参数的合法性,尤其是指针。比如,下面的函数会销毁一个映射表:
int pcutils_map_destroy(pcutils_map* map) { if (map == NULL) return -1; pcutils_map_clear(map); free(map); return 0; }
该函数首先判断传入的参数
map
是否为空指针。可以预期,传入该函数的参数
map
是由名为
pcutils_map_create()
的函数返回的。作为创建对象的函数接口,一般返回空值(
NULL
指针)表示失败,返回非空值则表示成功;如果
pcutils_map_create()
函数返回空值,则不用再调用
pcutils_map_destroy()
函数。换句话说,在调用
pcutils_map_destroy()
函数时,除非误用,否则不会给这个函数传递一个空值。
因此,这种判断貌似有必要,但仔细考虑后就会发现意义不大。在上面的代码中,程序将
NULL
作为非法值做了特别处理,但如果传入的指针值为1或者−1,它们显然也是非法值,那为何不对这两种情况做判断并返回对应的错误值呢?更进一步地,如何判断一个尚未分配的地址值呢?
实质上,C语言并没有提供任何能够判断一个指针的值是否合法的语言级能力或者机制。我们所知道的不合法的指针值通常就是0、−1,以及特定情况下和当前处理器的位宽不对齐的整数值。比如在32位系统中,对于指向32位整数的指针来讲,任何不能被4整除的指针值大概率是非法的。除此之外,我们没有其他有效的手段来判断一个指针值的合法性。因此,这类参数的有效性检查其实是多余的。
再者,在频繁调用的函数中执行此类不必要的参数有效性检查,会大大降低程序的执行效率。
因此,上述代码的最佳实现应该如下:
void pcutils_map_destroy(pcutils_map* map) { pcutils_map_clear(map); free(map); }
我们没有必要仅针对空值做参数的有效性检查。一方面,这种检查并不能覆盖所有的情形;另一方面,如果我们仅仅需要检查空值这种情形,那么程序会很快因为访问空指针而出错。后一种情况说明调用者误传了参数,在程序的开发阶段,借助调试器,我们可以迅速定位缺陷所在。
但在某些情况下,我们仍然希望在调用这类函数时,对传入的常见非法值
NULL
做一些特殊处理,以便可以及时发现调用者的问题。为此,我们可以使用
assert()
。
assert()
本质上是一个宏,而非函数,而且这个宏的行为依赖于
NDEBUG
宏。
assert()
通常的定义如下:
#ifdef NDEBUG # define assert(exp) \ do { \ } while (0) #else /* defined NDEBUG */ # define assert(exp) \ do { \ if (!(exp)) \ abort(); \ } while (0) #endif /* not defined NDEBUG */
在上面的代码中,
NDEBUG
是一个约定俗成的全局宏,通常由构建系统定义。当
NDEBUG
宏被定义时,意味着程序将被构建为发布版本,
assert()
不做任何事情;反之,当程序被构建为调试版本时,
assert()
将判断表达式
exp
的真假,若为假,则调用
abort()
函数终止程序的运行。
如此一来,我们可以将上述代码进一步修改为如下形式:
#include <assert.h> void pcutils_map_destroy(pcutils_map* map) { assert(map != NULL); pcutils_map_clear(map); free(map); }
此外,还有一种针对参数的合法性检查,或者说针对常规条件分支的优化方法,常见于一些优秀的C语言开源项目中。程序清单1.4列出了glib(Linux系统常用的C工具函数库,在一些场景中也可写作GLib)中用于快速验证UTF-8编码有效性的函数。
UNLIKELY
宏优化条件分支
#define VALIDATE_BYTE(mask, expect) \ do { \ if (UNLIKELY((*(uint8_t *)p & (mask)) != (expect))) \ goto error; \ } while (0) /* see IETF RFC 3629 Section 4 */ static const char * fast_validate(const char *str) { size_t n = 0; const char *p; for (p = str; *p; p++) { if (*(uint8_t *)p < 128) { n++; } else { const char *last; last = p; if (*(uint8_t *)p < 0xe0) { /* 110xxxxx */ if (UNLIKELY (*(uint8_t *)p < 0xc2)) goto error; } else { if (*(uint8_t *)p < 0xf0) { /* 1110xxxx */ switch (*(uint8_t *)p++ & 0x0f) { ... } } else if (*(uint8_t *)p < 0xf5) { /* 11110xxx excluding out-of-range */ switch (*(uint8_t *)p++ & 0x07) { ... } p++; VALIDATE_BYTE(0xc0, 0x80); /* 10xxxxxx */ } else goto error; } p++; VALIDATE_BYTE(0xc0, 0x80); /* 10xxxxxx */ n++; continue; error: return last; } } return p; }
上述代码多次使用了
UNLIKELY
宏,用于判断一些不太可能出现在正常UTF-8编码中的字符。这个宏以及成对定义的
LIKELY
宏利用了现代编译器的一些特性,它们可以告诉编译器一个分支判断的结果为真或者为假的可能性是大还是小。利用这两个宏,我们可以协助编译器充分利用处理器的分支预测能力,提高编译后代码的执行效率。
因此,如果非要检查传入参数的有效性,我们可以利用
UNLIKELY
宏,对旨在销毁映射表的代码作如下优化:
int pcutils_map_destroy(pcutils_map* map) { if (UNLIKELY(map == NULL)) return -1; pcutils_map_clear(map); free(map); return 0; }
这样编译器就会认为出现
map == NULL
这一条件的可能性较低,从而在生成最终的机器指令时,通过适当的优化,将可能性较低的条件判断对性能的影响降到最小。
注意,
LIKELY
和
UNLIKELY
宏是非标准宏,目前仅GCC或兼容GCC的编译器支持。这两个宏通常定义如下:
/* LIKELY */ #if !defined(LIKELY) && defined(__GNUC__) #define LIKELY(x) __builtin_expect(!!(x), 1) #endif #if !defined(LIKELY) #define LIKELY(x) (x) #endif /* UNLIKELY */ #if !defined(UNLIKELY) && defined(__GNUC__) #define UNLIKELY(x) __builtin_expect(!!(x), 0) #endif #if !defined(UNLIKELY) #define UNLIKELY(x) (x) #endif
其中使用了__
builtin_expect
这一GCC特有的优化指令。