一般情況下,你不需要知道Lua實現表的細節,就可以使用它。實際上,Lua花了很多功夫來隱藏內部的實現細節。但是,實現細節揭示了表操作的性能開銷情況。因此,要優化使用表的程序(這里特指Lua程序),了解一些表的實現細節是很有好處的。
Lua的表的實現使用了一些很聰明的算法。每個Lua表的內部包含兩個部分:數組部分和哈希部分。數組部分以從1到一個特定的n之間的整數作為鍵來保存元素(我們稍后即將討論這個n是如何計算出來的)。所有其他元素(包括在上述范圍之外的整數鍵)都被存放在哈希部分里。
正如其名,哈希部分使用哈希算法來保存和查找鍵。它使用被稱為開放地址表的實現方式,意思是說所有的元素都保存在哈希數組中。用一個哈希函數來獲取一個鍵對應的索引;如果存在沖突的話(意即,如果兩個鍵產生了同一個哈希值),這些鍵將會被放入一個鏈表,其中每個元素對應一個數組項。當Lua需要向表中添加一個新的鍵,但哈希數組已滿時,Lua將會重新哈希。重新哈希的第一步是決定新的數組部分和哈希部分的大小。因此,Lua遍歷所有的元素,計數并對其進行歸類,然后為數組部分選擇一個大小,這個大小相當于能使數組部分超過一半的空間都被填滿的2的最大的冪;然后為哈希部分選擇一個大小,相當于正好能容納哈希部分所有元素的2的最小的冪。
當Lua創建空表時,兩個部分的大小都是0。因此,沒有為其分配數組。讓我們看一看當執行下面的代碼時會發生什么:
類似下面的代碼
對于大的表來說,初期的幾次重新哈希的開銷被分攤到整個表的創建過程中,一個包含三個元素的表需要三次重新哈希,而一個有一百萬個元素的表也只需要二十次。但是當創建幾千個小表的時候,重新哈希帶來的性能影響就會非常顯著。
舊版的Lua在創建空表時會預選分配大小(4,如果我沒有記錯的話),以防止在初始化小表時產生的這些開銷。但是這樣的實現方式會浪費內存。例如,如果你要創建數百萬個點(表現為包含兩個元素的表),每個都使用了兩倍于實際所需的內存,就會付出高昂的代價。這也是為什么Lua不再為新表預分配數組。
如果你使用C編程,可以通過Lua的API函數lua_createtable來避免重新哈希;除lua_State之外,它還接受兩個參數:數組部分的初始大小和哈希部分的初始大小[1]。只要指定適當的值,就可以避免初始化時的重新哈希。需要警惕的是,Lua只會在重新哈希時收縮表的大小,因此如果在初始化時指定了過大的值,Lua可能永遠不會糾正你浪費的內存空間。
當使用Lua編程時,你可能可以使用構造式來避免初始化時的重新哈希。當你寫下
兩個部分的大小只會在Lua重新哈希時重新計算,重新哈希則只會發生在表完全填滿后,Lua需要插入新的元素之時。因此,如果你遍歷一個表并清除其所有項(也就是全部設為nil),表的大小不會縮小。但是此時,如果你需要插入新的元素,表的大小將會被調整。多數情況下這都不會成為問題,但是,不要指望能通過清除表項來回收內存:最好是直接把表自身清除掉。
一個可以強制重新哈希的猥瑣方法是向表中插入足夠多的nil。例如:
除非是在特殊情況下,我不推薦使用這個伎倆:它很慢,并且沒有簡單的方法能知道要插入多少nil才夠。
你可能會好奇Lua為什么不會在清除表項時收縮表。首先是為了避免測試寫入表中的內容。如果在賦值時檢查值是否為nil,將會拖慢所有的賦值操作。第二,也是最重要的,允許在遍歷表時將表項賦值為nil。例如下面的循環:
如果你想要清除一個表中的所有元素,只需要簡單地遍歷它:
但是,對于大表來說,這個循環將會非常慢。調用函數next時,如果沒有給定前一個鍵,將會返回表的第一個元素(以某種隨機的順序)。在此例中,next將會遍歷這個表,從開始尋找一個非nil元素。由于循環總是將找到的第一個元素置為nil,因此next函數將會花費越來越長的時間來尋找第一個非nil元素。這樣的結果是,這個“聰明”的循環需要20秒來清除一個有100,000個元素的表,而使用pairs實現的循環則只需要0.04秒。
[1] 盡管重新哈希算法始終設置數組的大小為2的冪,數組的大小依然可以為任何自然數值。而哈希的大小必須為2的冪,所以第二個參數總是會被圓整為不小于原值的最小的2的冪。
新聞熱點
疑難解答