检测未定义变量

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] 的下载)。或者,请参阅 [LuaStrict],由 ThomasLauer 为 `strict` 方法的扩展。

以下是这种方法的一些优缺点

优点

缺点

以下内容已从强制 `local` 声明中移动

以下代码由 Niklas Frykholm 编写,在 Lua 邮件存档中找到。我认为将它记录在维基中会很好,因为像这样的宝石很容易在数百封邮件中丢失或遗忘。关于强制局部变量声明的概念是阻止您使用未声明的变量。实际上,这也阻止您意外使用未声明的变量,该变量原本应该是局部作用域,但被视为全局变量,这在调试时可能会带来麻烦。

SR - 你能解释一下这个解决方案提供了什么,而 DetectingUndefinedVariables 没有提供吗?你是否知道 `etc/strict.lua`,但认为这种方法更好吗?

强制变量声明有很多有效的解决方案,但就我个人而言,我发现 Niklas Frykholm 的解决方案是最优雅且最不显眼的(而且对性能几乎没有影响,因为大多数程序中声明的变量都是局部范围的,并且代码只在声明全局变量时才会被执行)。

基本上,只要你在代码中的任何地方调用 GLOBAL_lock(_G)(注意 _G 是全局变量表),从那时起,只要你尝试使用未显式声明为 'local' 的变量,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 可以用作解释型语言,无需显式的编译步骤(尽管在内部它确实编译成字节码)。但是,我们这里指的是在代码正常执行之前检测未定义的全局变量。这可以在不真正执行所有代码的情况下完成,而只是解析它。这有时被称为源代码的“静态分析”。

为了在编译时检测这些变量,你可以在(类 Unix 操作系统下)使用以下命令行技巧与 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

对于 Lua 5.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 源代码以查找常见的编程错误,就像“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

注意:修改 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 工具

以下实用程序将对 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 解决方案

这段 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" 模块+补丁] 采用,该模块提供一个 checkglobals(f, env) 函数(完全用 Lua 实现)。简而言之,checkglobals 验证函数 f(默认情况下被认为是调用函数)仅使用表 env 中定义的全局变量(默认情况下被认为是 f 的环境)。checkglobals 需要一个小的补丁来向调试库的 debug.getinfo / lua_getinfo 函数添加一个额外的 'g' 选项,以列出函数 f 中词法上的全局变量访问。

语义感知编辑器

参见 ProgramAnalysis 下的编辑器/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 (差异)