Lua 源码解析(二):基本数据类型 —— 表(下)
前言
在 上一篇文章 中我们探讨了 Lua 中表的基本结构,以及表的创建、访问及插入元素等操作。但 Lua 中的表还有一个非常重要的特性:元表(metatable
)。元表是 Lua 中表的一个重要特性,它可以让我们对表进行一些特殊的操作,比如重载表的操作符、修改表的行为等。我们甚至可以利用元表来实现面向对象编程。本文将继续探讨 Lua 中表的元表。
元表与元方法
每个值都可以有 元表(metatable)(注意,是所有)。元表 是定义了原始数据在某些事件下行为的一个普通Lua表。你可以通过设置其元表的某些特定属性来改变某个值的某些行为。举个例子,一个非数字值进行加法操作时,Lua会在这个值的元表中查找__add
属性函数,找到了的情况下,Lua就会调用这个函数来执行加法操作:
1 | mt = {} |
元表中的每个事件对应的键都是一个字符串,内容是以两个下划线做前缀的事件名,其相应的值被称为 元值(metavalue
)。对于大部分事件,其元值必须是一个称为 元方法(metamethod
) 的方法。在上边说的例子里,键值是 _add
字符串且元函数是一个用来做加法操作的方法。若非另有说明,元函数实际上可以是任意可调用的值,它要么是个函数,要么是个带有元方法 __call
的值。
表和full userdata
有单独的元表,尽管多个表和userdata
之间可以共享它们的元表。其他类型的值共享每个类型的单独元表;即,存在一个单独的元表给所有数字使用,一个单独的元表给所有的字符串使用,等等。默认情况下,值没有元表,但是字符串库给字符串类型设置了一个元表。
字符串库所提供的函数都放在名为 string 的表中。其同时会设置字符串值的元表,其中 __index
字段指向了 string 表。因此我们可以使用面向对象的风格来调用字符串函数。例如 string.byte(s,i)
可以写成 s:byte(i) 的形式。例如:
1 | a = "123" |
ltm.h
中定义了所有的元方法,这些元方法的标识被放在名为 TMS
的枚举类型中:
1 | typedef enum { |
tm
即为 tag method
的缩写,这些方法对应的会在 luaT_init
函数中被初始化:
1 | void luaT_init (lua_State *L) { |
上述这些以 __
开头的字符串就是元方法的名字,它们就是元表中对应元方法的键了。
__index
__index
想必是最常用的元方法了。这个操作发生在 table
不是一个表或者 key
不存在于 table
中的情况下。此时将会在 table
的元表中查找其元值。
此事件的元值可以是一个方法、一个表、或者任何带有 __index
元值的值。如果是方法,它会将 table
和 key
作为参数来调用,调用结果(调整为单值)作为操作结果。否则,最终的结果是其元值索引 key
的结果。此索引是常规索引,而非直接索引,所以可以触发其他的 __index
元值(也即触发递归查找)。
利用 __index
元方法,我们可以在 Lua 中模拟面向对象编程的一些行为,如类与继承等,详见 Lua 入门
这里举一个简单的例子:
1 | t = {} |
注意如果一个函数被设为 __index
元方法,那么这个函数一定要有两个参数,第一个参数是表本身,第二个参数要查询的键。
__index
的实现
__index
这种逐级查找的行为主要通过 luaV_finishget
函数实现。该函数用于访问一个值的元表,并返回对应元方法的访问结果。注意,这里需要再次强调,任何类型的值都可以有元表,该函数中也会对不同类型的值进行不同的处理。
luaV_finishget
函数的实现如下:
1 | /* |
首先我们可以看到,函数整体是一个循环,这就是逐级查找的过程了,循环的最大次数由 MAXTAGLOOP
定义,Lua 5.4 版本中默认是 20000 次。如果超过了这个值还是没有查询到结果,就会抛出一个错误。
这里以一个递归的例子来说明,最终就会抛出上述的错误:
1 | t1 = {} |
从函数的注释中可以看出,如果 t
是一个表的话,则 slot
会是一个非 NULL
的指针(但肯定是一个空的表项,不然也不会查元表),否则 slot
会是 NULL
。
当 slot
为 NULL
时,会调用 luaT_gettmbyobj
函数来获取该值的 __index
元方法:
1 | const TValue *luaT_gettmbyobj (lua_State *L, const TValue *o, TMS event) { |
可以看到,对于基本类型,会直接从一个全局的数组中取得对应类型的元表,并通过 luaH_getshortstr
的方式访问元表中的元方法。如上述 string
类型的种种库函数,就是通过这种方式实现的。
1 | else { /* 't' is a table */ |
如果查询的对象是一张表的话则会通过 fasttm
宏来获取该表的元表中的 __index
元方法。
fasttm
等相关元方法快速查询的实现也十分巧妙,
首先,Table
结构体中有一个大小为 1 byte
的 flags
字段,用于标记该表是否有某个元方法,这样可以避免重复查询:
1 | typedef struct Table { |
如果某一位被置为 1
,则表示该表中没有对应的元方法。再回到 TMS
的定义中:
1 | typedef enum { |
可以看到,Lua 将最常用的几种元方法放在了前面,TM_EQ
之前的元方法都可以通过 fasttm
宏来快速查询,这也是因为 flags
只有 8 位。
这里不由得感慨 Lua 的设计之精妙,无论是对于空间还是时间的优化都做得非常好。
1 |
|
fasttm
宏也正是利用了 flags
字段,如果该表中没有对应的元方法,则会将对应的位设置为 1
,并返回 NULL
; 否则依然使用 luaH_getshortstr
函数来获取元方法。
1 | if (ttisfunction(tm)) { /* is metamethod a function? */ |
获取到了元方法后,如果是函数则会调用并将结果存放在 val
中并返回,否则会尝试访问 tm[key]
;如果上述尝试都没有结果则需要递归查找。
至此,就完成了 __index
元方法的查找过程。
__newindex
__newindex
事件发生在对表中不存在的键赋值时,此时会在表的元表中查找其元值。与 __index
类似,此元值可以是方法、表、或者任何带有 __newindex
元值的值。如果是方法,它会将 table
、key
和 value
作为参数来调用。否则,Lua 将再次对这个元值做索引赋值。这里的赋值流程是常规赋值,而不是直接的赋值,所以它可能会递归触发其他的 __newindex
元值。
无论何时,当 __newindex
元值被调用,Lua不会执行任何更多的赋值操作。如果需要,元函数自身可以调用 rawset
来做赋值。举例来说,如果我们这样写:
1 | local t = {} |
看似在 __newindex
中对 tt[key]
进行赋值,十分合理,但实际上这样会导致递归调用,最终会抛出错误,正确的方法是使用 rawset
:
1 | local t = {} |
rawset
函数用于直接将 table[index]
设为 value
,不会使用元值 __newindex
。参数 table
必须是一个表,参数 index
可以是除 nil
和 NaN
的任何值,参数 value
可以是任意 Lua
值,此函数会将 table 返回。
可以看到,当 __newindex
元值是一个函数时,传入的第一个参数是表本身,即修改会发生在原表上。
__newindex
的元值也可以是一张表:
1 | local t = {} |
相反地,当 __newindex
的元值是一张表时,赋值操作并不会影响到原表,而是会将值设置在元表中,当元表也有元表且实现了 __newindex
时,会递归调用。
__newindex
的实现
__newindex
的实现与 __index
类似,主要位于 luaV_finishset
函数中:
1 | /* |
函数的主体部分也是一个循环,用于逐级查找;如果 t
是一张表且元表中没有 __newindex
元方法,则会直接将值赋给 t[key]
,如果没有这个键则会调用 luaH_newkey
创建一个键。否则会分类别调用元方法。
如果元方法是一个函数,则会调用并返回,否则会尝试访问 tm[key]
,如果有结果则会调用 luaV_finishfastset
函数完成赋值操作(也即不会触发进一步的递归查找),否则会递归查找。
__gc
__gc
元函数被称为终结器(finalizers
),于垃圾收集器发现相应的表或 userdata 将要被销毁时被调用。finalizers 允许你将垃圾收集器与外部资源管理协调起来,例如关闭文件、网络或数据库连接,或者释放你自己的内存。
__gc
的作用与 C# 中的Finalize
方法有异曲同工之妙,都是用于非托管资源的释放。下面可以看到,它们也有着相同的缺点。
对于收集时要终结的对象(表或者 userdata
),需要把它标记为可触发终结(finalization
)。如果想将标记一个对象,我们需要设置一个对象的元表并且此元表要有元函数__gc
。注意,当你设置元表时没有 __gc
属性,而是之后再于元表上创建这个属性的话,这个对象将不会被标记。
当一个被标记的对象死亡时,其并不会立刻被垃圾收集器收集起来。相反,Lua会将其放到一个列表中。Lua 在收集完成后遍历这个列表。对于列表中的每个对象都会查找其是否有元函数__gc
,如果有,Lua 将这个对象作为单一参数来调用此函数。
在垃圾收集周期的最后,终结器会以和标记顺序相反的顺序来调用该周期内收集的对象的终结器。终结器的调用可能发生在常规代码执行时的任意时刻。
因为被回收的对象仍然会被终结器使用,所以其一定会被Lua复原(包括被其唯一关联的其他对象)。通常,这个复原是暂时的,而且其内存会在下一次GC周期内被释放。然而,如果终结器将对象保存到了某些全局位置(例如全局变量),那么这个复原就是持续的。此外,如果终结器将一个正在被终结的对象再次标记为可触发终结,那么终结器会在下个GC周期时的对象死亡的地方被再次调用。在任意情况下,没有被标记为可触发终结并已经死亡的对象,其内存只可能在GC周期内被释放。
下面举一个简单的例子:
1 | local t = {} |
可以看到,在执行完一个 Lua 文件后,进行了一次垃圾回收,此时 __gc
函数被调用。当然我们也可以手动触发垃圾回收:
1 | local t = {} |
__gc
的实现
__gc
的实现主要位于 lgc.c
中的 GCTM
函数中:
1 | static void GCTM (lua_State *L) { |
该函数会在一个表或者 userdata
将要被销毁时被调用。首先会获取到该对象的元表中的 __gc
元方法,如果存在则会调用该方法。
__mode
__mode
元方法用于控制元表的数据引用方式。 __mode
的值是一个字符串,共有以下三种取值:
k
:表示弱引用键v
:表示弱引用值kv
:表示弱引用键和值
这里弱引用的含义与 C++ 中的 weak_ptr
类似,即弱引用不会增加对象的引用计数,如果仅有弱引用指向一个对象,那么这个对象会被垃圾回收器回收。举例来说:
1 | local key = {"key"} |
在上述代码中,value
被赋值为 nil
,但是 t.key
仍然可以访问到 value
,这是因为 t
强引用了 value
。如果我们将 t
的元表设置为弱引用值:
1 | local key = {"key"} |
此时 t.key
为 nil
,因为 t
弱引用了 value
,所以在 value
被赋值为 nil
并进行垃圾回收后,value
本身被回收。
1 | local key = {"key"} |
同样地,我们也可以设置弱引用键,此时 t
弱引用了 key
,所以在 key
被赋值为 nil
并进行垃圾回收后,t
中的这一节点也将被回收。
由 __mode
元方法可以延伸出一种特殊的表:弱表(Weak Tables
):
弱表(Weak Tables) 就是一个其元素为弱引用的表。弱引用会被GC忽略。换言之,一个仅被弱引用指向的对象会被GC回收。
一个弱表可以同时或任一拥有弱键和弱值。拥有弱值的表允许收集其值,但是会阻止收集其键。一个同时拥有弱值和弱键的表的键和值都允许收集。任意情况下,当键或值被收集,其键值对都会从表中移除。表的“弱性”由元表中的__mode属性来控制。此元值如果存在,则必须为给出的字符串之一:“k”——表示拥有弱键的表,"v"表示拥有弱值的表,"kv"表示同时拥有弱键和弱值的表。
拥有弱键和强值的表也被称为临时表(ephemeron table)。在临时表中,键可达是值可达的前提。具体来说,某个键只被其值引用时,这个键值对将被删除。
任何对表的“弱性”做的修改都会在下一次GC周期生效。比如,当你将“弱性”改成一个更强的模式时,Lua仍然会在改动生效之前回收一些东西。
只有显式构建的对象才会从弱表中移除。纯值,例如数字和轻量C函数,其不受 GC 的约束,因此它们不会从弱表中移除(除了收集它们所关联的值)。尽管字符串受制于GC,但其没有显式的结构且与纯值平等,比起对象其行为更类似于值。因此其也不会从弱表中移除。
复原的对象(即,正被执行终结的对象,或仅被正在终结的对象所引用的对象)在弱表中的行为比较特殊。当对象明确被释放时,于弱值中的将会在执行终结之前被移除,而在弱键中的则只会在执行终结后的下一次收集中被移除。这是为了允许终结器通过弱表来访问与其对象所关联的属性。
如果弱表在一个收集周期中被复原的值里,那么在下个循环之前可能都没有得到适当的清理。
__mode
的实现
__mode
的实现主要位于 traversetable
函数中:
1 | static lu_mem traversetable (global_State *g, Table *h) { |
该函数主要在垃圾回收的过程中被调用,用于遍历表并对其中引用到的所有元素都进行标记。在遍历表的过程中,会检查表的元表中是否有 __mode
元方法,如果有则会根据其值来设置表的弱引用方式。这部分的具体逻辑涉及到 Lua 的 gc 过程,以及三色标记法等,我们会在 gc 部分再做介绍。
__len
__len
元方法用于重载 #
操作符,即获取表或者字符串的长度。如果对象不是一个字符串,Lua 将尝试调用其元函数。如果元函数存在,则调用将对象作为参数调用元函数,并将调用结果(通常调整为单个值)作为操作结果。如果元表不存在但是对象是 table
,Lua 使用表的取长操作,也即 luaH_getn
,详见表(上)。否则,Lua抛出将会抛出错误。
举例来说:
1 | local t = {1, 2, 3, 4, 5, 6, 7, 8} |
在上述代码中,首先会调用默认的 luaH_getn
函数获取表的长度;当我们设置了 __len
元方法后,会调用该方法来获取表的长度,这里即会返回表中偶数索引的个数。
__len
的实现
__len
的实现主要位于 luaV_objlen
函数中:
1 | /* |
该函数用于获取表或者字符串等对象的长度。可以看到这里针对表会先判断是否有 __len
元方法,如果有则会调用该方法,否则会调用 luaH_getn
函数获取表的长度。
其他元方法
除上述的这些常用元方法外,Lua 还有其他一些元方法,它们都用于重载不同的运算符,如 +
、-
、*
、/
等,增加了这些元方法作用类似于 C++ 中的运算符重载,可以使得对于表的操作更加灵活。重载运算符的元方法主要可以分为三类:
-
算术操作运算符:
TM_ADD
:加法操作,符号+
TM_SUB
:减法操作,符号-
TM_MUL
:乘法操作,符号*
TM_MOD
:取模操作,符号%
TM_POW
:幂运算,符号^
TM_DIV
:除法操作,符号/
TM_IDIV
:整除操作,符号//
-
位操作运算符:
TM_BAND
:按位与操作,符号&
TM_BOR
:按位或操作,符号|
TM_BXOR
:按位异或操作,符号~
TM_SHL
:左移操作,符号<<
TM_SHR
:右移操作,符号>>
TM_UNM
:取负操作,符号-
TM_BNOT
:按位取反操作,符号~
-
比较操作运算符:
TM_EQ
:等于操作,符号==
TM_LT
:小于操作,符号<
TM_LE
:小于等于操作,符号<=
-
其他操作:
TM_CONCAT
:连接操作,符号..
TM_CALL
:调用操作,符号()
TM_CLOSE
:关闭操作,常与goto
一起使用,这里不做详细介绍
通过定义这些元方法,我们可以对表的操作进行重载,使得表的操作更加灵活。例如:
1 | local t1 = {1, 2, 3} |