控制流:if、while,与短路
给语言加分叉(if)和循环(while),实现简单到你会怀疑「就这?」。但这一章不只是加两个 case —— 它藏着两个会让你「啊」出来的小问题:为什么 if 必须是内建语法、不能是你自己写的一个函数?为什么 and / or 能短路,而你写的函数做不到?两个问题,同一个答案:谁决定了什么时候求值。
if 和 while:几行搞定
先看实现,确实简单。if 就是「算条件,真就执行 then 那支,否则执行 else 那支」:
case "if": if (truthy(eval(stmt.cond))) exec(stmt.then); else if (stmt.els) exec(stmt.els); return;
while 就是「只要条件为真,就反复执行循环体」—— 用宿主语言(我们这里是 JS)自己的 while 来实现 Lox 的 while:
case "while": while (truthy(eval(stmt.cond))) exec(stmt.body); return;
就这些。至于 for,我们在解析阶段就把它脱糖成了 while(还记得吗,第 3 章说过语法树是中立的中间产物)—— for (init; cond; incr) body 被改写成 { init; while (cond) { body; incr; } }。求值器根本不知道 for 的存在,它只见过 while。少实现一样东西,还不损失表达力,这是脱糖的甜头。
for 是 while 的语法糖 —— 它没带来任何新能力,只是把常见的 while 写法写得更顺手。在解析时把它翻译回 while,叫脱糖(desugaring)。
这是个极其实用的省力策略:只在核心里实现最基本的那几样,把所有「方便写法」都在前端翻译成基本写法。真实语言里一大堆特性都是糖 —— Kotlin 的 for-in 脱糖成迭代器循环、data class 脱糖成一堆样板方法、?. 脱糖成 if-null 判断。你的语言核心越小,越好实现、越好维护。
精彩问题一:为什么 if 不能是个普通函数
停下来想个怪问题。既然 if 就是「条件真执行这个、假执行那个」,我能不能不把它做成内建语法,而是写成一个普通函数?像这样:
// 假想:把 if 写成一个普通函数
function myIf(condition, thenBranch, elseBranch) {
if (condition) return thenBranch;
else return elseBranch;
}
myIf(x > 0, 打印("正数"), 打印("负数")); // 想这么用
行不通。而且失败得很惨。问题出在函数调用的规矩上:调用一个函数前,它的所有参数都得先算好。所以 myIf(x > 0, 打印("正数"), 打印("负数")) 会先把两个「打印」都执行了,才把结果传进 myIf。无论 x 正负,「正数」和「负数」都会被打印出来 —— 分支的意义荡然无存。
这就是 if 必须是内建语法的根本原因:它需要「先看条件、再决定要不要求值某个分支」的能力,而普通函数调用没有这个能力 —— 参数在进门前就已经全被求值了。 if、while、and、or 这类东西之所以是「关键字 / 特殊形式」而不是「函数」,全因为它们要控制参数何时、是否被求值,而函数交不出这个控制权。
精彩问题二:短路,是同一件事
顺着上面的洞察,and / or 的「短路」就不神秘了。false and 会崩溃的东西 之所以安全(右边根本不会执行),是因为 and 也控制求值时机:先算左边,只有必要时才算右边:
case "logical": {
const left = eval(stmt.left);
if (stmt.op.type === "OR") {
if (truthy(left)) return left; // or:左边已经真,右边看都不看,直接返回
} else { // AND
if (!truthy(left)) return left; // and:左边已经假,右边看都不看,直接返回
}
return eval(stmt.right); // 只有到这儿,才求值右边
}
看那两个 return —— 它们在 eval(右边) 之前就返回了。「右边不被求值」不是优化,是语义:这正是为什么你能安心写 obj != nil and obj.value > 0,右边访问 obj.value 在 obj 为 nil 时压根不执行,所以不会崩。
if 不能当函数、and / or 能短路 —— 是同一个道理的两面:函数会急切地把参数全求值,而控制流需要推迟甚至跳过某些求值。
谁掌握「什么时候求值」的控制权,谁就能做出函数做不到的事。这是编程语言里一个极深的主题 —— 惰性求值、Kotlin 的 inline lambda、Swift 的 @autoclosure、React 的「传函数而不是传值」,全都是在争夺「求值时机」的控制权。你现在从实现的角度看清了它的根。
回流
「守卫式短路」是你每天都在写的模式,这一章告诉你它凭什么成立。
Kotlin / JS:user != null && user.isActive、list.isNotEmpty() && list[0] > 0 —— 右边依赖左边先成立才安全,靠的正是 && 的短路。如果 && 是个普通函数,两边会一起求值,这些写法全会崩。你之所以能这么写,是因为 && 是语言内建的、能控制求值时机的运算符,而不是一个函数。
为什么有些 API 让你「传一个函数」而不是「传一个值」?比如 Kotlin 的 lazy { 昂贵计算() }、日志库的 logger.debug { "拼一个很贵的字符串" }。因为传值会立刻求值那个昂贵的东西,而传函数(一个还没被调用的 lambda)就把「何时求值」的控制权交回给了接收方 —— 它可以决定不调、或延后调。这是普通代码「夺回求值时机控制权」的标准手法,而你现在明白了它要解决的到底是什么问题。
这一章的一句话
if 不能是函数、and/or 能短路,是同一件事:函数会急切求值全部参数,而控制流的本事,正是决定「什么时候、要不要」求值某段代码。
下一章,我们加上函数 —— 这需要一个新的环境和一叠调用栈。它也是通往闭包(第 15 章那个高潮)的最后一级台阶。