Lua陷阱(基礎篇)

最近這段時間雖然很忙,但是去年有很多想要說,想要寫的東西都沒有拿出來。一個考慮是自己思考不成熟的內容,拿出來會對大家產生誤導,但是我發現越是深挖就越發覺自己理解的淺顯和片面,就越不敢拿出來和大家交流了。後來我想通了,人非聖賢,怎麼可能句句真言,做人嘛,開心就好。今年我會分享更多的內容給大家,也許哪天不在了,但是我的文字還能被人看到也是一種幸福。


之前跟組內的小夥伴們做了一次關於 lua 的講座,內容淺顯易懂,涉及 lua 的語法和一些需要注意的地方,我很想把之後關於 lua 的更深入的內容給組裏的小夥伴繼續講解,但是好像一直沒有時間,今天就把第一篇分享給大家。文章後有我做演講的 PPT,PPT 裏面的代碼有一些是項目的真實代碼,有一些是我自己寫的例子,因爲項目的代碼也沒有涉及項目的具體內容,而且大部分是我自己寫的,所以我這裏就不改動了。有興趣的朋友可以下載來看看,強力推薦裏面有我做的一個關於 lua table 實現機制的動態效果 :)


下面是我後來回憶的講義:


這次分享的內容是關於lua在使用過程中容易遇到的問題以及對lua不熟悉而容易誤入的一些陷阱。本次分享更多的是我個人的學習筆記,如果有任何疑問,可以隨時提出來,即使沒有辦法解決,也能引起大家的思考。

我儘量模仿《C語言缺陷與陷阱》的方式來進行講解,這樣大家應該比較容易接受。

首先簡單介紹一下lua這門語言,在官網上作者就給出了lua的設計意圖,是一門可擴展的具備極強數據描述能力的,編程模式強大,輕便的C寫的腳本語言。我們項目嵌入的是lua5.1的版本,這次分享的內容也都限於5.1的版本,如果有其他版本的內容,我都會註明。

當然,這裏我不去討論爲什麼要使用lua,關於這個問題仁者見仁智者見智,有人極其喜歡這種語言,也有人從根本上就討厭它,甚至其他一切腳本語言,我就見過這樣的傢伙,詳情可以參看一下王垠寫的這篇文章:《什麼是腳本語言

廢話說完了,下面開始正式的:

根據我整理的內容,分爲這麼幾個部分:

for循環,表,語法糖和如何加速,容錯,環境和閉包,lua的棧。


for循環

單獨把for循環拿出來講,是因爲之前我在寫代碼的時候因爲不熟悉luafor循環犯過很多次錯誤,這裏講一講,算是給大家做了反面教材。

luafor循環有很多種,有數值型,有泛型,泛型裏又分有狀態和無狀態,還有自己構造閉包寫的循環,但從形式上常用的一般爲2種,一種是數字型,一種是泛型。

數字型的循環如下:

for v = e1, e2, e3 do

block

end

他等價於:

do

local var, limit, step = tonumber(e1), tonumber(e2), tonumber(e3)

if not (var and limit and step) then error() end

while (step > 0 and var <= limit) or (step <= 0 and var >= limit) do

local v = var

block

var = var + step

end

end

這裏有3點需要提醒大家,一點是for循環中初始值,終止值,步長(可以省略,省略默認爲1)的計算都只在最開始計算一次就保存起來了,換句話說即使你在for循環裏進行了控制變量的修改也沒辦法影響循環的過程,這點和C的循環是不同的,舉個例子如下:

for i = 1, 3 do

print(i)

i = 5

end

local a = {1,2,3}

for i = 1, #a do

a = nil

print(i)

end

兩處都打印1,2,3,進行3次循環。

另一點是上面的循環變量v是這個for循環的局部變量,當跳出for循環時,這個局部變量是沒辦法獲取到的,比如:

for i = 1, 3 do

print(i)

end

print(i)

最後一個print打印的是nil,因爲找不到i這個變量。想要保存就用外部變量來保存循環內的局部變量即可。

還有一點不是關於for循環的,而是對於運算符‘#’的,這個運算符的作用是取table中數組段的長度,這麼說你肯定明白不了是什麼意思,舉個例子,比如:

local a = {

[1] = 1,

[2] = 2,

[3] = 3,

[4] = 4,

[6] = 6,

}

print(#a)

結果是6。但是:

local a = {

[1] = 1,

[2] = 2,

[3] = 3,

[5] = 5,

[6] = 6,

}

print(#a)

結果是3。要說爲什麼是這個樣子,就涉及到lua表的設計了。這個再第二點裏面會談到。

泛型循環有3個重要的參數:迭代器,初始狀態,初始狀態輸入。這種for循環就像一個有限狀態機一樣,給定狀態轉移函數,初始狀態,初始狀態輸入就可以自動的運行下去,不停的產生出下一個狀態。不熟悉lua的同學可能要問,循環怎麼還有個狀態呢?狀態是什麼東西?我們知道,循環的目的是爲了遍歷,在我們熟悉的C語言中,for循環的語法非常簡單for (int i = 0; i < N; ++i) { XXX },看上去一目瞭然,這是因爲遍歷對象是線性結構的關係,對於非線性結構,如果需要遍歷,就沒辦法這麼簡單的++來查找下一個,學過數據結構的同學應該都對樹和圖的遍歷不陌生,怎麼說都不像是那麼簡單就能找到下一個,這裏也是一樣,在C++中比如STL的一些容器和luatable的遍歷都會使用到迭代器,雖然形式不同,但從本質上說都一樣,無論是C++的迭代器還是lua的迭代函數或者是樹、圖的遍歷算法,都是一個有限狀態機,爲的是查找到下一個值。上面說的數值型for循環其實只是這種迭代器的一個特例罷了,就好像互斥鎖是信號量引用計數爲1時的特例一樣。但是因爲數值型for循環較爲簡單,而且效率高,又符合C語言中數組的遍歷習慣,所以被保留下來,當然這是我自己猜測,lua的作者沒有這麼說。

泛型for的樣子大致如下:

for var_1, ···, var_n in explist do

block

end

他等價於:

do

local f, s, var = explist

while true do

local var_1, ···, var_n = f(s, var)

var = var_1

if var == nil then

break

end

block

end

end

這裏的explist其實就是上面說的三個參數:迭代器,初始狀態,初始狀態輸入。只不過通常我們看到的都是一個函數,比如pairs(),他並非是迭代器,而只是這裏的explist,僅僅用於產生出這3個參數的,真正的迭代器,是pairs()返回的next()函數,這個纔是迭代器函數。


table實現

這就涉及到luatable的設計了,lua的整體效率是很高的,其中,它的table實現的很巧妙爲這個效率貢獻很大。

lua 的 table 充當了數組和映射表的雙重功能,所以在實現時就考慮了這些,讓 table 在做數組使用時儘量提高了效率。

lua 是這樣做的。它把一個 table 分成數組段和 hash 段兩個部分。數字 key 一般放在數組段中,沒有初始化過的 key 值全部設置爲 nil 。當數字 key 過於離散的時候,部分較大的數字 key 會被移到 hash段中去。這個分割線是以數組段的利用率不低於 50% 爲準。 0 和 負數做 key 時是肯定放在 hash 段中的。

string 和 number 都放在一起做 hash ,分別有各自的算法,但是 hash 的結果都在一個數值段中。hash 段採用閉散列方法,即,所有的值都存在於表中。如果hash 發生碰撞,額外的數據記在空閒槽位裏,而不額外分配空間存放。當整個個表放滿後,hash 段會擴大,所有段內的數據將被重新 hash ,重新 hash 後,衝突將大大減少。

這種 table 的實現策略,首先保證的是查找效率。對於把 table 當數組使用時將和 C 數組一樣高效。對於 hash 段的值,查找幾乎就是計算 hash 值的過程(其中string 的 hash 值是事先計算好保存的),只有在碰撞的時候纔會有少許的額外查找時間,而空間也不至於過於浪費。在 hash 表比較滿時,插入較容易發生碰撞,這個時候,則需要在表中找到空的插槽。lua 在table 的結構中記錄了一個指針順次從一頭向另一頭循序插入來解決空槽的檢索。每個槽點在記錄 next 指針保存被碰撞的 key 的關聯性。

wKiom1MPK_ThXuKpAAD5D-RohXk568.jpg


luahash表的hash算法比較特別,一般的hash表都是根據key算出hash(key),然後把這個key放在hash表的hash(key)位置上,如果有衝突的話,就放在hash(key)位置的鏈表上。
但是luahash表中,如果有衝突的話,lua會找hash表中一個空的位置(從後往前找,假設爲x),然後把新的key放在這個空的位置x上,並且讓hash表中hash(key)處的節點的nk.next指向x。這個意思就是,假如有衝突的話,不用重新分配空間來存儲衝突的key,而是利用hash表上未用過的空格來存儲。但是這樣會引入另外一個問題,本來key是不應該放在x的,假設有另外一個key2hash(key2)算出來的位置也在x的話,那就表示本來x這個位置應該是給key2的,但是由於xkey佔用了,導致key2沒地方放了。這時候lua的處理方式是把key放到另外一個空格,然後讓key2佔回x。當hash表已經沒有空格的時候,lua就會resize這個hash表。這樣做的好處主要是不用動態申請內存空間,hash表初始化的時候有多少內存空間就用多少,不夠就resize這個hash表。


語法糖和如何加速

localfunction stripwords(inputstring, inputtable)

local retstring = {}

local itemno = 1;

for w in string.gmatch(inputstring, "%a+") do

if inputtable[w] then

retstring[itemno] = itemno > 1and" " .. w or w --Insert space between words

itemno = itemno + 1

end

end

return

table.concat(retstring)

end

利用邏輯運算的短路效應 lua 編程中,and or C 一樣是有短路效應的,不過他們的返回值並非 bool 類型,而是表達式中的左值或者右值。我們常常利用這個特性來簡化代碼。

function foo(arg)

arg=arg or "default"

...

end


利用 or 運算賦缺省值是最常用的技巧。上例中,如果 arg nil arg 就會被賦值爲 "default" 。但是這個技巧有個缺陷,當缺省值是 true 的時候會有點問題。

a=a or true -- 錯誤的寫法,當 a 明確寫爲 false 的時候,也會被改變成 true a= a ~= false -- 正確的寫法,當 a nil 的時候,被賦值爲 true ;而 false 則不變。

另外,巧妙使用 and or 還可以實現類似 C 語言中的 ?: 三元操作:

function max(a,b)

return a>b and a or b

end

上面這個函數可以返回 a b 中較大的一個,其邏輯類似 C 語言中的 return (a>b) ? a : b ;

經常可以看到這樣的代碼,在一個循環體裏不斷的拼接字符串:

for k, v in pairs(a) do

s= s..","

end

字符串的連接操作,會產生新的對象。這是由 lua 本身的 string 管理機制導致的。lua VM 內對相同的 string 永遠只保留一份唯一 copy ,這樣,所有字符串比較就可以簡化爲地址比較。這也是 lua table 工作很快的原因之一。這種 string 管理的策略,跟 java 等一樣,所以跟 java 一樣,應該儘量避免在循環內不斷的連接字符串,比如 s = s..x 這樣。每次運行,都很可能會生成一份新的 copy

同樣,記住,每次構造一份 table 都會多一份 table copy 。比如在 lua 裏,把平面座標封裝成 { x, y } 用於參數傳遞,就需要考慮這個問題。每次你想構造一個座標對象傳遞給一個函數,{ 10,20 }  這樣明確的寫出,都會構造一個新的 table 出來。要麼,我們想辦法考慮 table 的重用;要麼,乾脆用 x,y 兩個參數傳遞座標。

當一個table中有nil空洞的時候,最好不要使用獲取這個table長度的函數,比如unpack()ipairs(),舉幾個例子:

a = {

   [1] = 1,

   [2] = 2,

   [3] = 3,

   [4] = 4,

}

print(unpack(a))    --1 2 3 4

a[3] = nil

print(unpack(a))    --1 2 nil 4

a[2] = nil

print(unpack(a))    --1

for k, v in ipairs(a) do    --1

   print(k, v)

end

for k, v in pairs(a) do    --1 4

   print(k, v)

end

在使用變參作爲函數參數傳遞時需要特別注意lua中對於變參的一個處理,就是當變參後面還有明確參數的時候,變參只會取第一個值而忽略後面的值,這個在luamanual2.5節有寫http://www.lua.org/manual/5.1/manual.html#2.5,只是一般不被注意,舉兩個例子:

a = { 1, 2, 3 }

print(unpack(a))    --1 2 3

print(6, unpack(a), 6)    --6 1 6

function foo()

   return 1, 2, 3

end

local a, b, c, d = foo(), 4

print(a, b, c, d)    --1 4 nil nil

lua5.1和5.2都存在這樣一個問題,對於一個浮點數和整數的比較,lua是無法保證比較結果的,這是因爲在lua中,數字都被處理正double類型進行存儲的,所以你能夠想到的C中的double類型的問題,這裏都會出現。而且非常神奇,舉一個例子,曾經有個bug,在獎勵配置裏面存在小數概率,比如有一個table表示獎勵的概率:a = {99.1, 0.6, 0.1, 0.1, 0.1},當時做了一個檢查,就是判斷這個table概率表的概率和是否等於100,遍歷這個table的每一項相加,結果和100進行比較,但是發現是失敗的,而且這裏的比較結果依賴與這個table中這些數字的順序。如果想要比較要麼把和轉換成字符串再和"100"比較,或者把小數轉成整數進行比較。

比較重要的是看清楚該接口是否會對當前操作的棧有影響,比如一些lua接口在執行完操作後會將操作數彈棧,有些則不會,這個影響到調用完當前接口後是否需要自己維護棧,比如是否需要一個額外的lua_pop調用。例如,luaL_ref接口,會自動將棧頂操作數pop,之後並不需要額外的lua_pop操作來將操作數彈棧。


最後談一點:

仔細閱讀每一個lua接口的說明,比較重要的是看清楚該接口是否會對當前操作的棧有影響,比如一些lua接口在執行完操作後會將操作數彈棧,有些則不會,這個影響到調用完當前接口後是否需要自己維護棧,比如是否需要一個額外的lua_pop調用。例如,luaL_ref接口,會自動將棧頂操作數pop,之後並不需要額外的lua_pop操作來將操作數彈棧。

關於lua_toxxx和luaL_checkxxx的區別,這兩個函數都是從lua棧上獲取一個值,但是在檢查到類型不符時候,lua_toxxx只是返回null或者默認值;而luaL_check則是會拋出一個異常,下面的代碼不會再繼續執行;這裏就需要注意了,lua裏面使用的異常並不是c++的異常,只是使用了c的setjump和longjump來實現到恢復點的跳轉,所以並不會有C++所期望的棧的展開操作,所以在C++裏面看來是異常安全的代碼,此時也是“不安全”的,也不能保證異常安全,比如

Function1(lua_state state)

{

TestClass tmp();

luaL_checkstring(state,1);

}

當上面的luaL_checkstring出現異常時候,TestClass的析構函數並不會被調用,假如你需要在析構函數裏面釋放一些資源,可能會導致資源泄露、鎖忘記釋放等問題。所以在使用luaL_checkxxx時候,需要很小心,在luaL_checkxxx之前儘量不要申請一些需要之後釋放的資源,尤其是加鎖函數 。

推薦使用lua_toxxx來代替luaL_checkxxx,然後自己來檢查返回值是否正確。

關於lua還有很多內容可以談,比如一般腳本語言比編譯型語言更有優勢的處理字符串,字符串捕獲,等等有趣的內容,以後再慢慢完善補充。


參考:

http://blog.codingnow.com/cloud/HomePage

http://blog.sina.com.cn/s/blog_5d90e82f0101jv17.html

http://yulinlu.blog.163.com

http://lua-users.org/wiki/OptimisationTips

http://www.lua.org/bugs.html


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