章节目录

边界:哪些位置可以 yield

本节阅读量:

如果任何表达式内部都能突然暂停,那么编译器必须记录任意深度的求值上下文:加法已经算完左边了吗?函数的 callee 是否检查过?record 已经写了几个字段?这会把本章直接推向通用 CPS 或 CEK machine。

本章刻意选择一条更小、可完整实现的路线:yield 只允许沿当前 coroutine 自己的 begin、let 和 if 控制流出现。

1
2
3
4
5
coroutine body
  ├─ yield
  ├─ begin 的 first / second
  ├─ let 的 value / body
  └─ if 的 condition / then / else

这已经能表达多个暂停点、局部变量、分支和可变状态;但不会要求我们保存普通函数调用或算术指令中间的机器状态。

合法的结构化路径

最小合法 body 是:

1
(coroutine (yield 40))

begin 可以把“暂停后继续什么”写清楚:

1
2
3
4
(coroutine
  (begin
    (yield 40)
    42))

let 的 initializer 也可以暂停:

1
2
3
(coroutine
  (let x (yield 40)
    (+ x 2)))

恢复时,lowering 会把刚才产出的值交给 x,然后继续 body。

if 的 condition 和两个分支同样可以处在 flow 中:

1
2
3
4
5
6
(coroutine
  (if (yield 1)
      (begin
        (yield 40)
        42)
      0))
1
2
cd code/11_coroutines
./mini run examples/coroutine_if.lang

本语言沿用前面的条件规则:非零整数为真,因此第一次 yield 后恢复到 then 分支;最终结果是 42。没有被选中的 else 分支不会执行。

两个递归入口把 flow 与普通表达式分开

validation 的核心不是维护一张特殊形式名单,而是明确区分两种递归入口:

1
2
3
4
5
void validate_flow(const Expr& expr);

void validate_atomic(const Expr& expr,
                     bool inside_coroutine,
                     const std::string& yield_error);

validate_flow 表示“这里仍处在当前 coroutine 可以暂停的结构化流程中”。它只继续穿过 begin、let 和 if:

 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
void validate_flow(const Expr& expr) {
    switch (expr.kind) {
    case ExprKind::yield: {
        const auto& yield = static_cast<const YieldExpr&>(expr);
        validate_atomic(
            *yield.value,
            true,
            "yield cannot appear inside another yield value");
        return;
    }
    case ExprKind::begin: {
        const auto& begin = static_cast<const BeginExpr&>(expr);
        validate_flow(*begin.first);
        validate_flow(*begin.second);
        return;
    }
    case ExprKind::let: {
        const auto& let = static_cast<const LetExpr&>(expr);
        validate_flow(*let.value);
        validate_flow(*let.body);
        return;
    }
    case ExprKind::conditional: {
        const auto& conditional = static_cast<const IfExpr&>(expr);
        validate_flow(*conditional.condition);
        validate_flow(*conditional.then_branch);
        validate_flow(*conditional.else_branch);
        return;
    }
    default:
        validate_atomic(
            expr,
            true,
            "yield is only allowed through begin, let, and if in a coroutine body");
        return;
    }
}

注意 yield.value 改走 validate_atomic。这正是“payload 必须先作为一段不能暂停的普通表达式算完”的代码落点。

validate_atomic 则按已有 kind + switch 遍历普通表达式。它仍会递归检查所有子树,但看到属于当前流程的 yield 就报告调用者传入的上下文错误。几个关键分支是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
case ExprKind::lambda: {
    const auto& lambda = static_cast<const LambdaExpr&>(expr);
    validate_atomic(*lambda.body,
                    inside_coroutine,
                    "yield cannot appear inside a lambda body");
    return;
}
case ExprKind::call: {
    const auto& call = static_cast<const CallExpr&>(expr);
    validate_atomic(*call.callee,
                    inside_coroutine,
                    "yield cannot appear inside a function call");
    validate_atomic(*call.argument,
                    inside_coroutine,
                    "yield cannot appear inside a function call");
    return;
}
case ExprKind::coroutine:
    validate_flow(*static_cast<const CoroutineExpr&>(expr).body);
    return;
case ExprKind::yield:
    reject_yield(inside_coroutine, yield_error);

新的 CoroutineExpr 会重新进入 validate_flow,所以嵌套 coroutine 拥有自己的 yield 边界;普通 lambda 与 call 仍留在 atomic 入口,因此不能偷偷继承外层 coroutine 的暂停能力。

普通表达式内部不允许藏 yield

下面的 yield 位于 + 的右 operand:

1
2
(coroutine
  (+ 1 (yield 41)))

运行:

1
./mini run examples/yield_add_error.lang

会得到:

1
error: yield is only allowed through begin, let, and if in a coroutine body

同样,不能把 yield 藏进普通 call:

1
./mini run examples/yield_call_error.lang

这次错误为:

1
error: yield cannot appear inside a function call

record 字段、set! 的右侧、eq?、size、get 和 sub1 的内部也都属于普通表达式上下文。它们仍可使用已有计算和副作用,只是其中不能再插入暂停点。

lambda 和 letrec 函数体不是当前 coroutine 的流程

下面的 yield 语法上写在 coroutine 里,但真正执行它的是以后才会被调用的普通函数:

1
2
3
(coroutine
  (lambda x
    (yield x)))
1
./mini run examples/yield_lambda_error.lang

输出包含:

1
error: yield cannot appear inside a lambda body

letrec 函数体也一样。它可能在很多不同的时刻、递归深度和调用者中运行,不属于创建它的 coroutine 当前步骤。yield_letrec_error.lang 是对应检查。

这条限制也解释了本章“无栈”的含义:我们只保存自己明确生成的 closure,不截获 C++ 调用栈或任意源语言 call stack。

yield 的 payload 不能再 yield

下面的嵌套写法也不合法:

1
2
(coroutine
  (yield (yield 42)))
1
./mini run examples/yield_nested_error.lang

输出包含:

1
error: yield cannot appear inside another yield value

外层 yield 在暂停前必须先把 payload 算成一个 Value;若 payload 自己也能暂停,就需要同时决定两个未完成的 pause protocol。本章不把这两个层次混在一起。

yield 必须属于某个 coroutine

单独写:

1
(yield 42)

不是一个可以运行的顶层程序:

1
./mini run examples/yield_outside_error.lang

输出是:

1
error: yield used outside a coroutine

这不是 parser 的 arity 错误。parser 会如实构造 YieldExpr,随后 validation 根据它所在的词法位置报告错误;这样 mini ast 仍能展示读者实际写下的 surface 结构。

嵌套 coroutine 重新开始一套边界

一个 coroutine 可以在自己的 body 中创建另一个 coroutine。内层 (yield ...) 属于内层,不会让外层 step 暂停;外层若想暂停,仍要写自己的 yield。

1
./mini run examples/coroutine_nested.lang

该例子先创建 inner,外层通过 (resume inner) 得到 inner 的值,再在自己的 yield 处暂停。输出为 42,说明两个 state cell 和两条 continuation 链彼此独立。

validation 在哪一条流水线上

ast 是唯一直接观察 surface AST 的命令:

1
./mini ast examples/coroutine_protocol.lang

run、core、ir、alloc 和 compile 都调用同一个 validate_coroutines,然后才接收去糖后的 core AST:

1
2
3
4
5
6
7
8
9
parser
  ↓
surface AST
  ├─ ast
  └─ validate_coroutines
       ↓
     lower_coroutines
       ↓
     interpreter / IR / alloc / assembly

因此一个不合法 yield 不会出现“解释器拒绝了、编译器却悄悄接受”的两套行为。validation error 是 CLI 阶段错误:mini run 会打印 error: 并返回状态 1;它不是原生程序运行到一半的状态 70。


11.1 语言:一次 `resume` 只走到下一个边界

上一节

11.3 AST 与 parser:先保留表面语法,再统一去糖

下一节