南锋

南奔万里空,脱死锋镝余

Lua中的环境(Environment)

全局变量在大多数变成语言中是让人爱恨交织又不可或缺的。一方面,使用管全局变量会明显地使无关的代码部分纠缠在一起,容易导致代码复杂。

另一方面,谨慎地使用全局变量又能更好地表达程序中真正的全局概念;此外,虽然全局常量看似无害,但像Lua语言这样的动态语言是无法区分常量和变量的。像Lua这样的嵌入式语言更复杂:虽然全局变量时再整个程序中均可见的变量,但由于Lua语言是由宿主应用调用代码段的,因此“程序”的概念不明确。
Lua语言通过不使用全局变量的方法来解决这个难题,但又不遗余力地在Lua语言汇总对全局变量进行模拟。在第一种近似的模拟中,我们可以认为Lua语言把所有的全局变量保存在一个称为全局环境的普通表中。
由于不需要再为全局变量创造一种新的数据结构,因此使用一个表来保存全局变量的一个优点是简化了Lua 语言的内部实现。另一个优点是,可以像操作其他表一样操作这个表。为了便于实现这种操作方式,Lua语言将全局环境自身保存在全局变量_G中。例如,如下代码输出了全局环境中所有全局变量的名称:

1
for n in pairs(_G) do print(n) end

具有动态名称的全局变量

通常,赋值操作对于访问和设置全局变量已经足够了。
通常,赋值操作对于访问和设置全局变量已经足够了。然而,有时我们也需要某些形式的元变成。例如,我们需要操作一个全局变量,而这个全局变量的名称却存储在另一个变量中或者经由运行时计算得到。为了获取这个变量的值,许多程序员会写出下面的代码:

1
value = load("return " .. varname)()

例如,如果varname是x,那么字符串连接的结果就是”return x”,当执行时就能得到期望的结果。然而,在这段代码中涉及一个新代码段的创建和编译,在一定程度上开销昂贵。我们可以使用下面的代码来实现相同的效果,但效率却比之前的高出一个数量级:

1
value  = _G[varname]

由于全局环境是一个普通的表,因此可以简单地使用个对应的键直接进行索引。
类似地,我们可以通过编写_G[varname] = value给一个名称为动态计算出的全局变量赋值。不过,请注意,有些程序员对于这种机制的使用可能有些过度而写出诸如_G[“a”] = _G[“b”]这样的代码,而这仅仅是a = b 的一种复杂写法。
上述问题的一般化形式是,允许字段使用诸如”io.read”或”a.b.c.d”这样的动态名称。如果直接使用_G[“io.read”],显然是不能从表io中得到字段read的。但我们可以编写一个函数getfield让getfield(“io.read”)返回想要的结果。这个函数主要是一个循环,从_G开始逐个字段地进行求值:

1
2
3
4
5
6
7
function getfield(f)
local v = _G
for w in string.gmatch(f,"[%a_][%w_]*") do
v = v[w]
end
return v
end

我们使用函数gmatch来遍历f中的所有标识符。
与之对应的设置字段的函数稍显复杂。像a.b.c.d = v这样的赋值等价于一下的代码:

1
2
local temp = a.b.c
temp.d = v

也就是说,我们必须一直取到最后一个名称,然后再单独处理最后的这个名称。

示例 函数setfield

1
2
3
4
5
6
7
8
9
10
11
function setfield(f,v)
local t = _G
for w, d in string.gmatch(f, "([%a_][%w_]*)(%.?)") do
if d == "." then
t[w] = t[w] or {}
t = t[w]
else
t[w] = v
end
end
end

上例中使用的模式将捕获字段名称保存在变量w中,并将其后可选的点保存在变量d中。如果字段名后没有点,那么该字段就是最后一个名称。
下面的代码通过上例中的函数创建了全局表t和t.x,并将10赋值给了t.x.y:

1
2
3
setfield("t.x.y", 10)
print(t.x.y)
print(getfield("t.x.y"))

全局变量的声明

Lua语言中的全局变量不需要声明就可以使用。虽然这种行为对于小型程序来说较为方便,但在大型程序中一个简单的手误可能造成难以发现的bug。不过,如果我们乐意的话,也可以改变这种行为。由于Lua语言将全局变量存放在一个普通的表中,所以可以通过元表来访问不存在全局变量的情况。
一种方法是简单地检测所有对全局表中不存在键的访问:

1
2
3
4
5
6
7
8
setmetatable(_G,{
__newindex = funciton (_,n)
error("attempt to write to undeclared variable" .. n, 2)
end,
__index = function(_,n)
error("attempt to read undeclared variable " .. n , 2)
end,
})

这段代码执行后,所有试图对不存在全局变量的访问都将引发一个错误:

1
2
print(a)
stdin:1:attempt to read undeclared variable a

但是,我们应该如何声明一个新的变量呢?方法之一是使用函数rawset,它可以绕过元方法:

1
2
3
function declare (name initval)
rawset(_G,name,initval or false)
end

其中,or 和false保证新变量一定会得到一个不为nil的值。
另外一种更简单的方法是把对新全局变量的赋值限制在仅能在函数内进行,而代码段外层的代码则被允许自有赋值。
要检查赋值是否在主代码段中必须用到调试库。调用函数debug.getinfo(2,”S”)将返回一个表。其中的字段what表示调用元方法的函数是主代码段还是普通的Lua函数还是C函数。使用该函数,可以将__newindex元方法重写:

1
2
3
4
5
6
7
__newindex = funciton (t,n,v)
local w = debug.getinfo(2,"S").wath
if w ~= "main" and w ~= "C" then
error ("attempt to write to undeclared variable" .. n , 2)
end
rewset(t,n,v)
end

这个新版本还可以接受来自C代码的赋值,因为一般C代码都知道自己究竟在做什么。
如果要测试一个变量是否存在,并不能简单地将它与nil比较。因为如果它为nil,那么访问就回引发一个错误。这是,应该使用rawget来绕过元方法:

1
2
3
if rawset(_G, var) == nil then
...
end

正如前面所提到的,我们不允许值为nil的全局变量,因为值为nil的全局变量都会被自动地认为未声明。但是,要允许值为nil的全局变量也不难,只需要引入一个辅助表来保存已声明变量的名称即可。一旦调用了元方法,元方法就会检查该表,看变量是否是未声明过的。

示例 检查全局变量的声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
local declareNames = {}

setmetatable(_G,{
__newindex = funciton(t,n,v)
if not declareNames[n] then
local w = debug.getinfo(2,"S").wath
if w ~= "main" and w ~= "C" then
error("attempt to write to undeclared variable " .. n, 2)
end
declareNames[n] = true
end
rawset(t,n,v)
end,

__index = function (_,v)
if not declareNames[n] then
error("attempt to read undeclared variable " .. n , 2)
else
return nil
end
end,
})

现在,即使像 x = nil这样的赋值也能够声明全局变量了。
上述两种方法所导致的开销都基本可以忽略不计。在第一种方法中,在普通操作期间元方法不会被调用。在第二种方法中,元方法只有当程序访问一个值为nil的变量时才会被调用。

非全局环境

在Lua语言中,全局变量并一定非得是真正全局的。正如笔者此前所提到的,Lua语言甚至根本没有全局变量。Lua语言竭尽全力地让程序员有全局变量存在的幻觉。现在,让我们看看Lua语言是如何构建这种幻觉的。
首先,让我们忘掉全局变量而从自由名称的概念开始讨论。一个自由名称是指没有关联到显示声明上的名称,即它不出现在对应局部变量的范围内。例如,在下面的代码段中,x和y是自由名称,而z不是:

1
2
local z = 10
x = y + z

接下来就到了关键的部分:Lua语言编译器将代码段中的所有自由名称x转换为_ENV.x。因此,此前的代码段完全等价于:

1
2
local z = 10
_ENV.x = _ENV.y + z

但是这里新出现的_ENV变量又究竟是什么呢?

我们刚才说过,Lua语言中没有全局变量。因此,_ENV不可能是全局变量。在这里,编译器实际上又进行了一次巧妙的工作。笔者已经提到过,Lua语言包所有代码段都当做匿名函数。所以,Lua语言编译器实际上将原来的代码段编译为如下形式:

1
2
3
4
5
local _ENV = some value
return function (...)
local z = 10
_ENV.x = _ENV.y + z
end

也就是说,Lua语言是在一个名为_ENV的预定义上值(一个外部的局部变量,upvalue)存在的情况下编译所有的代码段的。因此,所有的变量要么是绑定到了一个名称的局部变量,要么是_ENV中的一个字段,而_ENV本身是一个局部变量。
_ENV的初始值可以是任意的表。任何一个这样的表都被称为一个环境。为了维持全局变量存在的幻觉,Lua语言在内部维护了一个表来用作全局变量。通过,当加载一个代码段时,函数load会使用预定义上的值来初始化全局环境。因此,原始的代码段等价于:

1
2
3
4
5
local _ENV = the global environment
return function (...)
local z = 10
_ENV.x = _ENV.y + z
end

上述赋值的结果是,全局环境中的字段x得到全局环境中字段y加10的结果。
乍一看,这可能像是操作全局变量的一种相当拐弯抹角的方式。笔者也不会去争辩说这是最简单的方式,但是,这种方式比那些更简单的实现方法具有更多的灵活性。
在继续学习前,让我们总结一下Lua语言中处理全局变量的方式:

  • 编译器在编译所有代码段前,在外层创建局部变量_ENV;
  • 编译器将所有自由名称var变换为_ENV.var;
  • 函数load使用全局环境初始化代码段的第一个上值,即Lua语言内部维护的一个普通的表。
    实际上,这也不是太复杂。
    有些人由于试图从这些规则中引申出额外的“魔法”而感到困惑;其实,这些规则并没有额外的含义。尤其是,前两条规则完全是由编译器进行的。除了是由编译器预定的,_ENV只是一个单纯的普通变量。抛开编译器,名称_ENV对于Lua语言来说根本没有特殊含义。类似地,从x到_ENV.x的转换时纯粹的语法转换,没有隐藏的含义。尤其是,在转换后,按照标准的可见性规则,_ENV引用的是所在位置所有可见的额_ENV变量。

使用_ENV

由于_ENV只是一个普通的变量,因此可以对其赋值或像访问其他变量一样访问它。赋值语句_ENV = nil会使得后续代码不能直接访问全局变量。这可以赢来控制代码使用哪种变量:

1
2
3
4
5
local print, sin = print,math.sin
_ENV = nil
print(13) -- 13
print(sin(13)) -- 0.42016703682664
print(math.cos(13)) -- error

任何对自由名称的赋值都会引发类似的错误。
我们可以显式地使用_ENV来绕过局部声明:

1
2
3
4
a = 13     -- 全局的
local a = 12
print(a) -- 12(局部的)
print(_ENV.a) -- 13(全局的)

用_G也可以:

1
2
3
4
a = 13    -- 全局的
local a = 12
print(a) -- 12 局部的
print(_G.a) -- 13 局部的

通常,_G和_ENV指向的是同一个表。但是,尽管如此,它们是很不一样的实体。_ENV是一个局部变量,所有对“全局变量”的访问实际上访问的都是_ENV。_G则是一个在任何情况下都没有任何特殊状态的全局变量。按照定义,_ENV永远指向的是当前的环境;而假设在可见且无人改变过其值的前提下,_G通常指向的是全局变量。
_ENV的主要用途是用来改变代码段使用的环境。一旦改变了环境,所有的全局访问就都将使用新表:

1
2
3
4
5
--将当前的环境改为一个新的空表
_ENV = {}
a = 1 -- 在_ENV中创建字段
print(a)
-- stdin:4:attempt to call global 'print' (a nil value)

如果新环境是空的,就会丢失所有的全局变量,包括函数print。因此,应该首先把一些有用的值放入新环境,比如全局环境:

1
2
3
4
a = 15		-- 创建一个全局变量
_ENV = {g = _G} -- 改变当前环境
a = 1 -- 在_ENV中创建字段
g.print(_ENV.a,g.a) -- 1, 15

这时,当访问“全局”的g(位于_ENV而不是全局环境中)时,我们使用的是全局环境,在其中能够找到函数print。
我们可以使用_G代替g,从而重写前面的例子:

1
2
3
4
a = 15					-- 创建一个全局变量
_ENV = {_G = _G} -- 改变的当前环境
a = 1 -- 在_ENV中创建字段
_G.print(_ENV.a,_g.a) -- 1 15

_G只有在Lua语言创建初始化的全局表并让字段_G指向它自己的时候,才会出现特殊状态。Lua语言并不关心该变量的当前值。不过尽管如此,就像我们在上面重写的示例中所看到的那样,将指向全局环境的变量命名为同一个名字(_G)是一个惯例。
另一种把旧环境装入新环境的方式是使用继承:

1
2
3
4
5
a = 1
local newgt = {} -- 创建新环境
setmetatable(newgt,{__index = _G})
_ENV = newgt -- 设置新环境
print(a) --1

在这段代码中,新环境从全局环境中继承了函数print和a。不过,任何赋值都会发生在新表中,虽然我们仍然能通过_G来修改全局环境中的变量,但如果误改全局环境中的变量也不会有什么影响。

1
2
3
4
5
-- 接此前的代码
a = 10
print(a,_G.a) -- 10 1
_G.a = 20
print(_G.a) -- 20

作为一个普通的变量,_ENV遵循通常的定界规则。特别地,在一段代码中定义的函数可以按照访问其他外部变量一样的规则访问_ENV:

1
2
3
4
5
6
7
8
_ENV = {_G = _G}
local function foo()
_G.print(a)
end
a = 10
foo() -- 10
_ENV = {_G = _G, a = 20}
foo() -- 20

如果定义一个名为_ENV的局部变量,那么对自由名称的引用将会绑定绑定到这个新变量上:

1
2
3
4
5
6
a = 2
do
local _ENV - {print = print,a = 14}
print(a) -- 14
end
print(a) -- 2

因此,,可以跟很容易地使用私有环境定义一个函数:

1
2
3
4
5
6
7
function factory(_ENV)
return function () return a end
end
f1 = factory{a = 6}
f2 = factory{a = 7}
print(f1()) -- 6
print(f2()) -- 7

factory函数创建了一个简单的闭包,这个闭包返回了其中“全局”的a。每当闭包被创建时,闭包可见的变量_ENV就成了外部factory函数的参数_ENV。因此,每个闭包都会使用自己的外部变量来访问其自由名称。
使用普遍的定界规则,我们可以有几种方式操作环境。例如,可以让多个函数共享一个公共环境,或者让一个函数改变它与其他函数共享的环境。

环境和模块

模块的缺点之一在于很容易污染全局空间,例如在私有声明中忘记local关键字。环境为解决这个问题提供了一种有趣的方式。一旦模块的主程序有一个独占的环境,则不仅该模块所有的函数共享了这个环境,该模块的全局变量也进入到了这个环境中。我们可以将所有的公有函数声明为全局变量,这样它们就会自动地进入分开的环境中。模块索要做的就是将这个环境赋值给变量_ENV。之后,当我们声明函数add时,它会变成M.add:

1
2
3
4
5
local M = {}
_ENV = M
function add(c1,c2)
return new(c1.r + c2.r, c1.i + c2.i)
end

此外,我们在调用同一模块中的其他函数时不需要任何前缀。在此前的代码中,add会从其环境中得到new,也就是M.new。
这种方法为模块提供了一种良好的支持,只需要程序员多做一点额外的工作。使用这种方法,完全不需要前缀,并且调用一个导出的函数与调用一个私有函数没有什么区别。即使程序员忘记了local关键词,也不会污染全局命名空间。相反,他只是让一个私有函数编程了公有函数而已。
不过尽管如此,笔者目前还是倾向于使用原始的基本方法,也许原始的基本方法需要更多的工作,但代码会更加清晰。为了避免错误地创建全局变量,笔者使用把nil赋给_ENV的方式。在把_ENV设为nil后,任何对全局变量的复制都会抛出异常。这种方式的另一个好处是无须修改代码也可以在老版本的Lua语言中运行。
为了访问其他模块,我们可以声明一个保存全局环境的局部变量:

1
2
3
local M = {}
local _G = _G
_ENV = nil

然后在全局名称前加上_G和模块名M即可。
另一种更规范的访问其他模块的做法是只把需要的函数或模块声明为局部变量:

1
2
3
4
5
6
7
8
9
10
-- 模块初始化
local M = {}

-- 导入部分:
-- 声明该模块需要的外部函数或模块等
local sqrt = math.sqrt
local io = io

-- 从此以后不能再进行外部访问
_ENV = nil

这种方式需要做更多的工作,但是它能清晰地列出模块的依赖。

_ENV 和load

正如笔者此前提到的,函数load通常把被加载代码段上的值_ENV初始化为全局环境。不过,函数load还有一个可选的第四个参数来让我们为_ENV指定一个不同的初始值。
例如,假设我们有一个典型的配置文件,该配置文件定义了程序要使用的几个常量和函数,如下:

1
2
3
-- 文件'config.lua'
width = 200
height = 300

可以使用如下的代码加载该文件:

1
2
env = {}
loadfile("config.lua","t",env)()

配置文件中的所有代码会运行在空的环境env中,类似于某种沙盒。特别地,所有的定义都会进入这个环境中。即使出错,配置文件也无法影响任何别的东西,甚至是恶意的代码也不能对其他东西造成任何破坏。除了通过消耗CPU时间和内存来制造拒绝服务攻击,恶意代码也做不了什么其他的事。
有时,我们可能想重复运行一段代码数次,每一次使用一个不同的环境。在这种情况下,函数load可选的参数就没用了。此时,我们有另外两种选择。
第一种选择是使用调试库中的函数debug.setupvalue。顾名思义,函数setupvalue允许改变任何指定函数的上值,例如:

1
2
3
4
5
f = load("b = 10; return a")
env = {a = 20}
debug.setupvalue(f,1,env)
print(f()) -- 20
print(env.b) -- 10

setupvalue的第一个参数是指定的函数,第二个参数是上值的索引,第三个参数是新的上值。对于这种用法,第二个参数永远是1:当函数表示的是一段代码时,Lua语言可以保证它只有一个上值且上值就是_ENV。
这种方式的一个小缺点在于依赖调试库。调试库打破了有关程序的一些常见假设。例如,debug.setupvalue打破了Lua语言的可见性规则,而可见性规则可以保证我们不能从词法定界的范围外访问局部变量。
另一种在几个不同环境中运行代码段的方式是每次加载代码时稍微对其进行一下修改。假设我们在要加载的代码段钱加入一行:

1
2
_ENV = ...;

请注意,由于Lua语言把所有的代码段都当做可变长参数函数进行编译,因此,多出的这一行代码会把传给代码段的第一个参数赋值_ENV,从而把参数设为环境。

1
2
3
4
5
6
7
prefix = "_ENV = ...;"
f = loadwithprefix(prefix,io.lines(filename,"*L"))
...
env1 = {}
f(env1)
env2 = {}
f(env2)
+