购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.4 提高代码可读性的最佳实践

在C语言的编码风格中,最重要的是代码的排版。排版决定了代码是否整洁和美观,对于代码的可读性具有非常重要的影响。

1.4.1 守好“80列”这条红线

在编写C语言程序时,我们要守好“80列”这条红线,即每行代码不超过80个字符。由于程序结构的限制,像C++、Java这样的面向对象编程语言很难坚守这条红线。但由于C语言的结构特性,在C代码中坚守“80列”这条红线是可行的,而且很有必要。C程序员应该把每行代码不超过80个字符作为金科玉律。

坚守一行代码不超过80个字符,起初主要是为了方便在不同的字符终端查看源代码。早期电传字符终端的列数为80,行数为25,一旦一行代码超过80列,早期电传字符终端就会自动绕行显示,从而导致阅读困难。而如今,坚守“80列”这条红线还可以带来其他额外的好处。

首先,可以防止代码中出现过多的缩进和嵌套。如果代码的缩进层次达到4级或更多级,就很容易超出80个字符的限制(尤其是当按照Linux内核的编码风格,使用每级缩进8个空格的制表符时),这就要求程序员放弃太多的缩进或嵌套,而代码的缩进层次不超过3级,也正是广大C程序员约定俗定的目标。像C++和Java这样的面向对象编程语言之所以难以坚守“80列”这条红线,就是因为程序结构中多了类以及命名空间等新的层次,从而导致代码的缩进层次不超过3级几乎不可能实现。

其次,如今计算机屏幕的尺寸越来越大,24寸显示器屡见不鲜。这也逐渐体现出坚守“80列”这条红线的另一个优势:如果每行代码都不超过80个字符,就很容易在同一个窗口中同时查看多个源代码文件,只要在编辑器内竖直分隔显示多个源文件即可。这为程序员的工作提供了不小的便利。

1.4.2 空格、大括号位置及缩进

适当添加空格、不滥用空格,对提高代码的可读性和维护代码的整洁性具有很大的帮助。这方面的最佳实践便是采纳Linux内核编码风格的相关规定。

(1)单目运算符和 ++ -- 运算符的前后不加空格,例如像下面这样的写法是正确的:

int *p = (int *)&a;
p++;
--a;

像下面这样的写法是不正确的:

a ++;
++ p;

(2)函数名称,包括可按函数方式调用的关键字(如 sizeof )和宏的后面,不要加空格,例如像下面这样的写法是正确的:

call_me(sizeof(int), MAX(x, y));

其中,参数列表中的逗号之后应该添加空格。

像下面这样的写法是不正确的:

call_me (sizeof (int),MAX (x,y));

(3)不要在行尾添加空格。这一点很重要,但很容易被忽视,因为行尾的空格在视觉上是看不出来的。为此,可以修改编辑器的设置,显示行尾的空白字符,比如将行尾的空格显示为灰色的句点。

(4)双目运算符或多目运算符的前后,以及关键字(如 if else for )的后面一般需要添加空格,例如:

if (a && b) {
}

但也有一些例外,比如双目的成员访问操作符( . -> )的前后不需要添加空格,例如:

temp->next = NULL;

除空格之外,有关大括号的位置,还有一个建议是遵循Linux内核编码风格的相关规定。相较于其他编码风格,Linux内核编码风格的相关规定最为简洁。但究竟是使用制表符还是使用空格进行缩进,以及缩进宽度是4个空格还是8个空格,则由读者自行决定。在实践中,相较于将if、while等语句后的左大括号( { )单独一行书写,笔者更喜欢将其置于行尾,如下所示:

int lots_of_args(int x, long y, short z, double a_double, float a_float)
{
    int haha = 0;
 
    if (x < foo(y, z)) {
        haha = bar[4] + 5;
    }
    else {      /* 笔者不太喜欢 } else { 这种写法 */
        while (z) {
            haha += foo(z, z);
            z--;
        }
 
        haha = ++x + bar();
    }
 
    return haha;
}

1.4.3 指针声明和定义中的星号位置

对于指针声明和定义中的星号位置,C和C++的习惯有所不同。对编译器来讲,两种写法都是正确的。例如,下面是C语言的风格:

void *get_context(struct node *node)

而C++一般采用下面的风格:

void* get_context(struct node* node)

建议C程序员坚守C语言的星号使用习惯。但是,相较于选择哪种风格,更重要的是在同一个程序的代码中,应该坚持使用同一种风格,不要在有些地方使用C语言的风格,而在另一些地方使用C++语言的风格。另外,不要使用下面这种兼顾两者但其实又两不像的风格:

void * get_context(...);

1.4.4 善用类型定义

Linux内核强烈要求慎用类型定义(typedef),但在某些情形下使用类型定义可以带来很多便利。根据笔者多年的工作经验,应考虑在下列场合使用类型定义。

1.当需要隐藏类型的实现细节时

可以在函数库的接口定义中使用类型定义,尤其当需要隐藏类型的实现细节时。也就是说,使用接口的程序员不需要关心类型的内部细节。比如,在Win32 API中,存在很多称为句柄(handle)的类型,比如 HWND 表示窗口句柄,代表一个窗口对象的值。在内部实现中,窗口句柄可能是一个指针,也可能是一个表示索引的整数。使用 HWND 的程序员不需要关心窗口句柄的内部实现,也不允许应用程序通过窗口句柄直接访问内部的数据结构,而只需要传递某个API返回的句柄给其他API使用即可。这种情况是使用类型定义的绝佳场合。比如 HWND 就可以用一个和指针等宽的无符号整数类型( uintptr_t )来定义:

typedef uintptr_t HWND

假定在Windows操作系统的内部实现中, HWND 可直接作为指针使用,那么在具体使用时,只要做一次强制类型转换即可,例如:

static void foo(HWND hWnd)
{
    WINDOW *pWin = (WINDOW *)hWnd;
 
    pWin->spCaption = strdup("Hello, world!");
 
    ...
}
2.对结构体指针类型使用类型定义

可以对结构体指针使用类型定义,并使用 _p 或者 _t 后缀,例如:

struct list_node {
    const char       *title;
    struct list_node *next;
};
 
typedef struct list_node *list_node_p;

使用 _p 后缀和 _t 后缀的区别是,当结构体的内部细节暴露在外时,意味着外部代码可以访问结构体内的成员,此时使用 _p 后缀;反之,当结构体的内部细节被隐藏时,意味着外部代码不可以访问结构体内的成员,此时结构体指针的作用类似于上面提到的句柄,对外部代码而言,结构体指针相当于一个普通的无符号整数值,因而使用 _t 后缀。

相比使用句柄的情形,若对结构体指针使用类型定义,则可以带来一个额外的优势:在内部使用时,不用进行强制类型转换。为此,我们在头文件中作如下声明和定义:

struct list_node;
typedef struct list_node *list_node_t;
 
/* Returns the title in the specific node */
const char *list_node_get_title(list_node_t node);

然后在内部的头文件或者源文件中,定义结构体的细节并实现相应的接口:

struct list_node {
    const char       *title;
    struct list_node *next;
};
 
const char *list_node_get_title(list_node_t node)
{
    return node->title;
}

对结构体指针使用类型定义,即使头文件中声明的结构体名称不变,我们也可以在不同的源文件中为结构体定义不同的内部细节。这将带来极大的灵活性,详见第6章。

另外,这种做法在C标准库中十分常见,比如C标准库中全部大写的 FILE DIR 等结构体,其内部细节不会暴露给应用程序。但用于描述目录项的结构体的细节则暴露给应用程序,并没有定义新的数据类型。

3.对枚举类型使用类型定义

对枚举类型使用类型定义并使用 _k 后缀,就可以和后缀为 _t _p 的类型区分开来。例如,下面的代码定义了一个名为 purc_document_type_k 的枚举类型来表示文档的类型:

typedef enum {
    PCDOC_K_TYPE_FIRST = 0,
    PCDOC_K_TYPE_VOID = PCDOC_K_TYPE_FIRST,
    PCDOC_K_TYPE_PLAIN,
    PCDOC_K_TYPE_HTML,
    PCDOC_K_TYPE_XML,
    PCDOC_K_TYPE_XGML,
 
    /* XXX: change this when you append a new operation */
    PCDOC_K_TYPE_LAST = PCDOC_K_TYPE_XGML,
} purc_document_type_k;
4.对结构体类型使用特别的命名规则

如果确实需要对结构体进行类型定义,则可以对类型定义名称采用全大写且不带下画线的命名法,以便提示它是一个结构体的类型定义名称,如 LINKEDLIST 。这样就不会与采用全小写加下画线形式的变量名或函数名,以及采用全大写形式但使用下画线的常量名或宏名产生混淆了。

typedef struct LINKEDLIST {
    const char         *title;
    struct linked_list *next;
} LINKEDLIST;

如果能接受驼峰命名规则,那么也可以使用首字母大写的驼峰命名法来定义结构体的类型名称,例如:

struct LinkedList {
    const char         *title;
    struct LinkedList  *next;
};
 
typedef struct LinkedList LinkedList;

但这里更推荐不使用后缀来定义结构体的类型名称,因为前面已经对整数类型、枚举类型和结构体指针类型使用了 _t 或者 _k 等后缀:

typedef struct linked_list {
    const char         *title;
    struct linked_list *next;
} linked_list;
 
typedef struct linked_list *linked_list_t;

在早期的C代码中,由于当时的编译器不允许新的类型名称和已有的结构体类型名称相同,因此我们经常会看到下面的代码:

struct _LINKEDLIST {
    const char         *title;
    struct linked_list *next;
};
 
typedef struct _LINKEDLIST LINKEDLIST;

或者

struct tagLINKEDLIST {
    const char         *title;
    struct linked_list *next;
};
 
typedef struct tagLINKEDLIST LINKEDLIST;

上述代码在结构体的类型名称中使用下画线和 tag 作为前缀以示区别,但现在已经不需要这样做了。

作为一个不建议自定义数据类型的例子,我们在新的C语言项目中,应避免对整数做类型定义。C99标准已经在 <stdint.h> 头文件中针对不同宽度的整数类型定义了新的数据类型,比如 uint8_t intptr_t intmax_t 等,因此我们没有必要再自行针对不同的整数类型自定义新的数据类型。

1.4.5 命名规则保持一致

在代码中,应采用一致的命名规则(K&R命名法或匈牙利命名法),而不能混用命名规则。但是,C代码更倾向于采用K&R命名法。因此笔者建议,除用于接口的函数名称之外,最好在内部的实现代码中统一采用K&R命名法,并避免出现像 spName 这样的命名风格。用于接口的函数名称采用匈牙利命名法或者带有小写前缀的驼峰命名法具有一定的优势,并且容易把这些接口和系统函数名或者其他函数库的接口名区分开来,从而在一定程度上避免命名污染。比如常用于解析JSON的开源函数库cJSON,其接口定义如下:

/* returns the version of cJSON as a string */
const char* cJSON_Version(void);
 
/* Supply malloc, realloc and free functions to cJSON */
void cJSON_InitHooks(cJSON_Hooks* hooks);
 
cJSON * cJSON_Parse(const char *value);
cJSON * cJSON_ParseWithLength(const char *value, size_t buffer_length);

除源代码中的函数名、变量名之外,用于组织源代码的目录和文件的命名规则也需要得到重视。下面给出一些常规建议:

(1)仅使用ASCII可打印字符(文件系统不允许的字符除外),而不要使用中文、表情符号等特殊字符;

(2)使用 - 连接多个单词而避免使用 _ ,这一点和C代码中的变量名不同;

(3)使用全小写的文件名和目录名。

如此, ordered-map.c 就是合乎上述建议的文件名,而 ordered_map.c 就不是合乎上述建议的文件名。

1.4.6 正确书写注释

虽然在编译时注释会被编译器忽略,但注释对代码的可读性具有极其重要的作用。代码如果没有注释或者注释很少,那么不仅其他人不容易看懂,即使是代码的作者,一段时间之后再次接触也会一头雾水。但代码中的注释要恰到好处,不宜过多或者画蛇添足。毕竟,如果一段代码的功能是清晰的,变量的命名符合习惯,则代码本身就可以说明其运行逻辑,而不必进行过多的注释。因此,我们更多看到的注释,通常存在于针对接口的说明中,比如函数的功能描述、各个参数的含义、有关返回值的说明、结构体中每个成员的含义等。

关于C代码中注释的书写,建议如下。

(1)为对外的接口提供详细的注释,并采用Doxygen(或其他文档生成工具,如GtkDoc)允许的格式来撰写注释。对于函数,应就其功能、参数以及返回值做详细说明;对于结构体,应就其公开的成员做详细说明。现在,越来越多的开源项目采用Doxygen允许的格式,以随同代码共存的注释形式来编写和维护接口的说明文档。这既方便开发者阅读,又便于维护,尤其在接口发生变化时,开发者可以在修改代码的同时完成对文档的修改。在本章的后面,读者可以看到采用Doxygen允许的格式撰写的接口注释。

(2)在不对外的内部模块中,应就文件的功能、内部函数的用途等做简要的注释。除非代码涉及复杂或重要的算法,否则不必过多地撰写注释。这是因为,当我们使用了合乎习惯的命名方法,并掌握了常见的接口设计模式之后,看接口及其实现就能知悉代码逻辑;此种情况下,额外地撰写注释就是画蛇添足。

(3)避免使用C++的注释形式。C++的注释形式(即“ // ”形式)输入方便,具有一些优点。但是如前所述,代码最重要的特质就是风格统一,因此在C代码中还是应该统一采用传统的注释风格。在Linux内核以及大多数重要的C语言程序中,我们很少看到C++风格的注释。一个好的习惯是仅在行尾的简短注释中使用C++风格的注释。

(4)巧用XXX、FIXME、TODO等短语。XXX具有告诫意味,表示这段代码非常重要,必须认真阅读。FIXME一般表示这段代码不够完善,存在提高空间。TODO一般表示这段代码的功能还不完整,有待未来完成。有些智能的代码编辑器会将注释中的这些特殊短语用特殊的颜色加以显示,以提醒代码的阅读者。

(5)学习Linux内核编码风格提倡的注释编写风格(见本章前面的内容),而不要添加额外的装饰用字符。

下面的代码片段定义的枚举类型用于区别不同的变体类型,其中两次使用了XXX标记,用于特别提醒开发者。在现代编辑器中查看这段代码,XXX会显示为醒目的红色。

typedef enum purc_variant_type {
    PURC_VARIANT_TYPE_FIRST = 0,
 
    /* XXX: keep consistency with type names */
#define PURC_VARIANT_TYPE_NAME_UNDEFINED    "undefined"
    PURC_VARIANT_TYPE_UNDEFINED = PURC_VARIANT_TYPE_FIRST,
#define PURC_VARIANT_TYPE_NAME_NULL         "null"
    PURC_VARIANT_TYPE_NULL,
#define PURC_VARIANT_TYPE_NAME_BOOLEAN      "boolean"
    PURC_VARIANT_TYPE_BOOLEAN,
#define PURC_VARIANT_TYPE_NAME_NUMBER       "number"
    PURC_VARIANT_TYPE_NUMBER,
#define PURC_VARIANT_TYPE_NAME_STRING       "string"
    PURC_VARIANT_TYPE_STRING,
#define PURC_VARIANT_TYPE_NAME_OBJECT       "object"
    PURC_VARIANT_TYPE_OBJECT,
#define PURC_VARIANT_TYPE_NAME_ARRAY        "array"
    PURC_VARIANT_TYPE_ARRAY,
#define PURC_VARIANT_TYPE_NAME_SET          "set"
    PURC_VARIANT_TYPE_SET,
 
    /* XXX: change this if you append a new type. */
    PURC_VARIANT_TYPE_LAST = PURC_VARIANT_TYPE_SET,
} purc_variant_type;
 
#define PURC_VARIANT_TYPE_NR \
    (PURC_VARIANT_TYPE_LAST - PURC_VARIANT_TYPE_FIRST + 1)

注意,在上述枚举量的定义中,还使用了将字符串常量和枚举量置于上下两行进行定义的技巧。这一技巧既方便了代码的维护,也能帮助我们在改变一个类型或新增一个类型时,确保对枚举量和对应的字符串常量做同步处理。

1.4.7 优雅编写条件编译代码

条件编译在C代码中十分常见。条件编译的常见用途有如下3种。

(1)当我们要将自己开发的软件运行在不同的操作系统中时,由于底层标准库在不同操作系统中的实现存在差异,我们需要使用条件编译来处理这种差异。

(2)类似地,当我们要针对不同的处理器或处理器架构编写特定的代码实现时,也需要使用条件编译。

(3)当我们要通过编译时配置选项来控制软件的功能(比如通过配置选项控制包含哪些功能模块)时,会经常使用条件编译。

如下代码来自MiniGUI的头文件,用于判断如何确定64位的整数数据类型,其中给出了条件编译的常见用法:

/* Figure out how to support 64-bit datatypes */
#if !defined(__STRICT_ANSI__)
#   if defined(__GNUC__)
#       define MGUI_HAS_64BIT_TYPE    long long
#   endif
#   if defined(__CC_ARM)
#       define MGUI_HAS_64BIT_TYPE    long long
#   endif
#   if defined(_MSC_VER)
#       define MGUI_HAS_64BIT_TYPE    __int64
#   endif
#endif /* !__STRICT_ANSI__ */
 
/* The 64-bit datatype isn't supported on all platforms */
#ifdef MGUI_HAS_64BIT_TYPE
typedef unsigned MGUI_HAS_64BIT_TYPE Uint64;
typedef signed MGUI_HAS_64BIT_TYPE Sint64;
#else
/* This is really just a hack to prevent the compiler from complaining */
typedef struct {
    Uint32 hi;
    Uint32 lo;
} Uint64, Sint64;
#endif

如上述代码所示,条件编译会严重割裂代码的连续性,从而极大破坏代码的可读性。因此,我们应该尽量避免使用条件编译。当然,由于C语言主要用来开发底层基础软件,因此C程序员难免会因为上面所说的3种用途而使用条件编译。为此,下面是4条可供C程序员参考的处理原则。

第一,使用恰当的注释说明条件编译代码块的作用,如下所示:

#ifdef foo
    ...
#else /* foo */
    ...
#endif /* not foo */
 
#ifdef foo
    ...
#endif /* foo */
 
#ifndef bar
    ...
#else /* not bar */
    ...
#endif /* bar */
 
#ifndef bar
    ...
#endif /* not bar */

上述代码在 #else #endif 代码行的末尾,使用了恰当的注释来说明条件编译代码块所对应的条件。

第二,在嵌套条件编译时,恰当地使用缩进来表示嵌套关系,如下所示:

#ifndef NULL
#   ifdef __cplusplus
#       define NULL            (0)
#   else
#       define NULL            ((void *)0)
#   endif
#endif  /* not defined NULL */

第三,避免使用过长的条件编译代码块,确保一个条件编译代码块不超过常见编辑器的最大行数(25~50行)。

第四,使用构建系统生成器提供的方法实现对软件功能的控制,避免使用条件编译。比如,在实现一个跨平台的文件系统操作接口时,需要考虑到不同操作系统之间(尤其是Windows操作系统和类UNIX操作系统之间)的巨大差异。这时,我们可以借助构建系统生成器,根据当前所要构建的目标系统,生成对应的构建文件,从而针对不同的目标平台编译不同的源文件。为此,我们可以将针对类UNIX操作系统(如Linux和macOS)的代码组织到一个源文件中,如 filesystem-unix-like.c ;而将针对Windows操作系统的代码组织到另一个源文件中,如 filesystem-windows.c 。这样就可以避免在源文件中使用大段的条件编译。

知识点:构建系统生成器

构建系统(build system)生成器(generator)是用于生成构建C、C++项目等所使用的构建系统的工具。这里的构建系统通常由一组Makefile组成。因此,我们可以将构建系统生成器理解成Makefile生成器。常见的构建系统生成器有GNU Autotools、CMake、Meson等。成熟的构建系统生成器通常具有跨平台的特征,可以帮助我们针对不同的平台组织我们的源文件,并按照目标构建系统的特性自动生成一些宏,从而提升代码的可移植性。

有关构建系统生成器的内容,我们将在第5章做详细阐述。 q0NH8dMHckvn31zxF9LEfuqKN32Sw9jyNLRDfjt4eF+hvivcLUzfMqvSTPVhd5jy

点击中间区域
呼出菜单
上一章
目录
下一章
×