第 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必须在运行时发现并报告错误。 - 如何保存其数据?不仅要知道它是数字,还要区分
3与4。
此外还要尽量高效。语言实现者设计出许多将这些信息压进少量位的技巧;先采用最经典、最简单的带标签联合:值含有类型标签和实际值负载。先定义 VM 内建值类别:
// value.h
typedef enum {
VAL_BOOL,
VAL_NIL,
VAL_NUMBER,
} ValueType;
这表示 VM 的类型观,不是用户语言中的每个类。后续类、字符串、函数加入时会增加类别;用户定义的一万个类实例在 VM 看来同属于“实例”类别。
保存数值与布尔数据时,若结构体同时包含 double 和 bool 字段会浪费空间,因为值不可能同时是两者。C 的 union 让字段重叠于同一块内存,其大小等于最大字段:


读取与写入不同 union 字段会把相同位重新解释为不同含义,既支持强力优化,也极不安全。最终组合为:
typedef struct {
ValueType type;
union {
bool boolean;
double number;
} as;
} Value;

典型 64 位机器上,四字节标签后会填充四字节,以令包含 double 的 union 按八字节对齐;Value 因而通常占 16 字节。即使缩小标签,数组内每个 Value 为对齐仍需填充。后面会进一步优化;当前它仍足够小,可在 C 栈上传值。现有值都是不可变标量,复制值不会改变 Lox 语义。
18.2 Lox 值与 C 值(Lox Values and C Values)
之前 Value 是 double 的别名,代码可直接当 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_ 守卫。

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,却还没有让用户程序生成它们。true、false、nil 只有三个可能值,若像数字一样占常量表和两字节 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:只有 nil 与 false 是假值,其他一切值(包括 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_NOT;a >= b 编译为 OP_LESS 后接 OP_NOT;a <= b 则为 OP_GREATER 后接 OP_NOT。这节省指令种类,也强调编译器可以自由选择任何保持用户可见行为的字节码序列。
严格说 IEEE 754 中 NaN 的所有比较都为 false,因此 NaN >= 1 不一定等价于 !(NaN < 1);本书暂不为这个细节增加复杂度,真实语言实现必须明确这类语义。
解析表中六个比较 token 都使用 binary(),并设置 PREC_EQUALITY 或 PREC_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 未使用位和结构体对齐填充位没有确定内容,两个语义相等的值可能在这些字节不同。

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