lua-table-header-image

Lua 表源码解析

本周继续我们的 Lua 源码阅读,这次来到了 table 的源码。Lua 里的 table 可兼具 arrayhashmap 的功能,紧凑精悍。

初始化

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;
}

借助 luaM_new 从内存中分配出一块新内存,然后主要分为 array 数组部分与 node 哈希表部分分别进行初始化。

数组部分的初始化发生在 setarrayvector 函数中:

static void setarrayvector (lua_State *L, Table *t, int size) {
  int i;
  luaM_reallocvector(L, t->array, t->sizearray, size, TValue);
  for (i=t->sizearray; i<size; i++)
     setnilvalue(&t->array[i]);
     // Expands to: (&t->array[i])->tt = LUA_TNIL;
  t->sizearray = size;
}

流程很简单,先 realloc 数组部分的内存,调整到预期大小,然后逐项置为 nil,并设置好 sizearray,就完成了数组部分的初始化。

哈希表这一部分发生在 setnodevector 函数中:

static void setnodevector (lua_State *L, Table *t, int size) {
  int lsize;
  // 如果哈希表预期长度为 0,填入一个全局唯一的 dummynode
  // 借助 dummynode,可以避免空表的特例判断,也可以避免 lsizenode 出现负值(2 ** 0 = 1)
  if (size == 0) {  /* no elements to hash part? */
    t->node = cast(Node *, dummynode);  /* use common `dummynode' */
    lsize = 0;
  }
  else {
    int i;
    lsize = ceillog2(size);
    if (lsize > MAXBITS)
      luaG_runerror(L, "table overflow");
    size = twoto(lsize);
    // 以上几步,先进行 log2,然后再做 pow2
    // 可以将 size 转换为比 size 大且为 2 的整数次幂中最小的数
    // 备注:这里的 ceillog2 并不是标准的数学意义上的 log2,做了一点小变换

    t->node = luaM_newvector(L, size, Node);
    // 初始化每个哈希表成员
    for (i=0; i<size; i++) {
      Node *n = gnode(t, i);
      // Expands to: (&(t)->node[i])
      // 将当前节点的 next 指针置 NULL, 将 key/value 置 nil
      gnext(n) = NULL;
      setnilvalue(gkey(n));
      setnilvalue(gval(n));
    }
  }
  // 存放尺寸和lastfree指针指向最后一个元素
  t->lsizenode = cast_byte(lsize);
  // lastfree 指针被用于后边哈希表插入的逻辑中,指向最后一个空闲的哈希表节点
  t->lastfree = gnode(t, size);  /* all positions are free */
}

如果哈希表的预期大小为 0,则以一个全局结构体 dummynode 作为占位符填入 node 字段;否则先将 size 向上扩展到最邻近的 2 的整数次幂,然后分配内存,逐个初始化哈希表里的每一个节点。

经过初始化之后,一个空的 table 长这个样子:

空 table 的内部结构

数据结构

了解了初始化过程后,我们再回过头来看看 Lua 内部的数据结构:

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;

CommonHeader、TValue 在之前的文章已做过分析;数组、哈希表的存储主要涉及的四个字段分别为 arraysizearray,以及 nodelsizenode。(注意其中 lsizenode 比较特殊,是实际哈希表长度的 log2。)

接着来看看组成哈希表的 Node 结构体:

typedef union TKey {
  struct {
    TValuefields;
    struct Node *next;  /* for chaining */
  } nk;
  TValue tvk;
} TKey;

typedef struct Node {
  TValue i_val;
  TKey i_key;
} Node;

keyvalue 构成,其中 value 就是普通的 TValue 类型,而 key 是一个 union。多了 nk 这一层套娃的结构体主要是为了提供 next 字段,用以将哈希表形成链,后文会阐述。

数组的插入

以这段代码为例:

local t = {}
t[1] = "1111"
t[2] = "2222"
t[3] = "3333"
t[4] = "4444"

其字节码为:

002B  0A000000           [1] newtable   0   0   0    ; array=0, hash=0
002F  09404080           [2] settable   0   256 257  ; 1 "1111"
0033  09C04081           [3] settable   0   258 259  ; 2 "2222"
0037  09404182           [4] settable   0   260 261  ; 3 "3333"
003B  09C04183           [5] settable   0   262 263  ; 4 "4444"
003F  1E008000           [6] return     0   1

我们分别分析一下几次 settable 背后的流程。

首次插入

进入到 settable opcode 的对应逻辑:

void luaV_execute (lua_State *L, int nexeccalls) {
    // ...
    switch (GET_OPCODE(i)) {
      // ...
      case OP_SETTABLE: {
        Protect(luaV_settable(L, ra, RKB(i), RKC(i)));
        continue;
      }
void luaV_settable (lua_State *L, const TValue *t, TValue *key, StkId val) {
  int loop;
  for (loop = 0; loop < MAXTAGLOOP; loop++) {
    const TValue *tm;
    if (ttistable(t)) {  /* `t' is a table? */
      // 首先获取它的hash部分
      Table *h = hvalue(t);
      TValue *oldval = luaH_set(L, h, key); /* do a primitive set */
      if (!ttisnil(oldval) ||  /* result is no nil? */
          (tm = fasttm(L, h->metatable, TM_NEWINDEX)) == NULL) { /* or no TM? */
        // 替换原来的旧值
        setobj2t(L, oldval, val);
        luaC_barriert(L, h, val);
        return;
      }
      /* else will try the tag method */
    }
    else if (ttisnil(tm = luaT_gettmbyobj(L, t, TM_NEWINDEX)))
      luaG_typeerror(L, t, "index");
    if (ttisfunction(tm)) {
      callTM(L, tm, t, key, val);
      return;
    }
    t = tm;  /* else repeat with `tm' */ 
  }
  luaG_runerror(L, "loop in settable");
}

最终调用的是 luaH_set 函数,我们继续一路跟进去:

TValue *luaH_set (lua_State *L, Table *t, const TValue *key) {
  const TValue *p = luaH_get(t, key);
  t->flags = 0;
  if (p != luaO_nilobject)
    return cast(TValue *, p);
  else {
    if (ttisnil(key)) luaG_runerror(L, "table index is nil");
    else if (ttisnumber(key) && luai_numisnan(nvalue(key)))
      luaG_runerror(L, "table index is NaN");
    return newkey(L, t, key);
  }
}

luaH_get 是用来在 table 中执行查询的,后文会解析。先尝试用这个函数在表中查询,如果查到了,直接返回原有指针,然后由外部的调用者将旧值覆盖即可(可见上边的 luaV_settable 函数);如果未查到,则进入 newkey 流程,这里我们继续跟进去:

static TValue *newkey (lua_State *L, Table *t, const TValue *key) {
  // 计算 key 在哈希表中的对应位置
  Node *mp = mainposition(t, key);
  // 如果该位置上已经有数据了,或者哈希表为空
  if (!ttisnil(gval(mp)) || mp == dummynode) {
    Node *othern;
    // 尝试寻找一个空闲位置
    Node *n = getfreepos(t);  /* get a free place */
    // 找不到空闲位置即进入 rehash 流程
    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 */
    }
    // ... 省略
  }
  gkey(mp)->value = key->value; gkey(mp)->tt = key->tt;
  luaC_barriert(L, t, key);
  lua_assert(ttisnil(gval(mp)));
  return gval(mp);
}

先借助 mainposition 计算出 key 应该对应到哈希表中的什么位置。由于哈希表目前还是空的(只存在一个 dummynode),将进入 if 分支。但是现在哈希表还是实在太空,在 getfreepos 中也找不到合适的空闲位置:

static Node *getfreepos (Table *t) {
  // while 的条件进不去,因为此时 t->lastfree == t->node
  while (t->lastfree-- > t->node) {
    if (ttisnil(gkey(t->lastfree)))
      return t->lastfree;
  }
  return NULL;  /* could not find a free place */
}

所以我们将进入 rehash 流程,在完成 rehash 之后,将重新调用 luaH_set 以完成这一次的 settable 操作。

由于 rehash 函数的代码比较难理解,我们就不详细分析代码了。简要来说,这个函数会计算出新的合适的数组大小与哈希表大小,然后调用 resize 函数来腾移数据。计算过程基于以下规则:先找到所有 key 为数字的元素,然后尝试调整数组大小 nasize = 2 ** n,把 key 为数组的元素按序尝试放置到数组中,使得新数组的空置比例低于 50%,而且数组的大小要尽可能大。剩下的元素,就将被划分到哈希表中。

因此,在一个 table 的不同阶段下,对于同一个元素,其被放置于数组部分还是在哈希表部分是可能发生变化的。

接着我们进入 resize 函数:

static void resize (lua_State *L, Table *t, int nasize, int nhsize) {
  int i;
  int oldasize = t->sizearray;
  int oldhsize = t->lsizenode;
  Node *nold = t->node;  /* save old hash ... */
  // 如果数组发生了扩容,则进行 realloc,并将多出来的部分 set nil
  if (nasize > oldasize)  /* array part must grow? */
    setarrayvector(L, t, nasize);
  /* create new hash part with appropriate size */
  setnodevector(L, t, nhsize);
  // 如果数组发生了缩容,则遍历少掉的那一部分,插入到哈希表中
  if (nasize < oldasize) {  /* array part must shrink? */
    t->sizearray = nasize;
    /* re-insert elements from vanishing slice */
    for (i=nasize; i<oldasize; i++) {
      // 如果多出来的部分元素不为nil
      if (!ttisnil(&t->array[i]))
        setobjt2t(L, luaH_setnum(L, t, i+1), &t->array[i]);
    }
    /* shrink array */
    luaM_reallocvector(L, t->array, oldasize, nasize, TValue);
  }
  /* re-insert elements from hash part */
  // 由于哈希表的长度发生了改变,因此所有数据都需要重新插入一遍
  for (i = twoto(oldhsize) - 1; i >= 0; i--) {
    Node *old = nold+i;
    // 将原来不为nil的元素重新插入hash中
    if (!ttisnil(gval(old)))
      setobjt2t(L, luaH_set(L, t, key2tval(old)), gval(old));
  }
  // 释放旧的hash部分
  if (nold != dummynode)
    luaM_freearray(L, nold, twoto(oldhsize), Node);  /* free old array */
}

resize 函数负责真正地调整数组、哈希表的大小,以及数据的腾移。完成 resize 之后,我们将逐层原路返回,回到 newkey 中,调用 luaH_set 重新完成新元素的插入。此时,我们计算得到的数组长度与哈希表长度应该分别为 10,基于这个前提,我们重新回顾下 luaH_set

TValue *luaH_set (lua_State *L, Table *t, const TValue *key) {
  // 此时 luaH_get 已经能成功从数组部分取得元素了
  // 返回的是一个 nil 值
  const TValue *p = luaH_get(t, key);
  t->flags = 0;
  // 但是那个 nil 值并非 luaO_nilobject 这个全局统一的 nil 实例
  // (两个指针地址不同)
  // 因此这里可以进入 cast 分支,直接将数组部分的第一个元素给递交了出去
  if (p != luaO_nilobject)
    return cast(TValue *, p);
  else {
    if (ttisnil(key)) luaG_runerror(L, "table index is nil");
    else if (ttisnumber(key) && luai_numisnan(nvalue(key)))
      luaG_runerror(L, "table index is NaN");
    return newkey(L, t, key);
  }
}

整个调用链路的示意图:

首次数组插入的调用链路

完成上述操作后,我们的表在内存里应当长这样:

首次数组插入后的内部结构

其他几次插入

第二次插入时,sizearray1,仍然需要走一遍跟第一次一致的扩容过程。

第三次插入时,sizearray2,还是需要走一遍扩容过程,将数组部分扩容到 4

第四次插入时,sizearray4,无需扩容了,直接替换原本第 3nil 元素即可。

初始化时插入

如果我们选择在声明变量时就已经将取值准备完毕,将进入另外一种稍微不同的流程里。如以下代码:

local t = {
    "value 1",
    "value 2",
    "value 3",
    "value 4",
}
002B  0A000002           [1] newtable   0   4   0    ; array=4, hash=0
002F  41000000           [2] loadk      1   0        ; "value 1"
0033  81400000           [3] loadk      2   1        ; "value 2"
0037  C1800000           [4] loadk      3   2        ; "value 3"
003B  01C10000           [5] loadk      4   3        ; "value 4"
003F  22400002           [6] setlist    0   4   1    ; index 1 to 4
0043  1E008000           [7] return     0   1

opcode 发生了变化。与之前不一样的是,这次是先将几个常量载入到寄存器中,然后调用 setlist 完成插入。setlist 简单来说,就是把从 1B 的寄存器按顺序批量插入到 A 之中,C 是当前的批次编号(后续解析到虚拟机的部分我们再仔细理一理)。我们来看 setlist 的代码:

      case OP_SETLIST: {
        int n = GETARG_B(i);  // to
        int c = GETARG_C(i);  // from
        int last;
        Table *h;
        // ... 省略无关代码
        runtime_check(L, ttistable(ra));
        h = hvalue(ra);
        last = ((c-1)*LFIELDS_PER_FLUSH) + n;
        if (last > h->sizearray)  /* needs more space? */
          luaH_resizearray(L, h, last);  /* pre-alloc it at once */
        for (; n > 0; n--) {
          TValue *val = ra+n;
          setobj2t(L, luaH_setnum(L, h, last--), val);
          luaC_barriert(L, h, val);
        }
        continue;
      }

可以看到,首先是调用 luaH_resizearray 先把数组部分进行扩容,然后按顺序一个一个 luaH_setnum。整个过程至多只会 resize 一次,十分高效。

如上文的分析,如果一个一个地插入,Lua 会发生比较频繁的 resizerehash 过程,性能损耗较大。所以,如果我们需要一个比较大的数组,最好不要从空表开始逐个插入,应该在创建的时候预先分配好空间(如使用一些特殊值进行占位),或者借助 LuaJIT 的 table.new 指定数组、哈希表部分的大小。

哈希的插入

首次插入

哈希表的插入流程也是自 settable 开始,然后依次进入到 luaV_settableluaH_setluaH_getnewkeyrehash,到目前为止,都跟数组的插入没有什么区别,直到 rehash 里计算到的 nasizenhsize 开始才产生差异。现在我们传递给 resize 的新的数组、哈希表大小中,数组大小无需变化,需要扩容的变成了哈希表:

static void resize (lua_State *L, Table *t, int nasize, int nhsize) {
  int i;
  int oldasize = t->sizearray;
  int oldhsize = t->lsizenode;
  Node *nold = t->node;  /* save old hash ... */
  // ... 省略
  setnodevector(L, t, nhsize);
  // ... 省略
  /* re-insert elements from hash part */
  // 由于哈希表的长度发生了改变,因此所有数据都需要重新插入一遍
  for (i = twoto(oldhsize) - 1; i >= 0; i--) {
    Node *old = nold+i;
    // 将原来不为nil的元素重新插入hash中
    if (!ttisnil(gval(old)))
      setobjt2t(L, luaH_set(L, t, key2tval(old)), gval(old));
  }
  // 释放旧的hash部分
  if (nold != dummynode)
    luaM_freearray(L, nold, twoto(oldhsize), Node);  /* free old array */
}

在扩容的同时也会做重新哈希、取模,目前我们的表还是空的,所以并没有什么特殊的事情发生。在 resize 结束之后,我们一路返回到 newkey,然后还是与数组的首次插入一致——再次调用 luaH_set 真正完成这次的插入操作。由于哈希表已被扩容,这一次调用 luaH_set 函数的过程中,luaH_get 能取到对应哈希表上的 nil 了,然后还是一样往上层返回,最终在 luaV_settable 函数中将 nil 取代为新的取值。

目前我们的 table 在内存里长这样:

首次哈希插入后的 table 内部结构

其他几次插入

第二次插入时,又发生了一遍扩容,然后还是老套路,扩容后重新调用 luaH_set 顺利完成插入;

第三次插入时,又发生了一遍扩容,哈希表的大小被扩大到 4,然后还是一样的老套路;

如此依次按 2 的幂次进行倍增。如果哈希表中数字类型的 key 比较多,也有可能发生数组的扩容,伴随着哈希表被缩容。在哈希表缩容的过程中,多出来的元素会重新调用 luaH_set,如果小于 sizearray,就会进入到数组部分之中。

在上述每一次插入时,都有可能发生哈希冲突,下面我们来具体讨论讨论哈希冲突的解决流程。为了描述简便,我们假设哈希表冲突发生在第次插入时。这时候哈希表的大小为 4,不会发生扩容,我们只需要专注于如何解决冲突。

假设我们的 table 当前状态如下:

哈希表冲突解析-初始状态

注意:由于哈希算法的存在,几个元素在这是乱序的。

假设元素 4 本来按照哈希算法应该插入到第个位置,但是从图中可以看到,这个位置已经被元素 3 给占用了,产生了哈希冲突。相应的处理流程发生在 newkey 函数里:

static TValue *newkey (lua_State *L, Table *t, const TValue *key) {
  // 计算 key 在哈希表中的对应位置
  Node *mp = mainposition(t, key);
  // 如果该位置上已经有数据了,或者哈希表为空
  if (!ttisnil(gval(mp)) || mp == dummynode) {
    Node *othern;
    // 尝试寻找一个空闲位置
    Node *n = getfreepos(t);  /* get a free place */
    // 找不到空闲位置即进入 rehash 流程
    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 */
    }

此时 mp 计算出来即为元素 3,不是 nil,因此进入 if 分支,先尝试借助 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 */
}

lastfreesetnodevector 时被设置为最后一个元素,也就是元素 3,很明显它不是空的,因此继续往上查找。刚好往上探了一次就找到了,于是此处返回第个元素(目前还是 nil)。得到这个可用的空闲节点后,我们继续 newkey 的流程:

    // ...
    lua_assert(n != dummynode);
    othern = mainposition(t, key2tval(mp));
    // 如果目前占用我们的那个元素,自身也是被其他元素占用了原本该有的位置
    if (othern != mp) {  /* is colliding node out of its main position? */
      // 那么将占用我们的元素整个挪到 freepos,然后自己鸠占鹊巢
      /* 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 */
      // 那么将自己放到 freepos 上,并链到已有元素的后边
      /* 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);
}

如注释所描述,分两种场景。概况起来,就是发生「一次冲突」时,使用类似线性散列探测的方法,从 freenode 开始,从后往前查找空闲节点,并将新元素链接到冲突节点的后边;「二次冲突(或更多)」时,不再追加成链的长度,而是直接把已成链的冲突节点挪开,自己站上自己本来就该有的位置。这样可以有效避免整个哈希表在频繁冲突时退化为非常长的链表。

我们假定目前只产生了「一次冲突」,也就是元素 1、2、3 都没有发生冲突,都在其原本该在的位置上。这种情况下,我们的新元素将被放置到 freenode 上,并链在自己原本该有的位置之后。经过哈希冲突处理后,我们的 table 内部状态应该长这样:

哈希表冲突解析-解决冲突后的内部状态

查询

啃完了插入这块硬骨头,剩下的功能都相对轻松。对于查询操作而言,逻辑很简单,最外层的入口为 luaH_get 函数:

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 */
      // 什么情况下会走串这条 case 语句呢
      // 就是在 key 是数字,但又不是整数的时候,如 table[3.1415] = "chenxy.me"
    }
    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 的类型进行分发,分别走到 luaH_getstrluaH_getnum,其他情况下就进入 default 流程。

default 流程与 luaH_getstr 逻辑基本一致,就是计算哈希,然后在哈希表中查找。如果发现哈希表对应节点的 key 不一致,而且又存在 next 字段,说明插入时发生了哈希冲突,需要依次在链上往后查找,直到此链结束未找到,或者中途找到了预期的 key,返回对应的 value

对于 luaH_getnum 函数,代码如下:

const TValue *luaH_getnum (Table *t, int key) {
  /* (1 <= key && key <= t->sizearray) */
  // 只要比 sizearray 小,那么都在数组部分
  if (cast(unsigned int, key-1) < cast(unsigned int, t->sizearray))
    return &t->array[key-1];
  
  // 否则在 hash 部分中
  else {
    lua_Number nk = cast_num(key);
    Node *n = hashnum(t, nk);
    do {  /* check whether `key' is somewhere in the chain */
      if (ttisnumber(gkey(n)) && luai_numeq(nvalue(gkey(n)), nk))
        return gval(n);  /* that's it */
      else n = gnext(n);
    } while (n);
    return luaO_nilobject;
  }
}

如果小于 sizearray,直接从数组部分取值。否则就从哈希表部分取值。注意到仍然需要处理哈希冲突的场景,代码与上边 defaultluaH_getstr 都是类似的。

长度计算

Lua 的长度计算,也就是 #t 并不完美,特别是表中存在 nil 时,很容易被糊弄。原因可以从代码中分析得到:

int luaH_getn (Table *t) {
  unsigned int j = t->sizearray;
  if (j > 0 && ttisnil(&t->array[j - 1])) {
	  // 如果数组的最后一个元素为 nil,那么在数组中二分查找「最后」一个 nil
    /* there is a boundary in the array part: (binary) search for it */
    unsigned int i = 0;
    while (j - i > 1) {
      unsigned int m = (i+j)/2;
      if (ttisnil(&t->array[m - 1])) j = m;
      else i = m;
    }
    return i;
  }
  // 如果数组部分的最后一个元素为非 nil……
  /* else must find a boundary in hash part */
  else if (t->node == dummynode)  /* hash part is empty? */
    // ……而且哈希表是空的,那么非常简单,返回数组大小即可
    return j;  /* that is easy... */
  // ……否则,在哈希表中(类似)二分查找「最后」一个 nil
  else return unbound_search(t, j);
}

分三种情况处理:

  • 如果数组的最后一个元素为 nil,则在数组部分二分查找「最后」一个 nil
-- 用这段代码来糊弄 Lua 吧!
local t = {}
t[1] = "yes"
t[3] = "yes"
t[4] = "yes"
t[5] = "yes"
t[7] = "yes"
print(#t)  -- 将输出 5

-- 可以借助 gdb 确认 sizearray 确实是 8:
-- $1 = {next = 0x555555598f00, tt = 5 '\005', marked = 1 '\001', flags = 0 '\000', lsizenode = 0 '\000', metatable = 0x0, array = 0x555555597ef0,
  node = 0x555555582ea0 <dummynode_>, lastfree = 0x555555582ea0 <dummynode_>, gclist = 0x4014000000000000, sizearray = 8}
  • 如果数组的最后一个元素非 nil,而且哈希表是空的,则返回数组大小
-- 用这段代码来糊弄 Lua 吧!
local t = {}
t[1] = "yes"
t[3] = "yes"
t[4] = "yes"
t[5] = "yes"
t[8] = "yes"
print(#t)  -- 将输出 8
-- 可以借助 gdb 确认 sizearray 确实是 8
  • 如果数组的最后一个元素非 nil,而且哈希表非空,那么在哈希表中「二分」查找最后一个 nil
-- 用这段代码来糊弄 Lua 吧!
local t = {}
t[1] = "yes"
t[2] = "yes"
t["yes"] = "no"
print(#t)  -- 将输出 2

总结

日常使用 Lua 表的过程中,可以多注意以下几点:

  • 数组部分与哈希表部分,默认都是按照 2 的幂次进行扩容、缩容;
  • 如果要构造一个大 table,不管是数组部分还是哈希表部分,最好借助 table.new 等工具进行预先分配;
  • 在一个 table 的整个生命周期中,同一个元素可能有时在数组部分,有时又在哈希表部分;
  • table 的长度计算很容易出现非预期结果,如果需要计算 table 的长度,最好不要出现 nil 值,也不要在数组部分出现「空洞」;
  • table 的长度计算在绝大多数情况下是有代价的,时间复杂度大约为 O(log N)

参考资料

题图来自:Photo by 五玄土 ORIENTO on Unsplash

Lua 表源码解析》有一个想法

  1. Pingback引用通告: Lua opcode 解析 | chenxy.me

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据