lua的table实现以及遍历方式

最近遇到一件很郁闷的事情,游戏里面我老是当队长!后来发现是因为队伍里每个人的数据会以游戏的ID为key,其余要用到的数据为value,放在一个table里面。然后用pairs遍历这个table,一个个加入队伍,所以谁当队长实际上和pairs的遍历顺序有关,具体来说是和pairs找到的第一个元素有关。一时兴起,重新看了一下lua的table的实现,终于找到原因!

一如既往,先报源码:

Ltable.c table的主要代码在这里

Lobject.h lua的基础对象的定义,包括table

假设我的ID是100005(纯粹是个假设,我当然不可能把我真的ID报出来啦,不过我的ID和这个100005一样苦逼啊有木有)。假设队员的ID是100001,100002,100003,100004,经过试验用pairs来遍历这些key的顺序是100005,100002,100003,100004,100001,下面就看看为什么遍历顺序是这样的。

先看看在Lobject.h中的关键数据结构:

/*

** Common Header for all collectable objects (in macro form, to be

** included in other objects)

*/

#define CommonHeader GCObject *next; lu_byte tt; lu_byte marked

....

typedef struct Table {

CommonHeader;

lu_byte flags; /* 1<<p means tagmethod(p) is not present */

lu_byte lsizenode; /* log2 of size of `node' array */

struct Table *metatable;

TValue *array; /* array part */

Node *node;

Node *lastfree; /* any free position is before this position */

GCObject *gclist;

int sizearray; /* size of `array' array */

} Table;

lua的table实际上是一个数组和hash表的结合体,定义中我们只关心lsizenode,array,node,lastfree和sizearray,其余的部分是每个lua基本类型都有的部分。lsizenode,node,lastfree这三个属性存储的就是哈希表部分;array和sizearray存储的就是数组部分。数组部分比较简单,array就是数组本身,sizearray是数组的大小;哈希表部分的话,node指向哈希表起始地址,lsizenode是log2(node指向的哈希表节点数目),注意2^lsizenode不等于哈希表中存储变量的数目,因为哈希表是有可能有冲突的,所以一个哈希表节点是一个链表的表头,他可能对应多个存储变量。lastfree指向node里面最后一个未用的节点(这个用法很特别,后面会详细说)。

/*

** Tagged Values

*/

#define TValuefields Value value; int tt

....

typedef struct lua_TValue {

TValuefields;

} TValue;

...

typedef union TKey {

struct {

TValuefields;

struct Node *next; /* for chaining */

} nk;

TValue tvk;

} TKey;

typedef struct Node {

TValue i_val;

TKey i_key;

} Node;

Node是哈希表中节点的结构,i_val是这个节点对应value,i_key是节点对应的key,其类型为TKey。TKey的定义很巧究(也很坑爹),这是一个union,TValue的定义实际上也是TValuefields这个宏,所以TKey的内存分布如下所示。

|------------------------------|

|value; | | |

|---------- tvk | |

|tt; | | nk |

|-------------------| |

|next | next | |

|-------------------------------|

画得很丑,将就着看吧。简单来说如果有TKey a;那么

a.tvk.value == a.nk.value

a.tvk.tt == a.nk.tt

lua之所以用这种方式来表示Node,感觉一个很重要的原因是为了适应不同参数类型的接口。

TKey中tvk是这个key的值,nk中的next则指向key冲突的下一个节点。lua的hash表的hash算法比较特别,一般的hash表都是根据key算出hash(key),然后把这个key放在hash表的hash(key)位置上,如果有冲突的话,就放在hash(key)位置的链表上。

但是lua的hash表中,如果有冲突的话,lua会找hash表中一个空的位置(从后往前找,假设为x),然后把新的key放在这个空的位置x上,并且让hash表中hash(key)处的节点的nk.next指向x。这个意思就是,假如有冲突的话,不用重新分配空间来存储冲突的key,而是利用hash表上未用过的空格来存储。但是这样会引入另外一个问题,本来key是不应该放在x的,假设有另外一个key2,hash(key2)算出来的位置也在x的话,那就表示本来x这个位置应该是给key2的,但是由于x被key占用了,导致key2没地方放了。这时候lua的处理方式是把key放到另外一个空格,然后让key2占回x。当hash表已经没有空格的时候,lua就会resize这个hash表。这样做的好处主要是不用动态申请内存空间,hash表初始化的时候有多少内存空间就用多少,不够就resize这个hash表。

下面来看看table的具体实现,见Ltable.c:

初始化table:

Table *luaH_new (lua_State *L, int narray, int nhash) {

Table *t = luaM_new(L, Table);

luaC_link(L, obj2gco(t), LUA_TTABLE);

t->metatable = NULL;

t->flags = cast_byte(~0);

/* temporary values (kept only if some malloc fails) */

t->array = NULL;

t->sizearray = 0;

t->lsizenode = 0;

t->node = cast(Node *, dummynode);

setarrayvector(L, t, narray);

setnodevector(L, t, nhash);

return t;

}

一目了然,无需多说,array和hash部分默认都是0,然后用narray和nhash来初始化array和hash部分。array部分就初始化为narray长度的数组,hash部分就初始化为2^ceil(log2(nhash))长度的哈希表。table的哈希表的长度必须是2的幂,至于增长规则会在后面说到。

table插入key:

/*

** inserts a new key into a hash table; first, check whether key's main

** position is free. If not, check whether colliding node is in its main

** position or not: if it is not, move colliding node to an empty place and

** put new key in its main position; otherwise (colliding node is in its main

** position), new key goes to an empty position.

*/

static TValue *newkey (lua_State *L, Table *t, const TValue *key) {

Node *mp = mainposition(t, key);

if (!ttisnil(gval(mp)) || mp == dummynode) {

Node *othern;

Node *n = getfreepos(t); /* get a free place */

if (n == NULL) { /* cannot find a free place? */

rehash(L, t, key); /* grow table */

return luaH_set(L, t, key); /* re-insert key into grown table */

}

lua_assert(n != dummynode);

othern = mainposition(t, key2tval(mp));

if (othern != mp) { /* is colliding node out of its main position? */

/* yes; move colliding node into free position */

while (gnext(othern) != mp) othern = gnext(othern); /* find previous */

gnext(othern) = n; /* redo the chain with `n' in place of `mp' */

*n = *mp; /* copy colliding node into free pos. (mp->next also goes) */

gnext(mp) = NULL; /* now `mp' is free */

setnilvalue(gval(mp));

}

else { /* colliding node is in its own main position */

/* new node will go into free position */

gnext(n) = gnext(mp); /* chain new position */

gnext(mp) = n;

mp = n;

}

}

gkey(mp)->value = key->value; gkey(mp)->tt = key->tt;

luaC_barriert(L, t, key);

lua_assert(ttisnil(gval(mp)));

return gval(mp);

}

之前已经说过了,就是一个利用哈希表空洞来存储冲突节点的算法。其中有一些要注意的地方,首先是getfreepos这个函数:

static Node *getfreepos (Table *t) {

while (t->lastfree-- > t->node) {

if (ttisnil(gkey(t->lastfree)))

return t->lastfree;

}

return NULL; /* could not find a free place */

}

这个函数会从后往前搜索hash表的空洞,找到的话就返回指向这个空洞的指针。lastfree在创建hash表的时候指向hash表最后一个元素。通过getfreepos可以知道hash表究竟还有没有空洞,如果没有的话,就要调用rehash来重新调整哈希表的大小了:

static void rehash (lua_State *L, Table *t, const TValue *ek) {

int nasize, na;

int nums[MAXBITS+1]; /* nums[i] = number of keys between 2^(i-1) and 2^i */

int i;

int totaluse;

for (i=0; i<=MAXBITS; i++) nums[i] = 0; /* reset counts */

nasize = numusearray(t, nums); /* count keys in array part */

totaluse = nasize; /* all those keys are integer keys */

totaluse += numusehash(t, nums, &nasize); /* count keys in hash part */

/* count extra key */

nasize += countint(ek, nums);

totaluse++;

/* compute new size for array part */

na = computesizes(nums, &nasize);

/* resize the table to new computed sizes */

resize(L, t, nasize, totaluse - na);

}

rehash中,nums是用来统计key的数量分布的,他的定义也说的很清楚

int nums[MAXBITS+1]; /* nums[i] = number of keys between 2^(i-1) and 2^i */

numusearray和numusehash的目的是统计数组和hash表部分key的使用情况,把它更新到nums里面去。然后根据nums计算要申请多大的数组部分以及多大的hash表,算法在computesizes处:

static int computesizes (int nums[], int *narray) {

int i;

int twotoi; /* 2^i */

int a = 0; /* number of elements smaller than 2^i */

int na = 0; /* number of elements to go to array part */

int n = 0; /* optimal size for array part */

for (i = 0, twotoi = 1; twotoi/2 < *narray; i++, twotoi *= 2) {

if (nums[i] > 0) {

a += nums[i];

if (a > twotoi/2) { /* more than half elements present? */

n = twotoi; /* optimal size (till now) */

na = a; /* all elements smaller than n will go to array part */

}

}

if (a == *narray) break; /* all elements already counted */

}

*narray = n;

lua_assert(*narray/2 <= na && na <= *narray);

return na;

}

nums就是key的分布情况,narray是所有key里面,可以放在array里面的key的个数(array大小最大只能到2^26,并且如果key不是整数不能放在array里)。computesizes的算法,简单来说就是找出一个最小的i,使其满足小于2^i的key的个数大于2^(i-1),这个2^i就是数组部分的大小。如果找不到满足条件的i,数组部分长度为0。这样做的话,就保证了数组部分包含尽量多的元素,同时使用率在一半以上。

computesizes算完之后就调用resize把hash表和array都重新分配一次,lua的resize和redis相比简单很多,一次过就把元素都重新插入到新的hash表和数组里面去了。注意:由于插入元素会导致rehash,rehash会重新调整元素是放在数组还是放在hash表,所以一个元素无论原来是在数组还是hash表,rehash后都不能确定它是在数组还是hash表,这就说明了为啥对一个当作数组使用的table的指定key赋值后,ipairs遍历这个table的结果通常不靠谱。

table的查找:

/*

** main search function

*/

const TValue *luaH_get (Table *t, const TValue *key) {

switch (ttype(key)) {

case LUA_TNIL: return luaO_nilobject;

case LUA_TSTRING: return luaH_getstr(t, rawtsvalue(key));

case LUA_TNUMBER: {

int k;

lua_Number n = nvalue(key);

lua_number2int(k, n);

if (luai_numeq(cast_num(k), nvalue(key))) /* index is int? */

return luaH_getnum(t, k); /* use specialized version */

/* else go through */

}

default: {

Node *n = mainposition(t, key);

do { /* check whether `key' is somewhere in the chain */

if (luaO_rawequalObj(key2tval(n), key))

return gval(n); /* that's it */

else n = gnext(n);

} while (n);

return luaO_nilobject;

}

}

}

如果key是string就调用luaH_getstr,如果是整数就调用luaH_getnum,默认的情况下找到key所在的节点,然后找到节点指向的链表中这个key的位置返回。luaH_getstr和luaH_getnum其实也是这个过程,只不过对string和number的哈希算法不同,number也有可能会放在数组部分而已。

table的遍历:

table的遍历分为ipairs和pairs,ipairs遍历数组部分,pairs遍历整个table。ipairs遍历顺序就是从0开始一次加1往后遍历table的数组部分。pairs的遍历实际上是调用luaH_next:

int luaH_next (lua_State *L, Table *t, StkId key) {

int i = findindex(L, t, key); /* find original element */

for (i++; i < t->sizearray; i++) { /* try first array part */

if (!ttisnil(&t->array[i])) { /* a non-nil value? */

setnvalue(key, cast_num(i+1));

setobj2s(L, key+1, &t->array[i]);

return 1;

}

}

for (i -= t->sizearray; i < sizenode(t); i++) { /* then hash part */

if (!ttisnil(gval(gnode(t, i)))) { /* a non-nil value? */

setobj2s(L, key, key2tval(gnode(t, i)));

setobj2s(L, key+1, gval(gnode(t, i)));

return 1;

}

}

return 0; /* no more elements */

}

算法一目了然,先查数组部分,然后查哈希表,找到当前key的下一个key。所以遍历的结果是先遍历数组部分,然后遍历哈希表部分,哈希表部分实际上就是把node顺序遍历一次。

分析到这里,已经基本知道原因了。number在lua里面是用double表示的,100001,100002,100003,100004,100005必定不能满足数组部分50%使用率的要求,所以都是放在哈希表里面。容纳这5个元素的哈希表的大小是8。lua的哈希number的哈希算法如下所示:

/*

** hash for lua_Numbers

*/

static Node *hashnum (const Table *t, lua_Number n) {

unsigned int a[numints];

int i;

if (luai_numeq(n, 0)) /* avoid problems with -0 */

return gnode(t, 0);

memcpy(a, &n, sizeof(a));

for (i = 1; i < numints; i++) a[0] += a[i];

return hashmod(t, a[0]);

}

其中hashmod是计算a[0]%(哈希表大小-1)然后取t中这个位置的元素。所以100005之所以排在第一位是因为hashmod中计算出来100005的位置比其他元素的位置靠前,所以他放在靠前的位置,这样在pairs的时候,会首先遍历到100005。至此谜底完全解开。

http://yulinlu.blog.163.com/blog/static/5881569820117351731462/