Skip to main content

第 18 章:值的类型(Types of Values)

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

当你是一只脑子很小的熊,又在思考事情时,有时会发现:心里原本看起来很像一回事的东西,一旦拿到外面让别人看,就完全不同了。

A. A. Milne,《小熊维尼》

前几章包含大量复杂技术和代码;本章只有一个核心概念与一批直接的改动。Lox 是动态类型语言:同一变量在不同时间可持有布尔值、数字或字符串。此刻 clox 的所有值却都只是数字;本章将加入布尔值与 nil,从而解决如何在运行时表示不同类型的值。

静态类型和动态类型之外还有“单一类型”(unityped):所有变量只有一种类型,通常就是机器寄存器整数。一些 Forth 与启发 C 的 BCPL 使用过这种模式;当前 clox 恰好就是单一类型的。

18.1 带标签联合(Tagged Unions)

C 把宇宙视为未分化的字节数组:程序决定使用多少字节、如何解释它们。为设计值表示,必须回答两个问题:

  • 如何表示一个值的类型?例如 true * 3 必须在运行时发现并报告错误。
  • 如何保存其数据?不仅要知道它是数字,还要区分 34

此外还要尽量高效。语言实现者设计出许多将这些信息压进少量位的技巧;先采用最经典、最简单的带标签联合:值含有类型标签和实际值负载。先定义 VM 内建值类别:

// value.h
typedef enum {
VAL_BOOL,
VAL_NIL,
VAL_NUMBER,
} ValueType;

这表示 VM 的类型观,不是用户语言中的每个类。后续类、字符串、函数加入时会增加类别;用户定义的一万个类实例在 VM 看来同属于“实例”类别。

保存数值与布尔数据时,若结构体同时包含 doublebool 字段会浪费空间,因为值不可能同时是两者。C 的 union 让字段重叠于同一块内存,其大小等于最大字段:

结构体的两个字段在内存中相邻。

联合体的两个字段重叠在同一块内存中。

读取与写入不同 union 字段会把相同位重新解释为不同含义,既支持强力优化,也极不安全。最终组合为:

typedef struct {
ValueType type;
union {
bool boolean;
double number;
} as;
} Value;

完整 Value:type 与 as 字段在内存中相邻。

典型 64 位机器上,四字节标签后会填充四字节,以令包含 double 的 union 按八字节对齐;Value 因而通常占 16 字节。即使缩小标签,数组内每个 Value 为对齐仍需填充。后面会进一步优化;当前它仍足够小,可在 C 栈上传值。现有值都是不可变标量,复制值不会改变 Lox 语义。

18.2 Lox 值与 C 值(Lox Values and C Values)

之前 Valuedouble 的别名,代码可直接当 C 数字使用;现在 Value 是“包含 double 的结构体”,必须显式跨越 C 静态值与 Lox 动态值的边界。

构造宏把原生 C 值提升为 Lox 值:

#define BOOL_VAL(value) ((Value){VAL_BOOL, {.boolean = value}})
#define NIL_VAL ((Value){VAL_NIL, {.number = 0}})
#define NUMBER_VAL(value) ((Value){VAL_NUMBER, {.number = value}})

解包宏从正确类型的 Value 中取回 C 值:

#define AS_BOOL(value) ((value).as.boolean)
#define AS_NUMBER(value) ((value).as.number)

nil 只有一个值,不携带需读取的数据,故没有 AS_NIL。解包前必须由类型断言保护:

#define IS_BOOL(value) ((value).type == VAL_BOOL)
#define IS_NIL(value) ((value).type == VAL_NIL)
#define IS_NUMBER(value) ((value).type == VAL_NUMBER)

BOOL_VAL(true) 使用 AS_NUMBER() 会把布尔的位模式误读成浮点数,C 不会阻止。_VAL 宏将 C 值提升到 Lox 的动态宇宙;AS_ 宏再将其取回。每一次 AS_ 都必须由相应 IS_ 守卫。

底部是 C 的静态类型天地,上方是 Lox 的动态值世界。

18.3 动态类型的数字(Dynamically Typed Numbers)

有了表示与转换工具,就要修正每一处边界。编译数字字面量时,将 strtod() 返回的 C double 包装后存入常量表:

static void number() {
double value = strtod(parser.previous.start, NULL);
emitConstant(NUMBER_VAL(value));
}

printValue() 暂时解包数字:

void printValue(Value value) {
printf("%g", AS_NUMBER(value));
}

随后会扩展它以支持其他类型。

18.3.1 单目取负与运行时错误(Unary negation and runtime errors)

现在 -false 是可能的用户输入,不能再假设操作数一定是数字。先增加不弹栈的查看函数:

static Value peek(int distance) {
return vm.stackTop[-1 - distance];
}

它用于先验证类型,再改变栈。后续垃圾收集器运行时,把操作数留在栈上也能保证 GC 找得到它。

case OP_NEGATE:
if (!IS_NUMBER(peek(0))) {
runtimeError("Operand must be a number.");
return INTERPRET_RUNTIME_ERROR;
}
push(NUMBER_VAL(-AS_NUMBER(pop())));
break;

Lox 当前的错误策略很朴素:错误致命,立即停止解释器,用户代码无法恢复。runtimeError() 使用 C 可变参数,先打印格式化消息,再报告对应源行并重置栈:

static void runtimeError(const char* format, ...) {
va_list args;
va_start(args, format);
vfprintf(stderr, format, args);
va_end(args);
fputs("\n", stderr);

size_t instruction = vm.ip - vm.chunk->code - 1;
int line = vm.chunk->lines[instruction];
fprintf(stderr, "[line %d] in script\n", line);
resetStack();
}

VM 在解码时已经推进 ip,故失败指令索引是当前指针减去 chunk 开头再减一。这里尚无函数调用栈,因此只能显示直接失败行;要使用 va_list 需要 #include <stdarg.h>

18.3.2 二元算术运算符(Binary arithmetic operators)

四则运算只在一个运算符上不同,既有 BINARY_OP 宏可在一处加入验证、解包和重新包装:

#define BINARY_OP(valueType, op) \
do { \
if (!IS_NUMBER(peek(0)) || !IS_NUMBER(peek(1))) { \
runtimeError("Operands must be numbers."); \
return INTERPRET_RUNTIME_ERROR; \
} \
double b = AS_NUMBER(pop()); \
double a = AS_NUMBER(pop()); \
push(valueType(a op b)); \
} while (false)
case OP_ADD: BINARY_OP(NUMBER_VAL, +); break;
case OP_SUBTRACT: BINARY_OP(NUMBER_VAL, -); break;
case OP_MULTIPLY: BINARY_OP(NUMBER_VAL, *); break;
case OP_DIVIDE: BINARY_OP(NUMBER_VAL, /); break;

valueType 本身是宏参数;目前都传 NUMBER_VAL,但比较运算将传 BOOL_VAL。宏确实庞大,不是通常推荐的 C 风格,却能保证四个分支保持一致。

18.4 两种新类型(Two New Types)

现在可内部表示布尔与 nil,却还没有让用户程序生成它们。truefalsenil 只有三个可能值,若像数字一样占常量表和两字节 OP_CONSTANT,既浪费又慢。VM 大量时间用于读取、解码指令;为常见常量设置短的专用指令是经典优化,Java 字节码也有载入常见小数字的专用指令。

// chunk.h
typedef enum {
OP_CONSTANT,
OP_NIL,
OP_TRUE,
OP_FALSE,
// 其余 opcode。
} OpCode;

在 Pratt 表中,三种关键字都绑定到同一个前缀函数:

[TOKEN_FALSE] = {literal, NULL, PREC_NONE},
[TOKEN_NIL] = {literal, NULL, PREC_NONE},
[TOKEN_TRUE] = {literal, NULL, PREC_NONE},
static void literal() {
switch (parser.previous.type) {
case TOKEN_FALSE: emitByte(OP_FALSE); break;
case TOKEN_NIL: emitByte(OP_NIL); break;
case TOKEN_TRUE: emitByte(OP_TRUE); break;
default: return;
}
}

VM 直接把相应值压栈,反汇编器也为它们增加简单指令分支:

case OP_NIL: push(NIL_VAL); break;
case OP_TRUE: push(BOOL_VAL(true)); break;
case OP_FALSE: push(BOOL_VAL(false)); break;

printValue() 现在必须按标签分派:

void printValue(Value value) {
switch (value.type) {
case VAL_BOOL: printf(AS_BOOL(value) ? "true" : "false"); break;
case VAL_NIL: printf("nil"); break;
case VAL_NUMBER: printf("%g", AS_NUMBER(value)); break;
}
}

18.4.1 逻辑非与假值(Logical not and falsiness)

! 不像单目负号那样只接受数字。Lox 跟随 Ruby:只有 nilfalse 是假值,其他一切值(包括 0)都是真值:

static bool isFalsey(Value value) {
return IS_NIL(value) || (IS_BOOL(value) && !AS_BOOL(value));
}
// chunk.h
OP_NOT,
OP_NEGATE,

复用已有 unary(),把 TOKEN_BANG 注册为前缀,并在 switch 中发射 OP_NOT。VM 的实现不需数值检查:

case OP_NOT:
push(BOOL_VAL(isFalsey(pop())));
break;

18.4.2 相等与比较运算符(Equality and comparison operators)

加入 ==!=>>=<<= 时,VM 只需三条新 opcode:

OP_EQUAL,
OP_GREATER,
OP_LESS,

字节码不必逐字对应源代码。a != b!(a == b) 语义相同,编译为 OP_EQUAL 后接 OP_NOTa >= b 编译为 OP_LESS 后接 OP_NOTa <= b 则为 OP_GREATER 后接 OP_NOT。这节省指令种类,也强调编译器可以自由选择任何保持用户可见行为的字节码序列。

严格说 IEEE 754 中 NaN 的所有比较都为 false,因此 NaN >= 1 不一定等价于 !(NaN < 1);本书暂不为这个细节增加复杂度,真实语言实现必须明确这类语义。

解析表中六个比较 token 都使用 binary(),并设置 PREC_EQUALITYPREC_COMPARISON。发射逻辑为:

case TOKEN_BANG_EQUAL: emitBytes(OP_EQUAL, OP_NOT); break;
case TOKEN_EQUAL_EQUAL: emitByte(OP_EQUAL); break;
case TOKEN_GREATER: emitByte(OP_GREATER); break;
case TOKEN_GREATER_EQUAL: emitBytes(OP_LESS, OP_NOT); break;
case TOKEN_LESS: emitByte(OP_LESS); break;
case TOKEN_LESS_EQUAL: emitBytes(OP_GREATER, OP_NOT); break;

相等性可用于任何两值,故交给 value 模块:

bool valuesEqual(Value a, Value b) {
if (a.type != b.type) return false;

switch (a.type) {
case VAL_BOOL: return AS_BOOL(a) == AS_BOOL(b);
case VAL_NIL: return true;
case VAL_NUMBER: return AS_NUMBER(a) == AS_NUMBER(b);
default: return false;
}
}

不同类型直接不相等;同类则比较实际内容。不能简单 memcmp() 两个 Value:union 未使用位和结构体对齐填充位没有确定内容,两个语义相等的值可能在这些字节不同。

两个相等 Value 可能在未使用的内存字节中不同。

JavaScript 等语言会将 0 和字符串 "0" 之类的不同类型值隐式转换后比较,带来了足够多问题以至于又增加了 ===;Lox 选择简单的同类型比较。对象加入后会补充引用相等性。

OP_EQUAL 调用 valuesEqual() 并把 C bool 包装为 Lox 布尔;数值比较可复用通用宏并传入 BOOL_VAL

case OP_EQUAL: {
Value b = pop();
Value a = pop();
push(BOOL_VAL(valuesEqual(a, b)));
break;
}
case OP_GREATER: BINARY_OP(BOOL_VAL, >); break;
case OP_LESS: BINARY_OP(BOOL_VAL, <); break;

至此纯数值计算器开始成为通用表达式求值器。尚缺少字符串;字符串大小可变,这个看似微小的差异会带来一系列内存表示问题,因此下一章专门讨论。

挑战(Challenges)

  1. 本章还能进一步减少哪些二元指令?若没有它们,编译器要如何生成等价代码?
  2. 反过来,增加哪些更专门的指令可使本章新增的常见用户代码运行更快?