深入Lua:Table的实现

£可爱£侵袭症+ 提交于 2021-02-04 04:04:54

Table的结构

Lua和其他语言最不同的地方在于,它只有一个叫表的数据结构:这是一个数组和哈希表的混合体。神奇的地方在于只通过表,就可以实现模块,元表,环境,甚至面向对象等功能。这让我们很好奇它内部的结构到底是怎么样的。

它的结构定义在lobject.h中,是这样的:

typedef struct Table {  // 这是一个宏,为GCObject共用部分,展开后就是:  // GCObject *next; lu_byte tt; lu_byte marked  // 所有的GC对象都以这个开始  CommonHeader;     
// 和快速判断元方法有关,这里可以先略过 lu_byte flags;
// 哈希部分的长度对数:1 << lsizenode 才能得到实际的size lu_byte lsizenode; // 数组部分的长度 unsigned int sizearray;
// 数组部分,为TValue组成的数组 TValue *array; // 哈希部分,为Node组成的数组,见下面Node的说明 Node *node;
// lastfree指明空闲槽位 Node *lastfree; // 元表:每个Table对象都可以有独立的元表,当然默认为NULL struct Table *metatable;
// GC相关的链表,这里先略过 GCObject *gclist;} Table;

现在我们只需要关注Table是由数组(array)和哈希表(node)两部分组成即可,哈希表是一个由Node组成的数组,Node包括Key和Value,Node结构如下:

typedef struct Node {  TValue i_val;     // value为TValue  TKey i_key;       // key为TKey,看下面} Node;
// TKey其实是一个联合,它可能是TValue,也可能是TValue的内容再加一个next字段。typedef union TKey { // 这个结构和TValue的差别只是多了一个next,TValuefields是一个宏,见下面 struct { // 这部分和TValue的内存是一样的 TValuefields; // 为了实现冲突结点的链接,当两个结点的key冲突后,用next把结点链接起来 int next; /* for chaining (offset for next node) */ } nk; TValue tvk;} TKey;
// TValue包括的域#define TValuefields Value value_; int tt_

Table的哈希表也是链接法,但它并不会动态创建结点,它把所有结点都放在Node数组中,然后用TKey中的next字段,把冲突的结点连接起来。这样的哈希表就非常的紧凑,只要一块连续的内存即可。请看下图:



黄色的结点表示非nil的值,白色的结点表示nil值(也就是空闲结点)。

0,6,7号结点的关系是:这三个结点的Key计算出来的槽位都落在0号,但因为0号被优先占据,所以另外两个只能另外找空地,就找到6, 7号位置,然后为了表现他们的关系,用next表示这个结点到下一个结点的偏移。

新建Table

Table的实现代码在ltable.h|c,其中luaH_new函数创建一个空表:

Table *luaH_new (lua_State *L) {  // 创建Table的GC对象  GCObject *o = luaC_newobj(L, LUA_TTABLE, sizeof(Table));  Table *t = gco2t(o);  // 元表相关  t->metatable = NULL;  t->flags = cast_byte(~0);  // 数组部分初始化空  t->array = NULL;  t->sizearray = 0;  // 哈希部分初始化空  setnodevector(L, t, 0);  return t;}

Table取值

取值的函数有luaH_getint, luaH_getshortstr, luaH_getstr, luaH_get,其中luaH_getint会涉及到数组部分和哈希部分,代码如下:

const TValue *luaH_getint (Table *t, lua_Integer key) {  // key在[1, sizearray)时在数组部分  // key<=0或key>=sizearray则在哈希部分  if (l_castS2U(key) - 1 < t->sizearray)    return &t->array[key - 1];  else {    // 1. 这里是哈希部分,整型直接key & nodesize得到数组索引,取出结点地址返回    Node *n = hashint(t, key);    for (;;) {      // 2. 比较该结点的key相等(同为整型且值相同),是则返回值      if (ttisinteger(gkey(n)) && ivalue(gkey(n)) == key)        return gval(n);  /* that's it */      else {        // 3. 如果不是,通过上面所说的next取链接的下一个结点        // 4. 因为是相对偏移,所以只要n+=nx即可得到连接的结点指针,再回到2        int nx = gnext(n);        if (nx == 0) break;        n += nx;      }    }    // 5. 如果找不到,就还回nil对象    return luaO_nilobject;  }}

luaH_getstr他luaH_get最终可能调用到getgeneric这个函数,这个函数也只是查找哈希部分,代码如下:

static const TValue *getgeneric (Table *t, const TValue *key) {  // mainposition函数通过key找到“主位置”的结点,  // 意思是用key算出Node数组的索引,从那个索引取出结点,  // 相当于上图中编号为6或7中结点的key取出的主位置结点是0号  Node *n = mainposition(t, key);  // 1. 初始的n就是主位置结点  for (;;) {  /* check whether 'key' is somewhere in the chain */    // 2. 判断n的key是否和参数key相等,相等那就是这个结点,luaV_rawequalobj根据不同类型    // 做不同处理    if (luaV_rawequalobj(gkey(n), key))      return gval(n);  /* that's it */    else {      // 3. 否则取链接的下一个结点的偏移      int nx = gnext(n);      // 4. 无偏移,说明没有下一个结点,直接返回nil对象      if (nx == 0)        return luaO_nilobject;  /* not found */      // 5. 取下一个结点给n,循环到第2      n += nx;    }  }}

Table设值

设值的逻辑比取值要复杂得多,因为涉及到空间不够要重建表的内容。对外接口主要luaH_setluaH_setint,之所以分出一个int函数当然是因为要处理数组部分,先来看这个函数:

void luaH_setint (lua_State *L, Table *t, lua_Integer key, TValue *value) {  // 1. 先取值  const TValue *p = luaH_getint(t, key);  TValue *cell;  // 2. 不为nil对象即是取到,保存在cell变量。  if (p != luaO_nilobject)    cell = cast(TValue *, p);  else {    // 3. 初始化一个TValue的key,然后调用luaH_newkey新建一个key,并返key关联的value到cell    TValue k;    setivalue(&k, key);    cell = luaH_newkey(L, t, &k);  }  // 最后将新value赋值给cell  setobj2t(L, cell, value);}

luaH_newkey函数的主要逻辑:

  1. 这个函数的主要功能将一个key插入哈希表,并返回key关联的value指针。

  2. 首先通过key计算出主位置,如果主位置为空结点那最简单,将key设进该结点,然后返回结点的值指针。如果不是空结点就要分情况,看3和4两种情况

  3. 如果该结点就是主位置结点,那么要另找一个空闲位置,把Key放进去,和主结点链接起来,然后返回新结点的值指针。

  4. 如果该结点不是主位置结点,把这个结点移到空闲位置去;然后我进驻这个位置,并返回结点的值指针。

这样说好像也难以理解,没关系,用几张图来说明:

情况2的:



情况3的,虚线是本来要插入的位置,实线是最终插入的位置,黄线是结点链接。



情况4的,Key要插入7号位置,7号结点移到6号,然后key进入7号位置。



现在来看函数代码应该就好懂了,函数代码经过精简:

TValue *luaH_newkey (lua_State *L, Table *t, const TValue *key) {  Node *mp;  TValue aux;  // 计算主位置  mp = mainposition(t, key);  // 主位置被占,或者哈希部分为空  if (!ttisnil(gval(mp)) || isdummy(t)) {    Node *othern;    // 找空闲位置,这里还涉及到没空闲位置会重建哈希表的操作,下一节说    Node *f = getfreepos(t);    if (f == NULL) {      rehash(L, t, key);      return luaH_set(L, t, key);    }    // 通过主位置这个结点的key,计算出本来的主位置结点    othern = mainposition(t, gkey(mp));    if (othern != mp) {      // 这种就对应上面说的情况4的处理,把结点移到空闲位置去      // 移动之前,要先把链接结点的偏移调整一下      while (othern + gnext(othern) != mp)  /* find previous */        othern += gnext(othern);      gnext(othern) = cast_int(f - othern);  /* rechain to point to 'f' */      // 把冲突结点移到空闲位置      *f = *mp;  /* copy colliding node into free pos. (mp->next also goes) */      // 如果冲突结点也有链接结点,也要调整过来      if (gnext(mp) != 0) {        gnext(f) += cast_int(mp - f);  /* correct 'next' */        gnext(mp) = 0;  /* now 'mp' is free */      }      setnilvalue(gval(mp));    }    else {      // 这是对应上面说的情况3      /* new node will go into free position */      if (gnext(mp) != 0)        gnext(f) = cast_int((mp + gnext(mp)) - f);  /* chain new position */      else lua_assert(gnext(f) == 0);      gnext(mp) = cast_int(f - mp);      mp = f;    }  }  // 到这里可以将key赋值给结点,并返回结点的值指针   setnodekey(L, &mp->i_key, key);  luaC_barrierback(L, t, key);  lua_assert(tti
snil(gval(mp)));
return gval(mp);
}

从上面看整个逻辑最复杂的部分就是结点链接的调整。

getfreepos函数用于找空闲结点,Table结构中有一个lastfree变量,它刚开始指向结点数组的最后,getfreepos使lastfree不断向前移,直到找到空闲的结点:

static Node *getfreepos (Table *t) {  if (!isdummy(t)) {    while (t->lastfree > t->node) {      t->lastfree--;      if (ttisnil(gkey(t->lastfree)))        return t->lastfree;    }  }  return NULL;  /* could not find a free place */}

如果lastfree移到数组最前面,说明找不到空闲结点,会返回空,这时开始重建Table。说明找不到空闲结点,其实是有可能存在空闲结点的,比如lastfree后面的结点如果被设置为nil,lastfree就没法知道了,因为它总是往前移,不管的后面结点。不管如何,只要移到数组最前面,就开始重建表。

Table重建

rehash函数要确定有多少整型key,并决定这些整型key有多少值放到数组部分去,然后剩下的值放到哈希部分,最后有可能会缩减空间,也可能会扩大空间。

我们把它分拆出来一步步看,先来看一些辅助函数:

  • 统计数组部分有多少个非nil值:

na = numusearray(t, nums);

na是非nil值(有效值)的数量,nums是一个数组,里面统计着各个范围内的有效值数量,类似下图这样:



nums会决定最后数组的大小

  • 统计哈希表部分的值数量,以及整数key的一些信息:

totaluse = na;totaluse += numusehash(t, nums, &na);

totaluse是有效值的总数量,nums是上面那个范围统计数组,na是整型key的值数量;最终得到几个有用的信息:

totaluse 有效值的总数量
na 整型key的有效值数量
nums 整型key的分布范围  
  • 有了这些信息,接下来就要计算出数组的尺寸:

asize = computesizes(nums, &na);

asize是计算后的数组大小,na返回多少个整型key的值进入数组部分。

asize总是为2的幂,而computesizes的目的是使数组的有效值尽可能密集,能超过数组大小的一半。

  • 得到数组的大小和哈希表的大小后,就可以重建Table:

luaH_resize(L, t, asize, totaluse - na);

上面所描述的步骤就是rehash做的事情,luaH_resize我尝试从源代码来解释:

void luaH_resize (lua_State *L, Table *t, unsigned int nasize,                                          unsigned int nhsize) {  unsigned int i;  int j;  AuxsetnodeT asn;  unsigned int oldasize = t->sizearray;  int oldhsize = allocsizenode(t);  Node *nold = t->node;  // 先把老的Node数组保存起来  // 如果数组尺寸变大,调用setarrayvector扩充  if (nasize > oldasize)    setarrayvector(L, t, nasize);  // 创建新的Node数组,我把代码简化了,lastfree会在这里重新指向数组尾  // node数组的大小为nhsize向上取整为2的幂  setnodevector(L, t, nhsize);  // 如果数组尺寸变小  if (nasize < oldasize) {  /* array part must shrink? */    t->sizearray = nasize;    // 将超出那部分移到哈希表去    for (i=nasize; i<oldasize; i++) {      if (!ttisnil(&t->array[i]))        luaH_setint(L, t, i + 1, &t->array[i]);    }    // 重设数组大小    luaM_reallocvector(L, t->array, oldasize, nasize, TValue);  }  // 将上面保存的Node数组的值,设回新的Node数组  for (j = oldhsize - 1; j >= 0; j--) {    Node *old = nold + j;    if (!ttisnil(gval(old))) {      setobjt2t(L, luaH_set(L, t, gkey(old)), gval(old));    }  }  // 最后释放老的Node数组  if (oldhsize > 0)  /* not the dummy node? */    luaM_freearray(L, nold, cast(size_t, oldhsize)); /* free old hash */}

重建表涉及到内容的搬迁,特别是哈希部分,如果有一张大表经常导致rehash,那么效率应该是很受影响的。

Table遍历

Table的遍历是由luaH_next函数实现:

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

它根据key先遍历数组,再遍历哈希表,比如数组部分key一直加1遍历,哈希部分是根据Key找到Node数组的位置往后遍历。

这会带来一个什么问题呢?如果Table的空洞很多,它的遍历效率一定会非常慢的,可以用下面的例子验证:

local function make_table()  local t = {}  local size = 10000000  for i = 1, size do    t[tostring(i)] = i  end  for i = 1, size-1 do    t[tostring(i)] = nil  end  return tend
local function test_pairs(t) local tm = os.clock() for i = 1, 10000 do for k, v in pairs(t) do end end tm = os.clock() - tm print("time=", tm)end
test_pairs(make_table())

上例的表先设置1千万个Key,然后删除成只有1个Key,此时遍历这个只有1个Key的表,会花费将近24S的时间,这给我们一个经验,一定要防止很多空洞的表出现。当然如果rehash之后会变正常,但rehash也会有很大的性能消耗的。

哈希表的主位置结点

上面代码多次看到mainposition这个函数,它的作用是根据Key计算出Node数组的槽位,并返回该槽位的结点指针来。因为Key可以是除了nil外的任何类型,所以Key的哈希值要分情况计算:

static Node *mainposition (const Table *t, const TValue *key) {  switch (ttype(key)) {    case LUA_TNUMINT:      return hashint(t, ivalue(key));    case LUA_TNUMFLT:      return hashmod(t, l_hashfloat(fltvalue(key)));    case LUA_TSHRSTR:      return hashstr(t, tsvalue(key));    case LUA_TLNGSTR:      return hashpow2(t, luaS_hashlongstr(tsvalue(key)));    case LUA_TBOOLEAN:      return hashboolean(t, bvalue(key));    case LUA_TLIGHTUSERDATA:      return hashpointer(t, pvalue(key));    case LUA_TLCF:      return hashpointer(t, fvalue(key));    default:      lua_assert(!ttisdeadkey(key));      return hashpointer(t, gcvalue(key));  }}
  • LUA_TNUMINT为整数,i % (size -1)即得到槽位,因为size是2的幂,所以减1才能减少冲突的概率。

  • LUA_TNUMFLT为浮点数,它不是强制转成整数,因为整数未必可以表示浮点数。它是用浮点数中的尾数放大到INT_MAX范围内的整数,再加上其指数,最后得到一个无符数的整数。

  • LUA_TSHRSTR为短字符串,因为短字符串的哈希值早已计算出,所以直接用它的哈希值得到槽位即可。

  • LUA_TLNGSTR为长字符串,长串用惰性求哈希值的方式,第1次要计算一次哈希值,计算完保存到TString结构中,以后直接用即可。其哈希值的计算方法不是遍历所有字节,这样如果遇到巨大的字符串可能有效率问题,它是从串中平均采出最多32个字节来计算的,这样最多就遍历32次。

  • LUA_TBOOLEAN为布尔值,由于C的布尔值其实就是整数,所以和整数处理方式一样。

  • LUA_TLIGHTUSERDATA为完全用户数据,它用数据的地址来求哈希值。

  • LUA_TLCF为轻量C函数,它用函数地址来求哈希值。

  • 其他的GC对象,用它们的地址来求哈希值。

Lua代码中处处有技巧,比如上面的长字符串求哈希值,建议直接阅读一下luaS_hashlongstr这个函数。

关于Table的思考

我们一步步地分析了Table的实现,确实也惊讶于其结构的紧凑。但是,从我个人观点看,Lua的Table并非是一个好的设计,其复杂性的根源在于混合了哈希表和数组,看似想用最少的数据结构做最多的事情,其实内部实现和上层应用都变复杂了,违返了单一职责原则。

假如Lua把Table中的数组部分分离出来,写成一个单独类型的对象,这样Table的逻辑会很清晰,也很易于优化。



往期精选

Unity3D游戏开发中100+效果的实现和源码大全 - 收藏起来肯定用得着

Shader学习应该如何切入?


喵的Unity游戏开发之路 - 从入门到精通的学习线路和全教程



声明:发布此文是出于传递更多知识以供交流学习之目的。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与我们联系,我们将及时更正、删除,谢谢。

作者:co lin

原文:https://zhuanlan.zhihu.com/p/97830462



More:【微信公众号】 u3dnotes



本文分享自微信公众号 - Unity3D游戏开发精华教程干货(u3dnotes)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!