table
实现了关联数组,即可以同时用数字和字符串索引的数组。table
是一种强大的语言构造。因为table
的泛型特点,简化了使用lua
编写程序所用的数据结构和算法。
哈希与数组
直到Lua 4.0
为止,table
都是作为纯哈希表实现的,所有的键值对都是显式存储的。在Lua 5.0
版本引入了table
的混合表示:每个table
包含了一个哈希部分和一个数组部分,两个部分都可以是空的。Lua
检测一个table
是不是作为一个数组来使用,并自动将数字索引的值移动到数组部分,而非原本的存储在哈希部分。这种分裂只在底层实现层次进行;访问table
域是透明的,即使是对虚拟机来说。table
会自动根据内容使用两个部分。
这个混合机制有两个优点。第一,访问整型key
的操作会变得更快了,因为不再需要哈希。第二,更重要的是,数组部分只占原来哈希部分的一半大小,因为哈希部分需要同时存储key
和value
,而数组部分的key
已经隐含在下标了。结果是,如果一个table
是作为数组使用的,它的表现就像数组一样,只要它的整型key
是密集分布的。而且,哈希部分没有内存或者时间的代价,因为作为数组使用时,哈希部分不存在。反过来说,如果table
是作为记录使用而非数组,那么数组部分就是空的。这些节省下来的内存是重要的。
Lua核心突出角色
从Lua 4.0
开始,全局变量就存储在普通的Lua table
里,称为全局table
。Lua 5.0
用元表和元方法取代了tag
和tag
方法(Lua 3.0
引入的)。元表是普通的Lua table
,元方法是作为元表的域存储的。Lua 5.0
也引入了环境table
,可以附加到Lua
函数上;它们就是Lua
函数索引的全局环境。Lua 5.1
将环境变量table
扩展到C
函数、userdata
和协程,取代了全局的环境变量。这些改动简化了Lua
的实现、Lua
和C
程序员所用的API
,因为全局变量和元方法可以在Lua
里操控,不再需要特殊函数了。
实现
先看看表的数据类型定义:
1 | typedef struct Table { |
从Node
类型来看,它包含两个成员,一个是key
,另一个是value(TValue)
。
1 | typedef union TKey { |
表查找
1 | const TValue *luaH_getnum (Table *t, int key) { |
可以看到,即使是一个正整数的key
,其存储部分也不见得会一定落在数组部分,这完全取决于它的大小是再落在了当前数组可容纳的空间范围内(OP_NEWTABLE中GETARG_B)。也解释了ipairs
遍历断裂的问题。
1 | local t = {} |
新增元素
当找不到对应的key
时,最终都会调用内部的newkey
函数分配一个新的key
来返回。
1 | static TValue *newkey (lua_State *L, Table *t, const TValue *key) { |
散列表部分的数据组织是,首先计算数据的key
所在的桶数组位置,这个位置称为mainposition
。相同mainposition
的数据以链表形式组织。
整个过程都是在散列桶部分进行的,理由是即使key
是一个数字,也已经在调用newkey
函数之前进行了查找,结果却没有找到,所以这个key
都会进入散列桶部分来查找。
rehash
以上操作涉及重新对表空间进行分配的情况。入口函数是rehash
,顾名思义,这个函数的作用就是为了做重新散列操作。
1 | static void rehash (lua_State *L, Table *t, const TValue *ek) { |
在重新散列的过程中,除了增大Lua表的大小以容纳新的数据之外,还希望能借此机会对原有的数组和散列桶部分进行调整,让两部分都尽可能发挥其存储的最高容纳效率。那么,这里的标准是什么呢?希望在调整过后,数组在每一个2次方位置容纳的元素数量都超过该范围的50%。 能达到这个目标的话,就认为这个数组范围发挥了最大的效率。
当数字键值的统计跑完之后,得到了这个数组每个元素的数据,也就是得到了落在每个范围内的数据数量。接着会计算怎样才能最大限度地使用这部分空间 。这个算法由函数computesizes
实现:
1 | static int computesizes (int nums[], int *narray) { |
迭代
在一般算法库的设计中,针对容器类的迭代,会提供一个迭代器的数据,这个数据主要用于维护当前迭代到容器的哪部分数据了,下次再根据这个位置查找下一部分数据。表迭代不是这样设计的,很大的原因是为了兼容数组部分和散列桶部分的访问 。 迭代操作传入的不是一个迭代器,而是key
。
1 | int luaH_next (lua_State *L, Table *t, StkId key) { |
不管是在数组部分还是散列桶部分查找数据,查找成功都会返回该key
的下一个数据。
这个函数一开始就进入findindex
中进行查询,并区分数组和散列桶部分。findindex
函数的返回结果是一个整数索引,如果这个索引在表的sizearray
之内,则说明落入到数组部分,否则就落入到散列桶部分。在luaH_next
函数中使用这个返回值时,看起来是两个循环,实际上已经根据这个值的范围进行了区分,不会同一个key
走入两个循环中。而在返回散列桶部分时,这个索引值为”sizearray+对应散列桶索引的值”。
取长度操作
在Lua
中,可以使用#
符号对表进行取长度操作。对Lua
中的表进行取长度操作时,如果没有提供该表的元方法_len
,那么该操作只针对该表的序列( sequence )
部分进行。 “序列”指的是表的一个子集{1 ... n}
,其中n
是一个正整数,并且里面每个键对应的数据都不为nil
。
1 | int luaH_getn (Table *t) { |
如果表中混合了这两种风格的数据,那么优先取数组部分的长度。如果表存在数组部分,在数组部分二分查找返回位置。如果前面的数组部分查不到满足条件的数据,进入散列表部分查找。
所以,尽量不要将一个表混用数组和散列桶部分,即一个表最好只存放一类数据。Lua
的实现上确实提供了两者统一表示的遍历,但是这不意味着使用者就应该混用这两种方式。
从lua-5.1.1中分离出来的table实现代码
reference
《Lua设计与实现》
Un hermano puede no ser un amigo, pero un amigo será siempre un hermano.