C语言下的引用计数(Rc)
事情是这样的,Tsoding在他的频道发布了这样一期视频,是关于在C语言中实现一个简易的Rc,并且使用它来实现了一个微型的Lisp解释器。我跟着他的视频敲完了所有代码。但老实说我并没有理解多少,所以在这里写一个博客并且盼望着能够从中学到一些什么
一、Lisp 是什么
1.1 Lisp 概述
Lisp的全称是List Processor(列表处理机),由John McCarthy在1958年创立,是目前仍在使用的第二古老高级编程语言,仅次于Fortran。和我们熟悉的C、Java、Python等语言相比,Lisp的核心差异在于 “程序即数据”(同像性) 这一独特特质,这也是它区别于其他主流语言的核心标志。
1.2 Lisp 核心特征
语法高度统一:一切皆为S-表达式(列表),摒弃传统中缀表示法。例如加法运算不会写成
1 + 2,而是采用前缀表示法的(+ 1 2)。函数式编程为核心:递归是核心编程方式,函数在Lisp中是“一等公民”,可作为参数、返回值或变量存储,灵活性极高。
早期特性奠基:是最早引入动态类型和自动内存管理的语言之一,这两大特性也成为后续编程语言内存管理方案的核心动因。
二、Lisp 底层数据结构
2.1 核心结构:Cons Cell(点对单元)
理解Lisp的关键,在于认清其列表的本质——它并非连续数组,而是由Cons Cell(点对单元) 构成的链表,这是Lisp数据结构的底层基石。每个Cons Cell分为两部分:CAR指向当前节点的数据(如整数、符号),CDR指向列表的“剩余部分”。
一个标准的Lisp列表,本质是“嵌套的Cons Cell链条”,且必须以NIL(空列表) 结尾。例如列表(1 2 3)在内存中,就是三个Cons Cell串联而成:
第一个Cons Cell:CAR为1、CDR指向第二个Cons Cell;
第二个Cons Cell:CAR为2、CDR指向第三个Cons Cell;
第三个Cons Cell:CAR为3、CDR指向NIL。
2.2 易混淆概念区分
Cons Cell:底层物理结构,类似一个有两个格子的“盒子”,是构成所有Lisp数据结构的基础单元。
点对(Dotted Pair):当Cons Cell的CDR指向非列表(也非NIL)时,称为点对,格式如
(420 . 69),常用来表示坐标、键值对等二元关系。标准列表:只有当Cons Cell链条以NIL结尾时,才是我们常说的“标准列表”,也是Lisp中最常用的数据形式。
这种链表结构赋予Lisp一个重要优势——结构共享:创建新列表时无需复制已有数据,只需新建Cons Cell并指向已有链表即可,这让数据操作更高效,但也给内存管理带来了一定挑战。
所以说Lisp的列表本质上就是链表
三、Lisp 解释器解析
3.1 Lisp 解释器核心:REPL循环
Lisp解释器的核心是REPL循环,简单来说就是四个步骤的循环执行:
读取(Read):输入字符串并解析为Lisp内部的链表结构;
求值(Eval):递归判断表达式类型(常量、变量、函数调用),若为函数调用则递归求值参数并执行逻辑(Eval是核心部分);
打印(Print):输出求值结果;
循环(Loop):等待下一次输入。
3.2 内存管理的必要性
由于Lisp的递归链表操作、结构共享特性,多个列表可能共用同一个Cons Cell节点。若手动调用free(),既无法判断谁该负责释放,也容易出现内存泄漏或重复释放的问题,因此自动内存管理就成了Lisp解释器的必选项。
首先,在一切开始之前,我们为什么需要用到Rc来管理这一切?
四、Lisp 解释器的核心内存管理方式
引用计数(RC)的核心逻辑很简单:给每个对象(如Lisp的Expr结构体)维护一个计数器,具体操作如下:
新指针指向对象时,计数器+1(对应
rc_acquire操作);指针失效时,计数器-1(对应
rc_release操作);计数器归零时,立即释放该对象占用的内存。
优点:实时性高,内存无人使用时立即回收,无不可预测停顿;实现简单,无需扫描整个内存堆。
缺点:每次指针赋值都要更新计数器,存在分散性能开销;无法处理循环引用(两个对象互相指向时,计数器永远无法归零,导致内存泄漏)。
所谓弱引用好像就是拿来解决这个问题的
Tsoding并没有完整的实现整个Lisp解释器,而是只简单实现了
求值(Eval):递归判断表达式类型(常量、变量、函数调用),若为函数调用则递归求值参数并执行逻辑(Eval是核心部分);
打印(Print):输出求值结果;
这两个部分。
我觉得从Rc的实现来开始切入是相当合适的
1 | typedef struct |
这是我们rc.h中的所有的函数声明。
观察Rc类型,我们可以发现其中只简单的包含两个元素,一个类型为pirtdiff_t的count,以及一个void (*)(void *)的函数指针,指向被Rc管理的类型自定义的析构函数。
那么我们是如何通过Rc来管理自定义的的呢,这里我们就用到了C语言的一个特性:内存偏移
在 C 语言中,我们通常认为指针指向的是一块内存的“起点”。但 rc.h 利用了一个空间上的错位:用户看到的起点,其实是数据的中点。
1 | +------------+-----------+ |
我们可以观察rc_aclloc函数的实现:
1 | void *rc_alloc(size_t size, void (*destroy)(void *data)) |
我们分配的连续内存中内嵌了 Rc 元数据头,用于存储引用计数和对应的析构函数指针。接口返回的指针直接指向自定义数据类型,调用 rc_alloc 后即可直接操作目标对象,Rc 元数据头作为底层实现细节被完全隐藏,无需显式访问。
然后我们可以来观察rc_release函数的实现
1 | void rc_release(void *data) |
这个函数的功能分三步:
通过指针运算取回我们需要的header
减少引用次数
当引用次数<=0时,调用析构函数,最后进行内存释放
我们为什么要这么设计?
这种模式是高度泛用的(Generic),它是 C 语言中模拟“多态析构”的标准工程实践。
这段代码本质上是解耦了内存管理逻辑(Rc)与具体的业务清理逻辑(Lisp)。
Rc 库的视角(通用性):
rc.h并不关心它管理的是 Lisp 的表达式、一个复杂的网络缓冲区,还是一个文件描述符。它只知道:“当没人用这块地时,我就通过这个预留的‘电话号码’(函数指针)通知物主来搬家。”业务层的视角(定制性):不同的数据类型可以注册不同的
destroy函数。如果是 Lisp
PAIR:destroy会递归释放左右子节点。如果是 文件对象:
destroy可能会负责fclose(fp)。如果是 线程池:
destroy可能会负责销毁互斥锁(Mutex)。
大概就是“我的引用用完了,剩下的内部资源清理,交给你专属的析构函数处理“这个逻辑
而在rc_acquire函数中,我们所做的工作仅仅只取回头部之后将count+1,随后返回原地址
1 | void *rc_acquire(void *data) |
可以发现 rc_alloc 并不进行“计数 +1”这个行为,它将初始计数设置为 0。真正的“计数 +1”是由 rc_acquire 负责的。
这种设计背后有一套严密的逻辑配合:
1. rc_alloc 的职责:初始化
rc_alloc 的任务是分配内存并设置好“元数据”。
它将
rc->count初始化为0。这意味着:刚分配出来的对象,在逻辑上还没有被任何人“持有”所有权。
这是一种“预分配”状态,等待被第一个使用者接管。
2. rc_acquire 的职责:增加引用
这是真正执行 rc->count += 1 的地方。
- 当我们希望一个指针正式指向这个对象并共享其所有权时,你必须显式调用
rc_acquire。
3. 为什么要分两步?(配合构造函数)
观察我们的构造函数(如 make_pair),可以发现这种设计的精妙之处:
1 | Expr *make_pair(Expr *left, Expr *right) |
关于这个,我们一会会提到的(确信)
内部成员的持有:
make_pair作为父节点,通过rc_acquire明确表示它“拥有”了子节点left和right。新对象的返回:
make_pair返回的新对象计数仍为0。直到main函数或另一个父节点决定接管它时,才会调用rc_acquire将其变为1。
总结
rc_alloc:负责 “生”,但不负责“养”,计数设为0。rc_acquire:负责 “养”(增加所有权),计数+1。rc_release:负责 “弃”(放弃所有权),计数-1,并在无人领养(计数归零)时销毁。
这种“由调用者决定何时加 1”的模式,能有效避免在构造复杂树状结构(如 Lisp 列表)时出现计数混乱。
Rc_count函数实际上是不太重要的,它仅仅只是返回当前对象的count而已。
我们刚才花了不少时间,把 rc.h 这个头文件的功能都梳理了一遍。
整体的思路是这样的:
我要做一个 Lisp 解析器 → 可 Lisp 里的列表结构,靠手动管理内存根本不现实 → 所以必须自己搞一套自动内存管理机制 → 于是就实现了 rc.h 这套引用计数库 → 最后再用它来支撑整个解析器的运行。
当然,Tsoding 本人是把这些内容混在一起开发的,中间甚至还重构了两次。他是一边写解析器的功能,一边慢慢完善 rc.h 的逻辑。rc_alloc 最开始甚至还自带 count++ 的逻辑,一直迭代到最后,才变成现在这种谁调用谁释放的样子。
虽说这种边做边改的方式,确实更贴近真实开发的流程。但我必须说实话,看完他那两个小时十八分钟的视频,我人是蒙的。当然这也就是这篇博客诞生的原因
想要实现一个lisp解析器,我们需要些什么?
首先我们应该需要一个数据结构来存储lisp的对象
1 | typedef enum |
我并没有学习过Lisp,所以关于这种数据类型,以下是Ai的回答:
在 C 语言这种静态强类型的语言中,变量的类型在编译时就固定了。但 Lisp 是一种动态类型语言,一个变量既可以是数字,也可以是字符串,甚至可以是一个列表。
为了用 C 语言模拟出 Lisp 的灵活性,这段代码使用了 标签联合体(Tagged Union,也叫有别联合) 的设计模式。下面我们逐一拆解这段代码的精妙之处,并解释它如何完美映射 Lisp 中的四大核心概念。
一、 Lisp 四大概念在代码中的映射
1. 表达式 (Expression)
在 Lisp 中,“万物皆为表达式”。代码、数据、数字、函数,全都是表达式。
代码体现:整个
struct Expr就是一个通用的“表达式”容器。设计逻辑:无论你手里的 Lisp 数据是一个单独的数字,还是一个包含几万个元素的列表,在 C 语言的函数签名里,它们统一都表现为
Expr *。这实现了高级语言中的多态性。
2. 原子 (Atom)
原子是不可分割的最小单元,对应代码中占位单一、不含有子表达式的类型。
代码体现:
EXPR_INTEGER:对应 union 中的int integer;。EXPR_SYMBOL:对应 union 中的const char *symbol;。
设计逻辑:它们是“实心”的。当你拿到一个
Expr且它的kind是这两者之一时,你直接读取里面的数值或字符串即可,不需要再做深层的递归解析。
3. 列表 (List)
列表是 Lisp 的灵魂,由点对(Cons Cell)链式拼接而成。
代码体现:
EXPR_PAIR对应 union 中的struct { Expr *left; Expr *right; } pair;。设计逻辑:这是典型的递归数据结构。一个
pair有两个指针left(通常叫 CAR)和right(通常叫 CDR)。left指向当前列表的元素(它本身可以是一个原子,也可以是另一个列表)。right指向列表的“剩余部分”。通过这种嵌套,C 语言成功在堆内存中还原了 Lisp 链表。
4. NIL (空列表)
NIL 代表“无”,是列表的终点,也是空列表 '() 的化身。
代码体现:
EXPR_NIL。设计逻辑:注意看,
union里面根本没有为EXPR_NIL准备任何数据字段!因为 NIL 不需要存储任何额外信息。当Expr的kind == EXPR_NIL时,程序只要知道“到头了”或者“这里为空”即可,既省内存又非常安全。
二、 为什么要这样设计?
除了完美契合 Lisp 哲学,这段代码在 C 语言的工程实践上拉满了细节:
1. enum + union:打造安全的动态类型
C 语言的 union(联合体)允许不同的变量共享同一块内存。但 union 有一个致命缺点:它不知道自己当前存的到底是哪种类型。 如果你存入了一个指针,却按整数去读取,程序就会引发段错误。
解决方案:在
union前面强行塞入一个Expr_Kind kind;。工作流:每次你要用这个
Expr时,先用switch(expr->kind)判断它是什么。如果是EXPR_INT,你才去读expr->integer。这个kind就是那块内存的**“身份证”**。
2. 匿名联合体 (Anonymous Union)
观察代码,union 后面并没有变量名(没有写 union { ... } data;)。
- 好处:在访问数据时,你可以直接写
expr->integer或expr->pair.left,而不需要写成冗长的expr->data.integer。这让代码极其干净。
3. 极致的内存优化
因为使用了 union,struct Expr 的总大小只取决于它内部占用空间最大的那个成员。
在 64 位系统上,
pair包含两个指针($8 + 8 = 16$ 字节),这是最大的。所以,无论这个
Expr存的是 NIL、一个整数,还是一个复杂的列表节点,它在内存中都只占固定的(16 字节的数据 + 4 字节的枚举 + 内存对齐填充)。这对于需要频繁分配小对象的 Lisp 解释器来说,内存碎片极小,性能极高。
4. 惊艳的编译时防御:__count_expr_kind 与断言
这是最体现作者 C 语言功底的地方:
1 | typedef enum { |
设计痛点:在后续开发中,如果你想给 Lisp 增加一个“浮点数”类型(
EXPR_FLOAT),你会在enum里插上一行。但很多程序员会忘记去修改eval里的switch-case,或者忘记修改对应的分配函数,导致难以排查的 Bug。解决方案:作者在
struct Expr的定义里直接写了static_assert。如果你敢在enum里多加一个类型,__count_expr_kind就会变成 5。此时编译器会直接拒绝编译并报错。它强迫你在增加新类型时,必须顺便把这个断言改成 5。而当你去改这个 5 的时候,你就会猛然醒悟:“哦!我还得去其他地方适配这个新类型!”
有了数据类型之后,我们就可以构造这一切了
在main.c中,我们有四个构造函数
1 | Expr *alloc_expr(Expr_Kind kind) |
这些函数负责将 rc_alloc 申请的原始内存“格式化”为具体的 Lisp 表达式。
make_nil():创建一个空列表。make_symbol(const char *name):创建一个符号。make_integer(int value):创建一个整数。make_pair(Expr *left, Expr *right):创建一个点对(Cons Cell)。这是唯一一个递归的构造函数,它会调用rc_acquire来接管子节点的所有权。
正是通过对rc_alloc的调用,让rc.h拥有了对于expr类型的管理能力
为什么在make_pair的时候需要接管子节点的所有权?
这是一个非常深刻的问题,触及了 生命周期管理 与 数据结构完整性 的核心。
简单直接的回答是:为了防止“过河拆桥”。
在 C 语言中,如果你创建了一个父节点(Pair),但它不“接管”子节点的所有权,那么子节点随时可能被外界释放,导致父节点手里握着两个指向荒原的野指针。
我们可以从以下三个逻辑维度来深度拆解这个设计:
1. 逻辑上的“从属关系”
在 Lisp 的列表结构中,一个 Pair 节点是由 left 和 right 组成的复合体。
从逻辑上看,
left和right现在是这个Pair的组成部分。如果父节点不接管所有权,它就无法保证自己生命周期内的完整性。
如果不接管会发生什么?
假设你写了这样一段代码:
1 | Expr *a = make_integer(10); |
正是因为 make_pair 内部调用了 rc_acquire(left),使得 a 指向的内存计数变为 2(一份在变量 a 手里,一份在父节点 p 手里)。即便外界释放了 a,计数依然为 1,内存安全无恙。
2. 支撑“递归释放”的连锁反应
你之前在研究 rc_release 时看到了它会调用 rc->destroy(rc + 1)。这个递归清理机制之所以能跑通,全赖 make_pair 当初接管了所有权。
构建时:
make_pair通过rc_acquire建立了“父子契约”。销毁时:当父节点
p被rc_release且计数归零时,它的析构函数destroy_expr也会被触发。权力交接:
destroy_expr内部会对left和right调用rc_release。
核心逻辑: 如果你当初没有 acquire(计数没加),现在的 release(计数减一)就会导致子节点被提前错误释放。只有**“生时接管,死时交出”**,才能保证整棵树的内存管理是闭环的。
3. 应对“多重引用”的复杂场景
Lisp 最强大的地方在于数据共享。
想象一下:一个复杂的表达式 A,既是列表 B 的头部,又是列表 C 的尾部。
场景:
A被B和C同时持有。机制:
make_pair(A, ...)给B用,A计数 $+1$。make_pair(..., A)给C用,A计数再 $+1$。
结果:此时
A的计数器精确地记录了它被两个父节点共享。无论B还是C谁先死,只要另一个还活着,A就不会被销毁。
这种 “去中心化” 的内存管理,必须依靠每一个 make_pair 在创建时自觉地通过 rc_acquire 宣示主权。
“
make_pair就像是一个领养机构。当你把两个子节点left和right交给它组成一个新的家庭(Pair)时,它不能只是简单地‘看着’它们,而是必须通过rc_acquire正式办理领养手续(接管所有权)。这样一来,即便原本的亲生父母(创建子节点的函数)撒手不管了,只要这个家庭还存在,孩子们就是安全的。而当这个家庭最终解散时,它也会负责任地通过析构函数,帮孩子们寻找下一条出路(递归释放)。”
make_pair 接管所有权,是为了实现 “数据结构的自完备性”。它让每一个 Expr 节点都变成了一个能自感知、自负责的独立生命体,从而构建出 Lisp 那样复杂而稳固的语法树。
有构造,就会有析构
1 | void destroy_expr(void *data) |
这个函数与rc.h进行配合使用,在rc_release中进行对应结构的资源清理,正如前文提到的,我们正式通过这层解耦,实现了对于EXPR_PAIR类型的递归资源释放。
1 |
|
这是一个打印信息的调试函数,这个函数倒是没什么,就仅仅是在类型为EXPR_PAIR时会循环调用他自己而已。并且我们通过宏来模拟了默认参数这个东西
1 | Expr *args_to_list(va_list args) |
这一块其实是很有意思的。
我们为什么需要一个make_list呢?make_pair(make_integer(1),make_pair(make_integer(2),make_pair(make_integer(3), make_nil())));
我相信没有人会想看到这一串破玩意儿的。所以我们构造了args_to_list和make_list_impl这两个函数,以及make_list宏
一、 宏的“预处理陷阱”:强制哨兵机制
在 C 语言中,处理变长参数(va_list)最头疼的问题是:函数不知道参数什么时候结束。 通常做法是要求调用者手动传一个 NULL,但这极易被遗忘。
作者通过宏 make_list 巧妙地解决了这个问题:
它在用户传入的参数
__VA_ARGS__后面自动追加了一个make_nil()。无论你写
make_list(a, b)还是make_list(a),预处理器都会在末尾补上一个NIL。这个补上的
NIL充当了哨兵(Sentinel),告诉后面的解析逻辑:“到这里就结束了”。
二、 为什么非要用递归?防止列表“反过来”
你可能会好奇,为什么要专门写一个递归的 args_to_list,而不是直接用 for 循环遍历参数?
这里涉及到栈与链表的结构冲突:
如果用简单的循环去构造列表,代码逻辑通常是“每拿到一个新元素,就把它插在当前列表的最前面”。
输入:
1, 2, 3循环过程:
NILmake_pair(1, NIL)->(1)make_pair(2, (1))->(2 1)make_pair(3, (2 1))->(3 2 1)
结果:顺序完全反了。
而 args_to_list 通过 递归的回溯(Backtracking) 完美保持了顺序:
1 | return make_pair(arg, args_to_list(args)); |
在这个调用中,make_pair 必须等待递归底部的 args_to_list 返回结果后才能执行。
执行逻辑(“先深入,后构造”):
递进阶段:拿到 1(等着) -> 拿到 2(等着) -> 拿到 3(等着) -> 撞到哨兵
NIL。回溯阶段:从
NIL开始,倒序往回“焊接”:make_pair(3, NIL)make_pair(2, (3))make_pair(1, (2 3))
最终输出:
(1 2 3)。
那么现在我们就剩下main以及eval函数
1 | Expr *eval(Expr *expr) |
eval 函数是解释器的求值入口,它根据表达式的 kind 标签进行逻辑分发:如果是整数等原子类型则直接原样返回;如果是列表(Pair),则默认将第一个元素视为符号指令,后续部分视为参数。在执行 + 指令时,它通过 while 循环遍历参数链表,将内部的整数逐个累加,最后返回一个新的整数对象;在执行 swap 指令时,它则通过重新解构和组合指针,将传入的点对进行左右对调并返回。这种设计通过大量的 assert 确保了输入结构的合法性,并利用底层的 make_ 系列函数在计算过程中生成新的结果对象,是整个解释器从“静态数据”转向“动态计算”的核心环节。
这个其实跟Rc的关系已经不大了,Rc更接近于管理这一切的底层
1 | int main() |
这里放一段ai生成的main以及运行结果
1 | ========== Lisp 解释器功能与内存测试 ========== |
这个Lisp解释器对于一个没有接触过函数式编程的我来说还是太难理解了,燃尽了。。。。
不过我其实还是很好奇这套gc还可以用在哪些地方上面