偵測未定義變數

lua-users home
wiki

問題

如何在 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。我們可以用 Xnil 解釋為 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

即使上述程式碼也不會傳回錯誤。當 Xnil 時,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 實際執行時才會發現錯誤。

很明顯的,我們可能想在早期偵測未定義的全局變數,例如在編譯時,或至少在生產發行前(例如測試組內)。以下是一些方法。

方法 #1:執行時檢查

讀取和寫入未定義的全局變數會在執行時被偵測到。這些方法透過覆寫當前執行的函數在環境資料表中的 __index__newindex 元方法來運作。Lua 會將讀取和寫入未定義的全局變數的動作傳送給這些元方法,這些元方法則可以編寫成引發執行時錯誤。

此方法由 Lua 發行版中「strict」模組 (etc/strict.lua (下載路徑:[Lua 5.1] 以及 [Lua 5.2])) 採用。或者,請參閱 Thomas Lauer 所撰寫的 [LuaStrict],這是 strict 方法的擴充功能。

以下是此方法的一些優點和缺點

優點

缺點

以下內容已從強制執行本地宣告處移動

以下由 Niklas Frykholm 編寫的程式碼是在 Lua 郵件檔案館中發現的。我認為,將這些內容記錄在 wiki 中是件好事,因為像這樣的高水平技巧很容易在數百封郵件中遺失或被人遺忘。執行本地變數宣告的目的在於防止您使用尚未宣告的變數。此方式也可以讓您避免意外使用一個尚未宣告的變數,此變數應在局部範圍內使用,但卻被視為全域性,而這可能會在您進行除錯時嚇您一大跳。

SR - 您能說明這個解決方案有哪些 DetectingUndefinedVariables 沒有提供的功能嗎?您知道 etc/strict.lua,但相信這種方法會更好嗎?

有許多有效方法可執行變數宣告,然而,我個人認為,Niklas Frykholm 的解決方案最為優雅且不會造成干擾(而且幾乎不會影響效能,因為程式中宣告的大部分變數都是局部範圍,而且程式只會在宣告全域性變數時被執行)。

基本上,只要您在程式碼中的某個地方呼叫 GLOBAL_lock(_G)(請注意 _G 是全域性變數資料表的代號),從那個時間點開始,不論何時,只要您嘗試在未明確宣告為「本地」的情況下使用變數,Lua 都會傳回一則錯誤訊息。

我對程式碼做了一點修正,讓你可以透過變數前面加兩個底線(例如:__name__global_count)來明確允許全域宣告,不過你可以選擇變更程式碼,改用其他你喜歡的命名方式(例如:G_nameG_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?

方法 2:靜態分析(編譯時期檢查)

另一種方法是在編譯時期偵測未定義的全域。Lua 當然可以用作直譯語言而不進行明確的編譯步驟(儘管它在內部會編譯為位元碼)。不過,我們的用意是,未定義的全域可以在程式碼如常執行之前就偵測出來。我們可以做到這一點,而不需要真正執行所有程式碼,只需要進行剖析即可。這有時稱為原始碼的「靜態分析」。

要在編譯時期偵測這些變數,你可以(在 類似 *nix 的作業系統中)使用以下命令列技巧和 Lua 5.1 編譯器 (luac)

luac -p -l myprogram.lua | grep ETGLOBAL

5.1 的全自動化指令碼 [analyzelua.sh]

for f in *.lua; do luac-5.1 -p -l "$f" | grep ETGLOBAL | cut -d ';' -f 2 | sort | uniq | sed -E 's/^ (.+)$/local \1 = \1;/' > "_globals.${f}.txt"; done

適用於 Lua5.2/5.3

luac -p -l myprogram.lua | grep 'ETTABUP.*_ENV'

5.2/5.3 的全自動化指令碼 [analyzelua.sh]

for f in *.lua; do luac-5.3 -p -l "$f" | grep 'ETTABUP.*_ENV' | cut -d ';' -f 2 | cut -d ' ' -f 1-3 | sort | uniq | sed -E 's/^ _ENV "(.+)"\s*$/local \1 = \1;/' > "_globals.${f}.txt"; done

這個指令會列出所有取得和修改全域變數的動作(包括已定義和未定義的變數)。你可能會發現,有些取得/修改動作被解譯為全域,而你實際上希望它們是區域變數(缺少「local」陳述式或變數名稱拼寫錯誤)。如果你遵照「避免使用全域變數(例如避免瘟疫)」的編碼風格(也就是說,盡可能使用區域變數(字元變數)),上述方法可以順利運作。

這種方法的延伸運用在 Lua 5.1.2 發行版的 tests/globals.lua 中,它使用 Lua 而不是 *nix 管道「 | grep ETGLOBAL」,而且,它的效率更高,能過濾預先定義的全域(例如:printmathstring 等)。另請參閱 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

注意:修改 lparser.c:singlevar 如下,以進行更正確的錯誤處理:--DavidManura
/* 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 */
}

以下是此方法的一些優點和缺點

優點

缺點

一個 Lua Lint 工具

下列的 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 解決方案

這段 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) }

--FabienFleutot

另一個 Metalua 解決方案:Metalint

「Metalint [4]」是一種實用程式,用來檢查 Lua 和 Metalua 來源檔案對全域變數的使用。除了檢查頂層全域變數外,它還會檢查模組中的欄位:例如,它會找出鍵盤輸入錯誤,例如 taable.insert(),而非 table.iinsert()。Metalint 搭配使用宣告檔案,其中列出已宣告哪些全域變數,以及可以使用它們做什麼事[4]

方法 #3:混合的執行時間/編譯時間方法

可以採取混合方法。請注意,最佳的偵測時間是在編譯時間偵測全域變數的存取(至少是不透過 _Ggetfenv() 的直接存取),而最佳的判斷時間是在執行時間判斷這些全域變數是否已定義(或可能暫時在「載入時間」判斷,大約是在 loadfile 完成時)。因此,一個折衷的方法就是將這兩個考量分開,並在最合適的時機進行。例如由「檢查全域變數」模組+補丁 採取的混合方法,提供了 checkglobals(f, env) 函式(完全使用 Lua 實作)。簡而言之,checkglobals 驗證函式 f(預設為呼叫函式)僅使用在表格 env 中定義的全域變數(預設為 f 的環境)。checkglobals 需要一個小補丁,才能在偵錯函式庫的 debug.getinfo / lua_getinfo 函式中新增一個附加的 'g' 選項,用於列出函式 f 中語彙內部存取的全域變數。

具語意感知的編輯器

請參閱 程式分析 之下的編輯器/IDE,這些編輯器會突顯未定義的變數。這可以使用靜態分析和/或呼叫 Lua 解譯器來實作。這種方式很方便,因為任何錯誤都會立即顯示在螢幕上的內容中,而不需要呼叫任何外部建置工具並瀏覽其輸出的結果。

Lua 語法延伸

Lua 編譯器已經提出了一些語法延伸,以由系統自動處理未定義的變數

歷史紀錄:舊 Lua 4 筆記

以下是 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 的「已定義」全域變數,以及從未存取過的「未定義」全域變數。

另請參閱


最新變更紀錄 · 偏好設定
編輯 · 歷史
最近編輯於 2019 年 3 月 18 日上午 2:21,GMT (diff)