字符串
“啊?对琐碎的劳动有点反感?”医生挑了挑眉毛。“可以理解,但有点错位。一个人应该珍惜那些让身体忙碌却让头脑和心灵不受束缚的枯燥任务。”
Tad Williams, 龙骨椅
我们的小型虚拟机目前可以表示三种类型的值:数字、布尔值和 nil
。这些类型有两个重要的共同点:它们是不可变的,并且它们很小。数字是最大的,但它们仍然可以容纳在两个 64 位字中。这个价格足够低,我们可以负担得起为所有值支付这个价格,即使对于布尔值和 nil,它们不需要那么多的空间。
不幸的是,字符串并不那么小巧。字符串没有最大长度。即使我们人为地将其限制在像 255 个字符这样的虚构限制内,对于每个值来说,这仍然是太多内存。
我们需要一种方法来支持大小可变的值,有时差异很大。这正是堆上的动态分配的用途。我们可以根据需要分配任意多的字节。我们会得到一个指针,用来跟踪值在虚拟机中流动的过程。
19 . 1值和对象
将堆用于较大的、可变大小的值,将堆栈用于较小的、原子值,会导致两级表示。每个可以在变量中存储或从表达式中返回的 Lox 值都将是一个值。对于像数字这样的小的、固定大小的类型,有效载荷直接存储在 Value 结构体本身中。
如果对象更大,其数据就位于堆上。然后 Value 的有效载荷是该内存块的指针。我们最终将在 clox 中拥有一小部分堆分配的类型:字符串、实例、函数,你懂的。每种类型都有自己独特的数据,但它们也有一些共享的状态,这些状态 我们未来的垃圾收集器 将用来管理它们的内存。
我们将这种通用表示称为 “Obj”。每个状态位于堆上的 Lox 值都是一个 Obj。因此,我们可以使用一个新的 ValueType 案例来引用所有堆分配的类型。
VAL_NUMBER,
在 enum ValueType 中
VAL_OBJ
} ValueType;
当 Value 的类型为 VAL_OBJ
时,有效载荷是指向堆内存的指针,所以我们为它添加了另一个联合案例。
double number;
在 struct Value 中
Obj* obj;
} as;
与其他值类型一样,我们为处理 Obj 值创建了一些有用的宏。
#define IS_NUMBER(value) ((value).type == VAL_NUMBER)
在 struct Value 之后添加
#define IS_OBJ(value) ((value).type == VAL_OBJ)
#define AS_BOOL(value) ((value).as.boolean)
如果给定的 Value 是一个 Obj,则这将评估为 true
。如果是这样,我们就可以使用这个
#define IS_OBJ(value) ((value).type == VAL_OBJ)
#define AS_OBJ(value) ((value).as.obj)
#define AS_BOOL(value) ((value).as.boolean)
它从值中提取 Obj 指针。我们也可以反过来。
#define NUMBER_VAL(value) ((Value){VAL_NUMBER, {.number = value}})
#define OBJ_VAL(object) ((Value){VAL_OBJ, {.obj = (Obj*)object}})
typedef struct {
这将获取一个裸 Obj 指针,并将其包装在一个完整的 Value 中。
19 . 2结构继承
每个堆分配的值都是一个 Obj,但 Objs 并不都一样。对于字符串,我们需要字符数组。当我们到达实例时,它们将需要它们的数据字段。函数对象将需要它的一块字节码。我们如何处理不同的有效载荷和大小?我们不能像对 Value 那样使用另一个联合,因为大小各不相同。
相反,我们将使用另一种技术。它已经存在很久了,以至于 C 规范专门为它提供了支持,但我不知道它是否有规范的名称。它是一个 类型混淆 的例子,但这个术语过于宽泛。在没有更好的想法的情况下,我将它称为结构继承,因为它依赖于结构体,大致遵循面向对象语言中状态的单一继承方式。
像一个带标签的联合一样,每个 Obj 都以一个标签字段开头,用于标识它是哪种类型的对象 —字符串、实例等。在标签之后是有效载荷字段。与其使用一个带有每种类型案例的联合,每种类型都是它自己独立的结构体。棘手的地方在于如何统一地对待这些结构体,因为 C 没有继承或多态的概念。我很快就会解释,但首先让我们把预备工作做完。
“Obj”这个名称本身指的是一个包含所有对象类型共享状态的结构体。它有点像对象的“基类”。由于值和对象之间存在一些循环依赖关系,因此我们在“值”模块中对它进行了前向声明。
#include "common.h"
typedef struct Obj Obj;
typedef enum {
实际定义在新的模块中。
创建新文件
#ifndef clox_object_h #define clox_object_h #include "common.h" #include "value.h" struct Obj { ObjType type; }; #endif
现在,它只包含类型标签。不久后,我们将添加一些其他簿记信息用于内存管理。类型枚举是这个
#include "value.h"
typedef enum { OBJ_STRING, } ObjType;
struct Obj {
显然,在后面的章节中添加更多堆分配的类型后,这将更有用。由于我们将会频繁地访问这些类型标签,因此值得创建一个小的宏来从给定的 Value 中提取对象类型标签。
#include "value.h"
#define OBJ_TYPE(value) (AS_OBJ(value)->type)
typedef enum {
这就是我们的基础。
现在,让我们在它之上构建字符串。字符串的有效载荷在单独的结构体中定义。同样,我们需要对它进行前向声明。
typedef struct Obj Obj;
typedef struct ObjString ObjString;
typedef enum {
定义与 Obj 并存。
};
在 struct Obj 之后添加
struct ObjString { Obj obj; int length; char* chars; };
#endif
字符串对象包含一个字符数组。这些字符存储在单独的、堆分配的数组中,这样我们就可以为每个字符串只分配必要的空间。我们还存储了数组中的字节数。这严格来说不是必需的,但可以让我们在不遍历字符数组查找空终止符的情况下了解为字符串分配了多少内存。
由于 ObjString 是一个 Obj,它还需要所有 Objs 共享的状态。它通过将它的第一个字段设置为一个 Obj 来实现这一点。C 规定结构体字段在内存中的排列顺序与它们声明的顺序一致。此外,当嵌套结构体时,内部结构体的字段会在原地展开。因此,Obj 和 ObjString 的内存看起来像这样
请注意,ObjString 的前几个字节与 Obj 完全对齐。这并非巧合 —C 规定 了这一点。这是为了实现一个巧妙的模式:你可以获取一个指向结构体的指针,并将其安全地转换为指向其第一个字段的指针,反之亦然。
给定一个 ObjString*
,你可以安全地将其转换为 Obj*
,然后从中访问 type
字段。在 OOP 的意义上,每个 ObjString 都是一个 Obj。“is”。当我们稍后添加其他对象类型时,每个结构体的第一个字段都将是一个 Obj。任何想要处理所有对象的代码都可以将其视为基 Obj*
,并忽略可能出现的其他字段。
你也可以反过来。给定一个 Obj*
,你可以将其“向下转换”为一个 ObjString*
。当然,你需要确保你拥有的 Obj*
指针确实指向了实际 ObjString 的 obj
字段。否则,你正在不安全地重新解释内存中的随机位。为了检测这种转换是否安全,我们添加另一个宏。
#define OBJ_TYPE(value) (AS_OBJ(value)->type)
#define IS_STRING(value) isObjType(value, OBJ_STRING)
typedef enum {
它接受一个 Value,而不是一个原始的 Obj*
,因为虚拟机中的大多数代码都使用 Value。它依赖于这个内联函数
};
在 struct ObjString 之后添加
static inline bool isObjType(Value value, ObjType type) { return IS_OBJ(value) && AS_OBJ(value)->type == type; }
#endif
快速问答:为什么不直接将这个函数的主体放入宏中?它与其他宏有什么不同?没错,这是因为主体两次使用了 value
。宏通过在主体中将参数名称出现的所有地方插入参数表达式来进行扩展。如果宏多次使用一个参数,那么该表达式就会被多次求值。
如果表达式有副作用,这很糟糕。如果我们将 isObjType()
的主体放入宏定义中,然后你这样做了,比如
IS_STRING(POP())
那么它将从堆栈中弹出两个值!使用函数可以解决这个问题。
只要我们确保在创建某个类型的 Obj 时正确设置类型标签,这个宏就会告诉我们何时可以安全地将一个值转换为特定对象类型。我们可以使用这些来做到这一点
#define IS_STRING(value) isObjType(value, OBJ_STRING)
#define AS_STRING(value) ((ObjString*)AS_OBJ(value)) #define AS_CSTRING(value) (((ObjString*)AS_OBJ(value))->chars)
typedef enum {
这两个宏接受一个 Value,该 Value 预期包含一个指向堆上有效 ObjString 的指针。第一个宏返回 ObjString*
指针。第二个宏通过该指针返回字符数组本身,因为这通常是我们最终需要的。
19 . 3字符串
好的,我们的虚拟机现在可以表示字符串值了。是时候将字符串添加到语言本身了。像往常一样,我们从前端开始。词法分析器已经对字符串文字进行了标记,所以轮到语法分析器了。
[TOKEN_IDENTIFIER] = {NULL, NULL, PREC_NONE},
替换 1 行
[TOKEN_STRING] = {string, NULL, PREC_NONE},
[TOKEN_NUMBER] = {number, NULL, PREC_NONE},
当语法分析器遇到字符串标记时,它会调用这个解析函数
在 number() 之后添加
static void string() { emitConstant(OBJ_VAL(copyString(parser.previous.start + 1, parser.previous.length - 2))); }
这将从词素中直接获取字符串的字符 直接。+ 1
和 - 2
部分修剪了开头和结尾的引号。然后它创建一个字符串对象,将其包装在一个 Value 中,并将其塞入常量表中。
为了创建字符串,我们使用 copyString()
,它在 object.h
中声明。
};
在 struct ObjString 之后添加
ObjString* copyString(const char* chars, int length);
static inline bool isObjType(Value value, ObjType type) {
编译器模块需要包含它。
#define clox_compiler_h
#include "object.h"
#include "vm.h"
我们的“对象”模块获得一个实现文件,我们在其中定义新函数。
创建新文件
#include <stdio.h> #include <string.h> #include "memory.h" #include "object.h" #include "value.h" #include "vm.h" ObjString* copyString(const char* chars, int length) { char* heapChars = ALLOCATE(char, length + 1); memcpy(heapChars, chars, length); heapChars[length] = '\0'; return allocateString(heapChars, length); }
首先,我们在堆上分配一个新的数组,它的大小正好适合字符串的字符和尾部的 终止符,使用这个低级宏,它使用给定的元素类型和计数来分配一个数组
#include "common.h"
#define ALLOCATE(type, count) \ (type*)reallocate(NULL, 0, sizeof(type) * (count))
#define GROW_CAPACITY(capacity) \
有了数组后,我们将词素中的字符复制过来并终止它。
您可能想知道为什么 ObjString 不能简单地指向源字符串中的原始字符。一些 ObjString 将在运行时作为字符串操作(如串联)的结果动态创建。这些字符串显然需要为字符动态分配内存,这意味着字符串需要在不再需要时释放该内存。
如果我们有一个字符串文字的 ObjString,并尝试释放其指向原始源代码字符串的字符数组,则会发生不好的事情。因此,对于文字,我们会先将字符复制到堆中。这样,每个 ObjString 都可靠地拥有其字符数组,并且可以释放它。
创建字符串对象的实际工作发生在这个函数中
#include "vm.h"
static ObjString* allocateString(char* chars, int length) { ObjString* string = ALLOCATE_OBJ(ObjString, OBJ_STRING); string->length = length; string->chars = chars; return string; }
它在堆上创建一个新的 ObjString,然后初始化其字段。这有点像面向对象语言中的构造函数。因此,它首先调用“基类”构造函数,使用一个新的宏来初始化 Obj 状态。
#include "vm.h"
#define ALLOCATE_OBJ(type, objectType) \ (type*)allocateObject(sizeof(type), objectType)
static ObjString* allocateString(char* chars, int length) {
类似于前面的宏,这主要存在是为了避免将 void*
冗余地强制转换为所需类型。实际的功能在这里
#define ALLOCATE_OBJ(type, objectType) \ (type*)allocateObject(sizeof(type), objectType)
static Obj* allocateObject(size_t size, ObjType type) { Obj* object = (Obj*)reallocate(NULL, 0, size); object->type = type; return object; }
static ObjString* allocateString(char* chars, int length) {
它在堆上分配给定大小的对象。请注意,大小不是仅仅是 Obj 本身的大小。调用者传入字节数,以便为创建的特定对象类型所需额外的有效负载字段留出空间。
然后它初始化 Obj 状态—现在,这只是类型标签。此函数返回到 allocateString()
,后者完成 ObjString 字段的初始化。瞧,我们可以编译和执行字符串文字。
19 . 4字符串操作
我们精美的字符串就在那里,但它们还没有做太多的事情。一个好的第一步是使现有的打印代码不乱用新的值类型。
case VAL_NUMBER: printf("%g", AS_NUMBER(value)); break;
在 printValue() 中
case VAL_OBJ: printObject(value); break;
}
如果值是堆分配的对象,则它会委托给“对象”模块中的辅助函数。
ObjString* copyString(const char* chars, int length);
在 copyString() 后添加
void printObject(Value value);
static inline bool isObjType(Value value, ObjType type) {
实现如下所示
在 copyString() 后添加
void printObject(Value value) { switch (OBJ_TYPE(value)) { case OBJ_STRING: printf("%s", AS_CSTRING(value)); break; } }
我们现在只有一个对象类型,但此函数将在后面的章节中增加额外的 switch case。对于字符串对象,它只是打印字符数组作为 C 字符串。
相等运算符也需要优雅地处理字符串。考虑
"string" == "string"
这是两个单独的字符串文字。编译器将对 copyString()
进行两次单独的调用,创建两个不同的 ObjString 对象,并将它们存储为块中的两个常量。它们是堆中的不同对象。但我们的用户(以及我们)期望字符串具有值相等。上面的表达式应该计算为 true
。这需要一些特殊的支持。
case VAL_NUMBER: return AS_NUMBER(a) == AS_NUMBER(b);
在 valuesEqual() 中
case VAL_OBJ: { ObjString* aString = AS_STRING(a); ObjString* bString = AS_STRING(b); return aString->length == bString->length && memcmp(aString->chars, bString->chars, aString->length) == 0; }
default: return false; // Unreachable.
如果两个值都是字符串,那么如果它们的字符数组包含相同的字符,则它们是相等的,无论它们是两个单独的对象还是完全相同的一个。这确实意味着字符串相等比其他类型的相等慢,因为它必须遍历整个字符串。我们将在稍后修改它,但这现在为我们提供了正确的语义。
最后,为了使用 memcmp()
和“对象”模块中的新内容,我们需要一些包含文件。这里
#include <stdio.h>
#include <string.h>
#include "memory.h"
还有这里
#include <string.h>
#include "object.h"
#include "memory.h"
19 . 4 . 1串联
成熟的语言提供了许多用于处理字符串的操作—访问单个字符、字符串的长度、更改大小写、拆分、连接、搜索等。当您实现自己的语言时,您可能需要所有这些。但对于这本书,我们保持非常简单。
我们支持的唯一有趣字符串操作是 +
。如果您对两个字符串对象使用该运算符,它将产生一个新的字符串,该字符串是两个操作数的串联。由于 Lox 是动态类型的,我们无法在编译时确定需要哪种行为,因为我们直到运行时才知道操作数的类型。因此,OP_ADD
指令动态检查操作数并选择正确的操作。
case OP_LESS: BINARY_OP(BOOL_VAL, <); break;
在 run() 中
替换 1 行
case OP_ADD: { if (IS_STRING(peek(0)) && IS_STRING(peek(1))) { concatenate(); } else if (IS_NUMBER(peek(0)) && IS_NUMBER(peek(1))) { double b = AS_NUMBER(pop()); double a = AS_NUMBER(pop()); push(NUMBER_VAL(a + b)); } else { runtimeError( "Operands must be two numbers or two strings."); return INTERPRET_RUNTIME_ERROR; } break; }
case OP_SUBTRACT: BINARY_OP(NUMBER_VAL, -); break;
如果两个操作数都是字符串,则将其串联。如果它们都是数字,则将它们相加。任何其他组合的操作数类型都是运行时错误。
要串联字符串,我们定义一个新函数。
在 isFalsey() 后添加
static void concatenate() { ObjString* b = AS_STRING(pop()); ObjString* a = AS_STRING(pop()); int length = a->length + b->length; char* chars = ALLOCATE(char, length + 1); memcpy(chars, a->chars, a->length); memcpy(chars + a->length, b->chars, b->length); chars[length] = '\0'; ObjString* result = takeString(chars, length); push(OBJ_VAL(result)); }
它非常冗长,因为使用字符串的 C 代码往往很冗长。首先,我们根据操作数的长度计算结果字符串的长度。我们为结果分配一个字符数组,然后将两个部分复制进来。像往常一样,我们仔细确保字符串已终止。
为了调用 memcpy()
,VM 需要一个包含文件。
#include <stdio.h>
#include <string.h>
#include "common.h"
最后,我们生成一个 ObjString 来包含这些字符。这次我们使用一个新的函数 takeString()
。
};
在 struct ObjString 之后添加
ObjString* takeString(char* chars, int length);
ObjString* copyString(const char* chars, int length);
实现如下所示
在 allocateString() 后添加
ObjString* takeString(char* chars, int length) { return allocateString(chars, length); }
之前的 copyString()
函数假设它不能拥有您传入的字符。相反,它保守地在堆上创建字符的副本,ObjString 可以拥有这些副本。对于字符串文字,传入的字符位于源字符串的中间,这是正确的选择。
但是,对于串联,我们已经动态地在堆上分配了一个字符数组。再复制一次将是多余的(并且意味着 concatenate()
必须记住释放其副本)。相反,此函数声称拥有您提供的字符串。
像往常一样,将此功能缝合在一起需要几个包含文件。
#include "debug.h"
#include "object.h" #include "memory.h"
#include "vm.h"
19 . 5释放对象
看看这个看似无害的表达式
"st" + "ri" + "ng"
当编译器处理它时,它为这三个字符串文字中的每一个分配一个 ObjString,并将它们存储在块的常量表中,并生成此字节码
0000 OP_CONSTANT 0 "st" 0002 OP_CONSTANT 1 "ri" 0004 OP_ADD 0005 OP_CONSTANT 2 "ng" 0007 OP_ADD 0008 OP_RETURN
前两个指令将 "st"
和 "ri"
推送到堆栈。然后,OP_ADD
弹出它们并将它们串联起来。这在堆上动态分配了一个新的 "stri"
字符串。VM 推送它,然后推送 "ng"
常量。最后一个 OP_ADD
弹出 "stri"
和 "ng"
,将它们串联起来,并将结果推送到堆栈:"string"
。太好了,这就是我们期望的。
但是,等等。那个 "stri"
字符串发生了什么?我们动态分配了它,然后 VM 在将其与 "ng"
串联之后丢弃了它。我们将其从堆栈中弹出,不再引用它,但我们从未释放它的内存。我们遇到了一个经典的内存泄漏。
当然,对于Lox 程序来说,忘记中间字符串并不用担心释放它们是完全可以的。Lox 自动代表用户管理内存。管理内存的责任不会消失。相反,它落在我们作为 VM 实现者的肩上。
完整的解决方案是一个垃圾收集器,它在程序运行时回收未使用的内存。我们有一些其他东西需要到位,然后再准备着手这个项目。在此之前,我们正在过着借来的时间。我们等待添加收集器的时间越长,做起来就越难。
今天,我们至少应该做最基本的事情:避免泄漏内存,确保即使用户的程序本身不再引用它们,VM 仍然可以找到每个分配的对象。高级内存管理器使用许多复杂的技巧来分配和跟踪对象的内存。我们将采取最简单的实用方法。
我们将创建一个链表,其中存储每个 Obj。VM 可以遍历该链表以找到分配在堆上的每个对象,无论用户的程序或 VM 的堆栈是否仍然引用它。
我们可以定义一个单独的链表节点结构,但随后我们也必须分配它们。相反,我们将使用一个侵入式链表—Obj 结构本身将成为链表节点。每个 Obj 都指向链中下一个 Obj 的指针。
struct Obj { ObjType type;
在 struct Obj 中
struct Obj* next;
};
VM 存储指向列表头的指针。
Value* stackTop;
在 struct VM 中
Obj* objects;
} VM;
当我们第一次初始化 VM 时,没有分配任何对象。
resetStack();
在 initVM() 中
vm.objects = NULL;
}
每次分配一个 Obj 时,我们都会将其插入列表中。
object->type = type;
在 allocateObject() 中
object->next = vm.objects; vm.objects = object;
return object;
由于这是一个单向链表,因此最容易插入的位置是头部。这样,我们就不需要另外存储指向尾部的指针并保持其更新。
“对象”模块直接使用“vm”模块中的全局 vm
变量,因此我们需要在外部公开它。
} InterpretResult;
在 enum InterpretResult 后添加
extern VM vm;
void initVM();
最终,垃圾收集器将在 VM 仍在运行时释放内存。但是,即使那样,当用户的程序完成时,通常也还会有一些未使用的对象仍然存在于内存中。VM 应该释放它们。
这没有复杂的逻辑。程序完成后,我们可以释放所有对象。我们现在可以并且应该实现它。
void freeVM() {
在freeVM()中
freeObjects();
}
我们定义的空函数很久以前终于做了一些事情!它调用了这个
void* reallocate(void* pointer, size_t oldSize, size_t newSize);
在reallocate()之后添加
void freeObjects();
#endif
以下是我们释放对象的方式
在reallocate()之后添加
void freeObjects() { Obj* object = vm.objects; while (object != NULL) { Obj* next = object->next; freeObject(object); object = next; } }
这是遍历链表并释放其节点的 CS 101 教科书实现。对于每个节点,我们调用
在reallocate()之后添加
static void freeObject(Obj* object) { switch (object->type) { case OBJ_STRING: { ObjString* string = (ObjString*)object; FREE_ARRAY(char, string->chars, string->length + 1); FREE(ObjString, object); break; } } }
我们不仅释放了 Obj 本身。由于某些对象类型还会分配其他它们拥有的内存,因此我们还需要一些特定于类型的代码来处理每个对象类型的特殊需求。在这里,这意味着我们释放字符数组,然后释放 ObjString。这两个都使用最后一个内存管理宏。
(type*)reallocate(NULL, 0, sizeof(type) * (count))
#define FREE(type, pointer) reallocate(pointer, sizeof(type), 0)
#define GROW_CAPACITY(capacity) \
它是一个围绕 reallocate()
的微小 包装器,它将分配“调整大小”为零字节。
像往常一样,我们需要一个包含文件将所有内容连接起来。
#include "common.h"
#include "object.h"
#define ALLOCATE(type, count) \
然后在实现文件中
#include "memory.h"
#include "vm.h"
void* reallocate(void* pointer, size_t oldSize, size_t newSize) {
有了这个,我们的 VM 不会再内存泄漏了。像一个好的 C 程序一样,它在退出之前清理了它的混乱。但它不会在 VM 运行时释放任何对象。稍后,当可以编写运行时间更长的 Lox 程序时,VM 会随着时间的推移消耗越来越多的内存,直到整个程序完成之前都不会释放任何字节。
我们将在添加 真正的垃圾收集器 之前不会处理这个问题,但这是一个大的进步。我们现在有了支持各种动态分配对象的架构。我们已经用它在 clox 中添加了字符串,这是大多数编程语言中最常用的类型之一。字符串反过来使我们能够构建另一个基本数据类型,尤其是在动态语言中:久负盛名的 哈希表。但这留待下一章 . . .
挑战
-
每个字符串都需要两个独立的动态分配—一个用于 ObjString,另一个用于字符数组。从值中访问字符需要两次指针间接寻址,这会影响性能。更有效的解决方案依赖于一种称为灵活数组成员的技术。使用它将 ObjString 及其字符数组存储在一个连续的分配中。
-
当我们为每个字符串文字创建 ObjString 时,我们会将字符复制到堆上。这样,当字符串稍后被释放时,我们知道可以安全地释放字符。
这是一个更简单的做法,但浪费了一些内存,这在资源非常有限的设备上可能是一个问题。相反,我们可以跟踪哪些 ObjString 拥有其字符数组,哪些是“常量字符串”,它们只是指向原始源字符串或其他不可释放的位置。添加对它的支持。
-
如果 Lox 是你的语言,你会让它在用户尝试使用
+
对一个字符串操作数和另一个不同类型的操作数进行操作时做什么?证明你的选择。其他语言是如何做的?
设计说明:字符串编码
在这本书中,我尽量不回避在真正的语言实现中遇到的棘手问题。我们可能并不总是使用最复杂的解决方案—毕竟这是一本入门书籍—但我认为假装问题根本不存在是不诚实的。然而,我确实回避了一个非常棘手的难题:决定如何表示字符串。
字符串编码有两个方面
-
什么是字符串中的单个“字符”?有多少个不同的值,它们代表什么?对这个问题的第一个被广泛采用的标准答案是 ASCII。它给了你 127 个不同的字符值并指定了它们是什么。它很棒 . . . 如果你只关心英语。虽然它有奇怪的,大部分被遗忘的字符,比如“记录分隔符”和“同步空闲”,但它没有一个变音符、重音符或抑扬符。它不能表示“jalapeño”、“naïve”、““Gruyère””或“Mötley Crüe”。
接下来是 Unicode。最初,它支持 16,384 个不同的字符(代码点),这很好地适合 16 位,还剩下几个位。后来它不断增长,现在有超过 100,000 个不同的代码点,包括像 💩(Unicode 字符“一堆便便”,
U+1F4A9
)这样的重要人类交流工具。即使是如此长的代码点列表也不足以表示语言可能支持的每个可能的可见字形。为了处理这个问题,Unicode 还拥有组合字符,它们会修改前面的代码点。例如,“a”后面加上组合字符“¨”会得到“ä”。(为了使事情更加混乱,Unicode 还有一个单独的代码点看起来像“ä”。)
如果用户访问“naïve”中的第四个“字符”,他们期望得到“v”还是“¨”?前者意味着他们将每个代码点及其组合字符视为一个单元—Unicode 所谓的扩展音节群—后者意味着他们按单个代码点进行思考。你的用户期望哪一种?
-
一个单元在内存中是如何表示的?大多数使用 ASCII 的系统为每个字符分配一个字节,并将高位保留为未使用。Unicode 有一些常见的编码。UTF-16 将大多数代码点打包成 16 位。当每个代码点都适合这种大小的时候,这很棒。当它溢出时,他们添加了代理对,它们使用多个 16 位代码单元来表示单个代码点。UTF-32 是 UTF-16 的下一个演变—它为每个代码点都分配了完整的 32 位。
UTF-8 比这两个都复杂。它使用可变数量的字节来编码代码点。较低的值的代码点适合于较少的字节。由于每个字符可能占用不同的字节数,因此你无法直接索引到字符串中以查找特定代码点。如果你想要,比如,第 10 个代码点,你不知道在字符串中它有多少个字节,除非遍历并解码所有前面的代码点。
选择字符表示和编码涉及到基本权衡。就像工程中的许多事情一样,没有完美的解决方案
-
ASCII 内存效率高且速度快,但它将非拉丁语言置于一旁。
-
UTF-32 速度快且支持整个 Unicode 范围,但它浪费了很多内存,因为大多数代码点确实倾向于在较低的范围内,在这种范围内,不需要完整的 32 位。
-
UTF-8 内存效率高且支持整个 Unicode 范围,但它的可变长度编码使得访问任意代码点速度很慢。
-
UTF-16 比所有这些都糟糕—这是 Unicode 超越其早期的 16 位范围的糟糕后果。它的内存效率不如 UTF-8,但由于代理对,它仍然是一种可变长度编码。如果可以,请避免使用它。不幸的是,如果你的语言需要在浏览器、JVM 或 CLR 上运行或与它们进行交互,你可能不得不使用它,因为它们都使用 UTF-16 来表示字符串,你不想每次将字符串传递给底层系统时都进行转换。
一种选择是采取最大限度的方法,做“最正确”的事情。支持所有 Unicode 代码点。在内部,根据字符串的内容选择每种字符串的编码—如果每个代码点都适合在一个字节中,则使用 ASCII,如果不存在代理对,则使用 UTF-16,等等。提供 API 允许用户遍历代码点和扩展音节群。
这涵盖了你的所有基础,但确实很复杂。它需要大量的实现、调试和优化。在序列化字符串或与其他系统交互时,你必须处理所有编码。用户需要了解两个索引 API,并知道在何时使用它们。这是较新的大型语言倾向于采用的方法—比如 Raku 和 Swift。
一个更简单的折衷方案是始终使用 UTF-8 进行编码,并且只公开一个与代码点一起工作的 API。对于想要使用音节群的用户,让他们使用第三方库来完成这项工作。这比 ASCII 不那么以拉丁语为中心,但没有那么复杂。你失去了快速直接索引代码点的能力,但你通常可以没有它,或者可以承受让它成为O(n) 而不是O(1)。
如果我要为编写大型应用程序的人设计一种大型工作语言,我可能会采用最大限度的方法。对于我的小型嵌入式脚本语言 Wren,我使用了 UTF-8 和代码点。