Lua程序设计 | 面向对象编程、环境、垃圾收集

From《Programming in Lua》 by Roberto Ierusalimschy

文章目录

  • 面向对象编程
    • 继承
    • 多重继承
    • 私有性
    • 单方法对象
    • 对偶表示
  • 环境
    • 具有动态名称的全局变量
    • 全局变量的声明
    • 非全局环境
    • 使用_ENV
    • 环境和模块
    • _ENV 和 load
  • 垃圾收集
    • 弱引用表
    • 记忆函数
    • 对象属性
    • 回顾具有默认值的表
    • 瞬表
    • 析构器
    • 垃圾收集器


面向对象编程

从很多意义上讲,Lua语言中的一张表就是一个对象。首先,表与对象一样,可以拥有状态。其次,表与对象一样,拥有一个与其值无关的的标识(self);特别地,两个具有相同值的对象(表)是两个不同的对象,而一个对象可以具有多个不同的值;最后,表与对象一样,具有与创建者和被创建位置无关的生命周期。

对象有其自己的操作。表也可以有自己的操作,例如:

Account = {balance = 0)
function Account.withdraw (v)
	Account.balance = Account.balance - v
end

上面的代码创建了一个新函数,并将该函数存入Account对象的withdraw字段。然后,我们就可以进行如下的调用:

Account.withdraw(100.00)

这种函数差不多就是所谓的方法了。不过,在函数中使用全局名称Account是一个非常糟糕的编程习惯。首先,这个函数只能针对特定对象工作。其次,即使针对特定的对象,这个函数也只有在对象保存在特定的全局变量中时才能工作。如果我们改变了对象的名称,withdraw就不能工作了:

a, Account = Account, nil
a.withdraw(100.00) 	-- ERROR!

这种行为违反对象拥有独立生命周期的原则。

另一种更加有原则的方法是对操作的接受者进行操作。因此,我们的方法需要一个额外的参数来表示该接受者,这个参数通常被称为self或this:

function Account.withdraw (self, v)
	self.balance = self.balance - v
end

此时,当我们调用该方法时,必须指定要操作的对象:

a1 = Account; Account = nil
···
a1.withdraw(a1, 100.00)	-- OK

通过使用参数self,可以对多个对象调用相同的方法:

a2 = {balance=0, withdraw = Account.withdraw)
···
a2.withdraw(a2, 260.00)

使用参数self是所有面向对象语言的核心点。大多数面向对象语言都向程序员隐藏了这个机制,从而使得我们不必显式地声明这个参数(虽然我们仍然可以在方法内使用self或者this)。Lua语言同样可以使用冒号操作符隐藏该参数。使用冒号操作符,我们可以将上例重写为a2:withdraw(260.00):

function Account:withdraw (v)
	self.balance = self.balance - v
end

冒号的作用是在一个方法调用中增加一个额外的实参,或在方法的定义中增加一个额外的隐藏形参。冒号只是一种语法机制,虽然很便利,但没有引入任何新的东西。我们可以使用点分语法来定义一个函数,然后用冒号语法调用它,反之亦然,只要能够正确地处理好额外的参数即可:

Account = {balance=0,
  withdraw = function (self, v)
    self.balance = self.balance - v
  end
}

function Account:deposit (v)
  self.balance = self.balance + v
end

Account.deposit(Account, 200.00)
Account:withdraw(100.00)

截至目前,我们的对象具有了标识、状态和对状态进行的操作,但还缺乏类体系、继承和私有性。让我们先来解决第一个问题,即应该如何创建多个具有类似行为的对象。更具体地说,我们应该如何创建多个银行账户呢?

大多数面向对象语言提供了类的概念,类在对象的创建中扮演了模子(mold)的作用。在这些语言中,每个对象都是某个特定类的实例(instance)。Lua语言中没有类的概念;虽然元表的概念在某种程度上与类的概念相似,但是把元表当作类使用在后续会比较麻烦。相反,我们可以参考基于原型的语言中的一些做法来在Lua语言中模拟类,例如Self语言。在这些语言中,对象不属于类。相反,每个对象可以有一个原型。原型也是一种普通的对象,当对象(类的实例)遇到一个未知操作时会首先在原型中查找。要在这种语言中表示一个类,我们只需要创建一个专门被用作其他对象(类的实例)的原型对象即可。类和原型都是一种组织多个对象间共享行为的方式。

在Lua语言中,我们可以使用上篇博文中所述的继承的思想来实现原型。更准确地说,如果有两个对象A和B,要让B成为A的一个原型,只需要:

setmetatable(A, {__index = B})

在此之后,A就会在B中査找所有它没有的操作。如果把B看作对象A的类,则只不过是术语上的一个变化。

让我们回到之前银行账号的示例。为了创建其他与Account行为类似的账号,我们可以使用__index元方法让这些新对象从Account中继承这些操作。

local mt = {__index = Account}

function Account.new (o)
  o = o or {}		-- 如果用户没有提供则创建一个新的表
  setmetatable(o, mt)
  return o
end

在这段代码执行后,当我们创建一个新账户并调用新账户的一个方法时会发生什么呢?

a = Account.new{balance = 0}
a:deposit(100.00)

当我们创建一个新账户a时,a会将mt作为其元表。当调用a:desposit(100.00)时,实际上调用的是a.deposit(a, 100.00),冒号只不过是一个语法糖。不过,Lua语言无法在表a中找到字段”deposit”,所以它会在元表的__index中搜索。此时的情况大致如下:

getmetatable(a).__index.deposit(a, 100.00)

a的元表是mt,而mt.__index是Account。因此,上述表达式等价于:

Account.deposit(a, 100.00)

即Lua语言调用了原来的deposit函数,传入了a作为self参数。因此,新账户a从Account继承了函数deposit。同样,它还从Account继承了所有的字段。

对于这种模式,我们可以进行两个小改进。第一种改进是,不创建扮演元表角色的新表而是把表Account直接用作元表。第二种改进是,对new方法也使用冒号语法。加入了这两个改动后,方法new会变成:

function Account:new (o)
	o = o or (}
	self.__index = self
	setmetatable(o, self)
	return o
end

现在,当我们调用Account:new()时,隐藏的参数self得到的实参是Account,Account.__index等于Account,并且Account被用作新对象的元表。可能看上去第二种修改(冒号语法)并没有得到大大的好处,但实际上当我们在下一节中引入类继承的时候,使用self的优点就会很明显了。

继承不仅可以作用于方法,还可以作用于其他在新账户中没有的字段。因此,一个类不仅可以提供方法,还可以为实例中的字段提供常量和默认值。请注意,在第一版Account的定义中,有一个balance字段的值是0。因此,如果在创建新账户时没有提供初始的余额,那么余额就会继承这个默认值:

b = Account:new()
print(b.balance) --> 0

当在b上调用deposit方法时,由于self就是b,所以等价于:

b.balance = b.balance + v

表达式b.balance求值后等于零,且该方法给b.balance赋了初始的金额。由于此时b有了它自己的balance字段,因此后续对b.balance的访问就不会再涉及元方法了。

继承

由于类也是对象,因此它们也可以从其他类获得方法。这种行为使得继承(即常见的面向对象的定义)可以很容易地在Lua语言中实现。

假设有一个类似于Account的基类,参见下例:

Account = {balance = 0}

function Account:new (o)
  o = o or {}
  self.__index = self
  setmetatable(o, self)
  return o
end

function Account:deposit (v)
	self.balance = self.balance + v
end

function Account:withdraw (v)
  if v > self.balance then error"insufficient funds" end
  self.balance = self.balance - v
end

若想从这个类派生一个子类SpecialAccount以允许客户透支,那么可以先创建一个从基类继承了所有操作的空类:

SpecialAccount = Account:new()

直到现在,SpecialAccount还只是Account的一个实例。下面让我们来见证奇迹:

s = SpecialAccount:new{limit=1000.00}

SpecialAccount就像继承其他方法一样从Account继承了 new。不过,现在执行new时,它的self参数指向的是SpecialAccount。因此,s的元表会是SpecialAccount,其中字段__index的值也是 SpecialAccount。因此,s 继承自 SpecialAccount,而 SpecialAccount 又继承自Account。之后,当执行s:deposit(100.00)时,Lua语言在s中找不到deposit字段,就会查找SpecialAccount,仍找不到deposit字段,就查找Account并最终会在Account中找到desposit的最初实现。

SpecialAccount之所以特殊是因为我们可以重新定义从基类继承的任意方法,只需要编写一个新方法即可:

function SpecialAccount:withdraw (v)
  if v - self.balance >= self:getLimit() then 
    error"insufficient funds" 
  end
  self.balance = self.balance - v
end

function SpecialAccount:getLimit ()
	return self.limit or 0
end

现在,当调用s:withdraw(200.00)时,因为Lua语言会在SpecialAccount中先找到新的withdraw方法,所以不会再从Account中查找。由于s.limit为1000.00 (我们创建s时设置了这个值),所以程序会执行取款并使s变成负的余额。

Lua语言中的对象有一个有趣的特性,就是无须为了指定一种新行为而创建一个新类。如果只有单个对象需要某种特殊的行为,那么我们可以直接在该对象中实现这个行为。例如,假设账户s表示一个特殊的客户,这个客户的透支额度总是其余额的10%,那么可以只修改这个账户:

function s:getLimit ()
	return self.balance * 0.10
end

在这段代码后,调用s:withdraw(200.00)还是会执行SpecialAccount的withdraw方法,但当withdraw调用self :getLimit时,调用的是上述的定义。

多重继承

由于Lua语言中的对象不是基本类型,因此在Lua语言中进行面向对象编程时有几种方式。上面所见到的是一种使用__index元方法的做法,也可能是在简易、性能和灵活性方面最均衡的做法。不过尽管如此,还有一些其他的实现对某些特殊的情况可能更加合适。在此,我们会看到允许在Lua语言中实现多重继承的另一种实现。

这种实现的关键在于把一个函数用作__index元方法。请注意,当一个表的元表中的__index字段为一个函数时,当Lua不能在原来的表中找到一个键时就会调用这个函数。基于这一点,就可以让__index元方法在其他期望的任意数量的父类中査找缺失的键。

多重继承意味着一个类可以具有多个超类。因此,我们不应该使用一个(超)类中的方法来创建子类,而是应该定义一个独立的函数createClass来创建子类。函数createClass的参数为新类的所有超类。该函数创建一个表来表示新类,然后设置新类元表中的元方法__index,由元方法实现多重继承。虽然是多重继承,但每个实例仍属于单个类,并在其中查找所有的方法。因此,类和超类之间的关系不同于类和实例之间的关系。尤其是,一个类不能同时成为其实例和子类的元表。在下例中,我们将类保存为其实例的元表,并创建了另一个表作为类的元表。

-- 一种多重继承的实现
-- 在表‘plist‘的列表中查找‘k’
local function search (k, plist)
  for i = 1, #plist do
    local v = plist[i][k]			-- 尝试第‘i’个超类
    if v then return v end
  end
end

function createClass(...)
  local c = {}						-- 新类
  local parents = {...}				-- 父类列表
  
  -- 在父类列表中查找缺失的方法
  setmetatable(c, {__index = function (t, k)
      return search(k, parents)
  end})
  
  -- 将‘c’作为其实例的元表
  c.__index = c
  
  -- 为新类定义一个新的构造函数
  function c:new(o)
    o = o or {}
    setmetatable(o, c)
    return o
	end
  
  return c			-- 返回新类
end

让我们用一个简单的示例来演示createClass的用法。假设前面提到的类Account和另一个只有两个方法setname和getname的类Named:

Named = {}
function Named:getname ()
	return self.name
end

function Named:setname (n)
	self.name = n
end

要创建一个同时继承Account和Named的新类NamedAccount,只需要调用createClass:

NamedAccount = createClass(Account, Named)

可以像平时一样创建和使用实例:

account = NamedAccount:new{name = "Paul"}
print(account:getname())	--> Paul

现在,让我们来学习Lua语言是如何对表达式account:getname()求值的;更确切地说,让我们来学习account[“getname”]的求值过程。首先,Lua语言在account中找不到字段"getname";因此,它就查找account的元表中的__index字段,在我们的示例中该字段为NamedAccount。

由于在NamedAccount中也不存在字段"getname",所以再从NamedAccount的元表中查找__index字段。由于这个字段是一个函数,因此Lua语言就调用了这个函数(即search)。该函数先在Account中查找"getname";未找到后,继而在Named中查找并最终在Named中找到了一个非nil的值,也就是最终的搜索结果。

当然,由于这种搜索具有一定的复杂性,因此多重继承的性能不如单继承。一种改进性能的简单做法是将被继承的方法复制到子类中,通过这种技术,类的__index元方法会变成:

setmetatable(c, {__index = function (t, k)
  local v = search(k, parents)
  t[k] = v -- 保存下来用于下次访问
  return v
end))

使用了这种技巧后,在第一次访问过被继承的方法后,再访问被继承的方法就会像访问局部方法一样快了。这种技巧的缺点在于当系统开始运行后修改方法的定义就比较困难了,这是因为这些修改不会沿着继承层次向下传播。

私有性

许多人认为,私有性(也被称为信息隐藏,information hiding )是一门面向对象语言不可或缺的一部分:每个对象的状态都应该由它自己控制。在一些诸如C++和Java的面向对象语言中,我们可以控制一个字段(也被称为实例变量)或一个方法是否在对象之外可见。

此前,我们所学习的Lua语言中标准的对象实现方式没有提供私有性机制。一方面,这是使用普通结构(表)来表示对象所带来的后果;另一方面,这也是Lua语言为了避免冗余和人为限制所釆取的方法。如果不想访问一个对象内的内容,那就不要去访问就是了。一种常见的做法是把所有私有名称的最后加上一个下画线,这样就能立刻区分出全局名称了。

不过,尽管如此,Lua语言的另外一项设计目标是灵活性,它为开发者提供能够模拟许多不同机制的元机制。虽然在Lua语言中,对象的基本设计没有提供私有性机制,但可以用其他方式来实现具有访问控制能力的对象。

这种做法的基本思想是通过两个表来表示一个对象:一个表用来保存对象的状态,另一个表用于保存对象的操作(或接口)。我们通过第二个表来访问对象本身,即通过组成其接口的操作来访问。为了避免未授权的访问,表示对象状态的表不保存在其他表的字段中,而只保存在方法的闭包中。例如,如果要用这种设计来表示银行账户,那么可以通过下面的工厂函数创建新的对象:

function newAccount (initialBalance)
  local self = {balance = initialBalance}
  
  local withdraw = function (v)
				  self.balance = self.balance - v
				  end
  
  local deposit = function (v)
				  self.balance = self.balance + v
				  end
  
  local getBalance = function() return self.balance end
  
  return {
    withdraw = withdraw,
    deposit = deposit,
    getBalance = getBalance
  }
end

首先,这个函数创建了一个用于保存对象内部状态的表,并将其存储在局部变量self中。然后,这个函数创建了对象的方法。最后,这个函数会创建并返回一个外部对象,该对象将方法名与真正的方法实现映射起来。这里的关键在于,这些方法不需要额外的self参数,而是直接访问self变量。由于没有了额外的参数,我们也就无须使用冒号语法来操作这些对象,而是可以像普通函数那样来调用这些方法:

acc1 = newAccount(100.00)
acc1.withdraw(40.00)
print(acc1.getBalance())	--> 60

这种设计给予了存储在表self中所有内容完全的私有性。当newAccount返回后,就无法直接访问这个表了,我们只能通过在newAccount中创建的函数来访问它。虽然我们的示例只把一个实例变量放到了私有表中,但还可以将一个对象中的所有私有部分都存入这个表。我们也可以定义私有方法,它们类似于公有方法但不放入接口中。例如,我们的账户可以给余额大于某个值的用户额外10%的信用额度,但是又不想让用户访问到这些计算细节,就可
以将这个功能按以下方法实现:

function newAccount (initialBalance)
  local self = {
    balance = initialBalance,
    LIM = 10000.00,
  }
  
  local extra = function ()
    if self.balance > self.LIM then
      return self.balance*0.10
    else
      return 0
    end
  end
  
  local getBalance = function()
    return self.balance + extra()
  end
  
  -- 同前...

与前一个示例一样,任何用户都无法直接访问extra函数。

单方法对象

上述面向对象编程实现的一个特例是对象只有一个方法的情况。在这种情况下,可以不用创建接口表,只要将这个单独的方法以对象的表示形式返回即可。可以回忆一下诸如io.lines或string.gmatch这样的迭代器,一个在内部保存了状态的迭代器就是一个单方法对象。

单方法对象的另一种有趣情况是,这个方法其实是一个根据不同的参数完成不同任务的分发方法。这种对象的一种原型实现如下:

function newObject (value)
  return function (action, v)
    if action == "get" then return value
    elseif action == "set" then value = v
    else error("invalid action")
    end
  end
end

其使用方法很简单:

d = newObject(0)
print(d("get")) 	--> 0
d("set", 10)
print(d("get"))		--> 10

这种非传统的对象实现方式是很高效的。每个对象使用一个闭包,要比使用一个表的开销更低。虽然使用这种方式不能实现继承,但我们却可以拥有完全的私有性:访问单方法对象中某个成员只能通过该对象所具有的唯一方法进行。

对偶表示

实现私有性的另一种有趣方式是使用对偶表示。让我们先看一下什么是对偶表示。

通常,我们使用键来把属性关联到表,例如:

table[key] = value

不过,我们也可以使用对偶表示:把表当作键,同时又把对象本身当作这个表的键:

key = {}
···
key[table] = value

这里的关键在于:我们不仅可以通过数值和字符串来索引一个表,还可以通过任何值来索引一个表,尤其是可以使用其他的表来索引一个表。

例如,在我们银行账户的实现中,可以把所有账户的余额放在表balance中,而不是把余额放在每个账户里。我们的withdraw方法会变成:

function Account.withdraw (self, v)
	balance[self] = balance[self] - v
end

这样做的好处在于私有性。即使一个函数可以访问一个账户,但是除非它能够同时访问表balance,否则也不能访问余额。如果表balance是一个在模块Account内部保存的局部变量,那么只有模块内部的函数才能访问它。因此,只有这些函数才能操作账户余额。

在我们继续学习前,必须讨论一下这种实现的一个大的缺陷。一旦我们把账户作为表balance中的键,那么这个账户对于垃圾收集器而言就永远也不会变成垃圾,这个账户会留在表中直到某些代码将其从表中显式地移除。这对于银行账户而言可能不是问题(除非销户,否则一个账户通常需要一直有效),但对于其他场景来说则可能是一个较大的缺陷。我们会在之后会学习如何解决这个问题,但现在我们先忽略它。

下例展示了如何使用对偶表示来实现账户。

local balance = {}

Account = {}

function Account withdraw (v)
	balance[self] = balance[self] - v
end

function Account:deposit (v)
	balance[self] = balance[self] + v
end

function Account:balance (v)
	return balance[self]
end

function Account:new (o)
  o = o or {}			-- 如果用户没有提供则创建表
  setmetatable(o, self)
  self.__index = self
  balance[o] = 0		-- 初始余额
  return o
end

我们可以像使用其他类一样使用这个类:

a = Account:new{}
a:deposit(100.00)
print(a:balance())

不过,我们不能恶意修改账户余额。这种实现通过让表balance为模块所私有,保证了它的安全性。

对偶表示无须修改即可实现继承。这种实现方式与标准实现方式在内存和时间开销方面基本相同。新对象需要一个新表,而且在每一个被使用的私有表中需要一个新的元素。访问balance[self]会比访问self.balance稍慢,这是因为后者使用了局部变量而前者使用了外部变量。通常,这种区别是可以忽略的。正如我们后面会看到的,这种实现对于垃圾收集器来说也需要一些额外的工作。

环境

全局变量在大多数编程语言中是不可或缺的。一方面,使用全局变量会明显地使无关的代码部分纠缠在一起,容易导致代码复杂。另一方面,谨慎地使用全局变量又能更好地表达程序中真正的全局概念;此外,虽然全局常量看似无害,但像Lua语言这样的动态语言是无法区分常量和变量的。像Lua这样的嵌入式语言更复杂:虽然全局变量是在整个程序中均可见的变量,但由于Lua语言是由宿主应用调用代码段(chunk)的,因此“程序”的概念不明确。

Lua语言通过不使用全局变量的方法来解决这个难题,但又不遗余力地在Lua语言中对全局变量进行模拟。在第一种近似的模拟中,我们可以认为Lua语言把所有的全局变量保存在一个称为全局环境的普通表中。在后续内容中,我们可以看到Lua语言可以用几种环境来保存“全局“变量,但现在还是来关注第一种近似的模拟。

由于不需要再为全局变量创造一种新的数据结构,因此使用一个表来保存全局变量的一个优点是简化了 Lua语言的内部实现。另一个优点是,可以像操作其他表一样操作这个表。为了便于实现这种操作方式,Lua语言将全局环境自身保存在全局变量_G中(因此,_G._G与_G等价)。例如,如下代码输出了全局环境中所有全局变量的名称:

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

具有动态名称的全局变量

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

value = _G[varname]

由于全局环境是一个普通的表,因此可以简单地使用对应的键(变量名)直接进行索引。

类似地,我们可以通过编写_G[varname]=value给一个名称为动态计算出的全局变量赋值。

上述问题的一般化形式是,允许字段使用诸如"io.read"或"a.b.c.d"这样的动态名称。如果直接使用_G[“io.read”],显然是不能从表io中得到字段read的。但我们可以编写一个函数getfield让getfield(“io.read”)返回想要的结果。这个函数主要是一个循环,从_G开始逐个字段地进行求值:

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这样的赋值等价于以下的代码:

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

也就是说,我们必须一直取到最后一个名称,然后再单独处理最后的这个名称。下例中的函数setfield完成了这个需求,并且同时创建了路径中不存在路径对应的中间表。

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:

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

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

全局变量的声明

Lua语言中的全局变量不需要声明就可以使用。虽然这种行为对于小型程序来说较为方便,但在大型程序中一个简单的手误就有可能造成难以发现的Bug。

由于Lua语言将全局变量存放在一个普通的表中,所以可以通过元表来发现访问不存在全局变量的情况。一种方法是简单地检测所有对全局表中不存在键的访问:

setmetatable(_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的值。

另外一种更简单的方法是把对新全局变量的赋值限制在仅能在函数内进行,而代码段外层的代码则被允许自由赋值。

要检查赋值是否在主代码段中必须用到调试库。调用函数debug.getinfo(2, “S”)将返回一个表,其中的字段what表示调用元方法的函数是主代码段还是普通的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代码的赋值,因为一般C代码都知道自己究竟在做什么。

如果要测试一个变量是否存在,并不能简单地将它与nil比较。因为如果它为nil,那么访问就会引发一个错误。这时,应该使用rawget来绕过元方法:

if rawget(_G, var) == nil then
  --'var'未被声明
  ···
end

正如前面所提到的,我们不允许值为nil的全局变量,因为值为nil的全局变量都会被自动地认为是未声明的。但是,要允许值为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语言是如何构建这种幻觉的。

首先,让我们忘掉全局变量而从自由名称的概念开始讨论。一个自由名称是指没有关联到显式声明上的名称,即它不出现在对应局部变量的范围内。例如,在下面的代码段中,x和y是自由名称,而z则不是:

local z = 10
x = y + z

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

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

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

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

local _ENV = sone value (某些值)
return function (...)
  local z = 10
  _ENV.x = _ENV.y + z
end

也就是说,Lua语言是在一个名为_ENV的预定义上值(一个外部的局部变量)存在的情况下编译所有的代码段的。因此,所有的变量要么是绑定到了一个名称的局部变量,要么是_ENV中的一个字段,而_ENV本身是一个局部变量(一个上值)。

_ENV的初始值可以是任意的表(实际上也不用一定是表)。任何一个这样的表都被称为一个环境。为了维持全局变量存在的幻觉,Lua语言在内部维护了一个表来用作全局环境。通常,当加载一个代码段时,函数load会使用预
定义的上值来初始化全局环境。因此,原始的代码段等价于:

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 (或函数loadfile )使用全局环境初始化代码段的第一个上值,即Lua语言内部维护的一个普通的表。

其实,这些规则并没有额外的含义。尤其是,前两条规则完全是由编译器进行的。除了是由编译器预先定义的,
_ENV只是一个单纯的普通变量。抛开编译器,名称_ENV对于Lua语言来说根本没有特殊含义。类似地,从x到_ENV.x的转换是纯粹的语法转换,没有隐藏的含义。尤其是,在转换后,按照标准的可见性规则,_ENV引用的是其所在位置所有可见的_ENV变量。

使用_ENV

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

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

任何对自由名称(“全局变量”)的赋值都会引发类似的错误。

我们可以显式地使用_ENV来绕过局部声明:

a = 13				-- 全局的
local a = 12
print(a)			--> 12 (局部的)
print(_ENV.a)		--> 13 (全局的)

用_G也可以:

a = 13					-- 全局的
local a = 12
print(a)				--> 12 (局部的)
print(_G.a)				--> 12 (局部的)

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

_ENV的主要用途是用来改变代码段使用的环境。一旦改变了环境,所有的全局访问就都将使用新表:

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

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

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

这时,当访问“全局”的g (位于_ENV而不是全局环境中)时我们使用的是全局环境,在其中能够找到函数print。

我们可以使用_G代替g,从而重写前面的例子:

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

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

另一种把旧环境装入新环境的方式是使用继承:

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

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

-- 接此前的代码
a = 10
print(a, _G.a)		--> 10	1
_G.a = 20
print(_G.a)			--> 20

作为一个普通的变量,_ENV遵循通常的定界规则。特别地,在一段代码中定义的函数可以按照访问其他外部变量一样的规则访问_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(a)		--> 14
end
print(a)		--> 2	(回到原始的_ENV中)

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

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。因此,每个闭包都会使用自己的外部变量(作为上值)来访问其自由名称。

使用普遍的定界规则,我们可以有几种方式操作环境。例如,可以让多个函数共享一个公共环境,或者让一个函数改变它与其他函数共享的环境。

环境和模块

一旦模块的主程序块有一个独占的环境,则不仅该模块所有的函数共享了这个环境,该模块的全局变量也进入到了这个环境中。我们可以将所有的公有函数声明为全局变量,这样它们就会自动地进入分开的环境中。模块所要做的就是将这个环境赋值给变量_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关键字,也不会污染全局命名空间。相反,他只是让一个私有函数变成了公有函数而已。

为了访问其他模块,我们可以声明一个保存全局环境的局部变量:

local M = {}
local _G = _G

_ENV = nil	

然后在全局名称前加上_G和模块名M即可。

另一种更规范的访问其他模块的做法是只把需要的函数或模块声明为局部变量:

-- 模块初始化
local M = {}

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

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

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

_ENV 和 load

正如我们此前提到的,函数load通常把被加载代码段的上值_ENV初始化为全局环境。不过,函数load还有一个可选的第四个参数来让我们为_ENV指定一个不同的初始值(函数loadfile也有一个类似的参数)。

例如,假设我们有一个典型的配置文件,该配置文件定义了程序要使用的几个常量和函数,如下:

-- 文件'config.lua'
width = 200
height = 300
···

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

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

配置文件中的所有代码会运行在空的环境env中,类似于某种沙盒。特别地,所有的定义都会进入这个环境中。即使出错,配置文件也无法影响任何别的东西,甚至是恶意的代码也不能对其他东西造成任何破坏。除了通过消耗CPU时间和内存来制造拒绝服务(DoS)攻击,恶意代码也做不了什么其他的事。

有时,我们可能想重复运行一段代码数次,每一次使用一个不同的环境。在这种情况下,函数load可选的参数就没用了。此时,我们有另外两种选择。

第一种选择是使用调试库中的函数debug.setupvalue。顾名思义,函数setupvalue允许改变任何指定函数的上值,例如:

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语言的可见性规则,而可见性规则可以保证我们不能从词法定界的范围外访问局部变量。

另一种在几个不同环境中运行代码段的方式是每次加载代码段时稍微对其进行一下修改。假设我们在要加载的代码段前加入一行:

_ENV = ...;

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

垃圾收集

Lua语言使用自动内存管理。程序可以创建对象(表、闭包等),但却没有函数来删除对象。Lua语言通过垃圾收集自动地删除成为垃圾的对象,从而将开发人员从内存管理的绝大部分负担中解放出来。更重要的是,将开发者从与内存管理相关的大多数Bug中解放出来,例如无效指针和内存泄漏等问题。

在一个理想的环境中,垃圾收集器对开发者来说是不可见的。不过,有时即使是最智能的垃圾收集器也会需要我们的辅助。在某些关键的性能阶段,我们可能需要将其停止,或者让其只在特定的时间运行。另外,一个垃圾收集器只能收集它确定是垃圾的内容,而不能猜测我们把什么当作垃圾。没有垃圾收集器能够做到让我们完全不用操心资源管理的问题,比如驻留内存和外部资源。

弱引用表、析构器和函数collectgarbage是在Lua语言中用来辅助垃圾收集器的主要机制。

  • 弱引用表允许收集Lua语言中还可以被程序访问的对象
  • 析构器允许收集不在垃圾收集器直接控制下的外部对象
  • 函数collectgarbage则允许我们控制垃圾收集器的步长

弱引用表

正如此前所说的,垃圾收集器不能猜测我们认为哪些是垃圾。一个典型的例子就是栈,栈通常由一个数组和一个指向栈顶的索引实现。我们知道,数组的有效部分总是向顶部扩展的,但Lua语言却不知道。如果弹出一个元素时只是简单地递减顶部索引,那么这个仍然留在数组中的对象对于Lua语言来说并不是垃圾。同理,即使是程序不会再用到的、存储在全局变量中的对象,对于Lua语言来说也不是垃圾。在这两种情况下,都需要我们(的代码)将这些对象所在的位置赋为nil,以便这些位置不会锁定可释放的对象。

不过,简单地清除引用可能还不够。在有些情况下,还需要程序和垃圾收集器之间的协作。一个典型的例子是,当我们要保存某些类型(例如,文件)的活跃对象的列表时。这个需求看上去很简单,我们只需要把每个新对象插入数组即可;但是,一旦一个对象成为了数组的一部分,它就再也无法被回收了!虽然已经没有其他任何地方在引用它,但数组依然在引用它。除非我们告诉Lua语言数组对该对象的引用不应该阻碍对此对象的回收,否则Lua语言本身是无从知晓的。

弱引用表就是这样一种用来告知Lua语言一个引用不应阻止对一个对象回收的机制。所谓弱引用是一种不在垃圾收集器考虑范围内的对象引用。如果对一个对象的所有引用都是弱引用,那么垃圾收集器将会回收这个对象并删除这些弱引用。Lua用语言通过弱引用表实现弱引用,弱引用表就是元素均为弱引用的表,这意味着如果一个对象只被
一个弱引用表持有,那么Lua语言最终会回收这个对象。

表由键值对组成,其两者都可以容纳任意类型的对象。在正常情况下,垃圾收集器不会回收一个在可访问的表中作为键或值的对象。也就是说,键和值都是强引用,它们会阻止对其所指向对象的回收。在一个弱引用表中,键和值都可以是弱引用的。这就意味着有三种类型的弱引用表,即具有弱引用键的表、具有弱引用值的表及同时具有弱引用键和值的表。不论是哪种类型的弱引用表,只要有一个键或值被回收了,那么对应的整个键值对都会被从表中删除。

一个表是否为弱引用表是由其元表中的__mode字段所决定的。当这个字段存在时,其值应为一个字符串:如果这个字符串是"k",那么这个表的键是弱引用的;如果这个字符串是"v",那么这个表的值是弱引用的;如果这个字符串是"kv",那么这个表的键和值都是弱引用的。下面的示例演示了弱引用表的基本行为:

a = {}
mt = {__mode = "k"}
setmetatable(a, mt)			-- 现在'a'的键是弱引用的了
key = {}					-- 创建第一个键
a[key] = 1
key = {}					-- 创建第二个键
a[key] = 2
collectgarbage() 			-- 强制进行垃圾回收
for k, v in pairs(a) do print(v) end
	--> 2

在本例中,第二句赋值key={}覆盖了指向第一个键的索引。调用collectgarbage强制垃圾收集器进行一次完整的垃圾收集。由于已经没有指向第一个键的其他引用,因此Lua语言会回收这个键并从表中删除对应的元素。然而,由于第二个键仍然被变量key所引用,因此Lua不会回收它。

请注意,只有对象可以从弱引用表中被移除,而像数字和布尔这样的“值”是不可回收的。例如,如果我们在表a (之前的示例)中插入一个数值类型的键,那么垃圾收集器永远不会回收它。当然,如果在一个值为弱引用的弱引用表中,一个数值类型键相关联的值被回收了,那么整个元素都会从这个弱引用表中被删除。

字符串在这里表现了一些细微的差别,虽然从实现的角度看字符串是可回收的,但字符串又与其他的可回收对象不同。从开发者的角度看,字符串是值而不是对象。所以,字符串就像数值和布尔值一样,对于一个字符串类型的键来说,除非它对应的值被回收,否则是不会从弱引用表中被移除的。

记忆函数

空间换时间是一种常见的编程技巧。我们可以通过记忆函数的执行结果,在后续使用相同参数再次调用该函数时直接返回之前记忆的结果,来加快函数的运行速度。

假设有一个通用的服务器,该服务器接收的请求是以字符串形式表示的Lua语言代码。每当服务器接收到一个请求时,它就对字符串运行load函数,然后再调用编译后的函数。不过,函数load的开销很昂贵,而且发送给服务器的某些命令的出现频率可能很高。这样,与其每次收到一条诸如"closeconnection"这样的常见命令就重复地调用函数load,还不如让服务器用一个辅助表记忆所有函数load的执行结果。

在调用函数load前,服务器先在表中检查指定的字符串是否已经被处理过。如果没有,就调用函数load并将返回值保存到表中。我们可以将这种行为封装为一个新的函数:

local results = {}
function mem_loadstring (s)
  local res = results[s]
  if res == nil then			-- 已有结果么
    res = assert(load(s))		-- 计算新结果
    results[s] = res			-- 保存结果以便后续重用
  end
  return res
end	

这种模式节省的开销非常可观。但是,它也可能导致不易察觉的资源浪费。虽然有些命令会重复出现,但也有很多命令可能就出现一次。渐渐地,表results会堆积上服务器收到的所有命令及编译结果;在运行了一段足够长的时间后,这种行为会耗尽服务器的内存。

弱引用表为解决这个问题提供了一种简单的方案,如果表results具有弱引用的值,那么每个垃圾收集周期都会删除所有那个时刻未使用的编译结果(基本上就是全部):

local results = {)
setmetatable(results, {__mode = "v"})	-- 让值成为弱引用的
function mem_loadstring (s)
	-- 同前...

实际上,因为索引永远是字符串,所以如果愿意的话,我们可以让这个表变成完全弱引用的:

setmetatable(results, {__mode = "kv"})

最终达到的效果是完全一样的。

记忆技术还可以用来确保某类对象的唯一性。例如,假设一个系统用具有三个相同取值范围的字段red、green和blue的表来表示颜色,一个简单的颜色工厂函数每被调用一次就生成一个新颜色:

function createRGB (r, g, b)
	return {red = r, green = g, blue = b}
end

使用记忆技术,我们就可以为相同的颜色复用相同的表。要为每一种颜色创建一个唯一的键,只需要使用分隔符把颜色的索引连接起来即可:

local results = {}
setmetatable(results, {__mode = "v"})		-- 让值成为弱引用的
function createRGB (r, g, b)
  local key = string.format("%d-%d-%d", r, g, b)
  local color = results[key]
  if color == nil then
    color = {red = r, green = g, blue = b}
    results[key] = color
  end
  return color
end

这种实现的一个有趣结果是,由于两种同时存在的颜色必定是由同一个表来表示,所以用户可以使用基本的相等运算符比较两种颜色。因为随着时间的迁移垃圾收集器会清理表resuIts,所以一种指定的颜色在不同的时间内可能由不同的表来表示。不过,只要一种颜色正在被使用,它就不会从results中被移除。因此,一种颜色与一种新颜色相比已经存在了多长时间,这种颜色对应的表也存在了对应长度的时间,也可以被新颜色复用。

对象属性

弱引用表的另外一种重要应用是将属性与对象关联起来。在各种各样的情况下,我们都需要把某些属性绑定到某个对象,例如函数名、表的默认值及数组的大小等。

当对象是一个表时,可以通过适当的唯一键把属性存储在这个表自身中(正如之前所看到的,创建唯一键的一种简单和防止出错的方法是创建一个新表并把它当作键使用)。不过,如果对象不是一个表,那么它就不能保存它自己的属性。另外,即使是表,有时我们也不想把属性保存在原始的对象中。例如,当想保持属性的私有性时,或不想让属性干扰表的遍历时,就需要用其他办法来关联对象与属性。

当然,外部表为对象和属性的映射提供了一种理想的方法,即之前介绍的对偶表示,其中将对象用作键、将对象的属性用作值。由于Lua语言允许使用任意类型的对象作为键,因此一个外部表可以保存任意类型对象的属性。此外,存储在外部表中的属性不会干扰其他对象,并且可以像表本身一样是私有的。

不过,这个看似完美的方案有一个重大缺陷:一旦我们把一个对象当作表中的一个键,那么就是引用了它。Lua语言无法回收一个正在被用作键的对象。例如,如果使用一个普通的表来映射函数和函数名,那么这些函数就永远无法被回收。我们可以使用弱引用表来解决这个缺陷,不过,这次我们需要的是弱引用的键。使用弱引用键时,如果没有其他的引用,则不会阻止键被回收。另一方面,这个表不能有弱引用的值,否则,活跃对象的属性也可能被回收。

回顾具有默认值的表

之前我们讨论过如何实现具有非nil默认值的表。我们已经见到过一种特殊的技术,也注明了还有两种技术需要弱引用表的支持待后续讨论。现在,我们进行讨论,这两种用于默认值的技术其实是刚刚学习过的对偶表示和记忆这两种通用技术的特例。

在第一种解决方案中,我们使用了一个弱引用表来映射每一个表和它的默认值:

local defaults = {}
setmetatable(defaults, {__mode = "k"})
local mt = {__index = function (t) return defaults[t] end}
function setDefault (t, d)
  defaults[t] = d
  setmetatable(t, mt)
end

这是对偶表示的一种典型应用,其中使用了defaults[t]来表示t.default。如果表defaults没有弱引用的键,那么所有具有默认值的表就会永远存在下去。

在第二种解决方案中,我们对不同的默认值使用了不同的元表,在遇到重复的默认值时会复用相同的元表。这是记忆技术的一种典型应用:

local metas = {}
setmetatable(metas, {__mode = "v"})

function setDefault (t, d)
  local mt = metas[d]
  if mt == nil then
    mt = {__index = function () return d end}
    metas[d] = mt		-- 记忆
  end
  setmetatable(t, mt)
end

在这种情况下,我们使用弱引用的值使得不再被使用的元表能够被回收。

这两种实现哪种更好取决于具体的情况。这两种实现具有类似的复杂度和性能表现,第一种实现需要为每个具有默认值的表(表defaults中的一个元素)分配几个字节的内存;而第二种实现则需要为每个不同的默认值分配若干内存(一个新表、一个新闭包和表metas中的一个元素)。因此,如果应用中有上千个具有少量不同默认值的表,那么第二种实现明显更好。不过,如果只有少量共享默认值的表,那么就应该选择第一种实现。

瞬表

一种棘手的情况是,一个具有弱引用键的表中的值又引用了对应的键。

这种情况比看上去的更加常见。一个典型的示例是常量函数工厂。这种工厂的参数是一个对象,返回值是一个被调用时返回传入对象的函数:

function factory (o)
	return (function () return o end)
end

这种工厂是实现记忆的一种很好的手段,可以避免在闭包已经存在时又创建新的闭包。下例使用记忆技术的常量函数工厂展示了这种改进。

do
  local mem = {}	-- 记忆表
  setmetatable(mem, {__mode = "k"})
  function factory (o)
    local res = mem[o]
    if not res then
      res = (function () return o end)
      mem[o] = res
  		end
    return res
  end 
end	

不过,这里另有玄机。请注意,表mem中与一个对象关联的值(常量函数)回指了它自己的键(对象本身)。虽然表中的键是弱引用的,但是表中的值却不是弱引用的。从一个弱引用表的标准理解看,记忆表中没有任何东西会被移除。由于值不是弱引用的,所以对于每一个函数来说都存在一个强引用。每一个函数都指向其对应的对象,因而对于每一个键来说都存在一个强引用。因此,即使有弱引用的键,这些对象也不会被回收。

不过,这种严格的理解不是特别有用。大多数人希望一个表中的值只能通过对应的键来访问。我们可以认为之前的情况是某种环,其中闭包引用了指回闭包(通过记忆表)的对象。

Lua语言通过瞬表的概念来解决上述问题。

在Lua语言中,一个具有弱引用键和强引用值的表是一个瞬表。在一个瞬表中,一个键的可访问性控制着对应值的可访问性。更确切地说,考虑瞬表中的一个元素(k,v),指向的v的引用只有当存在某些指向k的其他外部引用存在时才是强引用。否则,即使v (直接或间接地)引用了k,垃圾收集器最终会收集上并把元素从表中移除。

析构器

虽然垃圾收集器的目标是回收对象,但是它也可以帮助程序员来释放外部资源。出于这种目的,几种编程语言提供了析构器。析构器是一个与对象关联的函数,当该对象即将被回收时该函数会被调用。

Lua语言通过元方法__gc实现析构器,如下例所示:

o = {x = "hi"}
setmetatable(o, {__gc = function (o) print(o.x) end})
o = nil
collectgarbage() --> hi

在本例中,我们首先创建一个带有__gc元方法元表的表。然后,抹去与这个表的唯一联系(全局变量),再强制进行一次完整的垃圾回收。在垃圾回收期间,Lua语言发现表已经不再是可访问的了,因此调用表的析构器,也就是元方法__gc。

Lua语言中,析构器的一个微妙之处在于“将一个对象标记为需要析构“的概念。通过给对象设置一个具有非空__gc元方法的元表,就可以把一个对象标记为需要进行析构处理。如果不标记对象,那么对象就不会被析构。我们编写的大多数代码会正常运行,但会发生某些奇怪的行为,比如:

o = {x = "hi"}
mt = {)
setmetatable(o, mt)
mt.__gc = function (o) print(o.x) end
o = nil
collectgarbage() 	--> (prints nothing)

这里,我们确实给对象o设置了元表,但是这个元表没有__gc元方法,因此对象没有被标记为需要进行析构处理。即使我们后续给元表增加了元方法__gc,Lua语言也发现不了这种赋值的特殊之处,因此不会把对象标记为需要进行析构处理。

正如我们所提到的,这很少会有问题。在设置元表后,很少会改变元方法。如果真的需要在后续设置元方法,那么可以给字段__gc先赋一个任意值作为占位符:

o = {x = "hi"}
mt = {__gc = true}
setmetatable(o, mt)
mt.__gc = function (o) print(o.x) end
o = nil
collectgarbage() 	--> hi

现在,由于元表有了__gc字段,因此对象会被正确地标记为需要析构处理。如果后续再设置元方法也不会有问题,只要元方法是一个正确的函数,Lua语言就能够调用它。

当垃圾收集器在同一个周期中析构多个对象时,它会按照对象被标记为需要析构处理的顺序逆序调用这些对象的析构器。请考虑如下的示例,该示例创建了一个由带有析构器的对象所组成的链表:

mt = {__gc = function (o) print(o[1]) end}
list = nil
for i = 1, 3 do
  list = setmetatable({i, link = list}, mt)
end

list = nil
collectgarbage()
  --> 3
  --> 2
  --> 1

第一个被析构的对象是3,也就是最后一个被标记的对象。

一种常见的误解是认为正在被回收的对象之间的关联会影响对象析构的顺序。例如,有些人可能认为上例中的对象2必须在对象1之前被析构,因为存在从2到1的关联。但是,关联会形成环。所以,关联并不会影响析构器执行的顺序。

有关析构器的另一个微妙之处是复苏。当一个析构器被调用时,它的参数是正在被析构的对象。因此,这个对象会至少在析构期间重新变成活跃的,称为临时复苏。在析构器执行期间,我们无法阻止析构器把该对象存储在全局变量中,使得该对象在析构器返回后仍然可访问,称为永久复苏。

复苏必须是可传递的。考虑如下的代码:

A = {x = "this is A"}
B = {f = A}
setmetatable(B, {__gc = function (o) print(o.f.x) end})
A, B = nil
collectgarbage() --> this is A

B的析构器访问了A,因此A在B析构前不能被回收,Lua语言在运行析构器之前必须同时复苏B和A。

由于复苏的存在,Lua语言会在两个阶段中回收具有析构器的对象。当垃圾收集器首次发现某个具有析构器的对象不可达时,垃圾收集器就把这个对象复苏并将其放入等待被析构的队列中。一旦析构器开始执行,Lua语言就将该对象标记为已被析构。当下一次垃圾收集器又发现这个对象不可达时,它就将这个对象删除。如果想保证我们程序中的所有垃圾都被真正地释放了的话,那么必须调用collectgarbage两次,第二次调用才会删除第一次调用中被析构的对象。

由于Lua语言在被析构对象上设置的标记,每一个对象的析构器都会精确地运行一次。如果一个对象直到程序运行结束还没有被回收,那么Lua语言就会在整个Lua虚拟机关闭后调用它的析构器。这种特性在Lua语言中实现了某种形式的atexit函数,即在程序终结前立即运行的函数。我们所要做的就是创建一个带有析构器的表,然后把它锚定在某处,例如锚定到全局表中:

local t = {__gc = function ()
  --'atexit'的代码位于此处
  print("finishing Lua program")
end}
setmetatable(t, t)
_G["*AA*"] = t

另外一个有趣的技巧会允许程序在每次完成攻圾回收后调用指定的函数。由于析构器只运行一次,所以这种技巧是让每个析构器创建一个用来运行下一个析构器的新对象,参见下例:

do
  local mt = {__gc = function (o)
      -- 要做的工作
      print(nnew cycle")
      -- 为下一次垃圾收集创建新对象
      setmetatable({}, getmetatable(o))
  end}
  
  -- 创建第一个对象
  setmetatable({}, mt)
end
  
collectgarbage() --> 一次垃圾收集
collectgarbage() --> 一次垃圾收集
collectgarbage() --> 一次垃圾收集

具有析构器的对象和弱引用表之间的交互也有些微妙。在每个垃圾收集周期内,垃圾收集器会在调用析构器前清理弱引用表中的值,在调用析构器之后再清理键。这种行为的原理在于我们经常使用带有弱引用键的表来保存对象的属性,因此,析构器可能需要访问那些属性。不过,我们也会使用具有弱引用值的表来重用活跃的对象,在这种情况下,正在被析构的对象就不再有用了。

垃圾收集器

一直到Lua 5.0,Lua语言使用的都是一个简单的标记-清除式垃圾收集器(GarbageCollector, GC )。这种收集器又被称为“stop-the-world (全局暂停)"式的收集器,意味着Lua语言会时不时地停止主程序的运行来执行一次完整的垃圾收集周期。每一个垃圾收集周期由四个阶段组成:标记、清理、清除和析构。

标记阶段把根结点集合标记为活跃,根结点集合由Lua语言可以直接访问的对象组成。在Lua语言中,这个集合只包括C注册表。

保存在一个活跃对象中的对象是程序可达的,因此也会被标记为活跃(当然,在弱引用表中的元素不遵循这个规则)。当所有可达对象都被标记为活跃后,标记阶段完成。

在开始清除阶段前,Lua语言先执行清理阶段,在这个阶段中处理析构器和弱引用表。首先,Lua语言遍历所有被标记为需要进行析构、但又没有被标记为活跃状态的对象。这些没有被标记为活跃状态的对象会被标记为活跃(复苏,resurrected),并被放在一个单独的列表中,这个列表会在析构阶段用到。然后,Lua语言遍历弱引用表并从中移除键或值未被标记的元素。

清除阶段遍历所有对象(为了实现这种遍历,Lua语言把所有创建的对象放在一个链表中)。如果一个对象没有被标记为活跃,Lua语言就将其回收。否则,Lua语言清理标记,然后准备进行下一个清理周期。

最后,在析构阶段,Lua语言调用清理阶段被分离出的对象的析构器。

使用真正的垃圾收集器意味着Lua语言能够处理对象引用之间的环。在使用环形数据结构时,我们不需要花费额外的精力,它们会像其他数据一样被回收。

Lua 5.1使用了增量式垃圾收集器。这种垃圾收集器像老版的垃圾收集器一样执行相同的步骤,但是不需要在垃圾收集期间停止主程序的运行。相反,它与解释器一起交替运行。每当解释器分配了一定数量的内存时,垃圾收集器也执行一小步(这意味着,在垃圾收集器工作期间,解释器可能会改变一个对象的可达性。为了保证垃圾收集器的正确性,垃圾收集器中的有些操作具有发现危险改动和纠正所涉及对象标记的内存屏障)。

Lua 5.2引入了紧急垃圾收集。当内存分配失败时,Lua语言会强制进行一次完整的垃圾收集,然后再次尝试分配。这些紧急情况可以发生在Lua语言进行内存分配的任意时刻,包括Lua语言处于不一致的代码执行状态时,因此,这些收集动作不能运行析构器。

你可能感兴趣的:(Lua程序设计)