这篇文章将介绍如何在 Lua 中实现 面向对象编程
,这里会用到上一篇文章介绍的元表知识。之后则将深入介绍 Lua 的运行环境,我们将了解到 Lua 全局变量背后的原理。
面向对象编程
从很多意义上来讲,lua 中的一张表就是一个对象:
- 表和对象一样,可以拥有状态
- 表和对象一样,拥有一个与其值无关的标识(self)
- 两个具有相同值的对象(表)是不同的对象,一个对象可以拥有多个不同的值
对象有自己的操作,表也可以有自己的操作:
1 | account = {balance = 0} |
这种函数差不多就是所谓的方法,但是在函数中使用全局名称 account
是一个非常糟糕的编程习惯:
- 首先这个函数只能针对特定对象工作
- 即使针对特定对象,该函数也只能在对象保持在特定的全局变量中时才能工作。如下代码就会报错:
1 | Account = {balance = 0} |
1 | lua: account.lua:4: attempt to index a nil value (global 'Account') |
更加有原则的方法是对操作的接收者(receiver)进行操作,因此需要一个额外的参数来表示 receiver,该参数通常称为 self 或 this:
1 | function Account.withdraw(self, v) |
Lua 支持使用冒号运算符隐藏 self 参数,例如 a1:withdraw(100)
。冒号的作用是在一个方法调用中增加一个额外的实参,或者在方法定义中增加一个额外的隐藏形参。冒号只是一种语法机制,没有引入任何新的东西。可以使用点分语法来定义函数,然后使用冒号语法来调用它,反之亦然。
1 | Account = { |
类
Lua 没有类的概念,但我们可以参考 基于原型的语言
中的一些做法来在 Lua 中模拟类。在这些语言中,对象不属于类,相反每个对象可以有一个原型,原型也是普通的对象。当对象遇到一个未知的操作时,会首先在原型中查找。在这种语言中表示一个类,只需要创建一个专门的、被用作其他对象(类的实例)的原型对象即可。类和原型都是一种组织多个对象间共享行为的方式。
在 Lua 中,要让对象 B 称为 A 的一个原型,只需要:
1 | setmetatable(A, {__index = B}) |
之后 A 就会在 B 中查找所有它没有的操作,这样就类似于达到 把 B 看做对象 A 的类
。
1 | local mt = {__index = Account} |
这样当执行 a.deposit(a, 20)
后,最终执行的是 Account.deposit(a, 100)
。
可以对上述代码进行改进:(1)不创建扮演元表角色的新表而是把表 Account 直接用作元表(2)对 new 方法也使用冒号语法。
1 | function Account:new(o) |
继承不仅可以作用于方法,还可以作用于其他在新账户中没有的字段。因此一个类不仅可以提供方法,还可以为实例中的字段提供常量和默认值。例如 Account
的定义中,也包含了 balance
字段,其值为 0,因此如果创建时没有提供该值,就会使用这个字段:
1 | local b = Account:new() |
在 b
上调用 deposit
方法后时,self 就是 b,所以等价于 b.balance = b.balance + v
。表达式 b.balance
求值后等于 0(借助元方法),执行完 b.balance = 0 + v
后 b 就有了自己的 balance 字段,因此后续对 b.balance
的访问就不会再涉及元方法了。
继承
由于类也是对象,因此它们可以从其他类中获得方法。这种行为使得继承(即常见的面向对象的定义)可以在 Lua 中实现。
首先如下是 Account 基类:
1 | Account = { |
接下来如果想从这个类派生一个子类,那么可以先创建一个从基类继承了所有操作的空类。接下来可以重新定义从基类继承的任意方法,或者定义新的方法:
1 | SpecialAccount = Account:new() |
由于 s 的元表是 SpecialAccount
,而 SpecialAccount
的元表又是 Account
,因此当执行 s:deposit(100)
时,Lua 在 s 中找不到 deposit 字段,就会查找 SpecialAccount
,仍然找不到,则继续查找 Account
。而在执行 s:withdraw(200)
则会现在 SpecialAccount
中找到 withdraw
方法,就不会再去 Account
中查找了。
Lua 中对象有一个有趣的特性,即无需为了指定一种新行为而创建一个新类。如果只有单个对象需要某种特殊行为,那么可以直接在该对象中实现这种行为:
1 | function s:getLimit() |
执行这段代码后,调用 s:withdraw(200)
还是会执行 SpecailAccount
的 withdraw
方法,但是其调用 self:getLimit
时,则调用的是上述定义。
多重继承
其实在 Lua 中实现面向对象编程时有几种方式,使用 __index
元方法是在 简易、性能、和灵活性
方面最均衡的做法。接下来将介绍一种在 Lua 中实现多重继承的方式。这种实现的关键在于把一个函数用作 __index
元方法,这样 __index
元方法可以在任意数量的父类中查找缺失的键。
如下通过一个独立的函数 createClass
来创建子类。虽然是多重继承,但是每个实例仍然属于单个类,并在其中查找所有的方法。
1 | local function search(k, plist) |
如下展示了该多重继承类的用法:
1 | NamedAccount = createClass(Account, Named) |
由于这种搜索具有一定的复杂性,因此多重继承的性能不如单继承。一种改进性能的简单方法是将被继承的方法复制到子类中,此时类的 __index
元方法会变成:
1 | setmetatable(c, { __index = function(t, k) |
这种技巧的缺点是当系统运行后修改方法的定义就比较困难了,因为这些修改不会沿着继承层次向下传播。
私有性
Lua 中标准的对象实现方式没有提供私有性机制,一方面这是使用普通结构(表)来表示对象所带来的后果,另一方面这也是 Lua 为了避免冗余和人为限制所带来的方法。如果不想访问一个对象的内容,就不要去访问。一种常见的做法是在所有私有名称的最后加上一个 _
,这样就和公开名称进行了区分。
但是 Lua 是非常灵活性的,它为程序员提供了能够模拟许多不同机制的元机制。我们可以用其他方式来实现具有访问控制能力的对象。这种做法的基本思想是通过两个表来表示一个对象,一个表用来表示对象的状态,另一个表用来保存对象的操作(或接口),我们通过第二个表来访问对象本身。
如下是一个示例:
1 | function newAccount(initialBalance) |
这种设计给予了存储在表 self 中所有内容完全的私有性。这里的关键在于,由于没有了额外的参数,所以就无需使用冒号语法来操作这些对象,而是像普通函数那样调用这些方法。
1 | a = newAccount(100) |
单方法对象
面对对象编程实现的一个特例是对象只有一个方法的情况,此时可以不用创建接口表,而是将这个方法以对象的形式返回即可。例如在内部保存了状态的迭代器就是一个单方法对象。单方法对象其实可以根据不同的参数完成不同任务的分发方法。
一种示例:
1 | function newObject(value) |
这种非传统的对象实现方式非常高效,每个对象使用一个闭包,要比使用一个表的开销更低。虽然使用这种方式不能实现继承,但是却可以拥有完全的私有性:访问单方法对象中某个成员只能通过该对象所具有的唯一方法进行。
对偶表示
实现私有性的另一种方式是使用对偶表示(dual representation)。通常我们使用键来把属性关联到表中,例如:
1 | table[key] = value |
当我们使用对偶表示时:使用表来表示一个键,同时又把对象本身当做这个表的键:
1 | key = {} |
这里的关键在于:我们不仅可以通过数值或者字符串来索引一个表,还可以通过任何值来索引一个表,尤其是可以使用其他表来索引一个表。
例如我们可以把所有的账户余额放到表 balance
中,而不是把余额放到每个账户里。因此 withdraw
方法就会变成:
1 | function Account.withdraw(self, v) |
但是需要注意,一旦我们把账户对象作为表 balance 的键,那么这个账户对于垃圾回收期而言就永远不会变成垃圾,这个账户会留在表中,直到某些代码将其从表中显式地移除。
如下是完整地示例:
1 | local balance = {} |
这种实现通过让表 balance 为模块所私有,保证了它的安全性。对偶表示无需修改即可实现继承,这种实现方式与标准实现方式在内存与实现开销方面基本相同。新对象需要一个新的表,而且在每一个被使用的私有表中需要一个新的元素。但这种实现对于垃圾收集来说需要一些额外的工作。
环境
像 Lua 这种嵌入式语言,虽然全局变量是在整个程序中均可见的变量,但是 Lua 的使用同时是由宿主应用调用代码段(chunk)的,因此 程序
的概念不明确。
Lua 语言通过不使用全局变量的方式来解决这个难题,但又不遗余力地在 Lua 中对全局变量进行模拟。在第一种近似模拟中,可以认为 Lua 把所有的全局变量保存在一个全局环境的普通表中。Lua 将全局环境自身也保存在全局变量 _G
中(即 _G._G
== _G
)。如下代码输出了全局环境中所有的全局变量:
1 | > for n in pairs(_G) do print(n) end |
具有动态名称的全局变量
由于全局环境是一个普通的表,因此可以使用对应的键(变量名)直接进行索引。例如 value = _G[varname]
,其中 varname
本身是一个变量。
由于我们可以使用诸如 io.read
或者 a.b.c.d
这样的动态名称。但是直接使用 _G[“io.read”] 是无法从表 io
中得到字段 read
的。如下实现了一个函数:
1 | function getfield(f) |
全局变量的声明
Lua 中的全局变量不需要声明就可以使用,在大型程序中一个简单的手误就可能造成难以发现的 bug。因此我们可以改变这种行为:由于 Lua 将全局变量是放在一个 _G
(其实也是一个普通的 Lua 表),所以可以通过元表来发现 访问不存在全局变量的情况
。
1 | setmetatable(_G, { |
1 | lua: visit_global.lua:11: attempt to write to undeclared variable test |
但是如果的确想声明一个新的变量,可以使用 rawset
:
1 | function declare(name, initval) |
另一种更简单的方法时是把对新全局变量的赋值限制在仅能在函数内进行,而代码段外层的代码则被允许自由赋值。要检查赋值是否在代码段中必须用到 调试库
:
1 | __newindex= function(t, n, v) |
之前提到过,Lua 不允许值为 nil 的全局变量,因为值为 nil
的全局变量都会被自动认为是未声明的。但是要允许值为 nil 的全局变量也不难,只需要引入一个辅助表来保存已声明变量的名称即可。一旦调用了元方法,元方法就会检查该表
1 | local declaredNamed = {} |
Lua 也包含了一个 strict.lua
模块,它实现了对全局变量的检查,在 Lua 编程时使用它是一个好习惯。
非全局环境
在 Lua 中全局变量并不一定非得是真正全局的。Lua 甚至根本没有全局变量,Lua 只是竭尽全力让程序员有全局变量存在的幻觉。
一个自由名称是指没有关联到显式声明上的名称,即它不出现在对应局部变量的范围内。Lua 会将代码段中的所有自由名称 x
转换为 _ENV.x
。因此如下代码等效:
1 | local z = 10 |
1 | local z = 10 |
Lua 将所有的代码段都当做匿名函数,所以 Lua 实际上将原来代码段编译为如下形式:即 Lua 是在一个名为 _ENV
的预定义 upvalue(一个外部的局部变量)存在的情况下编译所有代码段的。因此所有变量要么是绑定到一个名称的局部变量,要么是 _ENV
中的一个一个字段,而 _ENV
本身是一个局部变量(一个 upvalue)。
1 | local _ENV = some value |
_ENV
的初始值可以是任意的表(实际上不一定是表),任何一个这样的表都被称为一个环境。为了维持全局变量存在的幻觉,Lua 在内部维护了一个表用作全局环境(global environment)。当加载一个代码段时,函数 load
会使用全局环境来初始化这个预定义的 upvalue。因此原始代码段等价于:
1 | local _ENV = the global environment |
总结一下,Lua 处理全局变量的方式:
- 编译器在编译所有代码段之前,在外层创建局部变量 _ENV
- 编译器将所有自由名称 var 变化为
_ENV.var
- 函数 load(或函数 loadfile)使用全局环境初始化代码段上第一个 upvalue,即
_ENV
抛开编译器而言,名称 _ENV
对于 Lua 来说根本没有特殊含义。从 x
到 _ENV.x
的转换是纯粹的语法转换,没有隐藏的含义。尤其是,在转换之后,按照标准的可见性规则,_ENV
应用的是其所在位置所有可见的 _ENV
变量。
使用 _ENV
接下来将探索一些由 _ENV
带来的灵活性的手段。为了把代码当做一个代码段运行,要么把代码保存在一个文件中运行,要么使用 do--end
把代码段包围起来。如果在交互模式下一行一行地输入代码,那么每一行代码都会变成一段独立的代码,因此每一行都会有一个不同的 _ENV
变量。
由于 _ENV 只是一个普通的变量,因此可以对其进行访问或赋值,_ENV=nil
会使得后续代码不能直接访问全局变量。这可以用来控制代码使用哪种变量。
1 | local print, sin = print, math.sin |
1 | # lua env.lua |
我们可以使用 _ENV.a
来绕过局部声明:
1 | a = 13 |
通常 _G
和 _ENV
指向的是同一个表。仍然需要注意, _ENV
其实是一个局部变量,所有对全局变量的访问实际上都是访问 _ENV
。_G
则是一个在任何情况下都没有特殊状态的全局变量。按照定义,_ENV
永远指向的是当前环境,而 _G
通常指向的是全局环境。
_ENV
的主要用途就是改变代码段使用的环境:
1 | a = 15 |
可以使用 _G
替代 g
,将指向全局环境的变量命名为 _G
是同一个惯例。
另一种把旧环境装入新环境的方式使用继承:
1 | a = 1 |
在这段代码中,新环境仍然从全局环境继承了函数 print
和 a
,但是任何赋值都会发生在新表中。
作为一个普通的变量 _ENV
遵循通常的定界规则,特别地,在一段代码中定义的函数可以按照访问其他外部变量一样的规则访问 _ENV
。
1 | _ENV = {_G = _G} |
如果定义一个名为 _ENV
的局部变量,那么对自由名称的引用将会绑定到这个新变量上:
1 | a = 2 |
因此可以很容易地使用私有环境定义一个函数,如下实例中创建了一个简单的闭包,该闭包返回其中全局的 a
。
1 | function factory(_ENV) |
环境和模块
编写模块时很容易污染全局空间,例如在私有声明中忘记 local 关键字。环境为解决该问题提供了一种有趣的方式。一旦模块的主程序块有一个独占的环境,则不仅该模块所有的函数共享了这个环境,该模块的全局变量也进入到了这个环境。模块所要做的就是将这个环境赋值给 _ENV
:
1 | local M = {} |
这里当声明函数 add
时,它会变成 M.add
。而且我们调用同一模块中的其他函数时也不需要添加任何前缀。最关键的是,即使程序员忘了加 local
,也不会污染全局命名空间,他只是让一个私有函数变成了公有函数。
如果避免错误创建全局变量,还有一种方式就是将 _ENV
设置为 nil,这样任何对全局变量的赋值都会导致异常,而为了访问全局变量,则继续声明一个保存全局环境的局部变量,访问全局名称只需要添加 _G
即可。
1 | local M = {} |
另一种更规范的访问其他模块的做法是只把需要的函数或模块声明为局部变量,这种方式需要更多的工作,但是能清晰列出模块的依赖。
1 | local M = {} |
_ENV
和 load
函数 load
通常将被加载代码段的 upvalue _ENV
初始化为全局环境,其实其第 4 个参数可以让我们为 _ENV
指定一个不同的初始值(loadfile 也有类似参数)。
利用该机制,我们可以给 load
的代码传递一个空环境 {}
,这样就起到安全沙盒的作用:所加载的代码不会对其他环境造成任何破坏。
如果想在不同环境中运行同一段代码,可以使用调试库函数 debugl.setupvalue
来改变任何指定函数上的 upvalue
。其第一个参数是指定的函数、第二个参数是 upvalue 的索引,第三个参数是新的 upvalue 值。当函数表示的是一段代码时,Lua 可以保证它只有一个 upvalue _ENV,其索引为 1。
另一种方式则是每次加载代码段时稍微对其进行修改一下。由于 Lua 会把所有代码段都当做可变长参数进行编译,因此如下多出的一行代码 _ENV = ...;
就会把传给代码段的第一个参数赋给 _ENV
,从而把参数设置为环境。
1 | prefix = "_ENV = ..." |