MoreRSS

site iconLuyu Huang修改

vscode-rss开发者
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Luyu Huang的 RSS 预览

用 C 语言实现协程

2024-06-14 00:00:00

协程与线程(进程)[1]都是模拟多任务(routine)并发执行的软件实现。操作系统线程在一个CPU线程中模拟并发执行多个任务,而协程(coroutine)在一个操作系统线程中模拟并发执行多个任务。因此协程也被称为“用户态线程”、“轻量级线程”。之所以说“模拟并发执行”,是因为任务实际并没有同时运行,而是通过来回切换实现的。

线程切换由操作系统负责,而协程切换通常由程序员直接控制。程序员通过resume/yield 操作控制协程切换。resume 操作唤醒一个指定协程;yield操作挂起当前协程,切换回唤醒它的协程。如果你用过 Lua的协程,就会很熟悉这套流程。不过 Lua 是基于 Lua虚拟机(LVM)的脚本语言,它只需要 LVM中执行“虚拟的”上下文切换。本文介绍如何用 C 语言(和一点点汇编)实现一个native 协程,执行真正的上下文切换。这个实现非常简单,总共不到 200行代码。我参考了 libco的实现。本文的完整代码见 toy-coroutine。

上下文切换

所谓的上下文,就是一段程序之前做了什么,接下来要做什么,以及做事情过程的中间产物。例如我们有函数ff需要知道下一个指令是什么才能接着往下执行,便是“接下来要做什么”。f函数还需要知道之前是谁调用了它,以便把结果返回给调用者,便是“之前做了什么”。在f函数执行过程中,局部变量要存好(不能被写坏),接下来的指令才能正确执行。这便是“过程的中间产物”。

在 x86-64 下,“之前做了什么” 存储在栈里。函数调用会执行call指令,把当前函数的下一个指令的地址压入栈顶,然后再跳转到被调用函数。被调用函数返回时执行ret指令,从栈顶取出调用者的返回点地址,然后跳转到返回点。因此栈上存有所有前序调用者的返回点地址。

函数的局部变量通常储存在 16个通用寄存器中,如果寄存器不够用,就存在栈里(只要在函数返回前将它们弹出,让栈顶是返回点地址即可)。函数调用的参数也是局部变量,存在约定的6 个通用寄存器里。如果不够用,也存在栈里。

至于“接下来要做什么”,其实也在栈里。上下文切换不过是调用一个函数,调用者在调用它之前已经把下一个指令的地址压栈了。当上下文切换函数返回,ret指令自然会跳转到接下来要执行的指令。所以上下文就是 16 个通用寄存器 +栈。

所有的协程共享同一个 CPU,也就共享同样的 16个通用寄存器。如果我们要把 A 协程切换成 B 协程,就要把当前 16个通用寄存器的值存在 A 协程的数据结构里;然后再从 B 协程的数据结构里取出B协程的寄存器的值,写回通用寄存器中。我们还要处理栈。不过栈与寄存器不同,x86-64规定 %rsp寄存器(也是通用寄存器之一)存的值便是栈顶的地址。不同的协程不必共享栈,它们可以分配各自的栈,上下文切换时将%rsp 指向各自的栈顶即可。

实际上我们不必存储全部的 16个通用寄存器,它们有些是暂存寄存器(ScratchRegisters),是允许被写坏的。这些寄存器的值可能在执行一次函数调用后就变了(被被调用函数写坏的)。编译器也不会在暂存寄存器里存储函数调用后还要用的值。参考libco 的实现,我们存储 13 个寄存器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum {
CO_R15 = 0,
CO_R14,
CO_R13,
CO_R12,
CO_R9,
CO_R8,
CO_RBP,
CO_RDI,
CO_RSI,
CO_RDX,
CO_RCX,
CO_RBX,
CO_RSP,
};

struct co_context {
void *regs[13];
};

有些寄存器有特殊的用途。这里我们只需要知道这三个:

  • %rsp: 栈寄存器,指向栈顶。
  • %rdi, %rsi:第一参数寄存器和第二参数寄存器,调用函数前将第一个参数存在%rdi 里,第二个存在 %rsi 里(剩下的四个依次是%rdx, %rcx, %r8,%r9, 不过这里我们用不上),然后执行 call指令。

接着我们定义一个函数做上下文切换,把当前通用寄存器的值保存在curr 中,再把 next中保存的寄存器的值写回各个通用寄存器。

1
extern void co_ctx_swap(struct co_context *curr, struct co_context *next);

Emm,这个函数没法用 C语言实现,我们得用到一点点汇编了。其实非常简单,我们只需要用movq 指令存取寄存器。代码如下:

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
.globl co_ctx_swap

co_ctx_swap:
movq %rsp, 96(%rdi)
movq %rbx, 88(%rdi)
movq %rcx, 80(%rdi)
movq %rdx, 72(%rdi)
movq %rsi, 64(%rdi)
movq %rdi, 56(%rdi)
movq %rbp, 48(%rdi)
movq %r8, 40(%rdi)
movq %r9, 32(%rdi)
movq %r12, 24(%rdi)
movq %r13, 16(%rdi)
movq %r14, 8(%rdi)
movq %r15, (%rdi)

movq (%rsi), %r15
movq 8(%rsi), %r14
movq 16(%rsi), %r13
movq 24(%rsi), %r12
movq 32(%rsi), %r9
movq 40(%rsi), %r8
movq 48(%rsi), %rbp
movq 56(%rsi), %rdi
movq 72(%rsi), %rdx
movq 80(%rsi), %rcx
movq 88(%rsi), %rbx
movq 96(%rsi), %rsp
movq 64(%rsi), %rsi

ret

不懂汇编没关系(其实我也不是很懂),只需要知道 movq指令将第一个操作数的值复制到第二个操作数中。%开头的标识符为寄存器。%rsp这样不带括号的,表示存取寄存器的值。(%rdi)这种带括号的,表示去内存里存取地址为 %rdi的数据。如果括号前面有数字几,就表示这个地址要加几。movq存取数据的长度为 8 字节,寄存器的长度也是 8 字节。

还记得前面说过,%rdi 是第一个参数,%rsi是第二个参数吗?所以 %rdi 就是struct co_context *curr96(%rdi) 就是curr->regs[12]88(%rdi) 就是curr->regs[11],……,(%rdi) 就是curr->regs[0]。上半部分把 13 个通用寄存器的值全部存到了curr 里。同理 %rsi 就是struct co_context *next(%rsi) 就是next->regs[0]8(%rsi) 就是next->regs[1],依次类推。于是下半部分把next 中保存的寄存器的值写回寄存器中。最后执行ret 指令返回。

注意 29 行写入 %rsp 的值就是上次挂起时第 4行保存的值,这个值我们原封未动,也没有做任何栈操作。因此最后ret 返回时,栈顶就是 co_ctx_swap的调用者设置的返回点地址。一个协程调用 co_ctx_swap将自己挂起,便陷入沉睡。当 co_ctx_swap返回之时,便是其它协程调用 co_ctx_swap将它唤醒之时。此时寄存器被还原、栈被还原、也回到了返回点。它便知道自己之前做了什么、接下来要做什么、中间产物是怎样的。

协程的初始化

struct co_context仅存储协程的上下文。我们还需要维护给协程分配的栈空间、记录入口函数地址等。我们定义struct coroutine 表示协程对象。

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
typedef void (*start_coroutine)();

struct coroutine {
struct co_context ctx;
char *stack;
size_t stack_size;
start_coroutine start;
};

struct coroutine *co_new(start_coroutine start, size_t stack_size) {
struct coroutine *co = malloc(sizeof(struct coroutine));
memset(&co->ctx, 0, sizeof(co->ctx));
if (stack_size) {
co->stack = malloc(stack_size);
} else {
co->stack = NULL;
}
co->stack_size = stack_size;
co->start = start;

return co;
}

void co_free(struct coroutine *co) {
free(co->stack);
free(co);
}

co_new 创建一个新协程,接受两个参数:start协程入口函数指针,和 stack_size 栈大小;这类似于pthread_createco_new分配协程的栈空间并设置好各个字段。

要把主线程切换到我们新创建的协程,这里有两个问题。一是主线程并不是一个协程,新协程跟谁交换上下文呢?二是新创建的协程的上下文是空的(19行),切换过去肯定跑不起来。

第一个问题很简单:创建一个就行。因为主线程已经跑起来了,要切换到新协程,主线程只需要一个“容器”把它的上下文装进去。直接执行main = co_new(NULL, 0) 创建主协程,调用co_ctx_swap(&main->ctx, &new->ctx)便可切换到新协程。此时主线(协)程的上下文保存在 main中,当新协程反向调用co_ctx_swap(&new->ctx, &main->ctx),便又切换回主协程了。

为了解决第二个问题,我们需要对新协程初始化。co_ctx_swap将新协程的上下文复制到 CPU 后,执行 ret返回栈顶记录的地址。因此我们要将栈顶置为协程入口函数的地址,这样在co_ctx_swap 返回后便跳转到协程入口函数了。

1
2
3
4
5
6
void co_ctx_make(struct coroutine *co) {
char *sp = co->stack + co->stack_size - sizeof(void*);
sp = (char*)((intptr_t)sp & -16LL);
*(void**)sp = (void*)co->start;
co->ctx.regs[CO_RSP] = sp;
}

因为 x86 的栈是从高地址向低地址增长的,初始栈为空,所以栈顶应该指向co->stack 的最末尾。又因为 x86 的栈必须 16字节对齐,所以执行 (intptr_t)sp & -16LL(-16 低 4 位为0,其它都为 1)得到栈顶地址。然后将栈顶置为co->start,也就是入口函数的地址。最后我们把保存的 rsp寄存器的值设置为栈顶地址,这个值会在 co_ctx_swap被复制到寄存器 %rsp 中。

现在我们的协程已经可以跑起来了。写一个简单的例子试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct coroutine *main_co, *new_co;

void foo() {
printf("here is the new coroutine\n");
co_ctx_swap(&new_co->ctx, &main_co->ctx);
printf("new coroutine resumed\n");
co_ctx_swap(&new_co->ctx, &main_co->ctx);
}

int main() {
main_co = co_new(NULL, 0);
new_co = co_new(foo, 1024 * 1024);
co_ctx_make(new_co);

co_ctx_swap(&main_co->ctx, &new_co->ctx);
printf("main coroutine here\n");
co_ctx_swap(&main_co->ctx, &new_co->ctx);
return 0;
}

把上面所有的 C 代码复制到文件 co.c,汇编代码存为co.S,然后执行 gcc -o co co.c co.S编译,运行试试!

协程的管理

现在的协程虽然可以跑,但是使用起来很不方便,需要手动交换上下文,也容易出错。我们需要实现resume/yield 操作。resume操作唤醒指定协程,也就是当前协程与指定协程交换。yield挂起当前协程,将当前协程与上次唤醒它的协程交换。因此我们需要记录当前运行的协程;而对于每个协程,要保存唤醒它的协程的指针。

协程切换要遵循这几条规则:

  • 主协程不能执行 yield 操作。这是显而易见的,因为它没有唤醒者。
  • 不能 resume 一个正在运行的协程。
  • 如果一个协程通过 resume 操作进入挂起状态,则不能由 resume操作唤醒。例如,上图所示的协程 B 在 resume 协程 C 后,只能被协程 C 的yield 操作唤醒。如果允许其它协程通过 resume操作唤醒它,则协程切换会陷入混乱。
  • 除主协程外的协程结束时需要执行 yield操作,之后进入死亡状态。死亡状态的协程不能被 resume 操作唤醒。

基于此,我们给协程定义五个状态:

1
2
3
4
5
6
7
enum {
CO_STATUS_INIT, // 初始状态
CO_STATUS_PENDING, // 执行 yield 操作进入的挂起状态
CO_STATUS_NORMAL, // 执行 resume 操作进入的挂起状态
CO_STATUS_RUNNING, // 运行状态
CO_STATUS_DEAD, // 死亡状态
};

我们使用全局变量 g_curr_co记录当前协程。每个协程还要记录当前状态和唤醒自己的协程。

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
struct coroutine {
struct co_context ctx;
char *stack;
size_t stack_size;
int status; // 协程状态
struct coroutine *prev; // 唤醒者
start_coroutine start;
};

struct coroutine *g_curr_co = NULL; // 当前协程

struct coroutine *co_new(start_coroutine start, size_t stack_size) {
struct coroutine *co = malloc(sizeof(struct coroutine));
...
co->status = CO_STATUS_INIT;
co->prev = NULL;
return co;
}

void check_init() {
if (!g_curr_co) { // 初始化主协程
g_curr_co = co_new(NULL, 0);
g_curr_co->status = CO_STATUS_RUNNING; // 主协程状态初始为 RUNNING
}
}

接着实现 resume 操作和 yield操作。根据上面描述的思路,实现起来很容易。

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
int co_resume(struct coroutine *next) {
check_init();

switch (next->status) {
case CO_STATUS_INIT: // 初始状态,需要执行 co_ctx_make 初始化
co_ctx_make(next);
case CO_STATUS_PENDING: // 只有处于 INIT 和 PENDING 状态的协程可以被 resume 唤醒
break;
default:
return -1;
}

struct coroutine *curr = g_curr_co;
g_curr_co = next; // g_curr_co 指向新协程
next->prev = curr; // 设置新协程的唤醒者为当前协程
curr->status = CO_STATUS_NORMAL; // 当前协程进入 NORMAL 状态
next->status = CO_STATUS_RUNNING; // 新协程进入 RUNNING 状态
co_ctx_swap(&curr->ctx, &next->ctx);

return 0;
}

int co_yield() {
check_init();

struct coroutine *curr = g_curr_co;
struct coroutine *prev = curr->prev;

if (!prev) { // 没有唤醒者,不能执行 yield 操作
return -1;
}

g_curr_co = prev; // g_curr_co 指向当前协程的唤醒者
if (curr->status != CO_STATUS_DEAD) {
curr->status = CO_STATUS_PENDING; // 当前协程进入 PENDING 状态
}
prev->status = CO_STATUS_RUNNING; // 唤醒者进入 RUNNING 状态
co_ctx_swap(&curr->ctx, &prev->ctx);

return 0;
}

除主协程外的协程结束运行时一定要执行 yield操作将自己切出,否则它不知道该返回到哪儿。为了不让使用者手动执行这个操作,我们将协程入口函数封装一层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void co_entrance(struct coroutine *co) {
co->start(); // 执行入口函数
co->status = CO_STATUS_DEAD;
co_yield(); // 已经置为 DEAD 状态了,切出后不会有人唤醒它了。这里 co_yield 永远不会返回
// 不会走到这里来
}

void co_ctx_make(struct coroutine *co) {
char *sp = co->stack + co->stack_size - sizeof(void*);
sp = (char*)((intptr_t)sp & -16LL);
*(void**)sp = (void*)co_entrance; // 设置入口地址为 co_entrance
co->ctx.regs[CO_RSP] = sp;
co->ctx.regs[CO_RDI] = co; // rdi 为第一参数寄存器,将它的值置为 co,这样 co_entrance 就能拿到它的参数了
}

这样我们的协程用起来就更方便了:

1
2
3
4
5
6
7
8
9
10
11
12
13
void foo() {
printf("here is the new coroutine\n");
co_yield();
printf("new coroutine resumed\n");
}

int main() {
struct coroutine *co = co_new(foo, 1024 * 1024);
co_resume(co);
printf("main coroutine here\n");
co_resume(co);
return 0;
}

传递参数

resume/yield 可以用于传递参数。运行上面的例子,我们发现co_yield 返回之时便是其它协程调用 co_resume之时;而 co_resume 返回之时又是其它协程调用co_yield 之时。因此 resume 操作接受参数,传递给 yield返回;yield 操作接受参数,传递给 resume返回。这样方便在协程之间传递数据。

我们在 struct coroutine 中新增一个 data字段用于传递参数。协程切换时,如果要给目标协程传递参数,就对目标协程的data 字段赋值。协程切换后,就能从 data字段中取出上一个协程传递的参数。

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
struct coroutine {
...
void *data; // 参数
};

int co_resume(struct coroutine *next, void *param, void **result) {
...

next->data = param; // 切换到 next 协程,给 next 协程的参数
co_ctx_swap(&curr->ctx, &next->ctx);
if (result) {
*result = curr->data;
}
return 0;
}

int co_yield(void *result, void **param) {
...

prev->data = result; // 切回 prev 协程,给 prev 协程的结果
co_ctx_swap(&curr->ctx, &prev->ctx);
if (param) {
*param = curr->data; // 其它协程唤醒它时给它的参数
}
return 0;
}

我们重新定义协程入口函数,让它接受参数和返回值。第一次 resume的参数传给入口函数;入口函数的返回值在最后一次 yield 时传出去。

1
2
3
4
5
6
7
typedef void *(*start_coroutine)(void *);

static void co_entrance(struct coroutine *co) {
void *result = co->start(co->data);
co->status = CO_STATUS_DEAD;
co_yield(result, NULL); // 协程的最后一次 yield 操作,将入口函数的返回值传出去
}

例子

现在,我们的协程库已经完全实现了。我们可以写一些例子测试一下。比如说我们可以创建一个源源不断生成以n 开头的自然数的协程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void *number(void *param) {
intptr_t i = (intptr_t)param;
co_yield(NULL, NULL); // 初始化后立刻 yield
while (1) {
co_yield((void*)i, NULL);
++i;
}
}

int main() {
struct coroutine *num = co_new(number, 1024 * 1024);
co_resume(num, (void*)0, NULL); // 初始化为以 0 开头的自然数流
for (int i = 0; i < 10; ++i) {
intptr_t n;
co_resume(num, NULL, (void**)&n);
printf("%ld ", n);
}
co_free(num);
return 0;
}

运行结果就是

1
0 1 2 3 4 5 6 7 8 9

这个协程就是一个无限流。我们还可以写一个将两个无限流逐项相加的协程:

1
2
3
4
5
6
7
8
9
10
void *add(void *param) {
struct coroutine **cov = param, *co0 = cov[0], *co1 = cov[1]; // cov 指向前序协程的栈,这里要立刻将其中的数据取出来
co_yield(NULL, NULL); // 同样,初始化后立刻 yield
while (1) {
intptr_t a, b;
co_resume(co0, NULL, (void**)&a);
co_resume(co1, NULL, (void**)&b);
co_yield((void*)(a + b), NULL);
}
}

然后将 0 开头的自然数流与 1 开头的自然数流逐项相加,得到奇数无限流(0+ 1 = 1, 1 + 2 = 3, 2 + 3 = 5, …)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main() {
struct coroutine *num0 = co_new(number, 1024 * 1024);
struct coroutine *num1 = co_new(number, 1024 * 1024);
struct coroutine *co_add = co_new(add, 1024 * 1024);

co_resume(num0, (void*)0, NULL); // 以 0 开头的自然数流
co_resume(num1, (void*)1, NULL); // 以 1 开头的自然数流

struct coroutine *cov[] = {num0, num1};
co_resume(co_add, cov, NULL); // 初始化 add 协程

for (int i = 0; i < 10; ++i) {
intptr_t s;
co_resume(co_add, NULL, (void**)&s);
printf("%ld ", s);
}

co_free(num0), co_free(num1), co_free(co_add);
return 0;
}

运行结果就是

1
1 3 5 7 9 11 13 15 17 19

当然还有更好玩的。我们可以实现一个斐波那契数列生成器。斐波那契数列可以自我定义:令f(i) 是以第 i 项开头的斐波那契数列,f(a) + f(b)表示将两个数列逐项相加,那么如下所示,f(2) = f(0) + f(1)。

1
2
3
4
    0  1  1  2  3  5
+ 1 1 2 3 5 8
-----------------------
1 2 3 5 8 13

所以我们可以这样做

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
void *fib(void *param) {
co_yield((void*)0, NULL); // 第 0 项
co_yield((void*)1, NULL); // 第 1 项
struct coroutine *f0 = co_new(fib, 1024 * 1024);
struct coroutine *f1 = co_new(fib, 1024 * 1024);
co_resume(f1, NULL, NULL); // f1 先走一步,让它成为以第 1 项开头的斐波那契数列

struct coroutine *co_add = co_new(add, 1024 * 1024);
struct coroutine *cov[] = {f0, f1};
co_resume(co_add, cov, NULL); // 将 f0 与 f1 逐项相加
while (1) {
intptr_t s;
co_resume(co_add, NULL, (void**)&s);
co_yield((void*)s, NULL);
}
}

int main() {
struct coroutine *f = co_new(fib, 1024 * 1024);
for (int i = 0; i < 10; ++i) {
intptr_t s;
co_resume(f, NULL, (void**)&s);
printf("%ld ", s);
}
}

运行结果便是斐波那契数列:

1
0 1 1 2 3 5 8 13 21 34

不过这种写法会创建大量协程,性能很低。仅供演示(炫技)


  1. 对于 Linux内核而言,线程和进程是同一个东西↩︎

理想国

2024-06-08 00:00:00

我最近读完了《理想国》。这本书为古希腊哲学先贤柏拉图所著,是哲学经典之作、奠基之作,内容晦涩深奥,比较难懂。本人才疏学浅,以我的贫乏的哲学素养,难以完全领会其中深邃的思想。然而读完之后,仍然对我的价值观造成了不小的冲击。这里斗胆分享两点我的体会。

什么是正义

中国有句古话,叫做成王败寇。胜者为正义,败者为邪恶。蒙古铁蹄南下,击败南宋,占据中原,成为正统王朝,自然是正义。元末朱元璋起兵驱逐鞑虏,恢复中华,自然也是正义。但如果朱元璋失败了会怎样?自然是和其它千千万万失败农民起义一样,被列为乱臣贼子,成为邪恶的化身。正义与邪恶似乎是相对的概念,胜利者定义的价值即为正义。“邪不压正”不是规律,而是结果:正是因为甲战胜了乙,甲便是“正”;如果“邪”压住了“正”,“邪”就变成了正。

我曾经一度相信这种观点。然而柏拉图全然否定了这个观点。因为如果把胜利者定义为正义,反对胜利者定义为邪恶,我们可以作出这样的推导:

胜利者是正义的,所以胜利者制定的法律也是正义的。因此遵纪守法是正义的,违法犯罪是不正义的。

那么按照现行法律,谋杀是不正义的、抢劫是不正义的、盗窃是不正义的、贪污是不正义的、诈骗是不正义的。

与这些行为相关的品质也是不正义的。所以伤害他人是不正义的、损人利己是不正义的、贪婪是不正义的、奸诈狡猾是不正义的。

我们说一个贪婪、损人利己、奸诈狡猾的人是坏人,他会做坏事。个人的力量有限,他能做的坏事相对较小。我们试图让一群这样的坏人聚在一起,看看他们能不能做一件更大的坏事,甚至是最大的坏事:把正义——当前的胜利者——推翻?

然而他们会让我们失望的:一群贪婪、损人利己、奸诈狡猾的人,无论如何不能团结起来。越是不正义的个人,越不能团结成不正义的群体。因此不正义的人终究无法完成任何伟业。书中原话是这么说的:

我们看到正义的人的确更聪明能干更好,而不正义的人根本不能合作。当我们说不正义者可以有坚强一致的行动,我们实在说得有点不对头。因为他们要是绝对违反正义,结果非内讧不可。他们残害敌人,而不至于自相残杀,还是因为他们之间多少还有点正义。就凭这么一点儿正义,才使他们做事好歹有点成果;而他们之间的不正义对他们的作恶也有相当的妨碍。因为绝对不正义的真正坏人,也就绝对做不出任何事情来。

因此我们发现,即使是黑帮,也要讲究道义,要求遵守纪律。他们能在一定程度上团结在一起,正是因为他们还有一些正义。蒙古能灭南宋,因为蒙古有一定的正义;而南宋,却冤杀岳飞,昏庸腐败,致使生灵涂炭,怎能说它没有不正义。而元朝末年亦是“人心离叛,天下兵起,使我中国之民,死者肝脑涂地,生者骨肉不相保”,也是它的不正义导致了灭亡。

所以无论怎么改朝换代,无论谁是胜利者,法律永远禁止谋杀、抢劫、盗窃、贪污和诈骗。所以邪不压正,是因为邪本来就不压正,这是规律,不是结果。

社会上流行这样的观点:好人和坏人是相对的,只是立场不同;小孩子才讲对错,大人只看利弊。但这种善恶相对的观点很危险。如果认为正义与邪恶是相对的概念,就不会相信真正的良善是存在的。人就可能会走邪路,成为自私自利、损人利己、为达目的不择手段的人。

为了探寻什么是正义,柏拉图构想了一个理想的城邦,一个“理想国”。在这个城邦里,不同的人分工合作,每个人在国家里执行一种最适合他天性的职务。工匠制作工具,农民种田,皮匠做鞋,以及“爱智慧的人[1]”担任领导者。因为柏拉图认为只有智慧和理性才能让这个城邦在各种情况下做出最正确的决策。在智慧和理性的领导下,这个城邦训练勇敢的护卫者,用音乐和体操教化人民。这样的城邦是智慧的、理性的、勇敢的、节制的。一个这样的城邦便是正义的城邦。

柏拉图将人与城邦类比。一个城邦有形形色色的人,一个人内心也有不同的部分。一个人内心有受理性控制的部分,也有受欲望控制的非理性的部分,也有受激情控制的部分。柏拉图认为在这三部分中,理性的部分应该担任“领导者”,就像理性的人在应当在城邦担任领导者一样。

理智既然是智慧的,是为整个心灵的利益而谋划的,还不应该由它起领导作用吗?激情不应该服从它和协助它吗?

正义的人不许可自己灵魂里的各个部分相互干涉,起别的部分的作用。他应当安排好真正自己的事情,首先达到自己主宰自己,自身内秩序井然,对自己友善。

不正义应该就是三种部分之间的争斗不和、相互间管闲事和相互干涉,灵魂的一个部分起而反对整个灵魂,企图在内部取得领导地位——它天生就不应该领导的而是应该像奴隶一样为统治部分服务的,——不是吗?我觉得我们要说的正是这种东西。

我们常说“做自己的主人”,柏拉图说人怎么才能做自己的主人呢?因为如果说一个人是自己的主人,那他同时也是自己的奴隶。他认为这句话的意思是,一个人内心理性的部分要做非理性部分的主人。理性会让人追求智慧,会在必要时抑制欲望与激情;在他需要战斗时,又会释放激情。这样,这个人便是智慧的、理性的、勇敢的、节制的。这样的人便是正义的人。

什么是快乐

我曾经认为,人的快乐不取决于人拥有多少物质,而取决于拥有的物质的变化。例如,假设你在路上捡到一千块钱,你会快乐,但仅限于得到这些钱的这一刻。你不会因为资产增加了一千元而一直开心。再比如年收入只有10 万的时候会想,要是我一年能赚 20 万就好了。然而当收入真的变为 20万时,他会发现快乐只存在于收入变化的这一小段时间,之后便开始追求更高的收入了。痛苦便与之相反:当人失去物质时,会感受到痛苦,但痛苦也仅存在于失去的这一刻。我甚至提出了一个“快乐公式”:函数\(f(t)\) 表示 \(t\) 时刻人拥有的物质,那么 \(t\) 时刻的快乐便是函数 \(f\) 的导数 \(f'(t)\)。如果 \(f'(t) > 0\)则人是快乐的,反之则是痛苦的。也就是说快乐和痛苦是对比出来的。

然而柏拉图全然否定了这个观点。柏拉图认为世界上有快乐,也有痛苦;还有一种介于快乐和痛苦之间的平静的状态。把快乐、平静、痛苦比喻为上、中、下三级。但人在受到痛苦时会把摆脱痛苦称为快乐,然而摆脱痛苦实际是中间的平静的状态,并不是什么享受。例如生病的人会说,没有什么比健康更快乐的了。然而他们在生病之前并不曾觉得那是最大的快乐。同样,当一个人正在享乐,让他突然停止享乐,进入平静的状态,也是痛苦的。然而平静怎么可以既是快乐又是痛苦呢?这便推导出矛盾了。因此柏拉图认为这种对比出来的快乐不是真的快乐,只是快乐的影像。

和痛苦对比的快乐以及和快乐对比的痛苦都是平静,不是真实的快乐和痛苦,而只是似乎快乐或痛苦。这些快乐的影像和真正的快乐毫无关系,都只是一种欺骗。

柏拉图认为,通过肉体上的享受得到的快乐,大多数属于“快乐的影像”,是某种意义上的脱离痛苦。例如人饥饿时吃食物会觉得无比的美味,这种快乐实际上是脱离痛苦。更进一步地,因欲望的满足而得到的快乐,大多属于“快乐的影像”。人对某事物有了欲望,求而不得,感到痛苦。欲望越强,越求而不得,就越痛苦,得到它时就越“快乐”。然而这种“快乐”某种意义上是痛苦的脱离,只是“快乐的影像”,不是真正的快乐。

柏拉图认为有一种真正的快乐,得到它之前不会感到痛苦,出现的时候能感受到强烈的快乐;停止之后也不留下痛苦。例如当你坚持学习,头脑变得越来越充实的时候;领悟了某个真理时那种”朝闻道,夕死可矣”的满足感。我还记得当时理解了Y-Combinator,学会了用 CPS 变换实现 call/cc的时候的那种兴奋感,真的能让人快乐好几天。柏拉图认为,用理性的部分追求智慧、美德得到的是实在的东西,而肉体上的享受是不实在的东西。让实在的东西填充内心才能得到可靠的真实的快乐。

那么,没有经验过真实的人,他们对快乐、痛苦及这两者之中间状态的看法应该是不正确的,正如他们对许多别的事物的看法不正确那样。因此,当他们遭遇到痛苦时,他们就会认为自己处于痛苦之中,认为他们的痛苦是真实的。他们会深信,从痛苦转变到中间状态就能带来满足和快乐。而事实上,由于没有经验过真正的快乐,他们是错误地对比了痛苦和无痛苦。正如一个从未见过白色的人把灰色和黑色相比那样。

因此,那些没有智慧和美德经验的人,只知聚在一起寻欢作乐,终身往返于我们所比喻的中下两级之间,从未再向上攀登看见和到达真正的最高一级境界,或为任何实在所满足,或体验到过任何可靠的纯粹的快乐。他们头向下眼睛看着宴席,就像牲畜俯首牧场只知吃草,雌雄交配一样。须知,他们想用这些不实在的东西满足心灵的那个不实在的无法满足的部分是完全徒劳的。由于不能满足,他们还像牲畜用犄角和蹄爪互相踢打顶撞一样地用铁的武器互相残杀。

柏拉图称肉体上的享受为不实在的东西,认为内心的欲望是不实在的无法满足的部分。现代科学在一定程度上支持柏拉图的观点。肉体上的享受得到的快乐来源于多巴胺。多巴胺能给人带来快乐,然而代价是当多巴胺消退时,人会感觉到空虚和痛苦,需要更大剂量的多巴胺才能弥补这些痛苦。于是人对多巴胺的追求永远不会满足,这个过程最终会变成一种折磨。而人在节制、自律的时候会分泌内啡肽,它给人带来的快乐是缓慢持续的;当它消退时也不会感到痛苦。自律和节制能给人带来更高级的快乐。这也是罗翔所说的,低级的快乐来自放纵,高级的快乐来自克制。

我过去提出的那个“快乐公式”只适用于通过多巴胺获取的快乐。也就是柏拉图所说的,往返于中下两级之间,从平静和痛苦之间感受快乐的影像。

最后柏拉图认为正义的人是真正快乐的,因为他们是理性的、节制的。他们会追求智慧,追求真理,这其中得到的快乐要胜与满足欲望得到的快乐。一个极端不正义的人欲望会无限膨胀,理性成为欲望的奴隶,他永远无法满足,给他再多的物质也得不到快乐。

  1. 哲学家 philosopher字面意思为“爱智慧的人”。philo- 爱,sophia 智慧。↩︎

AppImage: 一次打包,到处运行

2024-04-19 00:00:00

我们知道,不同于 Windows 将软件的所有文件安装在一个目录,一个 Linux软件的不同部分会被安装在不同路径。例如,可执行文件安装在/usr/bin 下;库文件安装在 /usr/lib下;文档、脚本等资源文件通常安装在 /usr/share下等。这是因为 Linux认为软件包之间会相互依赖,不同的软件可能依赖于同一个库,那么这个库就只应该存在一份。例如curl, ssh 和 nginx 都依赖于 libcrypt.so 这个共享库,其为 openssl的一部分。当我们使用 apt 安装 nginx 时,先会检测 openssl是否已经安装。如果没有,就先安装 openssl;否则直接安装 nginx。

这样做的好处是可以节省磁盘:所有软件依赖的相同的库只存在一份。因此安装一个Linux 系统通常只需要几 G 的磁盘空间,而 Windows 通常需要几十G。同时可以节省内存,因为共享库的加载方式是mmap,同一个共享库在内存中也只有一份。

但是这么做是有代价的。假设 A, B 软件都依赖于库 L,那么 A 和 B就只能依赖于同一个版本的库L。一台机器上有这么多软件,意味着整个依赖网络让他们相互钳制,版本号被限制,不能随意升级。一个发行版会确定各种软件包的版本(确定主次版本号,补丁号通常不做限制),组成软件库,确保它们相互兼容,没有依赖冲突。也就是说apt 安装的软件版本由当前 Ubuntu 版本决定的。这也是为什么Linux 发行版通常每年都要发布一个新版本,否则软件库会落后于时代。

如果你在用一个较老的发行版,想安装一些新软件,通常需要自己编译。自己编译的软件通常安装在/usr/local/* 下,与 /usr/*区分开。但是我公司用的开发环境的发行版太老了,g++ 版本 4.8,只支持 C++11,无法编译要求支持 C++ 17 的新软件。更糟糕的是这个发行版的 glibc版本也非常老,新软件即使在新系统中编译出来,也无法在这个老系统上运行。而gcc 工具链(包括 glibc)是操作系统的一部分,不能随意升级。

要是能像 Windows 一样将软件的依赖的各种 DLL 都打包到一起就好了!Linux有类似的解决方案,AppImage 就是其中一种。它可以将软件打包成一个二进制AppImage 文件,这是一个标准的 ELF 可执行文件。用户下载 AppImage文件后,直接 chmod +x 后就可以直接运行,非常方便。对于AppImage 来说,一个软件就是一个可执行文件。

1
2
chmod +x app.AppImage
./app.AppImage

原理

AppImage 的原理是将软件和相关依赖归档成一个磁盘镜像,打包在 AppImage文件里。这个归档的目录称为 AppDir,它的结构大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
AppDir
├── AppRun
├── icon.svg
├── app.desktop
└── usr
├── bin
│   └── app
├── lib
│   └── x86_64-linux-gnu
│   ├── ld-2.31.so
│   ├── libm.so.6
│   ├── libpthread.so.0
│   └── libc.so.6
└── share
└── icons
   └── icon.svg

运行 AppImage 文件时,其中的磁盘镜像会被挂载到/tmp/.mount_XXX.XXXXX 上,然后执行其中的 AppRun。AppRun可以是一个脚本,也可以是一个二进制,它负责做一些前序工作,设置各种环境变量(如LD_LIBRARY_PATH),然后启动目标程序。

Hello, AppImage

接下来我们动手制作一个 AppImage。我们有一个 C 程序hello.c

1
2
3
4
5
6
#include <stdio.h>

int main() {
printf("Hello Appimage\n");
return 0;
}

然后编译它 gcc -o hello hello.c。接着我们创建一个AppDir 目录,将 hello 放到AppDir/usr/bin/ 中。

1
2
3
4
5
6
7
$ mkdir -p AppDir/usr/bin
$ cp hello AppDir/usr/bin/
$ tree Appdir
AppDir
└── usr
└── bin
└── hello

接着我们要将程序依赖的共享库也打包进去。我们用 ldd 查看hello 依赖的共享库:

1
2
3
4
$ ldd hello
linux-vdso.so.1 (0x00007ffe66f8f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f849544b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8495650000)

hello 很简单,只依赖 libc。链接器/lib64/ld-linux-x86-64.so.2为程序加载各种共享库,是程序的解释器(interpreter),也需要打包进去。我们把这两个 .so 文件复制到 AppDir的对应目录:

1
2
3
4
5
6
7
8
9
AppDir
├── lib
│   └── x86_64-linux-gnu
│   └── libc.so.6
├── lib64
│   └── ld-linux-x86-64.so.2
└── usr
└── bin
└── hello

接下来我们创建 AppRun 脚本。这个脚本先设置LD_LIBRARY_PATH 环境变量,然后用 AppDir 中的链接器加载运行hello 程序:

1
2
3
4
#!/usr/bin/sh

export LD_LIBRARY_PATH=${APPDIR}/lib/x86_64-linux-gnu
${APPDIR}/lib64/ld-linux-x86-64.so.2 ${APPDIR}/usr/bin/hello

AppImage 运行时环境变量 APPDIR 便是 AppDir挂载的路径(/tmp/.mount_XXX.XXXXX),我们可以直接在脚本中引用它。最后我们需要一个desktop 文件配置一些元数据,还要准备一个图标文件:

1
2
3
4
5
6
[Desktop Entry]
Name=hello
Exec=hello
Icon=hello
Type=Application
Categories=Utility;

最终 AppDir 的目录结构是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
AppDir
├── AppRun
├── hello.desktop
├── hello.svg
├── lib
│   └── x86_64-linux-gnu
│   └── libc.so.6
├── lib64
│   └── ld-linux-x86-64.so.2
└── usr
└── bin
└── hello

要将 AppDir 打包成可执行文件,需要用到的工具是 appimagetool,可以到Github下载。appimagetool 本身也是个 AppImage,下载后即可运行。执行appimagetool AppDir 便可将 AppDir 打包成一个AppImage。运行它

1
2
$ ./hello-x86_64.AppImage
Hello Appimage

因为它打包了程序所需的所有依赖,所以理论上它可以在任意一个同架构(这里是X86_64)的 Linux 系统上运行,无论这个系统的 libc版本是多少。你也可以修改这个程序,让它引用一些较新的 libc里才有的函数(如 gettid, glibc 2.30 被加入),打包成AppImage 后再发给一个老系统(如 CentOS 7),看看它能不能正常运行。

使用 appimage-builder

上面例子中的程序很简单,只依赖一个libc。而实际情况下程序通常依赖很多共享库,这些共享库有可能又依赖更多其它的共享库。手动找出来非常麻烦,我们可以使用工具。appimage-builder就是一个很方便的工具。它的原理是运行目标程序,分析它访问了哪些共享库;然后使用包管理器(如apt)获取依赖,并制作成 AppDir。此外它还提供了一个功能强大的AppRun,支持路径映射,通过 hook 程序的文件访问函数,将指定路径映射到AppDir 中。

appimage-builder 是一个 Python 工具,可以使用 pip 安装:

1
pip install appimage-builder

要用 appimage-builder 制作 AppImage,我们首先需要准备一个“基础版”的AppDir,包含软件的可执行文件和一些相关依赖。通常那些make install 复制到 /usr/local/下的文件就是基础 AppDir 应当包含的文件。上面例子的基础 AppDir结构如下:

1
2
3
4
AppDir
└── usr
└── bin
└── hello

appimage-builder 基于一个 yaml 配置文件制作 AppImage,称为recipe。我们不必手动创建 recipe,可以用appimage-builder --generate命令生成,然后再根据需要修改。generate命令是一个向导程序,会询问这个应用的基本信息。

1
2
3
4
5
6
7
8
9
10
11
$ appimage-builder --generate
INFO:Generator:Searching AppDir
? ID [Eg: com.example.app]: tech.luyuhuang.hello
? Application Name: hello
? Icon: hello
? Executable path: usr/bin/hello
? Arguments [Default: $@]: $@
? Version [Eg: 1.0.0]: latest
? Update Information [Default: guess]: guess
? Architecture: x86_64
INFO:AppRuntimeAnalyser:/usr/bin/strace -f -E LD_LIBRARY_PATH= -e trace=openat --status=successful AppDir/usr/bin/hello

接着 appimage-builder 会用 strace运行目标程序,分析它打开了哪些共享库文件;然后用包管理工具分析共享库属于哪个软件包。结束后就会生成recipe 文件 AppImageBuilder.yml。它的结构如下:

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
version: 1
AppDir:
path: /path/to/AppDir
app_info: # 应用基础信息
id: tech.luyuhuang.hello
name: hello
icon: hello
version: latest
exec: usr/bin/hello
exec_args: $@
apt:
arch:
- amd64
allow_unauthenticated: true
sources: # 用到的软件源
- sourceline: deb http://archive.ubuntu.com/ubuntu/ focal main restricted
- sourceline: deb http://archive.ubuntu.com/ubuntu/ focal-updates main restricted
- ...
include: # 用到的软件包
- libc6:amd64
files:
include: [] # 额外需要包含到 AppDir 的文件
exclude: # 需要排除的文件
- usr/share/man
- usr/share/doc/*/README.*
- ...
test: # 测试配置
fedora-30:
image: appimagecrafters/tests-env:fedora-30
command: ./AppRun
debian-stable:
...
AppImage:
arch: x86_64
update-information: guess

我们通常需要关注这些配置:

  • AppDir.apt 软件包相关信息,由 generate命令探测出,通常不用自行修改。include为要用到的软件包,sources 是这些软件包相关的软件源。
  • AppDir.files 控制要包含哪些文件。支持使用 Glob表达式(如 *** 通配符)匹配文件路径。
    • include 为需要包含到 AppDir的文件的绝对路径列表,这些文件会被复制到 AppDir 中的对应位置。例如/usr/bin/bash 对应 $APPDIR/usr/bin/bash
    • exclude 则为需要在 AppDir中排除的文件路径列表,路径相对于 AppDir。
  • AppDir.test 为测试环境。appimage-builder会拉取其中指定的 Docker 镜像,并在其中测试 AppDir。

除这些自动生成的配置外,还有很实用的运行时配置。

1
2
3
4
5
6
AppDir:
runtime:
env:
LD_PRELOAD: '${APPDIR}/usr/lib/libjemalloc.so'
path_mappings:
- /bin/bash:$APPDIR/bin/bash
  • env 指定运行时的环境变量。appimage-builder 自带的AppRun 程序还支持一些特殊的环境变量
    • APPDIR_EXEC_ARGS 程序的命令行参数,默认为$@,即原样透传传给 AppRun 的参数。
    • APPDIR_LIBRARY_PATH 共享库搜索路径,效果等同于LD_LIBRARY_PATH
  • path_mappings 设置路径映射。支持将一个绝对路径映射到AppDir 中的路径,格式为 源路径:目标路径。例如/bin/bash:$APPDIR/bin/bash,每当程序访问/bin/bash 都会实际访问 AppDir 中的bin/bash

准备好 recipe 文件后执行appimage-builder --recipe AppImageBuilder.yml 即可生成AppImage。也可以加上 --skip-tests 跳过测试。

实战:制作 ccls 的 AppImage

ccls 是一个 C++ 的 language server。我想在公司的开发环境用上ccls,但是 ccls依赖的工具链和运行时环境都比较新,无法直接在公司的开发环境上编译、运行。因此我准备在Ubuntu 20.04 下编译 ccls 并制作成AppImage,让这个老系统也能用上新软件。

执行如下命令构建 ccls:

1
2
3
4
5
6
7
8
9
10
11
sudo apt-get install clang libclang-10-dev # 安装依赖
git clone --depth=1 --branch=0.20220729 --recursive https://github.com/MaskRay/ccls # 获取 ccls, 版本 0.20220729
cd ccls
cmake -H. -BRelease -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH=/usr/lib/llvm-10 \
-DLLVM_INCLUDE_DIR=/usr/lib/llvm-10/include \
-DLLVM_BUILD_INCLUDE_DIR=/usr/include/llvm-10/ \
-DCMAKE_INSTALL_PREFIX=/usr # 设置 prefix 为 /usr
cd Release
make -j8
make install DESTDIR=AppDir # 安装到 ./AppDir

这样我们就有了基础 AppDir:

1
2
3
4
AppDir
└── usr
└── bin
└── ccls

接着我们执行 appimage-builder --generate 生成recipe:

1
2
3
4
5
6
7
8
9
10
$ appimage-builder --generate
INFO:Generator:Searching AppDir
? ID [Eg: com.example.app]: com.github.MaskRay.ccls
? Application Name: ccls
? Icon: ccls
? Executable path relative to AppDir [usr/bin/app]: usr/bin/ccls
? Arguments [Default: $@]: $@
? Version [Eg: 1.0.0]: latest
? Update Information [Default: guess]: guess
? Architecture: x86_64

根据 ccls 的文档(和我的测试结果),ccls 运行时要访问 clang 的 lib目录。我的 clang 是用 apt 安装的,路径在/usr/lib/llvm-10/lib/clang/10.0.0。我们需要把这个路径打包进AppDir,并且将其映射到 AppDir 内。我们修改AppImageBuilder.yml:

1
2
3
4
5
6
7
AppDir:
files:
include:
- /usr/lib/llvm-10/lib/clang/10.0.0/** # 把这个路径下的全部文件包含进去
runtime:
path_mappings:
- /usr/lib/llvm-10/lib/clang/10.0.0:$APPDIR/usr/lib/llvm-10/lib/clang/10.0.0 # 映射到 AppDir 内

然后我们还要创建个图标。虽然是命令行程序,但是 AppImage要求每个应用都要有个图标,所以没办法。这里我们就 touch一个空文件就好:

1
2
mkdir -p AppDir/usr/share/icons
touch AppDir/usr/share/icons/ccls.svg

最后执行 appimage-builder --recipe AppImageBuilder.yml生成 AppImage。大功告成!

1
2
3
$ ./ccls-latest-x86_64.AppImage --version
ccls version 0.20220729-0-g7445891
clang version 10.0.0-4ubuntu1

总结

Linux的软件管理方式虽然节省了磁盘和内存空间,但是也增加了软件安装的难度。导致Linux的软件要么进入发行版使用包管理器安装;要么发布源码,编译安装。前者虽然安装方便,但是版本受限,不能随意升级;后者需要准备开发环境,安装较为麻烦。当编译依赖的工具链不满足要求时,软件安装会变得很棘手。

针对这个问题,Linux 有几种解决方案,例如 snap、容器,以及本文介绍的AppImage等。它们的解决思路其实差不多,都是将软件与其依赖一起打成包发布。它们各有优劣,对于AppImage 来说,优点就是使用方便,用户不需要安装任何环境,下载 AppImage即可执行;缺点是依赖于 AppRun 的前序处理,兼容性可能不如 snap和容器。个人感觉 Linux 桌面系统要想推广,软件安装还是要走 Windows 和macOS 这种形态,即打包软件依赖,降低安装门槛,提高兼容性。

参考资料:

2023 Annual Review

2024-01-01 00:00:00

In 2023 I spent most of my time on work, learning and dating.Compared with the last year, devoted less time on this blog andcommunity. It might be a pretext but to be honest, writing a blog postalways consumes a lot of my energy, especially when there was a lot ofovertime this year. Anyway, I should write more posts in the new year,and I’ll try to write some non-technical posts (well, partially becausethey’re easy to write (and read)).

Let’s talk a little about professional skills over here. In the past,I focused most of my efforts on coding skill, rather than engineeringskills, or let’s say, the business skills. This is because I love thehacker spirit, and am fascinated by intricate program structures andalgorithms. But your boss just need one who solve problems, them don’tcare about computer science. To solve a problem, you have to considermany things other than the computer - namely, the people around you. Andthat’s exactly what software engineering does - to use some engineeringmethods to prevent or eliminate mistakes made by humans. So when I focuson hacking, I must keep an open mind on other skills, especially thosemethodologies for problem-solving.

Goals

  • Keep up learning English
  • Read Understanding the LinuxKernel (I’m not sure if I can finish it)
  • Keep up daily LeetCodeexercises
  • Keep up writing blogs, basically one post amonth
  • Read some non-technicalbooks
  • Keep up exercises

Learning

I finished learning Structure and Interpretation of ComputerPrograms (SICP), an amazing book. Its content comprised offunctional programming, layering of program and data structure, OOP,infinite streams, the metacircular evaluator, lazy evaluation,compilation principle, etc. I have to say, SICP opened the gateof computer science for me. Before then, I don’t really comprehend theessential of computer science.

sicp

LeetCode

I kept on doing LeetCode like the past few years. The grid looks notbad.

leetcode

Now I’ve solved 1182 problems. Last year it’s 912, so I solved 270problems in 2023.

Language

I kept learning English in 2023, as I did in the past few years.After continuously learning vocabulary on the APP baicizhan for2024 days, I found it might not be a very effective way to memorize newwords for me at the moment. Therefore, In November, I started learningMerriam-Webster’s Vocabulary Builder. This book organizes wordsby their roots, besides telling you how to use a word, its history, andrelated knowledge.

webster

I also read another amazing vocabulary builder, Word Power MadeEasy. To me, this book like an introduction to the etymology and atthe same time teaches you how to memorize over 3,000 words and continuebuilding your vocabulary.

webster

In addition, I kept leaning Japanese on Duolingo as I did in2022.

webster

Creating

I only wrote 6 posts on the blog.

I created a repo luyuhuang/nvim, but it’sjust for personal configuration, should not be considered as acontribution. I submitted some pull requests and issues to the communityand some of have been accepted.

Something Happy

Hiking and seeing the skyline of the city at the summit is quitehappy, especially with the one you love.

webster

Finally

At the end of a year, I always feel time flies by quickly. But afterI wrote the annual review, I found that the time of a year is quitelong, because you can literally do many things in a year and grow quitea bit in certain aspects (say, I found my English writing skill is muchbetter than the last year). So in the new year, keep learning andgrowing, and I believe we’ll get a better result. Happy New Year toeverybody.

Scheme 语言

2023-07-09 00:00:00

我最近在读 SICP (Structure and Interpretation of ComputerPrograms),中文译名是《计算机程序的构造与解释》,感觉受益匪浅。我打算开个坑,总结分享一些我学到的内容。SICP综合性非常强,内容包括函数式编程、数据结构的分层与抽象、面向对象、无限流、元循环解释器、惰性求值、非确定性编程、逻辑编程、汇编语言与机器、编译原理等等。我只能选取一个主题抛砖引玉,这个系列文章的主题是continuation,主要内容可能包括:

  • Scheme 语言
  • Scheme 元循环解释器
  • 神奇的 call/cc
  • 通过 CPS 解释器实现 call/cc
  • 通过 CPS 变换(也就是传说中的“王垠 40行代码”)实现 call/cc

我最近已经在读最后一章了,待读完本书后再看情况更新一些内容。这些内容的基础是Scheme 语言,我们从介绍 Scheme 语言开始。本文介绍的 Scheme语言主要目的是让不了解 Scheme的同学看完之后能看得懂后面几篇文章,因此不会涉及到一些很细节的内容。(特别细节的内容我也不懂,SICP也没有很深入介绍)如果要深入了解,可以阅读相关的文档。

1 Scheme 的特性

Scheme 是一种 Lisp 的方言。而 Lisp是世界上第二古老的语言(第一古老的是Fortran),有着众多的方言。这些方言有着一个共同的特性——基于 S表达式 (S-expressions)

S 表达式可以是原子表达式 (atom)或者列表。原子表达式可以是数字,如 1,42, 3.14;可以是字符串,如"hello";可以是布尔值,如 #t,#f;也可以直接是符号,如 a, if,add。而列表则是将若干个 S表达式放在一对括号里,用空格隔开:

1
(<s-exp1> <s-exp2> <s-exp3> ...)

下面给出了一些 S 表达式的例子:

1
2
3
4
5
6
100
100.13
"Hello world"
(add 1 2)
(display "Hello world")
(list (list 1 2) (list "a" "b"))

前三个 S 表达式都是原子表达式。(add 1 2) 是一个长度为 3的列表,3 个元素分别是符号 add、数字 1 和数字2。(display "Hello world") 是一个长度为 2的列表,第一个元素是符号 display,第二个元素是字符串"Hello world"(list (list 1 2) (list "a" "b"))是一个长度为 3 的列表,三个元素分别是符号 list、列表(list 1 2)、列表 (list "a" "b")

Scheme 全部是由 S 表达式组成的。在 Scheme中,复合表达式的第一个元素作为表达式的类型,剩余的元素则作为表达式的参数。

s-exp

表达式类型决定这个表达式的语义和参数的含义。例如 if表达式规定有三个参数,第一个参数为条件,第二个参数为条件为真时执行的表达式,第三个参数为条件为假时执行的表达式。由于S 表达式可以任意嵌套,因此利用它就可以构造出任意复杂的代码。下面就是一段Scheme 代码的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
(define (queens board-size)
(define (queen-cols k)
(if (= k 0)
(list '())
(filter
(lambda (positions) (safe? positions))
(flatmap
(lambda (rest-of-queens)
(map (lambda (new-row)
(adjoin-position new-row k rest-of-queens))
(enumerate-interval 1 board-size)))
(queen-cols (- k 1))))))
(queen-cols board-size))

可以看到 S表达式层层嵌套,形成了一个树状结构,这其实就是语法树。也就是说这个语言实际是把语法树明确的写出来。后面我们能看到这种做法的好处:代码可以直接表示为数据结构,代码极其容易解析、编译。

2 编程环境

Scheme 作为 Lisp 的一种方言,它本身又有很多方言,例如 Chez Scheme,MIT Scheme, Racket 等。我们使用的环境是Racket,它功能强大,易于使用。我们可以到它的官网下载最新版本。Racket 自带一个IDE,叫 DrRacket,我们可以使用它学习编写 Scheme。

打开 DrRacket,就可以开始 Scheme编程了。程序的第一行需要声明所使用的语言#lang racket。编辑好了后点击 “Run” 便可执行代码。

s-exp

有些同学可能不习惯这种全是括号的语言,阅读代码需要数括号,十分麻烦。但如果代码做好缩进与对齐,之间的嵌套关系是一目了然的。我们可以让参数另起一行,相对类型缩进两个空格:

1
2
3
4
(type
arg1
arg2
...)

或者第一个参数与类型同行,后续参数与第一个参数对齐:

1
2
3
(type arg1
arg2
...)

如果第一个参数比较特殊,也可以让第一个参数与类型同行,剩余的参数另起一行,并缩进两个空格

1
2
3
4
(type special-arg1
arg2
arg3
...)

基本上就这三种缩进风格。使用 DrRacket可以自动缩进;阅读代码时一般不需要关心括号,直接看代码缩进即可,就像Python 一样。

3 基础表达式

一个高级语言一定具备这三个要素:

  1. 原子表达式 (primitiveexpressions):语言提供的最简单、最基础的元素。
  2. 组合方法 (means ofcombination):将原子表达式组合成复合元素的方法。
  3. 抽象方法 (means ofabstraction):给复合元素命名,从而将其作为一个整体操作。

我们说汇编语言不是高级语言,因为它有非常弱的组合能力和抽象能力。例如add $42 %eax 可以表示 eax + 42,但是要想表示(eax + 42) * 3就得写两条指令了,因为这个语言根本没有嵌套组合的能力。至于抽象能力,汇编中的函数(准确来说应该是subroutine)更像是个 goto。而 Scheme是非常高级的语言,因为它有非常强的组合能力和抽象能力,稍后我们可以看到。

3.1 原子表达式

原子表达式有这么几种:

  • 数字。可以是整数 10-12;浮点数3.14;有理数1/2-3/5,形式是两个由 /分隔的整数,注意中间不能有空格,因为这是一个原子。
  • 字符串。由双引号标识,如 "Hello world"
  • 布尔。有两种,#t#f
  • 符号。也就是所谓的“变量”,或者说标识符。例如 pi,值为3.141592653589793sqrt,为一个内建函数。不同于很多语言,Scheme的符号不局限于字母、数字和下划线,例如reset!*important*+1st-item都是有效的符号。

3.2 复合表达式

Scheme 中的复合表达式有两种,特殊形 (special form) 和函数调用。Scheme函数调用的语法是 (function arg1 arg2 ...),让 S表达式的第一个元素为函数,剩余元素为函数参数。例如下面的几个表达式都是函数调用:

1
2
3
(sqrt 2)
(+ 1 2)
(* (+ 1 2) (+ 3 4))

这里的 sqrt+*都是函数名,分别执行平方根、加法和乘法操作。与其他大部分语言不同,Scheme没有运算符,加减乘除运算、比较运算等都是函数。

对于初学者来说可能有些奇怪,但这种语法有很大的好处。首先表达式关系明确无歧义,程序员不需要记忆运算符优先级、是左结合还是右结合,且程序容易解析编译。使用方式统一,不会像C 语言一样,乘法运算是 a * b,指数运算却是pow(a, b)。不需要 C++那样复杂的运算符重载规则,直接定义一个名为+* 的函数即可。

下面给出了一些常用函数和调用方式:

1
2
3
4
5
6
7
8
9
10
11
(+ 1 1) ;; 加法
(- 1 1) ;; 减法
(* 2 3) ;; 乘法
(/ 3 2) ;; 除法。整数触发会返回有理数,这个例子返回 3/2
(= 2 2) ;; 判断两个数字是否相等
(< 2 3) ;; 第一个参数是否小于第二个参数。类似的还有 >, <= >=
(eq? a b) ;; 判断两个对象是否相同,可以理解成比较地址
(remainder 3 2) ;; 求余数。这个例子返回 1
(sqrt 2) ;; 开根号
(display "Hello world") ;; 打印到标准输出
(newline) ;; 打印换行符

分号 ; 在 Scheme 中用作单行注释。

看到这里,你可能会以为表达式 (if (> a b) a b)也是调用了一个 if函数。但,实际上不是。对函数求值时,会先依次对各个参数求值,然后再调用函数。而对于if 来说,当 (> a b) 为真时,只应该对a 求值,不应该对 b 求值。反之,只应该对b 求值。因此 if不能是函数,应该是一个特殊形。

S表达式就像是语法树的表示,而特殊形就是一种特定的语法,它定义这个语法有哪些子节点,含义分别是什么。下面给出了一些常用的特殊形和使用方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(if predicate consequence alternative) ;; 如果 predicate 为真返回 consequence, 否则返回 alternative

;; 方括号 [] 与圆括号 () 等价,可交错使用方括号和圆括号提升可读性。
(cond [predicate1 consequence1] ;; 依次判断: 如果 predicate1 为真返回 consequence1
[predicate2 consequence2] ;; 如果 predicate2 为真返回 consequence2
...
[else alternative]) ;; 如果所有的条件都不成立,则返回 alternative

(define var val) ;; 定义变量 var 的值为 val

(and exp1 exp2 ...) ;; 逻辑与,遵循短路原则(所以必须是特殊形)
(or exp1 exp2 ...) ;; 逻辑或,遵循短路原则
;; 逻辑非是一个函数 (not exp)

(begin exp1 exp2 ...) ;; 按顺序依次对 exp1, exp2, ... 求值,整个表达式的值为最后一个表达式的值。类似于 C 中的逗号运算符。

(lambda (arg1 arg2 ...) body ...) ;; 构造一个函数,第 4 节详细介绍

4 定义函数

lambda 特殊形创建一个函数,形式为(lambda (arg1 arg2 ...) body ...)。其中(arg1 arg2 ...) 为参数列表,剩下的 body ...为函数体,可由多个表达式组成,函数的返回值为最后一个表达式的值。我们通常结合define 定义函数,下面给出了一个例子

1
2
3
4
5
(define gcd
(lambda (a b)
(if (= b 0)
a
(gcd b (remainder a b)))))

这个函数实现欧几里得算法,求两个整数 ab的最大公约数。函数参数列表是 (a b),函数体只有一个if 表达式。if 表达式检查 b 是否为0,如果 b 为 0 则返回 a,否则递归调用自身(gcd b (remainder a b))。现在我们就可以调用gcd 了:

1
2
(gcd 10 12) ;; 2
(gcd 7 11) ;; 1

由于我们经常使用 definelambda定义函数,我们有一种简便的写法(define (fname args ...) body ...) 等价于(define fname (lambda (args ...) body ...))。因此gcd 还可写成这样

1
2
3
4
(define (gcd a b)
(if (= b 0)
a
(gcd b (remainder a b)))))

4.1 环境

函数可以嵌套定义。例如定义函数 prime?判断一个数是否是质数,我们寻找能整除它的大于 1的整数。如果找不到能整除它的整数,则它是一个质数

1
2
3
4
5
6
(define (prime? n)
(define (iter i)
(cond [(> (* i i) n) #t]
[(= (remainder n i) 0) #f]
[else (iter (+ i 1))]))
(iter 2))

prime? 所在的环境称为全局环境,iter所在的环境为 prime? 的内部环境。define执行的时候,会在它所处的环境中增加一个变量。当函数被调用时,会创建一个新环境,这个新环境继承函数定义时所在的环境;而函数的参数就在新环境中实例化。对表达式求值,会先在当前环境寻找变量的值,如果找不到则在上层环境寻找,依次类推。因此要考察一个函数的行为,必须考虑两个要素:这个函数的代码,和这个函数所在的环境。这两要素有时合在一起称为“闭包”。

env

当我们在全局环境中执行 (prime? 11)时,会有这么几步:

  • 在全局环境中找到 prime?变量,发现它是一个函数,调用它。
  • 读取闭包的 Env 字段,发现这个这个函数的环境是全局环境G,因此创建一个继承 G 的新环境,记作 E1。
  • 在 E1 中实例化参数,有 n: 11
  • 开始执行 prime? 的代码。
  • 执行 (define (iter i) ...),在 E1 中添加变量iteriter 是一个函数,所在的环境指向E1。
  • 执行 (iter 2),在 E1 中找到iter,发现它是一个函数,调用它。
  • 发现这个函数的环境是 E1,因此创建一个继承 E1 的新环境,记作E2。
  • 在 E2 中实例化参数,有 i: 2
  • 开始执行 iter 的代码。
  • 执行到 (> (* i i) n)
    • 在 E2 找查找变量 *,找不到;然后再 E1中找,还是找不到;最后在 G 中找到 * 是个内建函数。
    • 在 E2 中查找变量 i,找到 i: 2
    • 在 E2 中查找变量 n,找不到;然后在 E1 中找,找到n: 11
  • 执行 (iter (+ i 1)),可在 E2 中找到i: 2,在 E1 中找到 iter。调用iter
  • 发现 iter 所在的环境是 E1,因此创建一个继承 E1 的新环境E3。
  • 在 E3 中实例化参数,有 i: 3
  • 开始执行 iter 的代码,以此类推。

这便是 Scheme环境的运作机制。下一篇文章我们会实现这个机制,从而实现一个 Scheme解释器。

Scheme的函数是一等公民,我们可以将函数当作参数传递,也可以当成返回值返回。当函数被传递时,它所在的环境也将被传递。例如

1
2
3
4
5
(define (f x)
(lambda () x))

(define n (f 10))
(n) ;; 10

函数 f 返回一个函数,这个函数便保存了调用 f时创建的环境。因此我们可以通过这个函数获取到调用 f时传的值。后面我们可以看到这个机制有趣的应用。

4.2 letlet*

当我们需要中间变量时,例如计算 \(5(3x^2+1)^2 +4(3x^2+1)\),为了避免重复计算,我们需要一个中间变量 \(t=3x^2+1\)。这个使用我们会使用let 特殊形。

1
2
(let ([t (+ (* 3 x x) 1)])
(+ (* 5 t t) (* 4 t)))

let 的语法格式如下:

1
2
3
4
5
6
(let ([var1 val1] ;; 定义若干个变量
[var2 val2]
...)
body ;; 可在 body 中使用这些变量
...)
;; let 外不能使用这些变量

它其实是个语法糖,等价于使用 lambda创建一个函数,然后立刻调用它:

1
2
3
4
((lambda (var1 var2 ...)
body
...)
val1 val2 ...)

let有一个缺陷,就是定义后面的变量的值时不能引用前面的变量,也就是说(let ([a 1] [b (+ a 1)]) b) 是非法的。于是我们有let*

1
2
3
4
5
(let* ([var1 val1]
[var2 val2] ;; val2 可以引用 var1
...)
body
...)

它也是一个语法糖,等价于

1
2
3
4
5
(let ([var1 val1])
(let ([var2 val2])
(let ...
body
...))

let* 通过嵌套 let实现,因此允许引用前面的变量。

5 数据结构

前面介绍了代码的组合和抽象,这一节介绍数据结构。这一系列文章只会用到非常简单的数据结构。

5.1 有序对和列表

为了构造复合结构,我们用 cons 构造有序对(pair)car获取有序对的第一个元素,cdr 获取有序对的第二个元素。

1
2
3
(define p (cons 1 2))
(car p) ;; 1
(cdr p) ;; 2

有序对可以任意嵌套,如(cons (cons 1 2) (cons 3 4))。因为可以任意嵌套,所以理论上仅靠有序对就可以构造出任意复杂的数据结构。如果将有序对依次连接,就得到了一个链式列表:

1
(cons 1 (cons 2 (cons 3 (cons 4 '()))))

每个有序对的第一个元素 (car) 存储当前节点的值,第二个元素 (cdr)指向下一个节点。最后一个元素的 cdr 为 '(),表示NIL,链表的结尾。使用 list 函数可以快速创建一个列表:

1
(list 1 2 3 4) ;; 等价于 (cons 1 (cons 2 (cons 3 (cons 4 '()))))

这样对于列表来说,car用于获取列表的第一个元素,cdr 用于获取列表剩余的元素,而cons 在列表头部插入一个元素。

1
2
3
4
5
6
7
8
9
10
(define items (list 1 2 3 4))

(car items) ;; 1
(cdr items) ;; '(2 3 4)
(cons 0 items) ;; '(0 1 2 3 4)

(car (cdr items)) ;; 2
(car (cdr (cdr items))) ;; 3
(cdr (cdr (cdr items))) ;; '(4)
(cdr (cdr (cdr (cdr items)))) ;; '()

5.2 Quote

你可能会好奇 '()'(2 3 4) 中的单引号' 是什么意思。回想一下第 1 节,S表达式可以是原子表达式或列表。是的,这里的说的列表list 函数创建的列表是一个东西。也就是说,S 表达式

1
(1 2 3 4)

本身就是一个列表。但是这个表达式会被 Scheme 解释成调用函数1,传入参数 2, 3, 4。为了表示列表本身,我们用quote 特殊形。quote 接受一个 S表达式作为参数,不对这个表达式求值,而是直接返回它。下面是一些使用例子。

1
2
3
4
(quote (1 2 3 4)) ;; 等价于 (list 1 2 3 4)
(quote a) ;; 不认为 a 是一个变量,而是直接返回符号 a 本身
(quote (+ a b)) ;; 返回一个长度为 3 的列表,三个元素分别是符号 +, 符号 a, 符号 b
(quote (lambda (x) (* x x))) ;; 任何代码都可以放到 quote 里

由于 quote 十分常用,因此我们有一种简化形式。在任意 S表达式前加上单引号 ' 表示对这个 S 表达式 quote。

1
2
3
4
'a ;; 等价于 (quote a)
'() ;; 等价于 (quote ()),表示一个空列表,也称为 NIL
'(1 2 3 4) ;; 等价于 (list 1 2 3 4)
'(lambda (x) (* x x)) ;; 任何代码前面都可以加上单引号,表示代码本身

这有这非常重要的意义——意味着代码可以当作数据解析。这是其它非 Lisp系语言不具备的能力。我们会在下一篇文章中大量使用它,这里我们先看一些简单的例子:

1
2
3
4
5
(define code '(lambda (x) (* x x)))

(car code) ;; 'lambda
(cadr code) ;; '(x)
(caddr code) ;; '(* x x)

这里的 cadrcaddr是快捷函数。(cadr x) 等价于(car (cdr x))(caddr x) 等价于(car (cdr (cdr x)))。这种命名也很容易记忆:中间的ad 分布表示依次调用 carcdr

我们知道列表由有序对构成。S表达式使用括号表示列表,那么对于有序对这种更基础的元素,它如何表示呢?我们可以试验下:

1
(cons 1 2) ;; '(1 . 2)

如果括号里的两个元素用 .隔开,则表示这是一个有序对。但如果有序对的第二个元素被括号包裹,则会省略掉. 和第二个元素的括号:

1
2
3
'(1 . (2 . 3)) ;; '(1 2 . 3)
'((1 . 2) . (3 . 4)) ;; '((1 . 2) 3 . 4)
'(1 . (2 . (3 . (4 . ())))) ;; '(1 2 3 4)

因此 (cons 1 (cons 2 (cons 3 (cons 4 '())))) 的结果是'(1 2 3 4),看上去像是个列表了。这种语法的好处是,既能体现列表是由有序对构成的(可以显式写成(+ . (2 . (3 . ())))),又能让列表看上去很舒服(一般写作(+ 2 3))。

5.3 Quasiquote 与 unquote

Scheme还提供了一对方便我们构造特定列表的特殊形:quasiquoteunquote。它们同样接受一个 S表达式作为参数。(quasiquote exp) 可简写为`exp(unquote exp) 可简写为,exp。与 quote 类似,quasiquote也原样返回 S 表达式,但会对其中 unquote 的部分求值。

1
2
3
4
5
6
7
8
(define a 10)
(define b 20)
(define c '(x y))

`(1 2 ,a ,b) ;; '(1 2 10 20)
`(,a . ,b) ;; '(10 . 20)
`(1 ,c 2 3) ;; '(1 (x y) 2 3)
`(1 ,(* a b) 2) ;; '(1 200 2)

还有一个类似的语法是unquote-splicing,接受一个列表作为参数,(unquote-splicing list)简写为 ,@list。它会对列表求值并展开:

1
2
3
4
5
(define items (list 10 20 30))

`(1 2 ,@items 3) ;; '(1 2 10 20 30 3)
`(,@(list 1 2) 3 4) ;; '(1 2 3 4)
`(,@'(1) 2 3) ;; '(1 2 3)

5.4 常用函数

这里介绍一些操作列表的常用函数。

pair? 判断是否是有序对

1
2
3
4
(pair? 1) ;; #f
(pair? '()) ;; #f,NIL 不是有序对
(pair? (cons 1 2)) ;; #t
(pair? (list 1 2 3)) ;; #t,列表也是有序对组成的

list? 判断是否是列表

1
2
3
(list? '(1 2 3)) ;; #t
(list? '()) ;; #t,NIL 就是空列表
(list? '(1 2 . 3)) ;; #f,不以 NIL 结尾,不是列表

symbol? 判断是否是符号

1
2
3
4
5
6
(symbol? 12) ;; #f,数字不是符号
(symbol? 'abc) ;; #t
(symbol? (quote abc)) ;; #t
(symbol? pi) ;; #f,pi 的值是 3.14159,不是符号
(symbol? 'pi) ;; #t
(symbol? '(1 2 3)) ;; #f,列表不是符号

null? 判断列表是否为空。

1
2
3
4
(null? '()) ;; #t
(null? (list)) ;; #t
(null? (list 1 2)) ;; #f
(null? (cddr '(1 2))) ;; #t

memq 在列表中找到 car 等于给定值的有序对

1
2
3
(memq 2 '(1 2 3)) ;; '(2 3)
(memq 3 '(1 2 3)) ;; '(3)
(memq 4 '(1 2 3)) ;; #f,返回 false 表示不存在

assoc 假设列表的元素都是有序对,找到有序对的 car等于给定值的元素

1
2
3
(assoc 2 '((1 a) (2 b) (3 c))) ;; '(2 b)
(assoc 3 '((1 . a) (2 . b) (3 . c))) ;; '(3 . c)
(assoc 4 '((1 a) (2 b) (3 c))) ;; #f

append 连接两个列表

1
2
(append '(1 2 3) '(4 5)) ;; '(1 2 3 4 5)
(append '(1 2 3) '()) ;; '(1 2 3)

6 函数式编程

Scheme倡导函数式编程,除了函数是一等公民外,还有一点就是“非必要不赋值”。到现在为止,我们还没有介绍赋值语句。对于命令式编程来说,不使用赋值语句连个有限while 循环都写不出来。但是在函数式编程中,我们会熟练使用各种递归。

1
2
3
4
5
6
7
(define (sum items)
(if (null? items)
0
(+ (car items)
(sum (cdr items)))))

(sum '(1 2 3 4)) ;; 10

虽然无法通过赋值改变变量,但是我们在可以调用函数时改变参数的值。有人可能会说递归性能差,因为需要消耗栈空间。确实,上面的代码在调用(sum (cdr items)) 之前需要将 (car items)的值压栈,以便 sum返回后计算两者之和。但是我们只需要稍微修改一下写法:

1
2
3
4
5
6
(define (sum items)
(define (iter i s)
(if (null? i)
s
(iter (cdr i) (+ s (car i)))))
(iter items 0))

我们发现递归调用 (iter (cdr i) (+ s (car i)))的返回值直接作为原函数 (iter i s)的返回值,因此调用之前不需要压栈。这被称为尾递归。尾递归本质就是迭代,因为递归调用iter 的过程就是不断迭代更新变量 is 的过程。

6.1 Accumulate

刚才我们定义了一个函数求所有元素之和。那么如果要求所有元素之积呢?我们可以定义一个product 函数

1
2
3
4
5
6
(define (product items)
(define (iter i p)
(if (null? i)
p
(iter (cdr i) (* p (car i)))))
(iter items 1))

我们发现这个函数跟 sum几乎一样。这两个函数都是给定一个初始值,依次与列表中的元素执行某个操作,然后依次迭代;只是初始值(一个是0 另一个是 1)和操作(一个是 + 另一个是*)不同。在 Scheme 中,函数可以当作值传递,而+*都是函数。因此我们可以定义一个通用的函数,将初始值和操作作为参数传递进去:

1
2
3
4
5
6
7
8
9
10
(define (accumulate op init items)
(define (iter i res)
(if (null? i)
res
(iter (cdr i) (op res (car i)))))
(iter items init))

(accumulate + 0 '(1 2 3 4)) ;; 10
(accumulate * 1 '(1 2 3 4)) ;; 24
(accumulate append '() '((1 2) (3 4 5) (a b c d))) ;; '(1 2 3 4 5 a b c d)

6.2 Map

与之类似的函数是 mapmap将列表中的每个元素通过一个给定的函数映射成新值

1
2
(map - '(1 2 3 4 5)) ;; '(-1 -2 -3 -4 -5)
(map (lambda (x) (* x x)) '(1 2 3 4 5)) ;; '(1 4 9 16 25)

map 还支持传多个列表,如(map proc list1 list2 ...)。这些列表的长度要相等,并且列表的数量等于传入函数的参数数量。list1的元素作为第一个参数传给 proclist2的元素作为第二个元素传给 proc,以此类推。

1
2
(map + '(1 2 3) '(10 20 30)) ;; '(11 22 33)
(map list '(1 2 3) '(a b c) '(x y z)) ;; '((1 a x) (2 b y) (3 c z))

如何实现 map 呢?Scheme支持定义可变参数的函数。我们可以定义(define (map proc . lists)),这种情况下 lists便是一个包含剩余参数的列表。因为 (map proc list1 list2)也可以写作 (map proc . (list1 list2))(见 5.2节),因此不难理解这种写法。

反过来如果有 n 个参数存储在一个列表中,可以用 apply将它们传给一个指定函数:

1
2
(apply + '(1 2)) ;; 3
(apply * '(2 3 4)) ;; 24

这样我们可以实现 map 函数:

1
2
3
4
5
(define (map proc . lists)
(if (null? (car lists))
'()
(cons (apply proc (map car lists))
(apply map (cons proc (map cdr lists))))))

Filter

从列表中过滤出符合要求的函数,可以用filter。它接受一个返回布尔值的函数和一个列表作为参数,例如

1
2
(filter odd? '(1 2 3 4 5 6)) ;; '(1 3 5)
(filter even? '(1 2 3 4 5 6)) ;; '(2 4 6)

我们同样可以实现 filter

1
2
3
4
5
6
(define (filter proc items)
(cond [(null? items) '()]
[(proc (car items))
(cons (car items) (filter proc (cdr items)))]
[else
(filter proc (cdr items))]))

思考题:你能把 mapfilter改成迭代(尾递归)的形式吗?

7 赋值

虽然函数式编程不鼓励使用赋值,但是很多场景完全不使用赋值会非常不方便,并且有些场景适当地使用赋值可以提升代码性能,简化一些实现。Scheme使用 set! 特殊形执行赋值,使用格式是(set! var val)set! 先对 val表达式求值,然后将值赋值给 var。例如:

1
2
3
4
(define a 1)
(set! a (+ a 1)) ;; a = 2
(set! a (* a 2)) ;; a = 4
(set! a (cons a (+ a 1))) ;; a = '(4 . 5)

引入赋值会给系统增加很多不确定性。对于不使用赋值的函数,传入确定的参数必然得到确定的值,就像数学函数一样。而一旦引入赋值,就不一定了。可以看下面的例子:

1
2
3
4
5
6
7
8
9
10
(define (make-account n)
(lambda (d)
(set! n (+ n d))
n))

(define account (make-account 0))

(account 10) ;; 10
(account 10) ;; 20
(account -5) ;; 15

这里 (account 10)调用了两次,传入相同的参数但是返回不同的值。4.1节提到,当我们把函数当作值传递时,它所在的环境也会随之传递。因此我们可以把函数当作数据结构使用。上面的account 是一个函数,也可以认为是一个数据。

Racket中的有序对一旦构造好就不能修改。我们可以利用函数实现一个可修改的有序对:

1
2
3
4
5
6
7
8
9
10
11
12
13
(define (mcons 1st 2nd)
(let ([set-mcar! (lambda (v) (set! 1st v))]
[set-mcdr! (lambda (v) (set! 2nd v))])
(lambda (m)
(cond [(eq? m 'mcar) 1st]
[(eq? m 'mcdr) 2nd]
[(eq? m 'set-mcar!) set-mcar!]
[(eq? m 'set-mcdr!) set-mcdr!]))))

(define (mcar mpair) (mpair 'mcar))
(define (mcdr mpair) (mpair 'mcdr))
(define (set-mcar! mpair val) ((mpair 'set-mcar!) val))
(define (set-mcdr! mpair val) ((mpair 'set-mcdr!) val))

上面的例子可以认为是 Scheme 中的“面向对象编程”。mcons返回的函数可视为对象,它通过传入的参数决定执行的操作,因此这种写法又被称为消息传递模式(massage passing style)。mcons 可以称为构造器(constructor),调用构造器的返回值获取“成员”,(mpair 'mcar)这样的表达式就类似于 Java 中的 mpair.mcar。下面的代码展示了mcons 的一些用法。

1
2
3
4
5
6
7
(define p (mcons 1 2))
(mcar p) ;; 1
(mcdr p) ;; 2
(set-mcar! p 10)
(set-mcdr! p 20)
(mcar p) ;; 10
(mcdr p) ;; 20

8 总结

这篇文章介绍 Scheme 的一些基础内容,包括 S表达式的构造、常用特殊形的用法、函数的调用与定义、对环境的理解、有序对与列表、常用函数的使用、赋值操作等内容。这些内容足以写出很多Scheme 程序了。下一篇文章我们将用 Scheme 实现一个 Scheme的解释器,实现本文介绍的绝大部分语言特性。


参考资料:

  • [1] Structure and Interpretation of Computer Programs,Harold Abelson, The MIT Press
  • [2] The Little Schemer, Daniel P. Friedman, The MITPress

一种简单的事务实现

2023-06-18 00:00:00

在服务器编程中,事务往往是非常重要的,它的一个很重要的作用就是保证一系列操作的完整性。例如服务器处理某个请求要先后执行a, b 两个修改操作,它们都有可能失败;如果 a 成功了但 b失败了,事务会负责回滚 a 的修改。试想如果 a 操作是扣除余额,b操作是发货,如果发货失败,钱就得退回去。如果服务器使用了支持事务的数据库系统,如MySQL,事情就很好办。否则的话,实现类似的逻辑会比较棘手,也很容易犯错。

我希望有一种简单的事务系统,实现这样的效果:例如在下面的代码中,handler函数处理业务逻辑。只要 handler 函数的任意位置抛出异常,那么handler 中所有修改,无论是_G.DB.last_update_timedata.order 还是data.money,都将回滚。

1
2
3
4
5
6
7
function handler(data)
_G.DB.last_update_time = os.time()
data.order_id = get_order_id()
check_order(data)
data.money = data.money - 10
deliver(data)
end

因为我们的程序是单线程的,因此不用考虑事务隔离性之类的问题。所以这个所谓的“事务系统”只是一种自动回滚机制。

其实我在以前见过类似的事务实现。它的做法是将需要修改的数据(如上面的data)存储两份,一份是正式数据,一份是暂存数据。业务代码修改暂存数据,如果没有抛出异常,则让暂存数据覆盖正式数据(commit);否则让正式数据覆盖暂存数据(rollback)。暂存数据只是正式数据的浅拷贝,即使是这样,内存开销仍然非常大。而且由于是浅拷贝,这种机制对引用类型(如table)的字段无效。我认为这种做法并不够好。

最近我受到 SICP 4.3 节 Nondeterministic Computing的启发,想到其实回滚数据很简单——再改回去就好了。我们在修改数据的时候记录下数据在修改之前的值,如果捕获到异常,就把对应的数据改回修改之前的值。我们从pcall 开始动手:

1
2
3
4
5
6
7
8
9
10
11
local original_pcall = pcall
function pcall(f, ...)
begin()
local ok, res = original_pcall(f, ...)
if ok then
commit()
else
rollback()
end
return ok, res
end

由于 pcall可以嵌套,i.e. pcall(function() pcall(function() end) end),我们使用栈保存事务的上下文,在begin 中压栈,commitrollback时弹出栈。因此栈顶就是当前事务的上下文。调用 set执行修改操作,它会将数据的原始值保存在上下文中。

1
2
3
4
5
6
7
8
9
10
11
12
13
local stack = {}

local function begin()
table.insert(stack, {})
end

function set(tab, key, val)
local top = stack[#stack]
if top then
table.insert(top, {tab, key, tab[key]})
end
tab[key] = val
end

Commit时,当前事务的赋值操作全部生效,当前事务造成的副作用亦是上层事务的副作用,需要将当前事务记录的数据原始值移动到上层事务(如果有的话)的上下文中。回滚时,从后往前依次取出每次set操作的原始值,将数据设置成修改前的值,完成回滚操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
local function commit()
local top = table.remove(stack)
local pi = stack[#stack]
if pi then
for _, assign in ipairs(top) do
table.insert(pi, assign)
end
end
end

local function rollback()
local top = table.remove(stack)
for i = #top, 1, -1 do
local tab, key, val = table.unpack(top[i], 1, 3)
tab[key] = val
end
end

使用的时候不能直接赋值,需要调用set。当然也可以做成元表,不过我不是很喜欢这样。

1
2
3
4
5
6
7
8
9
10
val = {}
function test()
local old = val
set(_G, 'val', 42)
set(old, 1, 1)
error()
end

pcall(test) -- false nil
next(val) -- nil

整个实现可以说是非常简单且行之有效,开销也并不大。代码是我随手写的,它还有优化空间:stack中的旧数据存储可以使用更紧凑的数据结构;代码可以用 C实现提高性能等。