烦恼一般都是想太多了。

0%

PIL.22-Lua中的环境

在大多数编程语言中,全局变量是非常讨厌的。一方面,使用全局变量会导致复杂的代码,让程序中不相关的不相关的部分看起来纠缠在了一起。另一方面,谨慎的使用全局变量可以很好的表达一个程序中的全局部分;但是,全局常量是没有大的问题的,但是像Lua一样的动态语言没有办法来分别一个变量是不是常量。

Lua这样的嵌入式语言增加了另外一个混合的集成部分:全局变量在程序内均可见,但是Lua对于一个程序是什么没有清晰的概念,而以被塑主程序调用的一串代码来表示(chunks)。

Lua通过不使用全局变量,而是不遗余力的进行了模拟。在第一个近似方法中,我们可以认为LUa把所有的全局变量放在一个常规的表中,叫做 全局环境。在后面我们可以看到,Lua可以把它的“全局”变量保存在几个环境中。当现在,我们只关注第一个情况。

使用表来保存全局变量简化了Lua的内部实现,因为不需要单独为全局变量设计一个不同的数据结构。另外一个好处是我们可以像操纵其他表一样操纵这个全局环境。为了帮助这些操纵,Lua将全局环境本身存储在全局变量_G中。(结果就是,_G._G等于 _G)。具体而言,下面的代码打印出全局环境中所有变量的名字:

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

环境

对于每个Lua State,其在建立的时候,就有一个全局环境,这个环境通过 _G变量来引用,这是Lua内部保留的。

而对于每个执行的代码片段, chunk,都会在编译的时候,被Lua设置一个 自己的环境。这个环境就是它的第一个上值 _ENV,通常,会让 _ENV_G相等。我们可以通过代码来验证这一点:

print(_G, _ENV)

输出结果:

table: 0x7fea4ad002e0	table: 0x7fea4ad002e0

说明两者引用的对象相同。我们说道,一个没有加 local 的变量是全局的,很明显,在这个时候,我们定义一个全局变量的话,也会影响 _G

所以可以通过一个把 _ENV 变量变化一下来让他不要影响 _G

_ENV = {_G = _G}

g = "global var"
_G.print(_ENV.g, _G.g)

可以看到,全局环境 _G不再受到影响。而局部变量,并不会进入 _ENV中。

动态名字的全局变量

通常,访问并设置全局变量通过赋值就够了。然而,某些时候我们需要一些类似 元-编程的形式,例如当我们需要操作一个名字存储在其他变量中或是在运行时计算的全局变量。为了获得这样一个变量的值,某些程序员会写如下代码:

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

如果 varname 是 x,字符串连接后的返回值是 return x,这将会得到我们期待的结果。然而,这样的代码会创建并编译一个新的 chunk,代价是昂贵的。我们可以通过下面的代码来达成同样的目的,这个的效率会提高一个量级:

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开始,然后逐个字段进化。

function getfield (f)
local v = _G -- start with the table of globals
for w in string.gmatch(f, "[%a_][%w_]*") do
v = v[w]
end
return v
end

我们依赖 gmatch 来遍历所有 f 中的标识符。

对应来设置字段的函数有点复杂。类似 a.b.c.d = v 这样的赋值类似:

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

这就是说,我们必须获取直到最后一个名字,然后单独的操作最后这个名字。 setfield 做了这个工作,还会在值不存在时建立临时表。

function setfield (f, v)
local t = _G -- start with the table of globals
for w, d in string.gmatch(f, "([%a_][%w_]*)(%.?)") do
if d == "." then -- not last name?
t[w] = t[w] or {} -- create table if absent
t = t[w] -- get the table
else
t[w] = v
end
end
end

下面的代码会建立一个全局表 t , 另外一个表 t.x,然后把 10 赋给 t.x.y

setfield("t.x.y", 10)

print(t.x.y) -- 10
print(getfield("t.x.y")) -- 10

全局变量声明

Lua中的全局变量不需要声明。尽管在小程序中这个行为非常的方便,但在大程序中一个简单的排印错误就可能产生难以找到的Bug。然而,我们也可以改变这个行为。因为Lua在普通表内保存全局变量,我们可以使用元表来检查是否Lua在访问一个不存在的变量。

第一个方式简单检查任何对全局表中不存在键的访问:

setmetable(_G, {
__newindex = function (_, n)
error("attempt to write to undeclared variable ".. n, 2)
end,
__index = function (_, n)
error("attempt to read undeclared variable ".. n, 2)
end,
})

在这个代码后,所有试图访问一个不存在的全局变量都会触发一个错误:

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

但是我们怎么样来声明新变量呢?一个选择是使用 rawset,这通过下面的元方法:

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

or, false 保证新全局变量总是与值 nil 不同)

另外一个更简单的选择只在函数内限制对新的全局变量赋值,在一个chunk的外部等级上运行自由赋值。

为了检查一个赋值是不是在 main chunk中,我们必须使用 debug 库。 调用 debug.getinfo(2, "S")会返回一个表:表中的字段 what 表明 调用元方法的 函数是一个 main chunk,还是一个普通的Lua函数,或是一个C函数。使用这个函数,我们可以重写我们的 __newindex 元方法:

__newindex = function (t, n, v)
local w = debug.getinfo(2, "S").what
if w ~= "main" and w ~= "C" then
error("attempt to write to undeclared variable " .. n, 2)
end
rawset(t, n, v)
end

新版本的函数也会接受从C代码的赋值,就跟这个类型的代码通常会知道他们在做什么。

如果我们需要测试一个变量是否存在,我们不能简单的把它和 nil 比较,因为如果其是 nil 的话,就会产生一个错误。我们应该使用 rawget,这将会避免触发元方法:

if rawget(_G, var) == nil then
-- 'var' is undeclared
...
end

我们的设计不允许全局变量的值是 nil,因为这样会被自动的认为是未声明的。但纠正这个问题并不难。我们所需要的只是一个辅助的表,用这个表来保存所有声明过的变量名字。无论合适调用一个元方法,就会在这个表内检查这个变量是不是没有声明。代码可能如下:

local declaredNames = {}
setmetatable(_G, {
__newindex = function (t, n, v)
if not declaredNames[n] then
local w = debug.getinfo(2, "S").what
if w ~= "main" and w ~= "C" then
error("attempt to write to undeclared variable " .. n, 2)
end
declaredNames[n] = true
end
rawset(t, n, v)
end,
__index = function (_, n)
if not declaredNames[n] then
error("attempt to read undeclared variable" .. n, 2)
else
return nil
end
end,
})

现在,即使是类似 x = nil 这样的赋值也可以用来声明一个全局变量。

两种解决方式的开销都是微不足道的。第一种方式,在正常操作间元方法永不会被调用。第二种中,他们可能会被调用,但只是在程序访问一个值为 nil 的变量时。

Lua的发行版中有一个模块 strict.lua,使用上面的代码来实现全局变量的检查。在开发Lua代码的时候使用它是一个好习惯。

非全局环境

Lua中,全局变量不需要真正的是全局的。就跟我们已经提到的一样,Lua并不真正的有全局变量。

这听起来会有点奇怪,因为我们这些文章中都使用了全局变量了。其实是Lua是不遗余力的给程序员一个模拟的全局变量。现在我们就来看一下Lua是怎么样来做的。

首先,我们先忘记关于全局变量的一切。我们会先从 自由名字 的概念开始。 一个 自由名字 是一个没有显式声明限制的名字,这就是说,其不会出现在一个对应局部变量的范围内。具体而说,在下面的chunk中, x, y 是自由名字,而 z 不是:

local z = 10
x = y + z

现在到重要的部分了:Lua的编译器会被所有的自由名字如 x ,翻译为 _ENV.x。因此,前面的chunk 和下面完全相等:

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

但是,新的变量 _ENV又是什么?

_ENV

_ENV不能是一个全局变量;我刚才说了Lua没有全局变量。再次,编译器耍了个小把戏。我已经提到过,Lua把任何 chunk都当做一个 匿名函数 。实际上,Lua会把我们的原始chunk编译成下面这样:

local _ENV = some value
return function (...)
local z = 10
_ENV.x = _ENV.y + z
end

这就是说,Lua在一个预定义的上值(一个外部的局部变量)_ENV存在的情况下编译chunk。因此,所有的变量要么是一个局部的(如果绑定到一个名字),或者是 _ENV的一个字段。这里_ENV是一个局部变量(一个上值)。

_ENV的初始值可以是任何表。(实际上,其也不需要一定是一个表;后面会提到)这样的表被叫做环境。 为了保存全局变量的模拟,Lua内部保留了应一个用做 全局环境 的表。通常,在我们加载一个 chunk,函数 load 会以这个 全局环境来初始化预定义的上值。所以,我们原始的chunk 会变得和下面的相等:

local _ENV = the global environment
return function (...)
local z = 10
_ENV.x = _ENV.y + z
end

这个代码的结果就是,全局环境的 x 字段获得值为 y 字段的值加上10。

第一种见解认为,这看起来是一个非常复杂的方式来操作全局变量。我不会辩解这是最简单的方式,但是其提供的灵活性,以其他简单实现很难达到。

在我们继续以前,我们来总结一下Lua操纵全局变量的过程:

  • 编译器在其编译的chunk外建立一个局部变量 _ENV
  • 把所有的自由名字 var 翻译为 _ENV.var
  • 函数 load, loadfile以全局环境(Lua内部保留的一个普通表)初始化chunk的第一个上值。

除此之外,其他的并不那么复杂。

某些用户可能会变得很混淆,因为他们试图在这些规则上弄些魔法出来。没有什么额外的魔法。实际上,前面的两条是完全被编译器完成的。除了被编译器预定义的,_ENV是一个简单的变量。在编译器外,_ENV并没有什么特别的意义。类似的,从 x_ENV.x 的翻译也是一个简单的语发上的变化,并没有隐藏的意思。实际上,在翻译后,_ENV将会指向的任何_ENV变量在代码中可见的地方,遵循标准的可见性规则。

使用 _ENV

在本节中,我们会看到一些_ENV带来的灵活性。 要记住,我们必须在本节中以 一个 chunk来运行例子程序。我们如果在交互模式下一行行的输入,每行都会成为一个不同的chunk,因此也具有不同的_ENV变量。因此,我们使用 do ... end 来包围代码块。

因为_ENV是一个普通变量,我们可以像其他变量一样赋值或者访问。_ENV = nil 将会时任何接下来在chunk对全局变量的访问无效。 这个用来控制我们代码使用的变量是非常有效的:

local print, sin = print, math.sin
_ENV = nil
print(13) -- 13
print(sin(13)) -- 0.42016703682664
print(math.cos(13)) -- error!

所有对自由名字的赋值都会产生一个类似的错误。

我们可以显示的写出 _ENV 来绕过一个局部声明:

a = 13 -- global
local a = 12
print(a) -- 12 (local)
print(_ENV.a) -- 13 (global)

可以用_G来完成同样的事:

a = 13 -- global
local a = 12
print(a) -- 12 (local)
print(_G.a) -- 13 (global)

通常,_G 和 _ENV 引用同样的表,但是,不管这个事实,他们是不同的实体。 _ENV 是一个局部变量,所有访问 全局变量 的操作都是访问它。_G是一个全局变量,没有什么特殊的状态。从定义上讲,_ENV 总是向当前环境; _G通常引用全局环境,没有人会改变它的值。

_ENV 主要的用途就是来改变代码的环境。一旦我们改变环境,所有全局访问 都会使用这个新表。

-- change current environment to a new empty table
_ENV = {}
a = 1
print(a) -- stdin:4: attempt t ocall global 'print' (a nil value)

如果新环境是空的,我们就丢失了所有的全局变量,包括 print。所以,我们应该首先以一些常用的变量来保存,具体点说,就是用全局环境。

a = 15 -- create a global variable
_ENV = {g = _G} -- change current environment
a = 1 -- create a field in _ENV
g.print(_ENV.a, g.a) -- 1 15

现在,当我们访问全局的 g 时(存在于 _ENV 中,不在全局环境中)我们获得了全局环境,Lua会在这里面找到函数 print

我们可以使用 _G 名字来重写先前的例子:

a = 15
_ENV = {_G = _G}
a = 1
_G.print(_ENV.a, _G.a)

统一特殊的地方就是,当Lua建立初始化的全局表时,让 _G 字段指向 全局环境 _G本身。Lua不关心这个变量的当前值。

另外一个保存我们新环境的方式是通过继承:

a = 1
local newgt = {}
setmetatable(newgt, {__index = _G})
_ENV = newgt
print(a)

在代码中,新环境从全局环境继承了 print, a。然而,所有的赋值都是到新表中。错误的在全局环境中改变一个变量是没有危险的,尽管我们还能通过 _G 改变他们:

-- 从前面的代码继续
a = 10 -- 这个值将会在_ENV.a/newgt.a 中
print(a, _G.a) -- 10 1
_G.a = 20
print(_G.a) -- 20

因为是一个普通的变量,_ENV遵循常规的范围规则。实际上,在一个chunk内定义的函数 访问 _ENV 就和他们访问其他的外部变量一样:

_ENV = {_G = _G}
local function foo ()
_G.print(a) -- 编译为 _ENV._G.print(_ENV.a)
end

a = 10
foo() -- 10
_ENV = {_G = _G, a = 20}
foo() -- 20

如果我们定义了一个新的局部变量 _ENV,对自由名字的访问将会绑定到这个新变量:

a = 2
do
local _ENV = {print = print, a = 14}
print(a)
end
print(a)

因此,定义一个有一个私有环境的函数并不难:

function factory (_ENV)
return function () return a end
end

f1 = factory {a = 6}
f2 = factory {a = 7}

print(f1())
print(f2())

环境与模块

在编写模块中,有一个害处就是会很容易的污染全局环境,比如忘记了给一个私有声明忘记了 local 。环境提高了一个有趣的技术来解决这个问题。一旦模块的 main chunk有一个独占的环境,模块内的所有函数共享这个表,所有的全局变量也会在这个表内。我们可以声明所有公共函数为全局变量,他们就会自动的进入一个单独的表。模块只需要把这个表 赋值给 _ENV 就可以了。在这后,当我声明一个函数 add,就将会变为 M.add

local M = {}
_ENV = m
function add (c1, c2)
return new(c1.r + c2.r, c1.i + c2.i)
end

更多的是,我们可以不用加前缀就能调用其他函数 。在前面的例子中,add 从其环境中获得 new,也就是说,其调用的是 M.new

这个方法提供了对模块的很好的支持,而程序员只需要做很少的事情。这将完全不需要前缀了。在调用 导出函数 和私有汗衫没也不再有区别。 如果程序员忘记了 local,其也不会污染全局的命名空间;而一个私有函数 简单的变成公有的。

尽管这样,但我还是宁愿使用原始的基本变成方法。他会需要更多的工作,但是得出的代码会非常去清晰。为了避免错误的建立一个全局,我使用 _ENV = nil。在这之后,所有对全局变量的赋值都会出错。

为了访问其他模块,我们可以使用前面提到的方式之一。具体点,我们可以声明一个局部变量来保存全局环境。

local M = {}
local _G = _G
_ENV = nil

接下来我们就可以用_G访问全局名字,用M 来访问模块。

一个更守规矩的方式是只把我们需要的函数,最多是模块需要的函数声明为局部的。

-- module setup
local M = {}

-- IMport section:
-- declare everythin this module needs from outside
local sqrt = math.sqrt
local io = io

-- no more external access after this point

_ENV = nil

这个技术会做更多的活,但是更好的指出了模块的依赖。

_ENV 和 load

先前提到,load 在加载一个chunk的时候通常时用全局环境来初始化 _ENV上值。 然而,load 有一个可选的第四参数来允许我们给 _ENV一个不同的值。 (函数 loadfile有一个类似的参数)

作为一个初始化的例子,考虑我们有一个典型的配置文件,定义了几个常量和函数,接下来我们会使用它们;看起来会是这样的:


-- file 'config.lua'
width = 200
height = 200
...

我们可以用下面的代码来加载:

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

配置文件内的所有代码会在空环境 env 下运行,就跟沙盒一样。实际上,所有的定义都会进入这个函数。配置文件没有办法来影响其他什么东西,及时是犯错。即使是恶意的代码也不会造成伤害。

某些时候,我们需要执行一个chunk多次,每次有不同的环境表。在这样的情况下,load 额外的参数是不实用的。我们有其他两个选择。

第一个选择是使用函数 debugsetupvalue。就跟名字一样,setupvalue运行我们改变一个给定函数任何上值。下面的片段演示了其用法:

f = load("b = 10; return a")
env = {a = 20}
debug.setupvalue(f, 1, env) -- 第一个上值是 _ENV
print(f()) -- 20
print(env.b) -- 10

setupvalue的第一个参数是函数,第二个是上值索引, 接着是上值的新值。在这种用法中,第二个参数总是1:当一个函数代表一个chunk,Lua假设其只有一个上值 _ENV。

一个不好的地方就是这种方式依赖debug库。这个库打破了程序的某些通常假设。比如,debug.setupvalue打破了Lua的可见性规则:我们在词法范围外不能访问一个局部变量。

另外以不同环境运行一个chunk的选择是在加载它是进行设置。想象我们在chunk前添加了下面的行:

_ENV = ...;

要记住,Lua把任何的chunk编译成一个变参的函数。所以,_ENV会获得传给函数的第一个参数,相当是把这个参数设置为了环境。下面的代码片段展示了这点:

prefix = "_ENV = ...;"
f = loadwithprefix(prefix, io.lines(filename, "*L"))
...
env1 = {}
f(env1)
env2 = {}
f(env2)