从很多意义上讲,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.0) --> error
这种行为违反对象拥有独立声明周期的原则。
另一种更加有原则的方法是对操作的接受者进行操作。因此,我们的方法需要一个额外的参数来表示该接受者,这个参数通常被称为self
或者this
:
Account = {balance = 0}
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)
21.1 类
截至目前,我们的对象具有了标识、状态和对状态进行的操作,但还缺乏类体系、继承和私有性。让我们先来解决第一个问题,即应该如何创建多个具有类似行为的对象。更具体地说,我们应该如何创建多个银行账户呢?
大多数面向对象语言提供了类的概念,类在对象的创建中扮演了模子的作用。在这些语言中,每个对象都是某个特定类的实例。Lua 语言中没有类的概念;虽然元表的概念在某种程度上与类的概念类似,但是把元表当做类使用在后续会比较麻烦。相反,我们可以参考基于原型的语言中的一些做法来在 Lua 语言中模拟类,例如Self 语言(JavaScript 采用的也是这种方式)。在这些语言中,对象不属于类。相反,每个对象都有一个原型。原型也是一种普通的对象,当对象遇到一个未知操作时会首先在原型中查找。要在这种语言中表示一个类,我们只需要创建一个专门被用作其它对象的原型对象即可。类和原型都是一种组织多个对象间共享行为的方式。
在 Lua 语言中,我们可以使用 20.4.1 节中所述的继承的思想来实现原型。更准确地说,如果有两个对象 A 和 B,要让 B 成为 A 的一个原型,只需要:
setmetatable(A, {__index = B})
在此之后,A 就会在 B 中查找所有它没有的操作。如果把 B 看作对象 A的类,则只不过是术语上的一个变化。
让我们回到之前银行账号的示例。为了创建其他与 Account 行为类似的账号,我们可以使用 __index 元方法让这些新对象从 Account 中继承这些操作。
Account = {
balance = 0,
withdraw = function(self, v)
self.balance = self.balance - v
end,
deposit = function(self, v)
self.balance = self.balance + v
end
}
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:deposit(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 的访问就不会再涉及元方法了。
21.2 继承
由于类也是对象,因此他们也可以从其他类获得方法。这种行为使得继承可以很容易地在 Lua 语言中实现。
假设有一个类似于 Account 的基类,参见示例 21.1。
示例 21.1 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,而 SpecialAccount 又继承自 Account。之后,当执行 s:deposit(100.00)
时,Lua 语言在 s 中找不到 deposit 字段,就会查找 SpecialAccount,仍找不到 deposit 字段,就查找 Account 并最终会在 Account 中找到 deposit 的最初实现。
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 变成负的余额。
Lua 语言中的对象有一个有趣的特性,就是无须为了指定一种新行为而创建一个新类。如果只有单个对象需要某种行为,那么我们可以直接在该对象中实现这个行为。例如,假设账户 s 表示一个特殊的客户,这个客户的透支额度总是其余额的 10%,那么可以只修改这个账户:
function s:getLimit()
return self.balance * 0.10
end
在这段代码后,调用 s:withdraw(200.00)
还是会执行 SpecialAccount 的 withdraw 方法,但当 withdraw 调用 self:getlimit 时,调用的是上述的定义。
21.3 多重继承
由于 Lua 语言中的对象不是基本类型,因此在 Lua 语言中进行面向对象编程时有几种方式。上面所见到的是一种使用 __index 元方法的做法,也可能是在简易、性能和灵活性方面最均衡的做法。不过尽管如此,还有一些其他的实现对某些特殊的情况可能更加合适。在此,我们会看到允许在 Lua 语言中实现多重继承的另一种实现。
这种实现额关键在于把一个函数用作 __index 元方法。请注意,当一个表的元表中的 __index 字段为一个函数时,当 Lua 不能在原来的表中找到一个键时就会调用这个函数。基于这一点,就可以让 __index 元方法在其他期望的任意数量的父类中查找缺失的键。
多重继承意味着一个类可以具有多个超类。因此,我们不应该使用一个(超)类中的方法来创建子类,而是应该定义一个独立的函数 createClass 来创建子类。createClass 的参数为新类的所有超类,参见示例21.2。该函数创建一个表来表示新类,然后设置新类元表中的元方法 __index,由元方法实现多重继承。虽然是多重继承,但每个实例仍属于单个类,并在其中查找所有的方法。因此,类和超类之间的关系不同于类和实例之间的关系。尤其是,一个类不能同时成为其实例和子类的元表。在示例 21.2 中,我们将类保存为其实例的元表,并创建了另一个表作为类的元表。
示例 21.2 一种多重继承的实现
-- 在表 '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
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 0
end
return c --返回新类
end
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 = { 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
-- 在表 '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
Named = {}
function Named:getname()
return self.name
end
function Named:setname(n)
self.name = n
end
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";未找到后,继而在 Named 中查找并最终在 Named 中找到了一个非 nil 的值,也就是最终的搜索结果。
当然,由于这种搜索具有一定的复杂性,因此多重继承的性能不如单继承。一种改进性能的简单做法是将被继承的方法复制到子类中,通过这种技术,类的 __index 元方法会变成:
setmetatable(c, {__index = function(t, k)
local v = search(k, parents)
t[k] = v
return v
end})
使用了这种技巧后,在第一次访问过被继承的方法后,再访问就会像访问局部变量方法一样快了。这种技巧的缺点在于当系统开始运行后修改方法的定义就比较困难了,这是因为这些修改不会沿着继承层次向下传播。
21.4 私有性
许多人认为,私有性是一门面向对象语言不可或缺的一部分:每个对象的状态都应该由它自己控制。在一些诸如 C++ 和 Java 的面向对象语言中,我们可以控制一个字段或一个方法是否在对象外可见。另一种非常流行的面向对象语言 Smalltalk,则规定所有的变量都是私有的,而所有的方法都是公有的。另一种面向对象语言 Simula,则不提供任何形式的私有性保护。
此前,我们所学习的 Lua 语言中标准对象实现方式没有提供私有性机制。一方面,这是使用普通结构(表)来表示对象所带来的的后果;另一方面,这也是 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% 的信用额度,但是又不想让用户访问到这些计算细节,就可以将这个功能按以下方法实现:
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 函数。
21.5 单方法对象
上述面向对象编程实现的一个特例是对象只有一个方法的情况。在这种情况下,可以不用创建接口表,只要将这个单独的方法以对象的表示形式返回即可。如果读者觉得听上去有点奇怪,那么应该回忆一下诸如 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
这种非传统的对象实现方式是很高效的。虽然 d("set", 10)
这样的语法有些奇怪,但也不过只是比传统的 d:set(10) 多出了两个字符而已。每个对象使用一个闭包,要比使用一个表的开销更低。虽然使用这种方式不能实现继承,但我们却可以拥有完全的私有性:访问单方法对象中某个成员只能通过该对象所具有的唯一方法进行。
Tcl/Tk 对它的窗口部件使用了类似的方法。在 Tk 中,一个窗口部件的名称就是一个函数,这个含糊可以根据它的第一个参数完成所有针对该部件的操作。
21.6 对偶表示
实现私有性的另一种有趣方式是使用对偶表示。让我们先看一下什么是对偶表示。
通常,我们使用键来把属性关联到表,例如:
table[key] = value
不过,我们也可以使用对偶表示:把表作为键,同时又把对象本身当做这个表的键:
key = {}
...
key[table] = value
这里的关键在于:我们不仅可以通过数值和字符串来索引一个表,还可以通过任何值来索引一个表,尤其是可以使用其他的表来索引一个表。
例如,在我们银行账户中的实现中,可以把所有账户的余额放在表 balance 中,而不是把余额放在每个账户里。我们的 withdraw 方法会变成:
function Account.withdraw(self, v)
balance[self] = balance[self] - v
end
这样做的好处在于私有性。即使一个函数可以访问一个账户,但除非它能够同时访问表 balance,否则也不能访问余额。如果表 balance 是一个在模块 Account 内部保存的局部变量,那么只有模块内部的函数才能访问它。因此,只有这些函数才能操作账户余额。
在我们继续学习前,必须讨论一下这种实现的一个大的缺陷。一旦我们把账户作为表 balance 中的键,那么这个账户对于垃圾收集器而言就永远也不会变成垃圾,这个账户会留在表中直到某些代码将其中表中显式地移除。这对于银行账户而言可能不是问题,但对于其他场景来说则可能是一个较大的缺陷。我们会在 23.3 节中学习如何解决这个问题,但现在我们先忽略它。
示例 21.3 展示了如何使用对偶表示来实现账户。
示例 21.3 使用对偶表示实现账户
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()
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()) --> 100.00
不过,我们不能恶意修改账户余额。这种实现通过让表 balance 为模块所私有,保证了它的安全性。
对偶表示无须修改即可实现继承。这种实现方式与标准实现方式在内存和时间开销方面基本相同。新对象需要一个新表,而且在每一个被使用的私有表中需要一个新的元素。访问 balance[self] 会比访问 self.balance 稍慢,这是因为后者使用了局部变量而前者使用了外部变量。通常,这种区别是可以忽略的。正如我们后面会看到的,这种实现对于垃圾收集器来说也需要一些额外的工作。
21.7 练习
- 练习 21.1:实现一个类 Stack,该类具有方法 push、pop、top 和 isempty。
Stack = {
stack = {},
isempty = function(self)
return #(self.stack) <= 0
end,
push = function(self, v)
table.insert(self.stack, v)
end,
pop = function(self)
if (self:isempty()) then
error("stack is empty", 2)
else
return table.remove(self.stack)
end
end
}
function Stack:new(o)
o = o or {}
self.__index = self
setmetatable(o, self)
return o
end
local s = Stack:new({ stack = { 1, 2, 3 } })
print(s:isempty()) --> false
print(s:pop()) --> 3
print(s:pop()) --> 2
print(s:pop()) --> 1
print(s:isempty()) --> true
s:push(5)
print(s:pop()) --> false
- 练习 21.2:实现类 Stack 的子类 StackQueue。除了继承的方法外,还给予这个子类增加一个方法 insertbottom,该方法在栈的底部插入一个元素(这个方法使我们可以把这个类的实例用作队列)。
Stack = {
stack = {},
isempty = function(self)
return #(self.stack) <= 0
end,
push = function(self, v)
table.insert(self.stack, v)
end,
pop = function(self)
if (self:isempty()) then
error("stack is empty", 2)
else
return table.remove(self.stack)
end
end
}
function Stack:new(o)
o = o or {}
self.__index = self
setmetatable(o, self)
return o
end
StackQueue = Stack:new()
function StackQueue.insertbottom(self, v)
table.insert(self.stack, 1, v)
end
local sq = StackQueue:new({stack = {1, 2}})
print(sq:isempty()) --> false
sq:insertbottom(5)
print(sq:pop()) --> 2
print(sq:pop()) --> 1
print(sq:pop()) --> 5
print(sq:isempty()) --> true
- 练习 21.3:使用对偶表示重新实现类 Stack。
local stack = {}
local Stack = {}
function Stack:isempty()
return #stack[self] <= 0
end
function Stack:push(v)
table.insert(stack[self], v)
end
function Stack:pop()
return table.remove(stack[self])
end
function Stack:new(o)
o = o or {}
setmetatable(o, self)
self.__index = self
stack[o] = {}
return o
end
s = Stack:new()
print(s:isempty()) --> true
s:push(1)
s:push(2)
print(s:isempty()) --> false
print(s:pop()) --> 2
print(s:pop()) --> 1
print(s:isempty()) --> true
- 练习 21.4:对偶表示的一种变形是使用代理表示对象。每一个对象由一个空的代理表表示,一个内部的表把代理映射到保存对象状态的表。这个内部表不能从外部访问,但是方法可以使用内部表来把 self 变量转换为要操作的真正的表。请使用这种方式实现银行账户的示例,然后讨论这种方式的优点和缺点。
看不懂 不会