C语言下的引用计数(Rc)

Reference Counting in C

事情是这样的,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串联而成:

  1. 第一个Cons Cell:CAR为1、CDR指向第二个Cons Cell;

  2. 第二个Cons Cell:CAR为2、CDR指向第三个Cons Cell;

  3. 第三个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
2
3
4
5
6
7
8
9
10
typedef struct
{
ptrdiff_t count;
void (*destroy)(void *data);
} Rc;

void *rc_alloc(size_t size, void (*destroy)(void *data));
void *rc_acquire(void *data);
void rc_release(void *data);
ptrdiff_t rc_count(void *data);

这是我们rc.h中的所有的函数声明。

观察Rc类型,我们可以发现其中只简单的包含两个元素,一个类型为pirtdiff_t的count,以及一个void (*)(void *)的函数指针,指向被Rc管理的类型自定义的析构函数。


那么我们是如何通过Rc来管理自定义的的呢,这里我们就用到了C语言的一个特性:内存偏移

在 C 语言中,我们通常认为指针指向的是一块内存的“起点”。但 rc.h 利用了一个空间上的错位:用户看到的起点,其实是数据的中点。

1
2
3
+------------+-----------+
| Rc | struct |
+------------+-----------+

我们可以观察rc_aclloc函数的实现:

1
2
3
4
5
6
7
8
9
void *rc_alloc(size_t size, void (*destroy)(void *data))
{
Rc *rc = malloc(sizeof(Rc) + size);
assert(rc);
rc->count = 0;
rc->destroy = destroy;
printf("[RC] %p allocated\n", rc);
return rc + 1;
}

我们分配的连续内存中内嵌了 Rc 元数据头,用于存储引用计数和对应的析构函数指针。接口返回的指针直接指向自定义数据类型,调用 rc_alloc 后即可直接操作目标对象,Rc 元数据头作为底层实现细节被完全隐藏,无需显式访问。

然后我们可以来观察rc_release函数的实现

1
2
3
4
5
6
7
8
9
10
11
void rc_release(void *data)
{
Rc *rc = (Rc *)data - 1;
rc->count -= 1;
if (rc->count <= 0)
{
rc->destroy(rc + 1);
printf("[RC] %p released\n", rc);
free(rc);
}
}

这个函数的功能分三步:

  1. 通过指针运算取回我们需要的header

  2. 减少引用次数

  3. 当引用次数<=0时,调用析构函数,最后进行内存释放

我们为什么要这么设计?

这种模式是高度泛用的(Generic),它是 C 语言中模拟“多态析构”的标准工程实践。

这段代码本质上是解耦了内存管理逻辑(Rc)与具体的业务清理逻辑(Lisp)

  • Rc 库的视角(通用性)rc.h 并不关心它管理的是 Lisp 的表达式、一个复杂的网络缓冲区,还是一个文件描述符。它只知道:“当没人用这块地时,我就通过这个预留的‘电话号码’(函数指针)通知物主来搬家。”

  • 业务层的视角(定制性):不同的数据类型可以注册不同的 destroy 函数。

    • 如果是 Lisp PAIRdestroy 会递归释放左右子节点。

    • 如果是 文件对象destroy 可能会负责 fclose(fp)

    • 如果是 线程池destroy 可能会负责销毁互斥锁(Mutex)。

大概就是“我的引用用完了,剩下的内部资源清理,交给你专属的析构函数处理“这个逻辑

而在rc_acquire函数中,我们所做的工作仅仅只取回头部之后将count+1,随后返回原地址

1
2
3
4
5
6
void *rc_acquire(void *data)
{
Rc *rc = ((Rc *)data - 1);
rc->count += 1;
return 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
2
3
4
5
6
7
Expr *make_pair(Expr *left, Expr *right)
{
Expr *expr = alloc_expr(EXPR_PAIR); // 1. 分配,count = 0
expr->pair.left = rc_acquire(left); // 2. 这里的 left 被新对象持有了,count + 1
expr->pair.right = rc_acquire(right); // 3. 这里的 right 被新对象持有了,count + 1
return expr; // 返回 count = 0 的新对象
}

关于这个,我们一会会提到的(确信)

  • 内部成员的持有make_pair 作为父节点,通过 rc_acquire 明确表示它“拥有”了子节点 leftright

  • 新对象的返回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
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
typedef enum
{
EXPR_NIL,
EXPR_SYMBOL,
EXPR_INTEGER,
EXPR_PAIR,
__count_expr_kind,
} Expr_Kind;

typedef struct Expr Expr;

struct Expr
{
Expr_Kind kind;
static_assert(__count_expr_kind == 4, "Amount of expression kinds has changed");
union
{
const char *symbol;
int integer;
struct
{
Expr *left;
Expr *right;
} pair;
};
};

我并没有学习过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 不需要存储任何额外信息。当 Exprkind == 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->integerexpr->pair.left,而不需要写成冗长的 expr->data.integer。这让代码极其干净。

3. 极致的内存优化

因为使用了 unionstruct Expr 的总大小只取决于它内部占用空间最大的那个成员

  • 在 64 位系统上,pair 包含两个指针($8 + 8 = 16$ 字节),这是最大的。

  • 所以,无论这个 Expr 存的是 NIL、一个整数,还是一个复杂的列表节点,它在内存中都只占固定的(16 字节的数据 + 4 字节的枚举 + 内存对齐填充)。这对于需要频繁分配小对象的 Lisp 解释器来说,内存碎片极小,性能极高。

4. 惊艳的编译时防御:__count_expr_kind 与断言

这是最体现作者 C 语言功底的地方:

1
2
3
4
5
6
7
8
9
typedef enum {
EXPR_NIL, // 0
EXPR_SYMBOL, // 1
EXPR_INTEGER, // 2
EXPR_PAIR, // 3
__count_expr_kind, // 4 (利用枚举递增特性,自动算出前面有4个元素)
} Expr_Kind;

static_assert(__count_expr_kind == 4, "Amount of expression kinds has changed");
  • 设计痛点:在后续开发中,如果你想给 Lisp 增加一个“浮点数”类型(EXPR_FLOAT),你会在 enum 里插上一行。但很多程序员会忘记去修改 eval 里的 switch-case,或者忘记修改对应的分配函数,导致难以排查的 Bug。

  • 解决方案:作者在 struct Expr 的定义里直接写了 static_assert。如果你敢在 enum 里多加一个类型,__count_expr_kind 就会变成 5。此时编译器会直接拒绝编译并报错。它强迫你在增加新类型时,必须顺便把这个断言改成 5。而当你去改这个 5 的时候,你就会猛然醒悟:“哦!我还得去其他地方适配这个新类型!”


有了数据类型之后,我们就可以构造这一切了

main.c中,我们有四个构造函数

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
Expr *alloc_expr(Expr_Kind kind)
{
Expr *expr = rc_alloc(sizeof(Expr), destroy_expr);
expr->kind = kind;
return expr;
}

static_assert(__count_expr_kind == 4, "Amount of expression kinds has changed.Change the ctors");

Expr *make_nil(void)
{
return alloc_expr(EXPR_NIL);
}

Expr *make_integer(int integer)
{
Expr *expr = alloc_expr(EXPR_INTEGER);
expr->integer = integer;
return expr;
}

Expr *make_symbol(const char *symbol)
{
Expr *expr = alloc_expr(EXPR_SYMBOL);
expr->symbol = symbol;
return expr;
}

Expr *make_pair(Expr *left, Expr *right)
{
Expr *expr = alloc_expr(EXPR_PAIR);
expr->pair.left = rc_acquire(left);
expr->pair.right = rc_acquire(right);
return expr;
}

这些函数负责将 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 节点是由 leftright 组成的复合体。

  • 从逻辑上看,leftright 现在是这个 Pair组成部分

  • 如果父节点不接管所有权,它就无法保证自己生命周期内的完整性

如果不接管会发生什么?

假设你写了这样一段代码:

1
2
3
4
Expr *a = make_integer(10);
Expr *p = make_pair(a, make_nil());
rc_release(a); // 如果 make_pair 没调 rc_acquire
// 此时 a 被销毁了,p->pair.left 变成了一个野指针!

正是因为 make_pair 内部调用了 rc_acquire(left),使得 a 指向的内存计数变为 2(一份在变量 a 手里,一份在父节点 p 手里)。即便外界释放了 a,计数依然为 1,内存安全无恙。


2. 支撑“递归释放”的连锁反应

你之前在研究 rc_release 时看到了它会调用 rc->destroy(rc + 1)。这个递归清理机制之所以能跑通,全赖 make_pair 当初接管了所有权。

  • 构建时make_pair 通过 rc_acquire 建立了“父子契约”。

  • 销毁时:当父节点 prc_release 且计数归零时,它的析构函数 destroy_expr 也会被触发。

  • 权力交接destroy_expr 内部会对 leftright 调用 rc_release

核心逻辑: 如果你当初没有 acquire(计数没加),现在的 release(计数减一)就会导致子节点被提前错误释放。只有**“生时接管,死时交出”**,才能保证整棵树的内存管理是闭环的。


3. 应对“多重引用”的复杂场景

Lisp 最强大的地方在于数据共享

想象一下:一个复杂的表达式 A,既是列表 B 的头部,又是列表 C 的尾部。

  • 场景ABC 同时持有。

  • 机制

    1. make_pair(A, ...)B 用,A 计数 $+1$。

    2. make_pair(..., A)C 用,A 计数再 $+1$。

  • 结果:此时 A 的计数器精确地记录了它被两个父节点共享。无论 B 还是 C 谁先死,只要另一个还活着,A 就不会被销毁。

这种 “去中心化” 的内存管理,必须依靠每一个 make_pair 在创建时自觉地通过 rc_acquire 宣示主权。


make_pair 就像是一个领养机构。当你把两个子节点 leftright 交给它组成一个新的家庭(Pair)时,它不能只是简单地‘看着’它们,而是必须通过 rc_acquire 正式办理领养手续(接管所有权)。

这样一来,即便原本的亲生父母(创建子节点的函数)撒手不管了,只要这个家庭还存在,孩子们就是安全的。而当这个家庭最终解散时,它也会负责任地通过析构函数,帮孩子们寻找下一条出路(递归释放)。”

make_pair 接管所有权,是为了实现 “数据结构的自完备性”。它让每一个 Expr 节点都变成了一个能自感知、自负责的独立生命体,从而构建出 Lisp 那样复杂而稳固的语法树。


有构造,就会有析构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void destroy_expr(void *data)
{
Expr *expr = data;
switch (expr->kind)
{
case EXPR_NIL:
case EXPR_SYMBOL:
case EXPR_INTEGER:
break;
case EXPR_PAIR:
rc_release(expr->pair.left);
rc_release(expr->pair.right);
break;
case __count_expr_kind:
break;
default:
{
UNREACHABLE("Expr_Kind");
}
}
}

这个函数与rc.h进行配合使用,在rc_release中进行对应结构的资源清理,正如前文提到的,我们正式通过这层解耦,实现了对于EXPR_PAIR类型的递归资源释放。


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
#define dump_expr(expr) dump_expr_opt(expr, 0)
void dump_expr_opt(Expr *expr, int level)
{
for (int i = 0; i < level; ++i)
{
printf(" ");
}
switch (expr->kind)
{
case EXPR_NIL:
{
printf("NIL(%td)\n", rc_count(expr));
}
break;

case EXPR_SYMBOL:
{
printf("SYMBOL(%td): %s\n", rc_count(expr), expr->symbol);
}
break;
case EXPR_INTEGER:
{
printf("INTEGER(%td): %d\n", rc_count(expr), expr->integer);
}
break;
case EXPR_PAIR:
{
printf("PAIR(%td):\n", rc_count(expr));
dump_expr_opt(expr->pair.left, level + 1);
dump_expr_opt(expr->pair.right, level + 1);
}
break;
case __count_expr_kind:
break;
default:
UNREACHABLE("EXPR_Kind");
break;
}
}

这是一个打印信息的调试函数,这个函数倒是没什么,就仅仅是在类型为EXPR_PAIR时会循环调用他自己而已。并且我们通过宏来模拟了默认参数这个东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Expr *args_to_list(va_list args)
{
Expr *arg = va_arg(args, Expr *);
if (arg->kind == EXPR_NIL)
return arg;
return make_pair(arg, args_to_list(args));
}

#define make_list(...) make_list_impl(NULL, __VA_ARGS__, make_nil())

Expr *make_list_impl(void *first, ...)
{
va_list args;
va_start(args, first);
Expr *list = args_to_list(args);
va_end(args);
return list;
}

这一块其实是很有意思的。

我们为什么需要一个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 巧妙地解决了这个问题:

  1. 它在用户传入的参数 __VA_ARGS__ 后面自动追加了一个 make_nil()

  2. 无论你写 make_list(a, b) 还是 make_list(a),预处理器都会在末尾补上一个 NIL

  3. 这个补上的 NIL 充当了哨兵(Sentinel),告诉后面的解析逻辑:“到这里就结束了”。

二、 为什么非要用递归?防止列表“反过来”

你可能会好奇,为什么要专门写一个递归的 args_to_list,而不是直接用 for 循环遍历参数?

这里涉及到栈与链表的结构冲突:

如果用简单的循环去构造列表,代码逻辑通常是“每拿到一个新元素,就把它插在当前列表的最前面”。

  • 输入1, 2, 3

  • 循环过程

    1. NIL

    2. make_pair(1, NIL) -> (1)

    3. make_pair(2, (1)) -> (2 1)

    4. 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. 递进阶段:拿到 1(等着) -> 拿到 2(等着) -> 拿到 3(等着) -> 撞到哨兵 NIL

  2. 回溯阶段:从 NIL 开始,倒序往回“焊接”:

    • make_pair(3, NIL)

    • make_pair(2, (3))

    • make_pair(1, (2 3))

  3. 最终输出(1 2 3)

那么现在我们就剩下main以及eval函数

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
Expr *eval(Expr *expr)
{
if (expr->kind == EXPR_PAIR)
{
assert(expr->pair.left->kind == EXPR_SYMBOL);
if (strcmp(expr->pair.left->symbol, "+") == 0)
{
int result = 0;
Expr *args = expr->pair.right;
while (args->kind != EXPR_NIL)
{
assert(args->kind == EXPR_PAIR);
assert(args->pair.left->kind == EXPR_INTEGER);
result += args->pair.left->integer;
args = args->pair.right;
}
return make_integer(result);
}
else if (strcmp(expr->pair.left->symbol, "swap") == 0)
{
Expr *args = expr->pair.right;
assert(args->kind == EXPR_PAIR);
assert(args->pair.left->kind == EXPR_PAIR);
assert(args->pair.right->kind == EXPR_NIL);
Expr *arg = args->pair.left;
return make_pair(arg->pair.right, arg->pair.left);
}
else
{
TODO("Report unknow function error");
}
}
else
{
return expr;
}
}

eval 函数是解释器的求值入口,它根据表达式的 kind 标签进行逻辑分发:如果是整数等原子类型则直接原样返回;如果是列表(Pair),则默认将第一个元素视为符号指令,后续部分视为参数。在执行 + 指令时,它通过 while 循环遍历参数链表,将内部的整数逐个累加,最后返回一个新的整数对象;在执行 swap 指令时,它则通过重新解构和组合指针,将传入的点对进行左右对调并返回。这种设计通过大量的 assert 确保了输入结构的合法性,并利用底层的 make_ 系列函数在计算过程中生成新的结果对象,是整个解释器从“静态数据”转向“动态计算”的核心环节。

这个其实跟Rc的关系已经不大了,Rc更接近于管理这一切的底层

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
45
46
47
48
49
50
51
52
53
54
55
56
int main()
{
printf("========== Lisp 解释器功能与内存测试 ==========\n\n");

// --- 测试 1: 加法功能 (+ 10 20 30) ---
printf("【测试 1: 加法运算 (+ 10 20 30)】\n");
Expr *add_expr = rc_acquire(make_list(
make_symbol("+"),
make_integer(10),
make_integer(20),
make_integer(30)));

printf("构造表达式: ");
dump_expr(add_expr);
printf("当前引用计数: %td\n", rc_count(add_expr));

printf("--- 执行 Eval ---\n");
Expr *add_result = rc_acquire(eval(add_expr));
printf("计算结果: ");
dump_expr(add_result);

printf("--- 释放内存 ---\n");
rc_release(add_expr);
rc_release(add_result);
printf("加法测试内存已安全释放\n\n");

// --- 测试 2: 结构交换 (swap (69 . 420)) ---
printf("【测试 2: 结构交换 (swap (69 . 420))】\n");
// 构造 (swap (69 . 420))
Expr *swap_expr = rc_acquire(make_list(
make_symbol("swap"),
make_pair(make_integer(69), make_integer(420))));

printf("原始表达式: ");
dump_expr(swap_expr);

printf("--- 执行 Eval ---\n");
Expr *swap_result = rc_acquire(eval(swap_expr));

printf("交换后结果: ");
dump_expr(swap_result);
printf("结果引用计数: %td\n", rc_count(swap_result));

printf("--- 验证:释放原始表达式 ---\n");
rc_release(swap_expr);
// 此时 swap_expr 已销毁,但 swap_result 应该依然有效
printf("原始表达式已释放,结果依然存活: ");
dump_expr(swap_result);

printf("--- 最终清理 ---\n");
rc_release(swap_result);
printf("所有内存已归还系统\n");

printf("\n================ 测试结束 ================\n");
return 0;
}

这里放一段ai生成的main以及运行结果

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
========== Lisp 解释器功能与内存测试 ==========

【测试 1: 加法运算 (+ 10 20 30)】
[RC] 000001F17C372460 allocated
[RC] 000001F17C372400 allocated
[RC] 000001F17C372490 allocated
[RC] 000001F17C372760 allocated
[RC] 000001F17C3722E0 allocated
[RC] 000001F17C372310 allocated
[RC] 000001F17C372670 allocated
[RC] 000001F17C372220 allocated
[RC] 000001F17C3727F0 allocated
构造表达式: PAIR(1):
SYMBOL(1): +
PAIR(1):
INTEGER(1): 10
PAIR(1):
INTEGER(1): 20
PAIR(1):
INTEGER(1): 30
NIL(1)
当前引用计数: 1
--- 执行 Eval ---
[RC] 000001F17C372340 allocated
计算结果: INTEGER(1): 60
--- 释放内存 ---
[RC] 000001F17C372460 released
[RC] 000001F17C372400 released
[RC] 000001F17C372490 released
[RC] 000001F17C372760 released
[RC] 000001F17C3722E0 released
[RC] 000001F17C372310 released
[RC] 000001F17C372670 released
[RC] 000001F17C372220 released
[RC] 000001F17C3727F0 released
[RC] 000001F17C372340 released
加法测试内存已安全释放

【测试 2: 结构交换 (swap (69 . 420))】
[RC] 000001F17C372490 allocated
[RC] 000001F17C372250 allocated
[RC] 000001F17C372640 allocated
[RC] 000001F17C372730 allocated
[RC] 000001F17C372580 allocated
[RC] 000001F17C372790 allocated
[RC] 000001F17C372550 allocated
原始表达式: PAIR(1):
SYMBOL(1): swap
PAIR(1):
PAIR(1):
INTEGER(1): 69
INTEGER(1): 420
NIL(1)
--- 执行 Eval ---
[RC] 000001F17C372610 allocated
交换后结果: PAIR(1):
INTEGER(2): 420
INTEGER(2): 69
结果引用计数: 1
--- 验证:释放原始表达式 ---
[RC] 000001F17C372490 released
[RC] 000001F17C372730 released
[RC] 000001F17C372580 released
[RC] 000001F17C372790 released
[RC] 000001F17C372550 released
原始表达式已释放,结果依然存活: PAIR(1):
INTEGER(1): 420
INTEGER(1): 69
--- 最终清理 ---
[RC] 000001F17C372640 released
[RC] 000001F17C372250 released
[RC] 000001F17C372610 released
所有内存已归还系统

================ 测试结束 ================

这个Lisp解释器对于一个没有接触过函数式编程的我来说还是太难理解了,燃尽了。。。。

不过我其实还是很好奇这套gc还可以用在哪些地方上面