Lua 源码解析(二):基本数据类型 —— Lua 中的数据类型
前言
本节开始,我们将分析 Lua 中各种数据类型的定义与实现。众所周知,Lua 是一种动态弱类型语言,在使用时我们不需要显式地声明变量的类型,而是由 Lua 运行时根据变量的值自动推断其类型。
所谓动态弱类型语言,是指在运行时才确定变量的类型,而且变量的类型可以随时改变。这种特性使得 Lua 在编程时更加灵活,但也增加了一些运行时的开销。为了达到动态类型效果,Lua 需要在内部维护一些数据结构来表示不同的数据类型。本节我们将从 Lua 中的类型定义开始,逐步分析 Lua 中的基本数据类型。
本文基于 Lua 5.4 版本源码进行分析。
Lua 中的类型定义
Lua 中一些比较重要的宏被放在了 lua.h
中,其中就包括了 Lua 中的数据类型定义。我们可以在 lua.h
中找到如下的定义:
1 | /* |
PS: 为了 markdown 渲染的美观性,所有的代码块都使用 C++ 的语法高亮,但实际上 Lua 的源码是使用 C 语言编写的。
可以看到,除了 8 种基本的数据类型外,Lua 还定义了一个 LIGHTUSERDATA
类型,它们各自的作用我们先按下不表。观察上述宏定义的结构可以发现,类型定义的组织方式类似于枚举类型,其中 LUA_NUMTYPES
代表了 Lua 中的数据类型总数。而 LUA_TNONE
则表示一个无效的类型。
TValue
前面我们说到,Lua 会在内部维护一些数据结构来标识不同的数据类型,这个数据结构就是 TValue
。TValue
意为 “Tagged Value”,维护了真实的数据以及数据的类型标识。我们可以在 lobject.h
中找到 TValue
的定义:
lobject.h
中主要定义了一些 Lua 中的对象类型以及对象的操作函数。
1 | /* |
可以看到,首先定义了一个 Value
联合体用于存放 5 种不同类型的数据。然后定义了一个 TValue
结构体,其中包含了一个 Value
类型的字段 value_
以及一个 lu_byte
类型的字段 tt_
。tt_
意为 “Tag Type”,用于标识 value_
中存放的数据的类型。
1 | /* chars used as small naturals (so that 'char' is reserved for characters) */ |
lu_byte
是一个无符号字符类型(大小为一个字节,也就是八位),用于存放标识数据类型的值。在 Lua 中,lu_byte
用于存放数据类型的标识,lu_byte
可以理解为一个八位的二进制数字。通过对每一位的设置,就可以表示不同的数据类型。
1 | /* |
在 lobject.h
的注释中,我们可以看到 lu_byte
每一位的含义:其中 0-3 位用于存放数据类型的标识,4-5 位用于存放变体位,6 位用于标识数据是否可回收。
由于变体位只有两位,所以变体位的取值范围是 0~3。因此一个类型的变体最多只能有 4 种(包括自身)。
所谓变体位,是指对于某一种数据类型,可能会有多种不同的表示方式。例如,对于字符串类型,Lua 中有长字符串和短字符串两种表示方式,这时就可以使用变体位来区分。变体位的设置方式是将数据类型的标识左移 4 位,然后与变体位进行或操作。
此外可以发现,lu_byte
的最后一位是没有被使用的,Lua 中仅使用了 0~6 位。
了解了这些之后,就可以分析具体的类型标识了。我们逐一分析。
LUA_TNIL 0x00
Nil
在 Lua 中代表一个空值,类似于 C 语言中的 NULL
。
1 | /* Standard nil */ |
在 Lua 中,nil
共有三种变体,分别是 NIL
、EMPTY
和 ABSTKEY
。NIL
代表标准的 nil
值,EMPTY
代表一个空的位置(有别于存放了一个 nil
值的位置),ABSTKEY
则用于没有找到键值时的返回值。
可以看出,分别使用 makevariant
宏通过赋予变体位不同值的方式生成了不同的变体。
此外,后续还定义了一些宏用于将对象设置为 nil
值。
1 | /* set a value's tag */ |
可以看出,Lua 中将一个对象设置为 nil
的方式仅仅是将其类型标识设置为 NIL
(或者 EMPTY
),并没有对 Value
联合体中的具体值进行修改。
LUA_TBOOLEAN 0x01
Boolean
类型在 Lua 中代表了布尔值,只有两种取值:true
和 false
。观察上述的 TValue
结构体可以发现,布尔值并不在 Value
联合体中。在 Lua 中,布尔值通过 tt_
中变体位的值来直接表示,具体的定义如下:
1 |
可以看到,FALSE
和 TRUE
分别代表了布尔值 false
和 true
。它们的区别仅仅在于第一位变体位的值。
1 |
此外提供了一些宏用于判断以及设置布尔值。与 nil
的赋值相同,Lua 中设置布尔值的方式也是直接设置对应的类型标识位。
#define l_isfalse(o) (ttisfalse(o) || ttisnil(o))
从这个宏的定义可以看出,Lua 中只有false
和nil
会被认为是假值,其他的值都会被认为是真值,包括数字 0 和空字符串。
LUA_TLIGHTUSERDATA 0x02
Light Userdata
是轻量级的 userdata
类型,它是一个指向任意 C 语言数据的指针。Light Userdata
的特点是不受 Lua 垃圾回收机制的管理,适合用于简单的数据交换,但同时也需要注意一些内存泄漏的问题。
1 |
回顾一下前面的类型定义,事实上在 LUA_STRING
之前的类型都不会被 gc (垃圾回收)管理,它们要么是值类型,要么是轻量级的指针类型。在 lstate.h
中也可以看到这样一个宏:
1 | /* |
obj2gco
宏用于将一个 Lua 对象转换为一个 GCObject
对象,会返回一个指向 GCObject
的指针。可以看到在转换之前会先检查对象的类型是否大于等于 LUA_TSTRING
,也就是说只有 LUA_TSTRING
及之后的类型才会被 gc 管理。
回到 LUA_TLIGHTUSERDATA
类型,它的定义如下:
1 | /* |
LIGHTUSERDATA
也仅有一个变体,在设置 LIGHTUSERDATA
类型的值时,除了设置类型标识位外,还需要设置具体的指针值。也即 Value
联合体中的 p
字段。
LUA_TNUMBER 0x03
Number
类型在 Lua 中代表数字类型,包括整数和浮点数。
Lua在5.3
版本之前,在底层实现中,所有数字都是浮点数,没有整数的概念,整数在底层也是通过浮点数(IEEE表示法)进行表示与数据存储,我们以为的整数的运算在当时可能在多次运算之后会累计产生出我们意料之外的浮点误差。
而在Lua5.3版本开始,Lua添加了对整数的支持,让整数从浮点中独立出来,不再使用浮点数进行表示,并支持了位运算这类整数运算的操作符。
1 | /* Variant tags for numbers */ |
因此,在我们分析的 5.4
版本中,Number
类型有两种变体,分别是 NUMINT
和 NUMFLT
。NUMINT
代表整数类型,NUMFLT
代表浮点数类型。二者分别在 Value
联合体中的 i
和 n
字段中存放具体的数值。
1 |
由于数值类型在 Value
中有对应的字段,因此在设置数字时除了需要设置类型标识位外,还需要设置具体的数值。具体来说就是将 Value
联合体中的 n
或 i
字段设置为对应的数值。
关于 Lua 中的数字,我们可以再深入研究一下它们在 C 中分别是怎么定义的:
1 | /* type for integer functions */ |
可以看到,lua_Integer
类型在 C 中是一个 long long
类型,lua_Number
类型是一个 double
类型。这也就是说,在 Lua 中整数类型是一个 64 位的整数类型,浮点数类型是一个 64 位的双精度浮点数类型。
LUA_TSTRING 0x04
Lua 中有两种字符串类型:长字符串和短字符串。它们也通过变体位来区分。
1 | /* Variant tags for strings */ |
在 setsvalue
宏中可以看出,字符串实际上存放在 value.gc
中。事实上,后续类型的数据均放在 value.gc
中,这是因为这些类型都是可回收的对象,需要被 gc 管理。
LUA_TTABLE 0x05
Table
在 Lua 中是一种比较强大的数据结构,它既可以顺序存放数据,也可以通过键值对的方式存放数据。Table
同时维护了一个数组以及一个哈希表,关于具体的实现我们会在后续的文章中详细分析。
1 |
Table
也仅有一种变体,且存放在 value.gc
中。
LUA_TFUNCTION 0x06
虽然 Lua 并非严格意义上的函数式编程语言,但是 Lua 支持函数式编程的许多特性,如一等值函数、闭包、高阶函数、匿名函数等。Function
类型在 Lua 中代表了函数对象。
1 | /* Variant tags for functions */ |
Function
类型有三种变体,分别代表不同类型的函数。LCL
代表 Lua 闭包,LCF
代表轻量级 C 函数,CCL
代表 C 闭包。可以看到它们全部存放在 value.gc
中。
这里定义的函数实际上指的是运行时的函数实例,可以理解为 Function
由我们定义的函数原型(Proto
)实例化并且绑定了一些环境变量(Upvalue
)之后的结果。因此这两种类型在 Lua 中也需要定义:
1 |
PROTO
代表函数原型;UPVAL
代表上值,即函数的环境变量。
也正是因此,在 lobject.h
的开始处,定义了一些扩充的类型:
1 | /* |
LUA_TUSERDATA 0x07
Userdata
类型在 Lua 中代表了用户自定义的数据类型。Userdata
类型是一种通用的数据类型,可以用来表示任意类型的数据。Userdata
类型在 Lua 中是一个黑盒,Lua 无法直接操作 Userdata
类型的数据,只能通过元表(Metatable
)来操作。
1 |
Userdata
也仅有一种变体,存放在 value.gc
中。
LUA_TTHREAD 0x08
Thread
类型在 Lua 中代表了一个线程。线程是 Lua 中的一种特殊对象,它可以被创建、销毁、挂起、恢复等。线程的实现是基于协程的,Lua 中的线程是协程的一种特殊形式。
1 |
THREAD
并没有其他变体,只有一个标准的线程类型;存放在 value.gc
中。
Collectable Objects
在 Lua 中,除了 NIL
、BOOLEAN
、LIGHTUSERDATA
、NUMBER
四种类型外,其他的数据类型都是可回收的对象,即 value.gc
中所存放的对象。这些可 gc 的对象都共同包含了一个 CommonHeader
,用于标识 gc 系统会用到的一些信息。
换言之,可以理解为 TString
、Table
、Function
、Userdata
、Thread
等类型都是 GCObject
的子类,虽然它们并没有继承关系。
CommonHeader
的定义如下:
1 | /* |
可以看到,GCObject
中包含了一个 next
指针,用于连接所有的可回收对象,以及一个 tt
字段,用于存放数据类型的标识。此外还有一个 marked
字段,用于标识对象是否被标记(在 gc 过程中会用到)。
1 | /* Bit mark for collectable types */ |
此外有一些宏用于判断对象是否可回收,以及设置对象的 gc 信息。可以看到这里正是使用到了 tt_
的第 6 位来标识对象是否可回收。
与 GCObject
相对应的还有一个 GCUnion
联合体,用于存放不同类型的可回收对象。
1 | /* |
以及一些宏用于将 GCObject
转换为具体的类型。
当 Lua 创建一个新的可回收对象时,会先统一创建一个 GCObject
对象,然后根据具体的类型转换为 GCUnion
中的某一特定类型。举例来说,在 table
的创建函数中:
1 | Table *luaH_new (lua_State *L) { |
可以看到这里先创建了一个 GCObject
对象,然后通过 gco2t
宏将其转换为 Table
类型。
实际上这里使用
GCObject *
来指向一个具体的可回收对象的行为是一种多态的体现,作用与父类指针指向子类对象类似。CommonHeader
的存在也是为了实现这种多态。
总结
本节我们分析了 Lua 中的数据类型定义,以及 TValue
结构体的定义。总体来看,Lua 中有 8 种基本数据类型,分别是 NIL
、BOOLEAN
、LIGHTUSERDATA
、NUMBER
、STRING
、TABLE
、FUNCTION
、USERDATA
和 THREAD
。此外,还有两种用于函数的扩展类型 PROTO
和 UPVAL
。
Lua 是用一个名为 TValue
的结构体来表示不同的数据类型,TValue
结构体中包含了一个 Value
联合体和一个 lu_byte
类型的 tt_
字段。其中 Value
联合体用于存放具体的数据,tt_
字段用于存放数据的类型标识。
tt_
字段的每一位都有特定的含义,0-3 位用于存放数据类型的标识,4-5 位用于存放变体位,6 位用于标识数据是否可回收。通过对 tt_
字段的设置,Lua 可以表示不同的数据类型。
在后续的文章中,我们将逐一分析 Lua 中的每一种数据类型的定义与实现。