偵測未定義變數 |
|
如何在 Lua 中找出身分不明(未宣告)的變數?是一個經常被問到的問題。
下方會說明各種方法。這些方法在偵測未定義的全局變數時和偵測時間上有差異。首先,我們來探討問題的本質...
在 Lua 程式中,變數名稱的輸入錯誤很難發現,因為基本上 Lua 程式不會抱怨變數未定義。例如,考慮這個定義了兩個函數的程式
function f(x) print(X) end function g(x) print(X + 1) end
在載入這個程式碼的時候,Lua 沒有傳回任何錯誤。前兩行可能錯了(例如「x
」打成「X
」),也可能沒錯(也許 X
是另一個全局變數)。事實上,Lua 無法判斷這個程式碼是否有錯。原因是,如果 Lua 辨識不出變數是局部變數(例如使用「local」關鍵字的靜態宣告或函數參數定義),則變數會被解釋為全局變數(「X
」就是這種情況)。現在,比較難判斷或描述一個全局變數是否已定義。X
的值是 t['X']
,其中 t = getfenv()
是當前正在執行的函數的「環境資料表」。X
永遠都有值,但如果 X
是輸入錯誤的話,它的值可能是 nil
。我們可以用 X
為 nil
解釋為 X
未定義,但是 X
是否為 nil
只能在執行時判斷出來。例如
-- X is "undefined" f(X) -- print nil X = 2 -- X is defined f(X) -- prints 2 X = nil -- X is "undefined" again f(X) -- prints nil
即使上述程式碼也不會傳回錯誤。當 X
為 nil
時,print(X)
變成 print(nil)
,列印一個 nil
值是合法的。不過,只要呼叫函數 g
g(X)
這樣就會傳回 "attempt to perform arithmetic on global 'X' (a nil value)"
錯誤。原因是 print(X + 1)
變成 print(nil + 1)
,把 nil
加到一個數字是不合法的。不過,只有在程式碼 nil + 1
實際執行時才會發現錯誤。
很明顯的,我們可能想在早期偵測未定義的全局變數,例如在編譯時,或至少在生產發行前(例如測試組內)。以下是一些方法。
讀取和寫入未定義的全局變數會在執行時被偵測到。這些方法透過覆寫當前執行的函數在環境資料表中的 __index
和 __newindex
元方法來運作。Lua 會將讀取和寫入未定義的全局變數的動作傳送給這些元方法,這些元方法則可以編寫成引發執行時錯誤。
此方法由 Lua 發行版中「strict」模組 (etc/strict.lua
(下載路徑:[Lua 5.1] 以及 [Lua 5.2])) 採用。或者,請參閱 Thomas Lauer 所撰寫的 [LuaStrict],這是 strict
方法的擴充功能。
以下是此方法的一些優點和缺點
優點
缺點
本地
宣告處移動以下由 Niklas Frykholm 編寫的程式碼是在 Lua 郵件檔案館中發現的。我認為,將這些內容記錄在 wiki 中是件好事,因為像這樣的高水平技巧很容易在數百封郵件中遺失或被人遺忘。執行本地變數宣告的目的在於防止您使用尚未宣告的變數。此方式也可以讓您避免意外使用一個尚未宣告的變數,此變數應在局部範圍內使用,但卻被視為全域性,而這可能會在您進行除錯時嚇您一大跳。
有許多有效方法可執行變數宣告,然而,我個人認為,Niklas Frykholm 的解決方案最為優雅且不會造成干擾(而且幾乎不會影響效能,因為程式中宣告的大部分變數都是局部範圍,而且程式只會在宣告全域性變數時被執行)。
基本上,只要您在程式碼中的某個地方呼叫 GLOBAL_lock(_G)
(請注意 _G
是全域性變數資料表的代號),從那個時間點開始,不論何時,只要您嘗試在未明確宣告為「本地」的情況下使用變數,Lua 都會傳回一則錯誤訊息。
我對程式碼做了一點修正,讓你可以透過變數前面加兩個底線(例如:__name
、__global_count
)來明確允許全域宣告,不過你可以選擇變更程式碼,改用其他你喜歡的命名方式(例如:G_name
、G_global_count
)。(讀者問題:如果這樣直接宣告全域變數,而且變數前面加「__」,這樣不就又會產生拼寫錯誤了嗎?也就是說,設定 __valueX 和 __valueX 都會被視為合法,這不是很違反(大部分的)原始構想嗎?
--=================================================== --= Niklas Frykholm -- basically if user tries to create global variable -- the system will not let them!! -- call GLOBAL_lock(_G) -- --=================================================== function GLOBAL_lock(t) local mt = getmetatable(t) or {} mt.__newindex = lock_new_index setmetatable(t, mt) end --=================================================== -- call GLOBAL_unlock(_G) -- to change things back to normal. --=================================================== function GLOBAL_unlock(t) local mt = getmetatable(t) or {} mt.__newindex = unlock_new_index setmetatable(t, mt) end function lock_new_index(t, k, v) if (k~="_" and string.sub(k,1,2) ~= "__") then GLOBAL_unlock(_G) error("GLOBALS are locked -- " .. k .. " must be declared local or prefix with '__' for globals.", 2) else rawset(t, k, v) end end function unlock_new_index(t, k, v) rawset(t, k, v) end
--SamLie?
另一種方法是在編譯時期偵測未定義的全域。Lua 當然可以用作直譯語言而不進行明確的編譯步驟(儘管它在內部會編譯為位元碼)。不過,我們的用意是,未定義的全域可以在程式碼如常執行之前就偵測出來。我們可以做到這一點,而不需要真正執行所有程式碼,只需要進行剖析即可。這有時稱為原始碼的「靜態分析」。
要在編譯時期偵測這些變數,你可以(在 類似 *nix 的作業系統中)使用以下命令列技巧和 Lua 5.1 編譯器 (luac)
5.1 的全自動化指令碼 [analyzelua.sh]
適用於 Lua5.2/5.3
5.2/5.3 的全自動化指令碼 [analyzelua.sh]
這個指令會列出所有取得和修改全域變數的動作(包括已定義和未定義的變數)。你可能會發現,有些取得/修改動作被解譯為全域,而你實際上希望它們是區域變數(缺少「local
」陳述式或變數名稱拼寫錯誤)。如果你遵照「避免使用全域變數(例如避免瘟疫)」的編碼風格(也就是說,盡可能使用區域變數(字元變數)),上述方法可以順利運作。
這種方法的延伸運用在 Lua 5.1.2 發行版的 tests/globals.lua
中,它使用 Lua 而不是 *nix 管道「 | grep ETGLOBAL」,而且,它的效率更高,能過濾預先定義的全域(例如:print
、math
、string
等)。另請參閱 LuaList:2006-05/msg00306.html,以及 LuaLint。同時,也請參閱 Egil Hjelmeland 的 [globals]。globals.lua 進階版是 [globalsplus.lua](DavidManura),它也會在全域表格欄位中查詢。[lglob] [3](SteveDonovan)進行更進階的位元碼分析。
一個外部「linter」工具或有語義感知的文字編輯器(例如 [Lua for IntelliJ IDEA]、LuaInspect、較早的 LuaFish,或以下的 Metalua 程式碼)可以解析並靜態分析 Lua 程式碼,進而達到類似效果,以及偵測其他類型的程式碼錯誤或有問題的程式碼實務。舉例來說,LuaFish(相當實驗性質)甚至可以偵測到
string:length()
或 math.cos("hello")
無效。
[Lua Checker](5.1)是這樣一種工具,用來針對常見的程式設計錯誤分析 Lua 程式碼,很像 C 的「lint」程式所做的事。它包含一個 Lua 5.1 bison parser。
love-studio [OptionalTypeSystem] 允許在一般 Lua 註解中加入類型註解。
-- this is a description -- @param(a : number) some parameter -- @ret(number) first return value -- @ret(string) second return value function Thing:Method(a) return 3,"blarg" end --@var(number) The x coordinate --@var(number) The y coordinate local x,y = 0,0
它被描述成:「可選類型系統(正如 Gilad Bracha 在他的文章 Pluggable Type Systems 中所定義的)是一個類型系統,它 a.) 對程式設計語言的運行時語意沒有影響,而且 b.) 沒有強制在語法中加註類型。」
另一種方法是修補 Lua parser 本身。有關此類範例,請參閱 LuaList:2006-10/msg00206.html。
/* based on 5.1.4 */ static void singlevar (LexState *ls, expdesc *var) { TString *varname; FuncState *fs; check(ls, TK_NAME); varname = ls->t.seminfo.ts; fs = ls->fs; singlevaraux(fs, varname, var, 1); luaX_next(ls); /* luaX_next should occur after any luaX_syntaxerror */ }
以下是此方法的一些優點和缺點
優點
缺點
下列的 utility 會針對 Lua 程式碼執行 lint 檢查,偵測已宣告變數(且可以擴充成做其他有趣的事情)。
-- lint.lua - A lua linter. -- -- Warning: In a work in progress. Not currently well tested. -- -- This relies on Metalua 0.2 ( http://metalua.luaforge.net/ ) -- libraries (but doesn't need to run under Metalua). -- The metalua parsing is a bit slow, but does the job well. -- -- Usage: -- lua lint.lua myfile.lua -- -- Features: -- - Outputs list of undefined variables used. -- (note: this works well for locals, but globals requires -- some guessing) -- - TODO: add other lint stuff. -- -- David Manura, 2007-03 -- Licensed under the same terms as Lua itself. -- Capture default list of globals. local globals = {}; for k,v in pairs(_G) do globals[k] = "global" end -- Metalua imports require "mlp_stat" require "mstd" --debug require "disp" --debug local filename = assert(arg[1]) -- Load source. local fh = assert(io.open(filename)) local source = fh:read("*a") fh:close() -- Convert source to AST (syntax tree). local c = mlp.block(mll.new(source)) --Display AST. --print(tostringv(c)) --print(disp.ast(c)) --print("---") --for k,v in pairs(c) do print(k,disp.ast(v)) end -- Helper function: Parse current node in AST recursively. function traverse(ast, scope, level) level = level or 1 scope = scope or {} local blockrecurse if ast.tag == "Local" or ast.tag == "Localrec" then local vnames, vvalues = ast[1], ast[2] for i,v in ipairs(vnames) do assert(v.tag == "Id") local vname = v[1] --print(level, "deflocal",v[1]) local parentscope = getmetatable(scope).__index parentscope[vname] = "local" end blockrecurse = 1 elseif ast.tag == "Id" then local vname = ast[1] --print(level, "ref", vname, scope[vname]) if not scope[vname] then print(string.format("undefined %s at line %d", vname, ast.line)) end elseif ast.tag == "Function" then local params = ast[1] local body = ast[2] for i,v in ipairs(params) do local vname = v[1] assert(v.tag == "Id" or v.tag == "Dots") if v.tag == "Id" then scope[vname] = "local" end end blockrecurse = 1 elseif ast.tag == "Let" then local vnames, vvalues = ast[1], ast[2] for i,v in ipairs(vnames) do local vname = v[1] local parentscope = getmetatable(scope).__index parentscope[vname] = "global" -- note: imperfect end blockrecurse = 1 elseif ast.tag == "Fornum" then local vname = ast[1][1] scope[vname] = "local" blockrecurse = 1 elseif ast.tag == "Forin" then local vnames = ast[1] for i,v in ipairs(vnames) do local vname = v[1] scope[vname] = "local" end blockrecurse = 1 end -- recurse (depth-first search through AST) for i,v in ipairs(ast) do if i ~= blockrecurse and type(v) == "table" then local scope = setmetatable({}, {__index = scope}) traverse(v, scope, level+1) end end end -- Default list of defined variables. local scope = setmetatable({}, {__index = globals}) traverse(c, scope) -- Start check.
範例
-- test1.lua local y = 5 local function test(x) print("123",x,y,z) end local factorial function factorial(n) return n == 1 and 1 or n * factorial(n-1) end g = function(w) return w*2 end for k=1,2 do print(k) end for k,v in pairs{1,2} do print(v) end test(2) print(g(2))
輸出
$ lua lint.lua test1.lua undefined z at line 4
在 LuaInspect 中有一個範圍更廣的版本。Fabien 提供的另一個更類似 Metalua(且可能更好的)Metalua 實作在 [1] 中,且還有更簡單的一個版本在下面。另請參閱 MetaLua 資訊。
可以使用其他 Lua parser 執行類似的操作步驟(請參閱 LuaGrammar,特別是 LpegRecipes),例如 Leg [2]。
這段 Metalua 程式碼使用標準的 walker 函式庫列印清單,其中包含在它插入的程式中所使用的所有全局變數。
-{ block: require 'walk.id' -- Load scope-aware walker library -- This function lists all the free variables used in `ast' function list_globals (ast) -- Free variable names will be accumulated as keys in table `globals' local walk_cfg, globals = { id = { } }, { } function walk_cfg.id.free(v) globals[v[1]] = true end walk_id.block(walk_cfg, ast) -- accumulate global var names in the table "globals" print "Global vars used in this chunk:" for v in keys(globals) do print(" - "..v) end end -- Hook the globals lister after the generation of a chunk's AST: mlp.chunk.transformers:add(list_globals) }
「Metalint [4]」是一種實用程式,用來檢查 Lua 和 Metalua 來源檔案對全域變數的使用。除了檢查頂層全域變數外,它還會檢查模組中的欄位:例如,它會找出鍵盤輸入錯誤,例如 taable.insert(),而非 table.iinsert()。Metalint 搭配使用宣告檔案,其中列出已宣告哪些全域變數,以及可以使用它們做什麼事[4]。
可以採取混合方法。請注意,最佳的偵測時間是在編譯時間偵測全域變數的存取(至少是不透過 _G
或 getfenv()
的直接存取),而最佳的判斷時間是在執行時間判斷這些全域變數是否已定義(或可能暫時在「載入時間」判斷,大約是在 loadfile
完成時)。因此,一個折衷的方法就是將這兩個考量分開,並在最合適的時機進行。例如由「檢查全域變數」模組+補丁 採取的混合方法,提供了 checkglobals(f, env)
函式(完全使用 Lua 實作)。簡而言之,checkglobals
驗證函式 f
(預設為呼叫函式)僅使用在表格 env
中定義的全域變數(預設為 f
的環境)。checkglobals
需要一個小補丁,才能在偵錯函式庫的 debug.getinfo / lua_getinfo
函式中新增一個附加的 'g'
選項,用於列出函式 f
中語彙內部存取的全域變數。
請參閱 程式分析 之下的編輯器/IDE,這些編輯器會突顯未定義的變數。這可以使用靜態分析和/或呼叫 Lua 解譯器來實作。這種方式很方便,因為任何錯誤都會立即顯示在螢幕上的內容中,而不需要呼叫任何外部建置工具並瀏覽其輸出的結果。
Lua 編譯器已經提出了一些語法延伸,以由系統自動處理未定義的變數
以下是 Lua 4.0 中一個快速而粗糙的解決方案,用來防止指定未定義的全域變數:
function undefed_global(varname, newvalue) error("assignment to undefined global " .. varname) end function guard_globals() settagmethod(tag(nil), "setglobal", undefed_global) end
在 guard_globals()
被呼叫之後,任何指定的值為 nil 的全域變數都會產生錯誤。所以,一般而言,您會在載入腳本之後,並在執行它們之前呼叫 guard_globals()
。例如
SomeVariable = 0 function ClearVariable() SomeVariabl = 1 -- typo here end -- now demonstrate that we catch the typo guard_globals() ClearVariable() -- generates an error at the typo line
「getglobal」標籤方法也可以類似地用於攔截未定義全域變數的讀取。此外,使用更多程式碼便可以使用一個分開的表格,來區分值為 nil 的「已定義」全域變數,以及從未存取過的「未定義」全域變數。