我的 C 代码风格 2025 版本

2025-11-26

Format

format 这块是最不值得纠结的,我接受 Go 语言的哲学,就是这种事情应该交给工具去做。不要去 tab vs space,驼峰还是下划线,不要像 python 那种强制缩进,搞一套游标卡尺出来。在这里争论是很低层次的表现。团队合作的时候,一个项目的风格还是应该统一,可以提供 make format 之类的命令,在 CI 那边去 lint check 来保证这种风格统一。反正 2025 年,工具和 IDE 都特别强大了,不需要动手干预。

个人还是会有一些风格偏好,我的 format 配置在这里可以看到。

受常年写 Go 的影响,我的 C 语言代码风格更倾向于使用驼峰,而不是下划线。tab vs 空格,我站 tab。

函数的实现,我喜欢把返回类型放一行,然后把函数名放下一行,像这样:

static int
myFunc(int arg) {
}

这个好像是受云风的影响,理由是即便没有 IDE 的帮助,使用 grep ^myFunc 也可以很方便地找到函数。虽然这个理由放在 2025 年也不那么成立,函数什么编辑器 IDE 也很容易支持跳转到函数,但是这个编码习惯一直保持下来了。 但是要说习惯,也没有那么一致。早期我写 == 的风格是,值在前面,变量在后面,也就是

if (42 == x) {}

这个风格有好处是,当误把 == 写成 = 的时候,编译器会报错。但是现在没有这么教条了,因为 2025 年的编译器,即便你写成

if (x = 42) {}

-Wall 的时候它已经智能到提醒你,"你这里似乎想要 == 而不是 =",而值写前面变量写后面,这个操作很刻意,所以这样的习惯就没操持下来,算是去其糟粕。

结构体类型我也更多地去 typedef,直接用 T,而不是 struct T

typedef struct {
    int field;
} T;

或者先声明再定义:

typedef struct T T;

struct T {
    int field;
};

好处是可以少写一些 struct。

不暴露的函数签名都 static,其实 C 语言的设计应该做成默认 static,只有加关键字才对外可见...但是这已经是 C 语言标准的历史包袱,没法改了。

关于语言标准和编译器,宏,static inline, const

像 lua 它是坚持 C89 标准。有些嵌入式的项目,也是抱着旧的标准。坚持旧标准的理由是,这样的代码风格使用移植更容易,即便在一些老旧硬件,或者是自己搞的非标准的生态限制,编译器没法更新到最新,也能够运行旧风格的代码。

在我这边,我并不排斥新标准,但是也不盲目追求新标准。更倾向于实用主义吧。我基本上假定我的代码就是运行在 64 位的。然后主流的系统上,linux mac windows 这些。不假定是 arm 或者 x86 或者其它。所以没有嵌入式项目那种抱着旧标准不放的理由。然后编译器,只要 gcc 支持的都行。

像 computed goto,就是 gcc 扩展的功能,在实现字节码解释器这样的场景下,对性能特别关键。这不是 C 语言标准,但是主流编译器都支持了,比如 gcc,clang,那我觉得使用也没问题。更新的标准好像还有 _Generic 之类的 feature,这个功能我倒没有依赖。这也是实用主义的平衡吧:尽可能地用 C 标准而不依赖编译器扩展,但是也不要使用太新的标准以至于有编译器还没跟进的。

const 在 C 语言里面其实基本没什么用,还不如不要。因为指针类型都是可以强制转换的,安全也就不保证了,再 const 一下只是脱了裤子放屁。

对于宏的态度,我以前是尽量避免的。但是现在也接受比较克制地使用。宏对于性能很有帮助,相比函数调用,可以明确地知道宏的写法不会引入函数调用开销。现在我是明显地感受到函数调用的开销了,尤其是 cora 项目里面 trampoline 带来的影响。对于 “内联是优化之母” 这句话也有了更深刻的认识。gambit scheme 就是一堆鬼划符的宏,它说它是生成到 C,但是生成的 C 完全不可读,几乎是用宏定义出了一个 DSL 的级别。宏的性能确实是牛逼。

而 static inline,则像是黑盒,依赖编译器来决定是否能优化自动内联。也有它的好处,就是 debug 的时候还能用,而复杂的宏在 debug 的时候就用不了了。对于一些简短但性能关键的代码,选 static inline 是可以的,而用宏写也不排斥。

通用数据结构

C 语言里面非常痛的一个点,在于没有带一些内置的容器代码库。这些容器对于写代码的便利性可谓是质的提升。重复利用率太高了,所以个人最好是写一套。C 语言里面没有泛型,所以这一块以前比较恶心。直到我发现了这篇文章

void* 搞出来的东西就是非类型安全。而 C 语言又不方便弄泛型。但是利用一定技巧之后,就可以把类型信息藏到定义里,给编译器可用。 其中 Vector 的实现还是传统的 void* 来存放数组,但是类型不再需要用宏来提供了,直接从 union 的 type 字段中获取。

#define vector(T) union { Vector v; T type; }

在我的代码里面,大量用到了 vector(T)map(K,V)。这样的基础数据结构。

关于 map 实现,我用的是 nullprogram 这个老哥的hash-trie 方案,这哥们的博客简直是个宝藏,我不少 C 的东西都是从他那里学来的。hash-trie 的好处就是支持可变大小,使得它的使用场景不那么受限,如果是其它的需要扩容类的 hash,在扩容的时候就可考虑一次扩容完会造成的卡顿,而增量扩容又会带来的实现复杂度。tree 类的结构对于平衡旋转会相对麻烦,所以 hash-trie 就是我能知道的实现起来最简单,同时又能最通用的 kv 数据结构。

链表一类的,我倒没做啥封装,纯的 list(T) 是一个完全没有价值的数据结构,在需要的时候,结构体里面用有一些指针字段要串起来,只是目前我还是倾向于祼写而不是像 linux 内核那样的 list_head,不过这个技巧我倒是学到了,很受用。比如说,我要弄一个 GC 托管的类型,我只需要统一用 scmHead* 来表示,然后 scmHead 里面存储了具体类型是啥,于是就可以通过 offsetof 从 head* 计算出 T*,反射出原始的类型,然后使用。

struct T {
    // some field
    scmHead head;
    // some other field
};

有了这些通用数据结构,和一些技巧之后,写 C 的幸福感提升了一个档次。

字符串处理

C 语言中最值得吐糟的点之一,就是字符串设计。以 ''0' 结尾的连续数组,这玩意搞出多少溢出漏洞,求 length 又不方便,还不支持 utf8,所以是一点好处都没有。 所以我在之前调研了一下字符串库之后,就一直沿续下来,我会更倾向使用自己封装的字符串库。其实设计和 api 都特别简单,克服了 C 字符串的一些缺点。

  • str 只读,是字符串的视图。
  • strBuf 可变,是实际的 memory owner
typedef struct {
  char *str;
  unsigned int len;
} str;
struct _strBuf;
typedef struct _strBuf* strBuf;
struct _strBuf {
    int cap;
    int len;
    char data[];
};

在分配 strBuf 的时候,会故意多分配一个 ''0' 以兼容 C 语言的标准字符串。也就是说 "hello world" 给给它分配 12 字节,虽然 len 是 11,但是 cap 是至少 12。 常用的 C 语言的字符串 API 都可以用 str.h 的 API 替代。

S("hello world") 这个宏是挺有用的,可以用来快速创建 str。但是不能够用在 C 字符串 char* 上。

有一个痛点是,跟 C 语言的标准库交互上面,那边大量使用标准的 C 字符串,交互的时候就需要转来转去的。有些人推荐的做法是直接 C 标准库也不要了,但是我这边暂时还没把步子迈这么大。 还有一些人是把 printf 也自己实现一套,这样就可以 printf 自己的字符串。这个做法也暂时没采纳。

标准库

在 C 标准库中自带的内容,我都是倾向于使用的。并没有去封装一套自己的库。

通常用到的,最基础的有

  • assert
  • stdint
  • stdbool

标准 C 中连 bool 类型都没有,所以 stdbool 是必须的。然后 uint64_t 这类的东西,我没有去自己重新定义 u64 或者 i8 之类的,直接用标准库的 stdint。

memset / malloc 这类的,我在 cora 语言里面主要的分配都是带自 GC 托管内存了,也就是说大部分都是我自己实现的内存管理。而少量七七八八的,总会到 malloc 的,直接用标准库就行。

字符串前面说过了,就是避免用 C 的库,尽量用自己的实现。printf 这类绕不过交互,已没有重写一套。

变参宏 va_listva_startva_argva_end。这玩意简直就是糟粕,变参宏的组合性特别差。在不同平台的实现也不一样。尤其是在 benchmark 一下之后,我发现这性能

void f(int nargs, Obj ...p);
f(5, arg1, arg2, arg3, arg4, arg5);

还不如直接在栈上分配临时对象的:

void f(int nargs, Obj *p);

Obj args[5] = {arg1, arg2, arg3, arg4, arg5};
f(5, args);

所以觉得 C 语言支持变参函数,还不如不支持。这叫支持了个寂寞。

错误处理

C 语言和 Go 语言,错误处理都是不向用户隐藏什么由用户自己去弄的。只不过 Go 语言是多值返回,而 C 语言这边是传引用进去,再在函数中修改以便返回结果

int f(T *ret)

if f(&ret) != 0 {
    return -1;
}

我比较喜欢的一个技巧是用 goto 来处理 error,负责出错后的资源释放。

int f() {
    Obj obj;
    if (malloc(&obj) != 0) {
        goto error1;
    }
    if (init_obj(obj) != 0) {
        goto error2;
    }
    return 0;
    
error2:
    free(obj);
error1:
    return -1;
}

这就跟 defer 一样的处理顺序了,资源的持有是加加加的,出错后跳转到首个 error,再接下来依次释放资源。

asan, tsan

写 C 代码有一个好处是,工具链特别成熟,几乎叫最成熟没有之一。在这样成熟的生态下,就会有很多好东西可以用。比如说 debugger,profiling。以及内存检测工具,比如 asan。线程安全检测的工具,tsan。

利用好这些成熟的工具,绝对可以让 "C 代码不安全" 这个问题得到相对缓解。对于这些工具,我都是先在 Go 那边知道了有这些,发现好用。然后回来写 C 的时候,再发现原来 Go 都是从 C 这些工具链抄过去的,其实 C 这边的工具会更丰富。 只要是自己的项目,可控的代码,我根本没有发现内存泄漏之类的事情是个多少麻烦的问题。反而 rust 那种获得"安全性"的方式,像是编译器拿着一个冻鱼在不停地敲我的狗头,一点都不舒适。

结语

其实新出的所谓系统级编程语言,我都有尝试一下。rust, zig, odin, c3, 体验一圈下来,最后的感受是 c > c3 > odin > zig > rust。

rust 对标的是 C艹,而不是 C,两者完全不同的哲学。zig 是热度仅次于 rust 的,刚开始我以为自己会喜欢,但是后来发现,zig 的作者有太多自己的想法和口味了。有想法并不错,只是 zig 的作者所描绘的更好的 C 语言,其实已经是一门全新的语言了,而且他还刻意弄成和 C 不一样,这并不是我想要的 a better C。所以体验下来我就发现,c3 其实是最合我的口味的,它的标语是 "evolution not revolution"。奈何这个语言的成熟度是最低的,甚至我从 github 下载编译包后,本地都没能跑起来。最后是 odin,它就是 C,然后披了个 Go 语言的语法的皮。Go 语言我并不讨厌,odin 算是新语言中写起来是最顺畅的。

看完一圈新出的语言之后,我发现大多限制并不真的需要换语言来实现,而 C 语言本身已经足够满足绝大部分的需求了。我能从新语言里面学到一些东西,从而让我使用 C 的姿势更进化一点点。但是最终还是 C。 如果说有什么底层的需求,那就是用 C 就行了。而如果更上层的场景,我会倾向我自己开发的 cora 语言,这是一个 lisp,然后编译到 C,跟 C 可以完全交互,像 lua 那样是互补关系。

开发效率的差异,在于避免重复工作。避免重复工作的核心,是代码的复用和模块化。过去 C 语言的低效其实是源于它缺少了一些基础的东西,要重造很多轮子。而把自己最习惯的轮子用熟悉之后,就会形成自己的一套风格和库,再之后其实是越来越高效的。当然,在 AI 浪潮下,"古法编程"无论怎么高效也不值一提,这是题外话了。以上就是我对自己的 C 编程风格的一个总结。

最后一个有意思的,是我发现 handmade hero 这个系列,作者居然是用 C++ 编译器写 C。它的文件名是 cpp,但其实风格完全是 C 的风格。这也算是另一个角度的实用选择: C++ 语言已经庞大到大家都用一个子集。那这个编译器做得还不错,用来当 C 写,就可以看作是一个新语言带上一个扩展了一下的编译器,a better C 而不是 C++,也不是一门全新的语言。

c