跳到主要内容

第 8 章:语句与状态(Statements and State)

原文:Robert Nystrom, Crafting Interpreters, Chapter 8。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。

人的本性有一种难以理解的状态,是不断改变一切;又有一种同样难以理解的状态,是不断努力使一切保持不变。

Charles Dickens

原书插图:一颗正在记忆事物的大脑。

表达式计算值,但程序若没有状态和可观察效果,能做的事情很有限。本章加入语句、变量、赋值与块作用域。至此 Lox 可以保存值、修改值、向用户输出,并在嵌套环境中管理名称。表达式只产生值,语句则产生效果;变量声明可以被设计为产生值的表达式,但 Lox 和绝大多数命令式语言将它归为语句。

8.1 语句(Statements)

表达式的结果是值,语句的结果是效果。首先支持两种语句:

print "Hello, world!";
1 + 2;

第一条是打印语句,第二条是表达式语句。后者计算表达式但丢弃值;它看似无用,却为函数调用、赋值等未来有副作用的表达式提供统一位置。Pascal 区分有返回值的函数与无返回值的过程,因此没有一般的表达式语句。print 被做成语言内建语句而不是库函数,是为了在函数机制完成前就能观察到副作用;BASIC 和 Python 2 也曾拥有专门的 print 语句。

完整脚本变为语句列表,末尾的 EOF 要求解析器消费整个输入,不能悄悄忽略末尾的错误记号:

program -> statement* EOF ;
statement -> exprStmt | printStmt ;
exprStmt -> expression ";" ;
printStmt -> "print" expression ";" ;

8.1.1 语句语法树(Statement Syntax Trees)

与表达式一样,为语句创建独立基类 Stmt。更新 AST 生成器,生成 ExpressionPrint 两种节点:

defineAst(outputDir, "Stmt", Arrays.asList(
"Expression : Expr expression",
"Print : Expr expression"
));

生成出的 Stmt 也拥有访问者接口:

abstract class Stmt {
interface Visitor<R> {
R visitExpressionStmt(Expression stmt);
R visitPrintStmt(Print stmt);
}

static class Expression extends Stmt {
final Expr expression;
Expression(Expr expression) { this.expression = expression; }

@Override <R> R accept(Visitor<R> visitor) {
return visitor.visitExpressionStmt(this);
}
}

static class Print extends Stmt {
final Expr expression;
Print(Expr expression) { this.expression = expression; }

@Override <R> R accept(Visitor<R> visitor) {
return visitor.visitPrintStmt(this);
}
}

abstract <R> R accept(Visitor<R> visitor);
}

表达式与语句分成两棵层次结构,是因为它们在语言中有不同角色:表达式嵌在更大表达式中并产生值;语句按顺序执行并产生效果。文法中没有同时允许二者的位置,拆分层次也让 Java 编译器能帮助发现把语句错当表达式传递的失误。

8.1.2 解析语句(Parsing Statements)

程序入口不再只解析表达式,而是解析声明或语句:

List<Stmt> parse() {
List<Stmt> statements = new ArrayList<>();
while (!isAtEnd()) {
statements.add(statement());
}
return statements;
}

private Stmt statement() {
if (match(PRINT)) return printStatement();
return expressionStatement();
}

private Stmt printStatement() {
Expr value = expression();
consume(SEMICOLON, "Expect ';' after value.");
return new Stmt.Print(value);
}

private Stmt expressionStatement() {
Expr expr = expression();
consume(SEMICOLON, "Expect ';' after expression.");
return new Stmt.Expression(expr);
}

分号在此处成为重要同步点:它既终止语句,也可在错误恢复中作为重新开始的边界。

8.1.3 执行语句(Executing Statements)

解释器同时实现 Expr.Visitor<Object>Stmt.Visitor<Void>。表达式访问者返回值,语句访问者返回 Void

class Interpreter implements Expr.Visitor<Object>, Stmt.Visitor<Void> {
void interpret(List<Stmt> statements) {
try {
for (Stmt statement : statements) {
execute(statement);
}
} catch (RuntimeError error) {
Lox.runtimeError(error);
}
}

private void execute(Stmt stmt) {
stmt.accept(this);
}

@Override
public Void visitExpressionStmt(Stmt.Expression stmt) {
evaluate(stmt.expression);
return null;
}

@Override
public Void visitPrintStmt(Stmt.Print stmt) {
Object value = evaluate(stmt.expression);
System.out.println(stringify(value));
return null;
}
}

stringify() 把内部值转换为用户可读的 Lox 输出:

private String stringify(Object object) {
if (object == null) return "nil";

if (object instanceof Double) {
String text = object.toString();
if (text.endsWith(".0")) {
text = text.substring(0, text.length() - 2);
}
return text;
}

return object.toString();
}

这让 print 1.0; 显示为 1,符合语言表面风格而不是 Java 的装箱表示。

8.2 全局变量(Global Variables)

变量声明创建名称和值之间的绑定,变量表达式读取这一绑定。先从最容易理解的全局变量开始;大量可变全局状态会增加维护难度,但对于学习和简单语言,它免去了先理解作用域规则的负担。

变量声明语法为:

var beverage = "espresso";
var breakfast;

初始化器可省略,省略时值为 nil。读取变量只是一个新的表达式:

primary -> ... | IDENTIFIER ;

顶层可重新声明同一名称,后一个绑定取代前一个:

var a = "before";
print a; // "before".
var a = "after";
print a; // "after".

省略初始化器时名称已经绑定为 nil;在声明之前读取,仍是运行时未定义错误:

print a;
var a = "too late!";
var a;
print a; // "nil".

8.2.1 变量语法(Variable Syntax)

声明与普通语句要分开。控制流的分支或循环体只能容纳一条普通语句,若在那里直接允许 var,变量在分支未执行时是否存在、作用域在哪里都会变得难以理解。块语句本身可以出现在这些位置,并在内部重新允许完整的声明文法:

program -> declaration* EOF ;
declaration -> varDecl | statement ;
varDecl -> "var" IDENTIFIER ( "=" expression )? ";" ;
primary -> ... | IDENTIFIER ;

8.2.2 变量 AST 与解析(Parsing Variables)

增加两种表达式/语句节点:

defineAst(outputDir, "Expr", Arrays.asList(
// 既有节点省略。
"Variable : Token name"
));

defineAst(outputDir, "Stmt", Arrays.asList(
// 既有节点省略。
"Var : Token name, Expr initializer"
));

解析器先判断是否为 var 声明。把 declaration()statement() 分开,日后可以加入函数与类声明。declaration() 也是合适的恐慌模式同步点:每次解析顶层或块中的声明时捕获 ParseError,跳到下一条可能的声明或语句:

private Stmt declaration() {
try {
if (match(VAR)) return varDeclaration();
return statement();
} catch (ParseError error) {
synchronize();
return null;
}
}

private Stmt varDeclaration() {
Token name = consume(IDENTIFIER, "Expect variable name.");

Expr initializer = null;
if (match(EQUAL)) {
initializer = expression();
}

consume(SEMICOLON, "Expect ';' after variable declaration.");
return new Stmt.Var(name, initializer);
}

primary() 匹配标识符时构造变量表达式:

if (match(IDENTIFIER)) {
return new Expr.Variable(previous());
}

8.3 环境(Environments)

变量把名称映射为值。Lisp 社群很早就把这种结构称为环境。它本质是映射:Java 称 map 或 hashmap,其他语言也叫哈希表、字典、hash、table 或关联数组。单独用一个 Environment 类持有这个映射,而不把表直接塞进解释器,能为后续作用域和闭包建立自然模型:

原书插图:包含两个绑定的环境。

package com.craftinginterpreters.lox;

import java.util.HashMap;
import java.util.Map;

class Environment {
private final Map<String, Object> values = new HashMap<>();

void define(String name, Object value) {
values.put(name, value);
}

Object get(Token name) {
if (values.containsKey(name.lexeme)) {
return values.get(name.lexeme);
}

throw new RuntimeError(name,
"Undefined variable '" + name.lexeme + "'.");
}
}

映射键使用字符串而非 Token:不同位置但同样拼写的标识符应当查找同一绑定。使用 containsKey() 而不是仅检查 get() 返回值很重要:变量可合法保存 nil(Java 的 null),而未定义名称也会令 get() 返回 nulldefine() 不拒绝同一全局名称的再次声明,因此 REPL 中可方便重定义;这与 Scheme 的顶层规则一致。

未定义变量在 Lox 中是运行时错误而非语法错误。函数体可在定义时提及尚未执行到的名称,静态禁止会使递归,特别是相互递归,变得困难;运行时只在真正求值该引用时报告错误。

8.3.1 解释全局变量(Interpreting Global Variables)

解释器拥有全局环境:

private final Environment environment = new Environment();

@Override
public Void visitVarStmt(Stmt.Var stmt) {
Object value = null;
if (stmt.initializer != null) {
value = evaluate(stmt.initializer);
}

environment.define(stmt.name.lexeme, value);
return null;
}

@Override
public Object visitVariableExpr(Expr.Variable expr) {
return environment.get(expr.name);
}

初始化器在名称定义前求值,因此 var a = a; 会报告未定义变量;这避免了看似存在但尚无值的名称。

8.4 赋值(Assignment)

也可以设计不可重新赋值的变量;Haskell 便如此,Rust 也用 mut 标记可变性。Lox 是命令式语言,允许赋值。赋值既修改状态又产生被赋的值:

var a = "before";
a = "after";
print a;

8.4.1 赋值语法(Assignment Syntax)

和 C 一样,Lox 把赋值作为最低优先级表达式;Pascal、Python、Go 等语言则把赋值作为语句。扩展文法:

expression -> assignment ;
assignment -> IDENTIFIER "=" assignment
| equality ;

不能直接按这份规则递归下降,因为它左侧以 IDENTIFIER 开头却还需先解析普通 equality。解决办法是先解析 equality,若随后看到 =,再验证左侧树确实为变量:

private Expr assignment() {
Expr expr = equality();

if (match(EQUAL)) {
Token equals = previous();
Expr value = assignment();

if (expr instanceof Expr.Variable) {
Token name = ((Expr.Variable) expr).name;
return new Expr.Assign(name, value);
}

error(equals, "Invalid assignment target.");
}

return expr;
}

注意右值递归调用 assignment(),因此 a = b = c 解析为 a = (b = c)。即使左侧非法,也应先解析右侧,避免错误恢复失去同步。在 AST 中添加:

"Assign : Token name, Expr value"

赋值目标现在仅限变量。像字段赋值这样的形态会在类章节添加,且其左侧可任意长:

instance.field = "value";
makeList().head.next = node;
newPoint(x + 2, 0).y = 3;

因此解析器先解析一般表达式,发现 = 后再检查 AST 类型。圆括号节点也正是在这里发挥作用:

a = 3; // OK.
(a) = 3; // Error.

8.4.2 赋值语义(Assignment Semantics)

环境增加赋值方法:

void assign(Token name, Object value) {
if (values.containsKey(name.lexeme)) {
values.put(name.lexeme, value);
return;
}

throw new RuntimeError(name,
"Undefined variable '" + name.lexeme + "'.");
}

解释赋值时先计算右侧,更新环境,再返回该值:

@Override
public Object visitAssignExpr(Expr.Assign expr) {
Object value = evaluate(expr.value);
environment.assign(expr.name, value);
return value;
}

返回赋值值让 print a = 3; 这样的表达式有效,也使链式赋值自然工作。

例如:

var a = 1;
print a = 2; // "2".

8.5 作用域(Scope)

全局表不够。块应引入新作用域:

var a = "global";
{
var a = "block";
print a; // block
}
print a; // global

8.5.1 嵌套与遮蔽(Nesting and Shadowing)

内层同名变量遮蔽(shadow)外层变量。通过让每个环境指向一个可选外层环境,查找可以逐级向外:

原书插图:两个同名变量各自对应的环境。

原书插图:由外层链接起来的作用域环境。

原书插图:以全局作用域为根的环境树。

单独的块能各自使用同名绑定;块结束后该绑定消失:

{
var a = "first";
print a; // "first".
}

{
var a = "second";
print a; // "second".
}
{
var a = "in block";
}
print a; // Error! No more "a".
class Environment {
final Environment enclosing;
private final Map<String, Object> values = new HashMap<>();

Environment() { enclosing = null; }
Environment(Environment enclosing) { this.enclosing = enclosing; }

Object get(Token name) {
if (values.containsKey(name.lexeme)) return values.get(name.lexeme);
if (enclosing != null) return enclosing.get(name);
throw new RuntimeError(name, "Undefined variable '" + name.lexeme + "'.");
}

void assign(Token name, Object value) {
if (values.containsKey(name.lexeme)) {
values.put(name.lexeme, value);
return;
}
if (enclosing != null) {
enclosing.assign(name, value);
return;
}
throw new RuntimeError(name, "Undefined variable '" + name.lexeme + "'.");
}
}

8.5.2 块的语法与语义(Block Syntax and Semantics)

块语句是花括号之间的声明列表:

private Stmt statement() {
if (match(PRINT)) return printStatement();
if (match(LEFT_BRACE)) return new Stmt.Block(block());
return expressionStatement();
}

private List<Stmt> block() {
List<Stmt> statements = new ArrayList<>();
while (!check(RIGHT_BRACE) && !isAtEnd()) {
statements.add(declaration());
}
consume(RIGHT_BRACE, "Expect '}' after block.");
return statements;
}

AST 添加 "Block : List<Stmt> statements"。解释器切换到新的环境执行块,并且无论是否抛出异常都必须恢复旧环境:

@Override
public Void visitBlockStmt(Stmt.Block stmt) {
executeBlock(stmt.statements, new Environment(environment));
return null;
}

void executeBlock(List<Stmt> statements, Environment environment) {
Environment previous = this.environment;
try {
this.environment = environment;
for (Stmt statement : statements) execute(statement);
} finally {
this.environment = previous;
}
}

这里 environment 字段不能再是 finalfinally 是关键:若块中发生运行时错误或未来的 returnbreak 等非局部控制流,解释器仍要返回正确的外围环境。

挑战(Challenges)

  1. 恢复 REPL 对单个表达式的自动显示:输入语句时执行,输入表达式时求值并显示结果。
  2. 不再把未初始化变量隐式设为 nil,而是在读取尚未初始化或尚未赋值的变量时报告运行时错误。
  3. 分析下面程序的实际输出与预期:var a = 1; { var a = a + 2; print a; }。与熟悉的其他语言比较,并说明用户应当期待哪种语义。

设计笔记:隐式变量声明(Implicit Variable Declaration)

Lox 要求 var 显式声明变量。隐式变量声明将“声明新变量”和“向已有变量赋值”合为一种赋值语法:Python、Ruby、CoffeeScript 等会在赋给不存在的名称时创建它;JavaScript 虽有声明语法,却也能通过赋值创建变量,Visual Basic 则可配置是否允许。

隐式创建时必须定义遮蔽与目标作用域。Python 的赋值总在当前函数创建名称;Ruby 若外层已有同名变量则赋给它,否则在当前块创建;CoffeeScript 禁止遮蔽,尽可能向外赋值;JavaScript 找不到名称时会创建全局变量。其优点是语法少、入门快;但拼写错误可能悄悄创建变量,且在外层新增同名绑定会改变既有代码的意义。Python 后来增加 globalnonlocal,Ruby 也加入显式块局部变量,JavaScript 严格模式禁止隐式全局变量。

显式声明让读者立刻看出名称首次出现的位置,也便于作用域分析、重构工具和错误诊断。变量声明是程序中名字、生命周期和意图的文档。随着深层嵌套、函数式风格和闭包越来越常见,赋值究竟应创建局部变量还是复用外层变量更加难以推断;这正是 Lox 选择显式 var 的原因。