第 15 章 面向对象程序设计
Lua 中的表不仅在某种意义上是一种对象。像对象一样,表也有状态(成员变量); 也有与对象的值独立的本性,特别是拥有两个不同值的对象(table)代表两个不同的对 象;一个对象在不同的时候也可以有不同的值,但他始终是一个对象;与对象类似,表 的生命周期与其由什么创建、在哪创建没有关系。对象有他们的成员函数,表也有:
Account ={balance = 0}
function Account.withdraw (v)
Account.balance = Account.balance - v
end
这个定义创建了一个新的函数,并且保存在Account 对象的 withdraw 域内,下面我们可以这样调用:
Account.withdraw(100.00)
这种函数就是我们所谓的方法,然而,在一个函数内部使用全局变量名Account 是 一个不好的习惯。首先,这个函数只能在这个特殊的对象(指 Account)中使用; 第二,即使对这个特殊的对象而言,这个函数也只有在对象被存储在特殊的变量(指 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 参数的使用是很多面向对象语言的要点。大多数 OO 语言将这种机制隐藏起来, 这样程序员不必声明这个参数(虽然仍然可以在方法内使用这个参数)。Lua 也提供了通 过使用冒号操作符来隐藏这个参数的声明。我们可以重写上面的代码:
function Account:withdraw (v)
self.balance = self.balance - v
end
调用方法如下:
a:withdraw(100.00)
冒号的效果相当于在函数定义和函数调用的时候,增加一个额外的隐藏参数。这种 方式只是提供了一种方便的语法,实际上并没有什么新的内容。我们可以使用 dot 语法 定义函数而用冒号语法调用函数,反之亦然,只要我们正确的处理好额外的参数:
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)
现在我们的对象拥有一个标示符,一个状态和操作这个状态的方法。但他们依然缺少一个 class 系统,继承和隐藏。先解决第一个问题:我们如何才能创建拥有相似行为的 多个对象呢?明确地说,我们怎样才能创建多个 accounts?(针对上面的对象 Account 而言)
15.1类
一些面向对象的语言中提供了类的概念,作为创建对象的模板。在这些语言里,对 象是类的实例。Lua 不存在类的概念,每个对象定义他自己的行为并拥有自己的形状 (shape)。然而,依据基于原型(prototype)的语言比如 Self 和 NewtonScript,在 Lua中仿效类的概念并不难。在这些语言中,对象没有类。相反,每个对象都有一个 prototype (原型),当调用不属于对象的某些操作时,会最先会到 prototype 中查找这些操作。在 这类语言中实现类(class)的机制,我们创建一个对象,作为其它对象的原型即可(原型对象为类,其它对象为类的 instance)。类与 prototype 的工作机制相同,都是定义了特定对象的行为。
在 Lua中,使用前面章节我们介绍过的继承的思想,很容易实现 prototypes.更明确 的来说,如果我们有两个对象 a 和 b,我们想让 b作为 a 的 prototype 只需要:
setmetatable(a, { _index = b})
这样,对象 a 调用任何不存在的成员都会到对象 b 中查找。术语上,可以将b看作 类,a 看作对象。回到前面银行账号的例子上。为了使得新创建的对象拥有和 Account 相似的行为,我们使用_ index metamethod,使新的对象继承Account。注意一个小的优 化:我们不需要创建一个额外的表作为 account 对象的 metatable;我们可以用 Account 表本身作为 metatable:
function Account:new (o)
o = o or {} --create object if user does not provide one
setmetatable(o,self)
self. index = self
return o
end
(当我们调用 Account:new 时,self 等于 Account;因此我们可以直接使用 Account 取代 self。然而,使用 self 在我们下一节介绍类继承时更合适)。有了这段代码之后,当 我们创建一个新的账号并且掉用一个方法的时候,有什么发生呢?
a = Account:new{balance = 0}a:deposit(100.00)
当我们创建这个新的账号 a 的时候,a 将 Account 作为他的 metatable(调用 Account:new 时,self 即 Account)。当我们调用 a:deposit(100.00),我们实际上调用的是 a.deposit(a,100.00)(冒号仅仅是语法上的便利)。然而,Lua 在表 a 中找不到 deposit,因 此他回到 metatable 的 index 对应的表中查找,情况大致如下:
getmetatable(a)._index.deposit(a, 100.00)
a的metatable是Account,Account.__index也是Account(因为new函数中self.__index = self)。所以我们可以重写上面的代码为:
Account.deposit(a, 100.00)
也就是说,Lua传递 a 作为 self 参数调用原始的 deposit函数。所以,新的账号对象从 Account继承了 deposit 方法。使用同样的机制,可以从 Account 继承所有的域。继承 机制不仅对方法有效,对表中所有的域都有效。所以,一个类不仅提供方法,也提供了他的实例的成员的默认值。记住:在我们第一个 Account 定义中,我们提供了成员 balance默认值为 0,所以,如果我们创建一个新的账号而没有提供 balance的初始值,他将继承 默认值:
b = Account:new()
print(b.balance) --> 0
当我们调用 b 的 deposit 方法时,实际等价于:
b.balance =b.balance + v
(因为 self 就是 b)。表达式 b.balance 等于 0 并且初始的存款(b.balance)被赋予 b.balance。下一次我们访问这个值的时候,不会在涉及到 index metamethod,因为 b己经 存在他自己的 balance 域。
15.2 继承
通常面向对象语言中,继承使得类可以访问其他类的方法,这在 Lua 中也很容易现 实:假定我们有一个基类 Account:
Account ={balance = 0}
function Account:new (o)
o = o or {}
setmetatable(o, self)
self._index =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 的 metatable 是 SpecialAccount,_index 也是 SpecialAccount。 这样,s继承了 SpecialAccount,后者继承了 Account。当我们执行:
s:deposit(100.00)
Lua 在 s 中找不到 deposit 域,他会到 SpecialAccount 中查找,在 SpecialAccount 中 找不到,会到 Account 中查找。使得 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 不会到 Account中查找,因为它第一次救在 SpecialAccount 中发现了新的 withdraw 方法,由于s.limit 等于 1000.00(记住 我们创建 s 的时候初始化了这个值)程序执行了取款操作,s 的 balance 变成了负值。
在 Lua 中面向对象有趣的一个方面是你不需要创建一个新类去指定一个新的行为。 如果仅仅一个对象需要特殊的行为,你可以直接在对象中实现,例如,如果账号 s 表示 一些特殊的客户:取款限制是他的存款的 10%,你只需要修改这个单独的账号:
function s:getLimit ()
return self.balance * 0.10
end
这样声明之后,调用 s:withdraw(200.00)将运行 SpecialAccount 的 withdraw 方法,但 是当方法调用 self:getLimit 时,最后的定义被触发。
15.3多重继承
由于 Lua 中的对象不是元生(primitive)的,所以在 Lua 中有很多方法可以实现面向对 象的程序设计。我们前面所见到的使用index metamethod 的方法可能是简洁、性能、灵 活各方面综合最好的。然而,针对一些特殊情况也有更适合的实现方式。下面我们在Lua中多重继承的实现。
实现的关键在于:将函数用作 _ index。记住,当一个表的 metatable 存在一个 _index 函数时,如果 Lua 调用一个原始表中不存在的函数,Lua 将调用这个 _index 指定的函数。 这样可以用_index 实现在多个父类中查找子类不存在的域。
多重继承意味着一个类拥有多个父类,所以,我们不能用创建一个类的方法去创建 子类。取而代之的是,我们定义一个特殊的函数 createClass 来完成这个功能,将被创建 的新类的父类作为这个函数的参数。这个函数创建一个表来表示新类,并且将它的metatable 设定为一个可以实现多继承的 _index metamethod。尽管是多重继承,每一个 实例依然属于一个在其中能找得到它需要的方法的单独的类。所以,这种类和父类之间 的关系与传统的类与实例的关系是有区别的。特别是,一个类不能同时是其实例的 metatable 又是自己的 metatable。在下面的实现中,我们将一个类作为他的实例的 metatable,创建另一个表作为类的 metatable:
-- look up for `k' in listof tables 'plist'
local function search (k, plist)
for i=1, table.getn(plist) do
local v = plist[i][k] -- try 'i'-thsuperclass
if v then return v end
end
end
function createClass (...)
local c = {} -- new class
-- class will search foreach method in the listof its
-- parents (`arg'is the list of parents)
setmetatable(c, { index =function (t, k)
return search(k, arg)
end})
-- prepare `c' to be the metatable of its instances
c. index = c
-- define a new constructor for this newclass
function c:new (o)
o = o or {}
setmetatable(o, c)
return o
end
-- return newclass
return c
end
让我们用一个小例子阐明一下 createClass 的使用,假定我们前面的类 Account 和另 一个类 Named,Named只有两个方法setname and getname:
Named = {}
function Named:getname ()
return self.name
end
function Named:setname (n)
self.name = n
end
为了创建一个继承于这两个类的新类,我们调用 createClass:
NamedAccount =createClass(Account, Named)
为了创建和使用实例,我们像通常一样:
<pre name="code" class="csharp">account = NamedAccount:new{name = "Paul"}
print(account:getname()) --> Paul
现在我们看看上面最后一句发生了什么,Lua 在 account中找不到 getname,因此他查找 account 的 metatable 的 index,即 NamedAccount。但是,NamedAccount 也没有getname,因此 Lua 查找 NamedAccount 的 metatable 的 index,因为这个域包含一个函 数,Lua 调用这个函数并首先到 Account 中查找 getname,没有找到,然后到 Named 中 查找,找到并返回最终的结果。当然,由于搜索的复杂性,多重继承的效率比起单继承要低。一个简单的改善性能的方法是将继承方法拷贝到子类。使用这种技术,index 方法 如下:
...
setmetatable(c, { index =function (t, k)
local v = search(k, arg)
t[k] = v --save for nextaccess
return v
end})
...
应用这个技巧,访问继承的方法和访问局部方法一样快(特别是第一次访问)。缺点 是系统运行之后,很难改变方法的定义,因为这种改变不能影响继承链的下端。
15.4私有性(privacy)
很多人认为私有性是面向对象语言的应有的一部分。每个对象的状态应该是这个对 象自己的事情。在一些面向对象的语言中,比如 C++和 Java 你可以控制对象成员变量或 者成员方法是否私有。其他一些语言比如 Smalltalk 中,所有的成员变量都是私有,所有 的成员方法都是公有的。第一个面向对象语言 Simula不提供任何保护成员机制。
如前面我们所看到的 Lua中的主要对象设计不提供私有性访问机制。部分原因因为 这是我们使用通用数据结构 tables 来表示对象的结果。但是这也反映了后来的 Lua 的设 计思想。Lua 没有打算被用来进行大型的程序设计,相反,Lua 目标定于小型到中型的 程序设计,通常是作为大型系统的一部分。典型的,被一个或者很少几个程序员开发,甚至被非程序员使用。所以,Lua避免太冗余和太多的人为限制。如果你不想访问一个 对象内的一些东西就不要访问(If you do not want to access something inside an object, justdo not do it.)。
然而,Lua 的另一个目标是灵活性,提供程序员元机制(meta-mechanisms),通过 他你可以实现很多不同的机制。虽然 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 + self.extra()
end
...
15.5 Single-Method 的对象实现方法
前面的 OO程序设计的方法有一种特殊情况:对象只有一个单一的方法。这种情况 下,我们不需要创建一个接口表,取而代之的是,我们将这个单一的方法作为对象返回。这昕起来有些不可思议,如果需要可以复习一下 7.1 节,那里我们介绍了如何构造迭代 子函数来保存闭包的状态。其实,一个保存状态的迭代子函数就是一个 single-method 对 象。关于 single-method 的对象一个有趣的情况是:当这个single-method 实际是一个基于 重要的参数而执行不同的任务的分派Cdispatch)方法时。针对这种对象:
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 的窗口部件(widgets)使用了相似的方法,在 Tk 中一个窗口部件的名字表 示一个在窗口部件上执行各种可能操作的函数(a widget command)。