卷 IV · 义CH 13深度 13/20

控制流:if、while,与短路

给语言加分叉(if)和循环(while),实现简单到你会怀疑「就这?」。但这一章不只是加两个 case —— 它藏着两个会让你「啊」出来的小问题:为什么 if 必须是内建语法、不能是你自己写的一个函数?为什么 and / or 能短路,而你写的函数做不到?两个问题,同一个答案:谁决定了什么时候求值。

if / while短路求值时机脱糖

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。少实现一样东西,还不损失表达力,这是脱糖的甜头。

◆ 脱糖:把「方便的」翻译成「基本的」

forwhile语法糖 —— 它没带来任何新能力,只是把常见的 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 必须是内建语法的根本原因:它需要「先看条件、再决定要不要求值某个分支」的能力,而普通函数调用没有这个能力 —— 参数在进门前就已经全被求值了。 ifwhileandor 这类东西之所以是「关键字 / 特殊形式」而不是「函数」,全因为它们要控制参数何时、是否被求值,而函数交不出这个控制权。

精彩问题二:短路,是同一件事

顺着上面的洞察,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.valueobj 为 nil 时压根不执行,所以不会崩。

◆ 一切都是「求值时机」的问题

if 不能当函数、and / or 能短路 —— 是同一个道理的两面:函数会急切地把参数全求值,而控制流需要推迟甚至跳过某些求值。

谁掌握「什么时候求值」的控制权,谁就能做出函数做不到的事。这是编程语言里一个极深的主题 —— 惰性求值、Kotlin 的 inline lambda、Swift 的 @autoclosure、React 的「传函数而不是传值」,全都是在争夺「求值时机」的控制权。你现在从实现的角度看清了它的根。

回流

⟲ 回流 · 你早就在依赖短路,只是没深想

「守卫式短路」是你每天都在写的模式,这一章告诉你它凭什么成立。

Kotlin / JS:user != null && user.isActivelist.isNotEmpty() && list[0] > 0 —— 右边依赖左边先成立才安全,靠的正是 && 的短路。如果 && 是个普通函数,两边会一起求值,这些写法全会崩。你之所以能这么写,是因为 && 是语言内建的、能控制求值时机的运算符,而不是一个函数。

为什么有些 API 让你「传一个函数」而不是「传一个值」?比如 Kotlin 的 lazy { 昂贵计算() }、日志库的 logger.debug { "拼一个很贵的字符串" }。因为传值会立刻求值那个昂贵的东西,而传函数(一个还没被调用的 lambda)就把「何时求值」的控制权交回给了接收方 —— 它可以决定不调、或延后调。这是普通代码「夺回求值时机控制权」的标准手法,而你现在明白了它要解决的到底是什么问题。

这一章的一句话

if 不能是函数、and/or 能短路,是同一件事:函数会急切求值全部参数,而控制流的本事,正是决定「什么时候、要不要」求值某段代码。

下一章,我们加上函数 —— 这需要一个新的环境和一叠调用栈。它也是通往闭包(第 15 章那个高潮)的最后一级台阶。