lua table實現原理(5.3源碼)

原文地址:https://blog.csdn.net/y1196645376/article/details/94348873

1.table的特性

1. 在Lua中table是個非常重要的類型,通過使用table的一些特性可以實現許多數據結構,例如map,array queue,stack等。
2. 通過使用者角度來講,table既可以當作array使用也可以當作map使用,那麼對於設計者來講,那麼需要保證table的高效率的查找、插入、遍歷。
3. 當然,table的設計者還提出了metatable(元表)的概念,以供使用者可以用來實現繼承、操作符重載等設計,不過metatable暫時不在這邊文章進行討論。

2.table的定義

typedef union TKey {
  struct {
    TValuefields;
    int next;  /* 用於標記鏈表下一個節點 */
  } nk;
  TValue tvk;
} TKey;

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


typedef struct Table {
  CommonHeader;
  lu_byte flags;  /* 1<<p means tagmethod(p) is not present */
  lu_byte lsizenode;  /* log2 of size of 'node' array */
  unsigned int sizearray;  /* size of 'array' array */
  TValue *array;  /* array part */
  Node *node;
  Node *lastfree;  /* any free position is before this position */
  struct Table *metatable;
  GCObject *gclist;
} Table;
  • flags : 元方法的標記,用於查詢table是否包含某個類別的元方法
  • lsizenode : (1<<lsizenode)表示table的hash部分大小
  • sizearray : table的數組部分大小
  • array : table的array數組首節點
  • node :table的hash表首節點
  • lastfree : 表示table的hash表空閒節點的遊標
  • metatable : 元表
  • gclist : table gc相關的參數

爲了提高table的插入查找效率,在table的設計上,採用了array數組和hashtable(哈希表)兩種數據的結合。

所以table會將部分整形key作爲下標放在數組中, 其餘的整形key和其他類型的key都放在hash表中。

3.hash表結構

在table中的實現中,hash表佔絕大部分比重,下面是table中hash表的結構示意簡圖:

hash表在解決衝突有兩個常用的方法:

1. 開放定址法:當衝突發生時,使用某種探查(亦稱探測)技術在散列表中形成一個探查(測)序列。沿此序列逐個單元地查找,直到找到給定 的關鍵字,或者碰到一個開放的地址(即該地址單元爲空)爲止(若要插入,在探查到開放的地址,則可將待插入的新結點存人該地址單元)。查找時探查到開放的 地址則表明表中無待查的關鍵字,即查找失敗。
2. 鏈地址法:又叫拉鍊法,所有關鍵字爲同義詞的結點鏈接在同一個單鏈表中。若選定的散列表長度爲m,則可將散列表定義爲一個由m個頭指針組成的指針數 組T[0…m-1]。凡是散列地址爲i的結點,均插入到以T[i]爲頭指針的單鏈表中。T中各分量的初值均應爲空指針。在拉鍊法中,裝填因子α可以大於 1,但一般均取α≤1。

簡單對比可以發現以上兩種方法的優缺點:

開放定址法相比鏈地址法節省更多的內存,但在插入和查找的時候擁有更高的複雜度。
但是table中的hash表的實現結合了以上兩種方法的一些特性:

查找和插入等同鏈地址法複雜度。
內存開銷近似等同於開放定址法

原因是table中的hash表雖然採用鏈地址法的形式處理衝突,但是鏈表中的額外的節點是hash表中的節點,並不需要額外開闢鏈表節點;下面是TKey結構的介紹:

那麼如何將hash表的空餘節點利用起來作爲鏈表的節點呢?這個算法的實現得益於lastfree這個指針的作用,後面會詳細介紹。

4.table的創建

lua中通過luaH_new來創建一個新table:

Table *luaH_new (lua_State *L) {
  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中的array部分和hash部分都爲空。

5.luaH_get分析

table中通過這個函數來從表中查找key對應的值;可以看到它會通過key的類型不同,從而進行不同的處理。

const TValue *luaH_get (Table *t, const TValue *key) {
  switch (ttype(key)) {
    case LUA_TSHRSTR: return luaH_getshortstr(t, tsvalue(key));
    case LUA_TNUMINT: return luaH_getint(t, ivalue(key));
    case LUA_TNIL: return luaO_nilobject;
    case LUA_TNUMFLT: {
      lua_Integer k;
      if (luaV_tointeger(key, &k, 0)) /* index is int? */
        return luaH_getint(t, k);  /* use specialized version */
      /* else... */
    }  /* FALLTHROUGH */
    default:
      return getgeneric(t, key);
  }
}
  • 如果key是nil,則直接返回nil。
  • 如果key是整數類形,則調用luaH_getint來處理,因爲整形key可能會在array中取值。
  • 如果key是浮點數類型,則首先判斷key是否能轉化爲整數,如果是則調用luaH_getint,否則調用getgeneric
  • 如果key是短字符串類型,則調用luaH_getshortstr來處理。(其實這個case有點不理解,短字符串也可以交給getgeneric來處理)
  • 其他類型的key,都使用getgeneric來處理。

下面着重分析luaH_getintgetgeneric這兩個函數的流程。

luaH_getint

const TValue *luaH_getint (Table *t, lua_Integer key) {
  /* (1 <= key && key <= t->sizearray) */
  if (l_castS2U(key) - 1 < t->sizearray)
    return &t->array[key - 1];
  else {
    Node *n = hashint(t, key);
    for (;;) {  /* check whether 'key' is somewhere in the chain */
      if (ttisinteger(gkey(n)) && ivalue(gkey(n)) == key)
        return gval(n);  /* that's it */
      else {
        int nx = gnext(n);
        if (nx == 0) break;
        n += nx;
      }
    }
    return luaO_nilobject;
  }
}

前面說過,table由array和hashtable組成,所以對於整形的key需要先去數組範圍內找:

  • 如果key的大小在數組大小範圍內,那麼就直接在數組中查找值並返回。
  • 否則,獲取int的hash值對應的hashslot,然後在slot-link上找到key對應的值並返回。(和鏈地址法的查找是一樣的)
  • 如果找不到,則返回nil。

getgeneric

static const TValue *getgeneric (Table *t, const TValue *key) {
  Node *n = mainposition(t, key);
  for (;;) {  /* check whether 'key' is somewhere in the chain */
    if (luaV_rawequalobj(gkey(n), key))
      return gval(n);  /* that's it */
    else {
      int nx = gnext(n);
      if (nx == 0)
        return luaO_nilobject;  /* not found */
      n += nx;
    }
  }
}

其實getgeneric流程就是傳統的鏈地址法查找流程,不過值得注意的是mainposition函數,在這裏面區分了lua對於各種類型的hash方式:

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

mainpostion爲hash值%hash表的大小。
值得注意的是對於字符串的hash處理,lua區分了長字符串和短字符串(5.3之後對字符串按照長短做了區分處理)

  • 對於短字符串,lua都存放在stringtable中,所以對於短字符串只有一個實體。可以直接使用string在stringtable中的hash值。
  • 對於長字符串,lua中可能會存在多個實例,所以需要通過luaS_hash來計算其hash值。

6.luaH_set分析

TValue *luaH_set (lua_State *L, Table *t, const TValue *key) {
  const TValue *p = luaH_get(t, key);
  if (p != luaO_nilobject)
    return cast(TValue *, p);
  else return luaH_newkey(L, t, key);
}

luaH_set 不是傳統意義上的set,也就是直接傳入key和value然後設置,而是傳入key會返回這個key對應的TValue,然後再通過setobj2t對這個TValue進行設置。所以這個set函數就很簡單了:

  • 首先調用luaH_get查找table是否已經存在這個key了,有則直接返回。
  • 否則調用luaH_newkey創建key,並返回對應的TValue。(注意此時key一定不在數組部分內)

那麼luaH_set的分析就轉化爲luaH_newkey的分析:

看流程圖有點複雜,下面給出實際的例子來舉例(先不考慮Rehash部分):

//假設tb的hash表默認大小爲8個元素
local tb = {}
tb[3] = 'a'
tb[11] = 'b'
tb[19] = 'c'
tb[6] = 'd'
tb[14] = 'e'

1. 執行完local tb = {}之後,tb中的hash表狀態是這樣:


空餘節點指針lastfree指向了最後一個node。

注意:table創建默認hash表大小爲0,這裏爲了方便描述假設初始大小爲8,這樣就不用管rehash部分了

2. 執行完tb[3] = 'a’之後,tb中的hash表狀態是這樣:

因爲3的mainposition爲3,所以放在了n3位置。

注意:key的manposition等於hash(k)%table_size, 所以mainposition(3)=3%8

3. 執行完tb[11] = 'b’之後,tb中的hash表狀態是這樣:

因爲11的mainposition也爲3,然而3位置已經被佔用了,所以此時使用lastfree獲取一個空節點n7,將當前key存儲在n7位置上,並且使用頭插法將n7節點插入在mainposition節點n3之後,所以這裏的next = n7 - n3 = 4

4. 執行完tb[19] = 'c’之後,tb中的hash表狀態是這樣:

原因和上面類似,只不過注意的是,19是插入在mainposition節點n3和mp的next節點n7之間,所以需要重新維護n3的next值。

5. 執行完tb[6] = 'd’之後,tb中的hash表狀態是這樣:

在這一步中有些不一樣的處理,首先還是算出6的mainposition爲6,然後發現n6已經被key:19佔用了。但是此時我們不能直接使用lastfree來存儲key:6,因爲19和6不是同一個鏈表上的,也就是說key:19搶了key:6的位置:
                                                       mainpositon(19) = 19 % 8 = 3                                                                                                                                                              mainpositon(6) = 6 % 8 = 6
對於這種情況,我們需要key:19讓出位置,通過lastfree申請一個空節點n5,然後將19的位置換到n5上(注意維護next節點)。然後將key:6放在n6節點上。

這部分操作就是流程圖上Resetpos部分

6. 執行完tb[14] = 'e’之後,tb中的hash表狀態是這樣:

這裏和步驟3類似。

7. 下面再來分析一下Rehash的部分

假如此時table裏面的array和hash表狀態如下:

此時再插入一個key:10,因爲lastfree已經無法獲取空節點了,所以觸發了rehash。

  • 首先通過numusearray計算數組部分val不爲nil的所有整形數目,和nums[]。對於nums[i] = j,其意義表示key在2^i-1 到2^i之間其整形key的個數有j個。
  • 然後再通過numusehash計算hash部分Val不爲nil的所有整形數目,和nums[]。
  • 通過整形key的個數確定array的大小,computesizes,這裏確定array的大小有個規則就是要滿足2的冪size,並且整形key數目num > arraySize / 2,還要保證放入整形key的數目高於arraySize / 2。
  • 最後根據array大小和總key個數,確定hash表的大小。(ps:hash表的大小也只能是2的冪,如果不是則向上對齊)

通過上面的規則可以計算得到array部分的大小爲4,hash表大小爲7 - 3 = 4。(7是指Key的總數,3是指能放入數組的Key的個數)

無論是array的rehash還是hash表的rehash都是先開闢新的內存,然後將原來的元素重新插入。

插入key:10後的狀態爲:

值得注意的是:table元素的刪除是通過table[key] = nil來實現的,然而通過我們上面對luaH_set介紹我們可以知道,僅僅是把把key對應的val設置爲nil而已,並沒有真正的從table中刪除這個key,只有當插入新的key或者rehash的時候纔可能會被覆蓋或者清除。

8.luaH_getn分析

lua中獲取一個table的長度一般是使用#操作符,不過這裏並不是直接取的table的array部分的長度,而是說table整數key索引序列連續長度。也就是說如果我們把tale當做一個數組在使用的時候返回的就是數組的長度。例如:

在上圖所示table中一共有1,2,3,‘a’,‘b’,'c’六個key,雖然數組部分只有1,2兩個,但是對table取長度爲3,因爲整數key連續長度爲1,2,3。

值得注意的是,假如在整數key序列中間出現了中斷,那麼取出來的長度是“不確定的”

如上兩圖table1和table2,table的array部分和hash部分都是整數key。雖然索引序列都在3處出現了中斷,但是分別對table1和table2取長度值分別是6和2。按道理來說兩個table的長度都應該是2,但是爲什麼會出現一個6這個奇怪的“錯誤值”呢?
根本原因是table對於取其長度的算法導致的,而luaH_getn就是用來求table的長度值的函數。下面來看下這個函數源碼:

int luaH_getn (Table *t) {
  unsigned int j = t->sizearray;
  if (j > 0 && ttisnil(&t->array[j - 1])) {
    /* 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;
  }
  /* else must find a boundary in hash part */
  else if (isdummy(t))  /* hash part is empty? */
    return j;  /* that is easy... */
  else return unbound_search(t, j);
}
static int unbound_search (Table *t, unsigned int j) {
  unsigned int i = j;  /* i is zero or a present index */
  j++;
  /* find 'i' and 'j' such that i is present and j is not */
  while (!ttisnil(luaH_getint(t, j))) {
    i = j;
    if (j > cast(unsigned int, MAX_INT)/2) {  /* overflow? */
      /* table was built with bad purposes: resort to linear search */
      i = 1;
      while (!ttisnil(luaH_getint(t, i))) i++;
      return i - 1;
    }
    j *= 2;
  }
  /* now do a binary search between them */
  while (j - i > 1) {
    unsigned int m = (i+j)/2;
    if (ttisnil(luaH_getint(t, m))) j = m;
    else i = m;
  }
  return i;
}

lua在取table其長度值,實際上在找一個整形key,滿足

  • table[key] != nil
  • table[key+1] == nil

一旦找到一個這樣的key,那麼這個key就會被認定爲table的長度。

爲了提高查找效率,lua源碼並沒有進行遍歷查找,而是通過二分查找。(時間複雜度從O(n)降到O(logn)
具體算法流程如下:

  • 如果table數組部分的最後一個元素爲nil,那麼將在數組部分進行二分查找
  • 如果table數組部分的最後一個元素不爲nil,那麼將在hash部分進行二分查找

由此可見,上面兩個例子中出現差異的問題就是,table[4]作爲數組部分的最後一個元素導致兩個table分別在數組部分和hash部分查找key導致的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章