现代化 C 使用体验

发布: 2022-04-30   上次更新: 2024-04-15   分类: 编程语言   标签: C

文章目录

C 发展历程

即使从 K&R C 的 1978 年开始算起,到 2022 年 C 也有 44 年的历史了。

不知 C 在读者心中是什么样子,在笔者印象中,C 的表达力很差,只有数组与指针两种高级数据结构,标准库小并且有很多历史问题,没有包管理机制,最致命的是,需要手动管理内存,现代年轻程序员很难对 C 感兴趣了,可选择的高级语言太多了,比如:如日中天的 Java,后起之秀的 Go/Rust,为什么要去选择 C?

笔者也是最近一年有重新学习 C 的想法,主要原因是现在的工作与数据库相关实现,而老牌数据库像 PostgresQL、MySQL 等都是用 C 来实现的,虽然如今新兴的数据库有更多的语言选择,但之前 C 在这方面积累了几十年的经验,不是轻易就能超过的,而且像数据库这类复杂的软件,即使采用相似的思路,性能也可能差距甚远。比如哈希函数的选择?冲突怎么解决?如何确定哈希桶大小?有了 C 语言底子,才有可能去看懂这些传统软件的实现过程。

背景介绍

为了体验 C 的开发体验,笔者先是看了 21st Century C 这本书,这本书比较短,可以很快看完,不过这本书讲的也比较浅,之前不了解 C 的工具链的话,看这本书会有所帮助,但对写 C 代码帮助有限,于是又重新温习了 C程序设计语言(第2版·新版),不得不说,即使这么多年了,这本书依然是学习 C 最好的教材,内容简洁、扼要,案例典型,全篇没有一句废话,这本书也比较短,不做习题的话,一周也可以看完。如果说这本书有缺点,那就是变量名不需要在函数一开始全部定义出来了,现在的 C 编译器比之前先进了不少。

有了 K&R C 的底子,就可以直接开始实战了。笔者最近一个月用 C 开发了一个 2K 行的项目:jiacai2050/oh-my-github,不算太 trivial,主要是想从这个项目中体验以下内容:

  • C 的开发流程,熟悉相关工具链
  • C99/C11 的语言特性
  • C 编码风格要领,如何设计 API 才能避免使用者踩坑

下面章节就针对这三点,总结下近几个月的收获。由于接触时间有限,文中难免有不足之处,欢迎读者批评指正。

工具链

首先聊聊工具链。开发一个正式项目前,有一些比较繁琐的事情要做,比如配置开发、调试环境,安装依赖等。对于 C 来说,LSP 支持的比较好,笔者使用的 language server 是 clangd,查看变量定义、查看引用、自动补全等功能都支持。clangd 使用 compile_commands.json 这个文件做配置,一些构建工具可以直接生成它,对于简单的个人项目,也可以直接用 compile_flags.txt 做配置,使用样例:

-I
/opt/homebrew/Cellar/jansson/2.14/include
-I
/opt/homebrew/Cellar/pcre2/10.40/include

对于老牌 C 程序员来说,他们可能会更熟悉 universal-ctags/ctags,遇到 LSP 不能胜任的时候,也可以试试它。

包管理

开发工具配置好后,在正式写代码之前,往往还需要安装依赖,这就涉及到一个重要话题:包管理。

包管理最重要的一点是保证每个项目的依赖是固定的,即 reprodubile build,这不是个简单的事情,在直接、间接依赖某库多个版本时,如何选择正确的版本,npm2 那种把每个库的依赖单独下载是一种解法,Go 的 Minimal version selection 也是一种解法。

NPM 依赖在磁盘上的结构

在 Go 中,在同一大版本下,会选择满足需求的最大小版本

在介绍 C 如何进行包管理前,先来回顾下 C 代码的组织方式。一个 C 项目主要有两类文件:

  • .h 头文件,主要用来做声明,包括函数签名、类型等
  • .c 源码文件,主要用来对头文件中的声明提供实现

这两类文件在构建的不同阶段会用到,具体可参考下面的图片(来源):

C 构建、执行流程

可以看到,头文件只在第二个阶段(即预处理)会用到,源码文件只会在第三个阶段(即编译)时用到,第四、五两个阶段会把用户自己的代码与第三方依赖的代码链接的一起,形成最终的可执行文件。

当一个项目作为类库时,一般不直接提供源码,而是会提供源码文件对应的:

  • .so 共享库(shared object)或
  • .a 静态库(static library,使用 archive 命令创建)

这样一方面是保护源代码不泄漏,另一方面是加速构建流程。用户只需要编译自己的代码即可,三方依赖不需要反复编译。

对于 C 而言,没有严格的包组织方式,只要能在编译、链接期间找到对应的文件即可。社区内有些包管理器,比如 vcpkgconan-io/conanCMake 等。对于个人项目来说,也可以选择下面这种方式:通过操作系统提供的工具(brew 或 apt 等)安装依赖,然后手写 Makefile 来配置编译、链接参数。

这种方式看似简陋,但是能比较有效地解决问题,再加上容器技术,也能比较好做到版本隔离。唯一不足的是无法准确指定依赖版本。

Makefile

下面来介绍下 Makefile 的基本使用,一个功能完备的示例可参考:Makefile

1
2
3
4
5
6
7
P = program_name
OBJECTS = main.o
CFLAGS = -g -Wall -O3
LDLIBS =
CC = gcc
$(P): $(OBJECTS)
	$(CC) $(OBJECTS) $(LDFLAGS) -o $(P)

这是一个比较基础的 Makefile 模板,Makefile 有默认的规则把 .c 文件转为 .o 文件,大致的命令:

1
2
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

因此可以通过 CFLAGS 来定义编译期的参数。Makefile 中有几个常用的变量

  • $@ 表示 target name
  • $* 表示 target name without suffix
  • $< 表示 target dependencies,即冒号后面的内容

手动配置 CFLAGS 不仅繁琐,还容易出错,可以借助 pkg-config 这个工具来简化配置,比如安装了依赖 libcurl ,可以用下面的方式来找到它的编译参数与链接参数:

1
2
# pkg-config --cflags --libs libcurl
-I/usr/include/aarch64-linux-gnu -lcurl
  • -I 用来设置头文件的搜索目录
  • -l 用来指定链接器要链接的类库

一般 C 类库分发时,会提供对应的头文件与编译好的共享库或静态库,在 Debian 系统下,可以通过下面的命令查找 libcurl4-openssl-dev 所安装的文件:

1
2
3
4
5
6
dpkg -L libcurl4-openssl-dev

/usr/include/aarch64-linux-gnu/curl/curl.h
/usr/include/aarch64-linux-gnu/curl/easy.h
/usr/lib/aarch64-linux-gnu/libcurl.a
/usr/lib/aarch64-linux-gnu/libcurl.so

至于链接器选择静态库还是共享库,每个链接器的做法不一致,可参考对应平台的文档。GNU ld 的做法 是通过 -l: 的方式来指定静态库,比如: -l:libXYZ.a 只会去找 libXYZ.a ,而 -lXYZ 则会扩展成 libXYZ.solibXYZ.a 。参考:

语言特性

解读指针声明

指针作为 C 中最重要的一类型,往往会给初学者造成较大困扰,不仅仅是使用上,光是解读指针定义就不是件容易的事情。比如:

1
2
int *ptr;
int *ptr2[10];

ptr 比较好理解,是指向 int 类型的指针,那 ptr2 呢?是指向数组的指针,还是元素为指针的数组?

其实这个问题在 K&R C 这本书有一点睛之笔,即:

The syntax of the declaration for a variable mimics the syntax of expressions in which the variable might appear.

也就是说,变量的声明语法,阐明了该变量在表达式中的类型。翻译过来比较绕,看几个例子就明白了:

1
int *ptr;

*ptr 是一个类型为 int 的表达式,因此 ptr 必须是指针,指向 int

1
int arr[100];

arr[i] 是一个类型为 int 的表达式,因此 arr 必须是数组,数组元素为 int

1
int *arr[100];

*arr[i] 是一个类型为 int 的表达式,因此 arr[i] 必须是指针,因此 arr 必须是数组,元素是 int 的指针。

1
int (*ptr)[100];

(*ptr)[100] 是一个类型为 int 的表达式,因此 ptr 必须是指针,指向一个 int 类型数组

1
int *comp()

*comp() 是一个类型为 int 的表达式,因此 comp() 必须返回一个 int 指针,因此 comp 是一个函数,返回值是 int 的指针

1
int (*comp)()

(*comp)() 是一个类型为 int 的表达式,因此 *comp 必须是一个函数,因此 comp 是一个函数指针

通过上面的解释,如果读者一时没有理解也不要紧,平时写代码用到时再来揣摩其中的奥妙。对于复杂的声明,一般推荐用 typedef 的方式。比如:

1
2
3
4
5
typedef int *int_ptr;
typedef int_ptr array_of_ten[10];


array_of_ten a1 = ...;

通过这种方式定义的 a1 理解起来就没什么难度了,它首先是一数组,数组的元素是指向 int 的指针。K&R C 有一个程序,可以将复杂声明转为文字描述:K&R - Recursive descent parser。 这里有一个在线的版本:

固定宽度的类型

C99 的 stdint.h 中新增了以下类型,来解决之前整型大小不固定的问题:

  • int8_t、int16_t、int32_t、int64_t
  • uint8_t、uint16_t、uint32_t、uint64_t
  • int_least8_t、int_least6_t、int_least32_t、int_least64_t
  • int_fast8_t、int_fast6_t、int_fast32_t、int_fast64_t
  • uintmax_t、uintptr_t

内存管理

C 作为一门系统级语言,没有 runtime,通过 malloc 之类函数申请的内存需要程序员手动释放。这一点也是现在高级语言所尽力避免的,因为程序员相对计算机来说,是非常不可靠的。不过幸好 C 也再演进,在 GNU99 扩展中(clang 也支持),提供了一个 cleanup 属性来简化内存的释放。

先来看看没用 cleanup 之前怎么处理内存释放(完整代码):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static char *request(const char *url) {
    CURL *curl = NULL;
    CURLcode status;
    struct curl_slist *headers = NULL;
    char *data = NULL;

    curl_global_init(CURL_GLOBAL_ALL);
    curl = curl_easy_init();
    if (!curl)
        goto error;

    data = malloc(BUFFER_SIZE);
    if (!data)
        goto error;

    ...... // 省略中间逻辑
error:
    if (data)
        free(data);
    if (curl)
        curl_easy_cleanup(curl);
    if (headers)
        curl_slist_free_all(headers);
    curl_global_cleanup();
    return NULL;
}

request 函数是 C 中一个比较常见的做法,即函数出错时 goto 到统一地方,在那里统一进行内存清理。这种方式个人觉得比较丑陋,比较容易漏掉某个变量的释放。下面看看使用 cleanup 后的写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
void free_char(char **str) {
  if (*str) {
    printf("free %s\n", *str);
    free(*str);
  }
}

int main() {
  char *str __attribute((cleanup(free_char))) = malloc(10);
  sprintf(str, "hello");
  printf("%s\n", str);
  return 0;
}

// 依次输出:

// hello
// free hello

可以看到, str 在 main 函数退出前执行了 free_char 来进行资源使用。为了方便使用,可以通过 #define 定义下面的宏:

1
2
3
4
#define auto_char_t char* __attribute((cleanup(free_char)))

// 使用方式:
auto_char_t str = malloc(10);

需要明确一点:cleanup 只能用在局部变量中,不能用在函数参数、返回值中。因此一个完整的 C 项目还需要用其他手段来保证内存安全,主要的工具有 ASANvalgrind,这两者目前不支持混用,读者可按情况选择。这里给出 valgrind 一个使用示例:

1
valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all ./$(CLI) /tmp/test.db

在内存泄漏时,会有大致如下报告:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
==609== HEAP SUMMARY:
==609==     in use at exit: 276 bytes in 37 blocks
==609==   total heap usage: 35,019 allocs, 34,982 frees, 279,075,706 bytes allocated
==609==
==609== 48 bytes in 6 blocks are still reachable in loss record 1 of 3
==609==    at 0x4849E4C: malloc (vg_replace_malloc.c:307)
==609==    by 0x57DB83F: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x57DCE2F: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x5843C5B: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x57DB73F: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x57DC8DB: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x57D8443: gcry_control (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x4CE9BC3: libssh2_init (in /usr/lib/aarch64-linux-gnu/libssh2.so.1.0.1)
==609==    by 0x48D58CF: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.7.0)
==609==    by 0x48831FB: curl_global_init (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.7.0)
==609==    by 0x10A0AB: omg_setup_context (omg.c:184)
==609==    by 0x10DD0B: main (cli.c:19)

==609== 84 bytes in 25 blocks are definitely lost in loss record 2 of 3
==609==    at 0x4849E4C: malloc (vg_replace_malloc.c:307)
==609==    by 0x10D86B: omg_parse_trending (omg.c:1116)
==609==    by 0x10DBEF: omg_query_trending (omg.c:1176)
==609==    by 0x10DD6B: main (cli.c:38)
==609==
==609== 144 bytes in 6 blocks are still reachable in loss record 3 of 3
==609==    at 0x4849E4C: malloc (vg_replace_malloc.c:307)
==609==    by 0x57DB83F: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x57DCE2F: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x5843C4F: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x57DB73F: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x57DC8DB: ??? (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x57D8443: gcry_control (in /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.2.8)
==609==    by 0x4CE9BC3: libssh2_init (in /usr/lib/aarch64-linux-gnu/libssh2.so.1.0.1)
==609==    by 0x48D58CF: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.7.0)
==609==    by 0x48831FB: curl_global_init (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.7.0)
==609==    by 0x10A0AB: omg_setup_context (omg.c:184)
==609==    by 0x10DD0B: main (cli.c:19)
==609==
==609== LEAK SUMMARY:
==609==    definitely lost: 84 bytes in 25 blocks
==609==    indirectly lost: 0 bytes in 0 blocks
==609==      possibly lost: 0 bytes in 0 blocks
==609==    still reachable: 192 bytes in 12 blocks
==609==         suppressed: 0 bytes in 0 blocks

可以非常清楚地看到泄漏的地方,然后按图索骥去修复相应逻辑即可。修复后的报告:

1
2
3
4
5
6
==481== LEAK SUMMARY:
==481==    definitely lost: 0 bytes in 0 blocks
==481==    indirectly lost: 0 bytes in 0 blocks
==481==      possibly lost: 0 bytes in 0 blocks
==481==    still reachable: 192 bytes in 12 blocks
==481==         suppressed: 0 bytes in 0 blocks

除了使用工具来避免内存问题,更优雅的方式是在设计 API 时就保证尽少地分配,区分好边界,这在后面 API 设计时会提到,这里不再赘述。以下链接有更多关于 cleanup 的讨论:

字符串

C 中没有字符串类型,只是定义了当字符数组的最后一个元素为 NULL 时,这个数组可以当作字符串使用,这种方式的字符串称为 Null-terminated byte strings。由于没有记录字符串长度,因此很多操作都需要 O(n) 的时间,最近一个比较有名的例子是 GTA 的开发人员,通过去掉 sscanf 将性能提升了 50%。

而且 C 中没有 StringBuilder 这种可变的字符串,执行一些像 replace/split 操作时需要手动申请内存,这不仅仅使用上很难受,更重要的是容易内存泄漏,出现安全问题。笔者在这里有两个推荐方式来简化 C 中字符串的处理:

  • 尽量使用固定大小的局部变量

    在进行一些字符串操作时,有时候不需要动态申请内存,直接用固定大小的栈内存即可,这样也省去了 free 的烦恼。比如:

    1
    2
    3
    
    char url[128];
    sprintf(url, "%s/user/repos?type=all&per_page=%zu&page=%zu&sort=created",
            API_ROOT, PER_PAGE, page_num);
  • 尽量使用社区内成熟的字符串库,而不是 string.h 比如 Redis 作者的 Simple Dynamic Strings,更多可参考:Good C string library - Stack Overflow
  • 与之类似的,C 中也有 hashtable 的实现:

Raw String

在实际开发中,难免会用到一些较为复杂的字符串场景,其他编程语言会提供 raw string 来简化处理。在目前的 C 标准中,还不支持这种用法,但是 GNU99 扩展提供了这个功能:

1
printf(R"(hello\nworld\n)");

除了 GNU99 扩展的这种用法外,还可以利用 xxd 命令来将某个文件的内容嵌入 C 代码中,操作方式如下:

1
2
3
4
5
6
7
8
echo hello > hello.txt
xxd -i hello.txt

# -i 参数会输出 C 头文件格式的变量定义
unsigned char hello_txt[] = {
  0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a
};
unsigned int hello_txt_len = 6;

需要注意到, hello_txt 没有以 NULL 结尾,这在某些场景下不是很方便,可以采用下面的命令来追加上:

1
2
3
4
5
6
7
xxd -i hello.txt | tac | sed '3s/$$/, 0x00/' | tac > hello.h
cat hello.h
# 输出
unsigned char hello_txt[] = {
  0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a
};
unsigned int hello_txt_len = 6;

Designated Initializers

毫不夸张的说,这是 C99 中最让人兴奋的特性,它让 C 更像现代化语言的同时,也更加实用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
int a[6] = { [4] = 29, [2] = 15 };
// 等价于
int a[6] = { 0, 0, 15, 0, 29, 0 };


// 嵌套的结构
struct a {
  struct b {
    int c;
    int d;
  } e;
  float f;
} g = {.e.c = 3 };

在使用这种赋值方式时,没有被赋值到的字段,自动初始化成零值,这是非常重要的一点,对于指针来说,它会指向 NULL 而不是任意的地址。

static_assert

这是 C11 增加的功能,能够在编译期检查断言的真否:

1
2
3
4
5
6
7
#include <assert.h>
int main(void)
{
  static_assert(2 + 2 == 4, "2+2 isn't 4");      // well-formed
  static_assert(sizeof(int) < sizeof(char),
                "this program requires that int is less than char"); // compile-time error
}

Generic selection

C11 的这个特性在一定程度上支持了泛型编程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <math.h>

// Possible implementation of the tgmath.h macro cbrt
#define cbrt(X) _Generic((X),                   \
                         long double: cbrtl,    \
                         default: cbrt,         \
                         float: cbrtf           \
                         )(X)

int main(void)
{
  double x = 8.0;
  const float y = 3.375;
  printf("cbrt(8.0) = %f\n", cbrt(x)); // selects the default cbrt
  printf("cbrtf(3.375) = %f\n", cbrt(y)); // converts const float to float,
  // then selects cbrtf
}

多线程

C11 新增了下面两个头文件用于对多线程的支持:

编码风格

错误处理

在传统 C 中,一般的做法是函数返回一个整型的错误码,错误信息通过读取一全局变量来获得。比如 libc,处理逻辑大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
void do_something(some_arg_t args, out_t *out) {
  error_code err = do_task1();
  if(err) {
    goto error;
  }

  err = do_task2();
  if(err) {
    goto error;
  }

  err = do_task3();
  if(err) {
    goto error;
  }

 error:
  ...
}

真正的返回值通过最后一个指针参数来传递。这种做法虽然历史悠久,但有如下两个弊端:

  • 处理啰嗦。每个函数调用的地方需要处理错误,没法进行链式调用
  • 强制进行一次内存分配。因为需要将返回值赋值给指针参数,因此不可避免的需要进行一次内存分配

Modern C and What We Can Learn From It 视频中介绍了另一种处理方式:直接返回一个 struct,里面同时包括真正的数据与错误信息:

1
2
3
4
5
6
7
typedef struct {
  char *data;
  isize_t size;
  valid_t valid;
} file_contents_t;

file_contents_t read_file_contents(const char* filename);

其他函数在使用结果时,进行 contents.valid 判断即可,这种方式就解决了上面两个问题,使用效果如下:

1
2
3
4
5
6
7
file_contents_t fc = read_file_contents("milo.cat");
image_t img = load_image_from_file_contents(fc);
texture_t texture = load_texture_from_image(img);

if(texture.valid) {
  ...
}

由于直接返回了值类型的 struct ,这间接减少了手动管理内存的压力。而且由于没有指针的指向,因此理论上程序会运行的更快。

API 封装

在上面介绍 C 的包管理时,介绍到了 C 程序使用类库时,只需要关心头文件,这里面定义了使用这个库的公开接口,也就是说实现和接口分开的。

不过一般意义上头文件只会对函数进行封装,只进行函数的声明,没有实现,但其实也可以对 struct 封装。比如:

1
2
3
4
5
/* Opaque pointer representing an Emacs Lisp value.
   BEWARE: Do not assume NULL is a valid value!  */
typedef struct emacs_value_tag *emacs_value;

emacs_value init_value();

这里的 emacs_value 就是对结构体 emacs_value_tag 的封装,结构体的真正定义在源码文件中,类库只需要提供该结构体的构造函数即可,用户不需要感知结构体大小与实现。

关于 C 的 API 设计,更多可以参考这个文档(PDF),这是它的讨论

总结

作为一门历史悠久的语言,C 并没有过时,相反随着时代进步,它也在逐步演进。对于长期使用高级语言的程序员来说,刚转向 C 时,可能会觉得它功能太过简陋,开发效率低,但这其实只是表面现象,通过对整个生态圈的熟悉,这种感觉会逐渐消失。而且由于黑魔法少,程序员对整个代码库会更有控制感。

In the beginning you always want results.

In the end all you want is CONTROL.

V2EX 上讨论 💬

扩展阅读

评论

欢迎读者通过邮件与我交流,也可以在 MastodonTwitter 上关注我。