如何用动态语言有效地处理数据类型

140 阅读13分钟

动态类型语言为我们选择存储在变量中、传递给函数调用或返回给调用者的数据类型提供了几乎完全的自由。虽然有些语言在运行时仍然提供强大的类型保证(Python、Lua等),但其他语言是类型系统的狂野西部(JavaScript)。

程序员经常发现自己问的一些问题包括:"动态变量/值在引擎盖下到底是什么样子的?","如果一个变量可以属于任何数据类型,我的语言如何知道它需要为内存中的变量留出多少空间?"。希望你在点击这个页面时能找到一些答案。

要学习这篇文章中的代码样本,你只需要具备C语言的基本知识和熟悉任何鸭子类型的语言,如JavaScript、Lua、Ruby、PHP或Python。

解释型语言如何在运行时操作

几乎所有的解释型编程语言都有一个叫做 "字节码 "的中间表示,字节码是一组非常低级的指令,用来命令虚拟机,通常缩写为 "VM"。一条字节码指令通常可以转换为一组简单的任务。

虚拟机主要有两种:基于堆栈的和基于寄存器的。 基于堆栈的虚拟机使用堆栈作为其主要的数据结构来存储和获取数值。 基于寄存器的虚拟机使用 "寄存器",它通常只是一个数组的索引。 为了提高效率,虚拟机通常用低级别的编译语言编写,这些语言通常是静态类型的(Rust、C、C++、Zig)。 因此,我们的寄存器或堆栈必须是统一的数据类型,能够在其内部代表多种数据类型。

这个问题的关键可以归结为以下几点:

我们需要在我们的实现语言中定义一个静态数据类型来为我们的解释语言存储动态值。

每一种动态语言都有一套固定的数据类型。对于JavaScript来说,数据类型有function,object,string,number,boolean,symbol,undefined,BigInt, 和null 。每一个可能在JS中表示的值都属于这些类别之一。数组也属于object (通过评估typeof [1, 2] === "object" 可以看出)。

所以我们需要表示的数据类型的数量是一个编译时的常数。让我们考虑一个编程语言的解释器,它有number,boolean,object, 和null (其中object 吞噬了字符串、数组、hashtables和其他堆数据结构)。

有了这个新发现,并使用C语言作为我们的实现语言,我们的问题可以被重述为。

在C语言中创建一个数据结构,可以容纳一个堆对象、一个布尔值、一个空值或一个数字。

等等......这不就是一个结构吗? 不完全是,但这是一个好的开始。

臃肿的结构

#include <stdbool.h>

typedef struct Value_t Value;
struct Value_t {
  double number;
  bool boolean;
  // We leave the implementation of `HeapObject` to your imagination :^)
  HeapObject* object;
};

到目前为止,情况还不错,现在让我们试着想象一下解释器如何将上述两个数字相加。 考虑一个接受两个值并返回其总和的函数。

Value add_values(Value const* a, Value const* b) {
  Value sum;
  sum.number = a.number + b.number;
  return sum;
}

但是,如果ab 不是数字呢?一些语言(如JS)的语义规定,+ 操作符可以用于任何类型的值。如果ab 碰巧是字符串,我们应该把它们连接起来。 由于动态值可以在运行时改变其类型,解释器需要某种方法来知道Value 结构在任何时候携带什么数据类型。为此,我们引入一个新的enum ,表示我们语言中的数据类型。

typedef enum { T_NUM, T_OBJECT, T_BOOL } Type;

struct Value {
  Type type; // determines the type of a value
  double number;
  bool boolean;
  HeapObject* object; // heap allocated strings, arrays, symbols etc.
};

有了它,我们就可以改变我们的添加函数,使之表现得更加合理。

Value add_values(Value const* a, Value const* b) {
  Value sum;
  if (a.type == T_NUM && b.type == T_NUM) {
    sum.type = T_NUM;
    sum.number = a.number + b.number;
  } else if (a.type == T_OBJECT && b.type == T_OBJECT) {
    if (a.object->is_string() && b.object->is_string()) {
      sum.type = T_OBJECT;
      sum.string = concat_strings(a, b);
    } else {
      // ...
    }
  }
  return sum;
}

在这一点上,应该很清楚,解释器可以对这样的数据结构进行所有需要的操作。 但我们可以做得比一个臃肿的结构更好。

标签联盟

我们为结构提供多个字段的原因是,我们的值可以在任何时候改变其数据类型。然而,由于我们语言的语义,Value 在任何时候都只能携带一种数据类型。 一个值不能同时是数字和字符串。 举例说明。

let a = 1
console.log(typeof a) // number
a = 'x'
console.log(typeof a) // string

这意味着当Value 结构的object 字段携带有意义的数据时,所有其他字段都不做任何工作,只是占用空间!我们需要存储一个double 或一个bool 或一个HeapObject* ,但绝不会超过一个。我们为每个Value 分配的空间是sizeof(bool) + sizeof(HeapObject*) + sizeof(bool) + sizeof(Type) 字节大。

printf("%d bytes\n", sizeof(Value)); // 32 bytes

由于我们只存储所有可能的数据类型中的一个,我们只需要分配足够的空间来存储最大的一个。 这意味着我们可以用sizeof(Type) + max(sizeof(bool), sizeof(double), sizeof(HeapObject*) ) 字节的空间来代替。 为了进行这种优化,我们可以像这样使用一个联合类型。

struct Value_t {
  Type type;
  union {
    double number;
    bool boolean;
    HeapObject* object;
  } data;
};
printf("%d bytes\n", sizeof(Value)); // 16 bytes

这就为我们节省了少量的字节!现在,add_values 函数的实现需要进行轻微的调整。

Value add_values(Value const* a, Value const* b) {
  Value sum;
  if (a.type == T_NUM && b.type == T_NUM) {
    sum.type = T_NUM;
    sum.data.number = a.data.number + b.data.number;
  } else if (a.type == T_OBJECT && b.type == T_OBJECT) {
    sum.type        = T_OBJECT;
    sum.data.object = concat_string(a.data.object, b.data.object);
  } else if (...) {
    // .. handle other cases
  }
  return sum;
}

标签联盟是当今许多主流解释器使用的一种值表示法。 这里是Lua解释器使用的值表示法的一个简短的片段。

typedef union {
  GCObject *gc;
  void *p;
  lua_Number n;
  int b;
} Value;

typedef struct lua_TValue {
  Value value; // union
  int tt;      // type tag
} TValue;

你会发现CPython解释器中也有类似的值表示。

标签联盟是一个很好的中间地带,也是解释器的一个流行选择。 但如果你是一个硬编码的速度狂人,请继续阅读。

NaN装箱

16字节是很好的,但是一些聪明的编译器工程师认为我们可以做得更好。 NaN装箱(ab)使用了IEEE-754浮点标准。

一个双精度浮点的位被分为3个部分。1位符号,11位指数和52位尾数。 这些位的确切解释对我们的目的并不重要,可以在标准中为好奇的读者找到。我们需要知道的是如何表示NaN --一个意味着 "不是数字 "的值,通常是由除以0等不良算术运算的结果出现。 标准规定,任何64位浮点数的指数位都被设置为1,被认为是NaN 。如果NaN 的第52位被设置,它就是一个安静的NaN,与信号NaN相比,它不会产生任何异常。

只要安静位和指数位被设置,大多数CPU就会将双数识别为NaN ,而不关心其中的其他位。这意味着我们可以使用剩余的位来存储任何我们想要的信息,同时使用普通的双数(不是NaNs)来存储我们语言中的数值。

注意:我们在这篇文章中交替使用了 "双数 "和 "64位浮点数 "这两个术语。

我们首先将我们旨在表示的数值类型分成3个方便的类别:

  • 即时/单子值。是单子的值,可以在不追寻指针的情况下访问(null,true,false, 等等)。
  • 堆分配的数据。生活在堆中的值,在类似JS的语言中,这意味着函数、数组、字符串和键值对对象。
  • 数字。一个64位的浮点值,在我们的语言中是一个普通的数字。

有了这个分类,我们新的数值表示法的规则就可以清楚地规定了。

  • 当一个双数不是NaN时,我们把它作为我们语言中的一个常规数字。
  • 如果一个双数是NaN,而它的符号位是1,它就是一个指向HeapObject 的指针。
  • 如果一个双数是NaN,并且它的符号位是0,那么它就是一个即时值,其类型可以通过检查它的3位标签(解释如下)来确定。

类型标签

现在,数字和对象可以只通过双数的NaN/符号位来识别,我们只需要类型标签(以前是Type )来识别单子值,如null,true,false, 等等。我们在尾数中保留了3个比特,并将每个比特模式映射到一个类型标签来实现这一点。

  • 000 -> (在我们的语言中产生的一个实际的安静的楠木)NaN
  • 001 ->null
  • 010 ->true
  • 011 ->false

如果你需要,还有4个类型标签的空间。 请记住,只有在处理单子值时才需要标签。 对于对象(其符号位为1),不需要标签位,也可以不设置。

指针

在64位中,保留了13位来标识我们的指针,它的符号位被设置为1,是一个安静的nan。我们如何在剩下的51位中表示一个64位指针呢?

事实证明,大多数CPU架构只用48位来寻址内存,其他的位没有动过。 这意味着我们剩下的空闲位在主要的架构上都是我们需要的。 一个指针可以被编码为0xfff8XXXXXXXXXXXX ,其中Xs是内存地址的位,fff8 是符号和nan位。

掌握了这些知识,我们就可以开始实现NaN的盒式值表示了。 我们经常需要把64位的值作为一个原始的比特序列来检查,甚至更多的时候需要作为一个双精度的浮点数来检查。 为此,我们再次使用一个联合。

typedef union BoxedValue_t BoxedValue;
union BoxedValue_t {
  uint64_t bits; // to inspect the raw bits
  double number; // to interpret the value as an IEEE float
};

// sizeof(BoxedValue) is 8

联合体允许我们在不同的镜头下查看同一组比特。另一个技巧是使用跨类型的指针脱指(*(uint64_t*)(&double_value)),或者将一种类型的比特memcpy ,并依靠编译器优化memcpy 的调用。现在,我们坚持使用联合体。

然后我们定义一堆方便的位掩码,帮助我们提取出双数的某些部分。

#define MASK_SIGN  0x8000000000000000UL // to extract the sign bit
#define MASK_EXP   0x7ff0000000000000UL // to extract the exponent bits
#define MASK_QNAN  0x0008000000000000UL // to extract the quiet-nan bit
#define MASK_TAG   0x0007000000000000UL // to extract the 3 bit type tag
#define MASK_PTR   0x0000ffffffffffffUL // to extract the pointer bits

现在给定任何64位浮点,我们能够使用num & MASK_TAG 来获得它的类型标签,同样地,所有其他的段也是如此。 接下来,我们将类型名称映射到它们的类型标签值。

#define TAG_NULL  0x0001000000000000UL // 001
#define TAG_TRUE  0x0002000000000000UL // 010
#define TAG_FALSE 0x0003000000000000UL // 011

一些访问器和标签检查宏是为了方便。

#define IS_NUMBER(v) (!isnan(v.number))
#define IS_OBJECT(v) (isnan(v.number) && (MASK_SIGN && (v.bits)))
#define AS_NUMBER(v) (v.number)
#define AS_OBJECT(v) ((void*)((v.bits) & MASK_PTR))

最后是一些哨兵常量。

#define QNAN  0xfff8000000000000
#define KNULL (QNAN | TAG_NULL) // `NULL` is reserved by C stdlib
#define TRUE  (QNAN | TAG_TRUE)
#define FALSE (QNAN | TAG_FALSE)

为了方便创建纳米框值,我们还定义了一些辅助工具。

// These could just as easily have been macros, but the compiler can optimize away
// trivial calls anyway, and making them functions leads to better error messages.

BoxedValue make_num(double value) {
  return (BoxedValue){.number = value};
}

BoxedValue make_imm(uint64_t value) {
  assert(value == QNAN || value == TRUE || value == FALSE);
  // An immediate value can be identified purely based on the NaN bits
  return (BoxedValue){.bits = value};
}

BoxedValue make_ptr(void *ptr) {
  BoxedValue value;
  value.bits = QNAN | (uint64_t)(ptr);
  return value;
}

现在,来演示一下我们的add_values 函数是如何变化的。

BoxedValue add_values(BoxedValue a, BoxedValue b) {
  if (IS_NUMBER(a) && IS_NUMBER(b)) {
    BoxedValue sum = make_num(AS_NUMBER(a) + AS_NUMBER(b));
    return sum;
  } else if (IS_OBJECT(a) && IS_OBJECT(a)) {
    // you know the drill...
  }
  // ...
}

NaN标签的完整实现与测试可以在这个gist中找到。wren编程语言使用同样的技术来编码它的值,这可以从它在GitHub上的源代码中看到。

Lua的5.2解释器也在x86上使用了这一技巧,在这里可以看出。

需要注意的是-- 并不是所有的CPU架构都能保证在产生和处理安静的NaN时,浮点数的52位不会被触动。 因此,如果你想使用这个技巧,在不支持NaN装箱的时候,也要有一个标记的联合实现作为备份。

指针标记

在热门的解释器(V8、SpiderMonkey、JSC)以及Smalltalk和LISP的古老虚拟机中都存在另一种值表示法--"指针标记"。

指针标记利用了64位指针是8字节对齐的这一事实。 这意味着指针中包含的每个内存地址都是8的倍数。 在二进制中,每个8的倍数都有3个最低位设置为0 。我们可以使用这3个空余位来存储我们对单子值的类型标记。

如果我们把我们的双数放在堆上,我们现在就可以只用一个指针来表示所有的值类型了!

typedef Object* Value;

NaN装箱和指针标记之间的一个显著区别是,在这种表示方法中,64位数字被分配到堆的其他地方。 我们使用标记来区分堆对象、数字和单子值。 让我们推出几个宏来表示这些标记。

#define TAG_HEAP  0b000 // an Object*
#define TAG_TRUE  0b001 // singleton - true
#define TAG_FALSE 0b010 // singleton - false
#define TAG_NULL  0b011 // singleton - null
#define TAG_NUM   0b100 // pointer to a double

#define MASK_TAG 0x0000000000000007

#define BITS(val) (uint64_t)(val)
#define GET_TAG(val) (BITS(val) & MASK_TAG)
#define SET_TAG(val, tag) (val = (Value)((BITS(val) ^ MASK_TAG) | tag))

Value make_num(double num) {
  Value value = (Value)malloc(sizeof(double));
  *(double*)value = num;
  SET_TAG(value, TAG_NUM);
  return value;
}

就像NaN标记一样,指针标记也是不可靠的,因为它依赖于对齐的内存地址。 如果你对NaN编码有足够的了解,你应该能够相当容易地实现指针标记。 如果你遇到困难,Max Bernstein的这篇博文可以作为一个可靠的参考。

下一步是什么?

就这样,我们已经涵盖了编译器工程师历来使用的所有主要的值表示法。 下一次,当静态类型的十字军吹嘘他们的值表示法的紧凑性时,你知道该用什么来压制他们。

这就是说,我们必须看到更大的画面。 这些不仅仅是使编程语言更快的聪明技巧,而是在工程的几个方面使用的新型数据压缩技术。

因此,当你点击离开这个页面时,你留下的不是无用的编译器工程马戏团的技巧,而是现实生活中的优化模式。