检测未定义变量 |
|
如何在 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] 的下载)。或者,请参阅 [LuaStrict],由 ThomasLauer 为 `strict` 方法的扩展。
以下是这种方法的一些优缺点
优点
缺点
以下代码由 Niklas Frykholm 编写,在 Lua 邮件存档中找到。我认为将它记录在维基中会很好,因为像这样的宝石很容易在数百封邮件中丢失或遗忘。关于强制局部变量声明的概念是阻止您使用未声明的变量。实际上,这也阻止您意外使用未声明的变量,该变量原本应该是局部作用域,但被视为全局变量,这在调试时可能会带来麻烦。
强制变量声明有很多有效的解决方案,但就我个人而言,我发现 Niklas Frykholm 的解决方案是最优雅且最不显眼的(而且对性能几乎没有影响,因为大多数程序中声明的变量都是局部范围的,并且代码只在声明全局变量时才会被执行)。
基本上,只要你在代码中的任何地方调用 GLOBAL_lock(_G)
(注意 _G
是全局变量表),从那时起,只要你尝试使用未显式声明为 'local' 的变量,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 可以用作解释型语言,无需显式的编译步骤(尽管在内部它确实编译成字节码)。但是,我们这里指的是在代码正常执行之前检测未定义的全局变量。这可以在不真正执行所有代码的情况下完成,而只是解析它。这有时被称为源代码的“静态分析”。
为了在编译时检测这些变量,你可以在(类 Unix 操作系统下)使用以下命令行技巧与 Lua 5.1 编译器(luac)一起使用
5.1 的全自动命令 [analyzelua.sh]
对于 Lua 5.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 源代码以查找常见的编程错误,就像“lint”程序对 C 代码做的那样。它包含一个 Lua 5.1 bison 解析器。
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 在他的论文《可插拔类型系统》中定义的那样)是一个类型系统,它 a.) 对编程语言的运行时语义没有影响,并且 b.) 不强制在语法中使用类型注解。”
另一种方法是修补 Lua 解析器本身。有关此类示例,请参见 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 */ }
以下是这种方法的一些优缺点
优点
缺点
以下实用程序将对 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 解析器来实现类似的功能(参见 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" 模块+补丁] 采用,该模块提供一个 checkglobals(f, env)
函数(完全用 Lua 实现)。简而言之,checkglobals
验证函数 f
(默认情况下被认为是调用函数)仅使用表 env
中定义的全局变量(默认情况下被认为是 f
的环境)。checkglobals 需要一个小的补丁来向调试库的 debug.getinfo / lua_getinfo
函数添加一个额外的 'g'
选项,以列出函数 f
中词法上的全局变量访问。
参见 ProgramAnalysis 下的编辑器/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 值的“已定义”全局变量和从未访问过的“未定义”全局变量。