从很多意义上讲,Lua语言中的一张表就是一个对象。首先,表与对象一样,可以拥有状态。其次,表与对象一样,拥有一个与其无关的标识(self);
特别地,两个具有相同值的对象(表)是两个不同的对象,而一个对象可以具有多个不同的值;最后,表与对象一样,具有创建者和被创建位置无关的声明周期。
对象有其自己的操作。表也可以有自己的操作,例如:
1 | Account = {balance = 0} |
上面的代码创建了一个新函数,并将该函数存入Account对象的withdraw字段。然后,我们就可以进行如下的调用:
1 | Account.withdraw(100.00) |
这种函数差不多就是所谓的方法了。不过,在函数中使用全局名称Account是一个非常槽糕的编程习惯。首先,这个函数只能针对特定对象工作。其次,即使针对特定的对象,这个函数也只有在对象保存在特定的全局变量中时才能工作。如果我们改变类对象的名称,withdraw就不能工作了:
1 | a, Account = Account, nil |
这种行为违反对象拥有独立声明周期的原则。
另一种更加有原则的方法是对操作的接受者进行操作。因此,我们的方法需要额外的参数来表示该接受者,这个参数通常被称为self或this:
1 | function Account.withdraw(self,v) |
此时,当我们调用该方法时,必须指定要操作的对象:
1 | a1 = Account; Account = nil |
通过使用参数self,可以对多个对象调用相同的方法:
1 | a2 = {balance = 0, withdraw = Account.withdraw} |
使用参数self是所有面向对象语言的核心店。大多数面向对象语言都向程序员隐藏了这个机制,从而使得程序员不必显式地声明这个参数(虽然程序员仍然可以在方法内使用self或者this)。Lua语言同样可以使用冒号操作符隐藏该参数。使用冒号操作符,我们可以将上例重写为a2:withdraw(260.00):
1 | function Account:withdraw(v) |
冒号的作用是在一个方法调用中增加一个额外的参数,或在方法的定义中增加一个额外的隐藏行参。冒号只是一种语法机制,虽然很便利,但没有引入任何新的东西。我们可以使用点分语法来定义一个函数,然后用冒号语法调用它,反之亦然,只要能够正确地处理好额外参数即可:
1 | Account = { |
类(class)
截至目前,我们的对象具有了标识、状态和对状态进行的操作,但还缺乏类体系、继承和私有性。让我们先来解决第一个问题,即应该如何创建多个具有类似行为的对象。更具体地说,我们应该如果创建多个银行账户呢?
大多数面向对象语言提供了类的概念,类在对象的创建中扮演了模子的作用。在这些语言中,每个对象都是某个特定类的实例。Lua语言中没有类的概念;虽然元表的概念在某种程度上与类的概念相似,但是把元表当作类使用在后续会比较麻烦。相反,我们可以参考基于原型的语言中的一些做法来在Lua语言中模拟类,例如Self语言。在这些语言中,对象不属于类。相反,每个对象可以有一个原型。原型也是一种普通的对象,当对象(类的实例)遇到一个为知操作时会首先在原型中查找。要在这种语言中表示一个类,我们只需要创建一个专门被用作其他对象的原型对象即可。类和原型都是一种组织多个对象间共享行为的方式。
在Lua语言中,我们可以使用继承的思想来实现原型。更准确第说,如果有两个对象A和B,要让B成为A的一个原型,只需要:
1 | setmetatable(A,{__index = B}) |
在此之后,A就会在B中查找所有它没有的操作。如果把B看作对象A的类,则只不过是术语上的一个变化。
让我们回到之前银行账户的示例。为了创建其他与Account行为类似的账号,我们可以使用__index元方法让这些新对象从Account中继承这些操作。
1 | local mt = {__index = Account} |
在这段代码执行后,当我们创建一个新账户并调用新账户的一个方法时会发生什么呢?
1 | a = Account.new{balance = 0} |
当我们创建一个新账户a时,a会将mt作为其元表。当调用a:deposit(100.00)时,实际上调用的是a.deposit(a,100.00),冒号只不过是一个语法糖。不过,Lua语言无法在表a中找到字段”deposit”,所以它会在元表的__index中搜索。此时的情况大致如下:
1 | getmetatable(a).__index.deposit(a,100.00) |
a的元表是mt,而mt.__index是Account。因此,上述表达式等价于:
1 | Account.deposit(a,100.00) |
即,Lua语言调用了原来的deposit函数,传入了a作为self参数。因此新账户a从Account继承了函数deposit。同样,它还从Account继承了所有的字段。
对于这种模式,我们可以进行两个小改进。第一种改进是,不创建扮演元表角色的新表而是把Account直接用作元表。第二种改进是,对new方法也使用冒号语法。加入了这两个改动后,方法new会变成:
1 | function Account:new(o) |
现在,当我们调用Account:new()时,隐藏的参数self得到的实参是Account,Account.__index等于Account,并且Account被用作新对象的元表。可能看上去第二种修改(冒号语法)并没有得到大大的好处,但实际上当我们在使用类的继承的时候,使用self的优点就会很明显了。
继承不仅可以作用于方法,还可以作用于其他在新账户中没有的字段。因此,一个类不仅可以提供方法,还可以为实例中的字段提供常量和默认量。请注意,在第一版Account的定义中,有一个balance字段的值是0。因此,如果在创建新账户时没有提供初始的余额,那么余额就会继承这个默认值:
1 | b = Account:new() |
当在b上调用 deposit方法时,由于self就是b,所以等价于:
1 | b.balance = b.balance + v |
表达式b.balance求值后等于零,且该方法给b.balance赋了初始的金额。由于此时b有了它自己的balance字段,因此后续对b.balance的访问就不会再涉及元方法了。
继承(Inheritance)
由于类也是对象,因此它们也可以从其他类获得方法。这种行为使得继承(即常见的面向对象的定义)可以很容易地在Lua语言中实现。
Account类
1 | Account = {balance = 0} |
若想从这个类派生一个子类SpecialAccount以允许客户透支,那么可以先创建一个从基类继承了所有操作的空类:
1 | SpecialAccount = Account:new() |
直到现在,SpecialAccount还只是Account的一个实例。下面让我们来见证奇迹:
1 | s = SpecialAccount:new{limit = 1000.00} |
SpecialAccount就像继承其他方法一样从Account继承了new。不过,现在执行new时,它的self参数指向的是SpeciaAccount。因此,s的元表会是SpecialAccount,其中字段__index的值也是SpecialAccount。因此,s继承自SpecialAccount,而 SpecialAccount又继承自Account。之后,当执行s:deposit(100.00)时,Lua语言在s中找不到deposit字段,就会查找 SpecialAccount,仍找不到deposit字段,就查找Account并最终会在Account中找到deposit的最初实现。
SpecialAccount之所以特殊是因为我们可以重新定义从基类继承的任意方法,只需要编写一个新方法即可:
1 | function SpecialAccount:withdraw(v) |
现在,当调用s:withdraw(200.00)时,因为Lua语言会在 SpecialAccount中先找到新的withdraw方法,所以不会再从Account中查找。由于s.limit为1000.00(我们创建s时设置了这个值),所以程序会执行取款并使s变成负的余额。
Lua语言中的对象有一个有趣的特行,就是无须为了指定一种新行为而创建一个新类。如果只有单个对象需要某种特殊的行为,那么我们可以直接在该对象中实现在和行为。例如,账户s表示一个特殊的客户,这个客户的透支额度总是其余额的10%,那么可以只修改这个账户:
1 | function s:getLimit() |
在这段代码后,调用s:withdraw(200.00)还是会执行 SpecialAccount的withdraw方法,但当withdraw调用 self:getLimit时,调用的是上述的定义。
多重继承(Multiple Lnheritance)
由于Lua语言中的对象不是基本类型,因此在Lua语言中进行面向对象编程时又几种方式。上面缩减的是一种使用__index元方法的做法,也可能是在简易、性能和灵活性方面最均衡的做法。不过尽管如此,还有一些其他的实现对某些特殊的情况可能更加合适。在此,我们会看到允许在Lua语言中实现多重继承的另一种实现。
这种实现的关键在于把一个函数用作__index元方法。请注意,当一个表的元表中的__index字段为一个函数时,当Lua不能在原来的表中找到一个键时就会调用这个函数。基于这一点,就可以让__index元方法在其他期望的任意数量的父类中查找缺失的键。
多重继承以为这一个类可以具有多个超类。因此,我们不应该使用一个(超)类中的方法来创建子类,而是应该定义一个独立的函数createClass来创建子类。函数createClass的参数为新类的所有超类。该函数创建一个表来表示新类,然后设置新类元表中的元方法__index,由元方法实现多重继承。虽然是多重继承,但每个实例仍然属于单个类,并在其中查找所有的方法。因此,类和超类之间的关系不同于类和实例之间的关系。尤其是,一个类不能同时成为其实例和子类的元表。
实例:一种多重继承的实现
1 | -- 在表'plist'的列表中查找'k' |
让我们用一个简单地示例来演示 createClass的用法。假设前面提到的类Account和另一个只有两个方法setname和getname的类Named:
1 | Named = {} |
要创建一个同时继承Account和Named的新类NamedAccount,只需要调用createClass:
1 | NamedAccount = createClass(Account, Named) |
可以像平时一样创建和使用实例:
1 | account = NamedAccount:new{name = "Paul"} |
现在,让我们来学习Lua语言是如何对表达式account:getname()求值的;更确切地说,让我们来学习account[“getname”]的求值过程。首先,Lua语言在account中找不到字段”getname”;因此,它就查找account的元表中的__index字段,在我们的示例中该字段为NamedAccount。由于在NamedAccount中也不存在字段”getname”,所以再从NamedAccount的元表中查找__index字段。由于这个字段是一个函数,因此Lua语言就调用了这个函数(即search)。该函数现在Account中查找”getname”;未找到后,继而在Named中查找并最终在Named中找到了一个非nil的值,也就是最终的搜索结果。
当然,由于这种搜索具有一定的复杂性,因此多重继承的性能不如单继承。一种改进性能的简单做法是将被继承的方法复制到子类中,通过这种技术,类的__index元方法会变成:
1 | setmetatable(c,{__index = function(t,k) |
使用了这种技巧后,在第一次访问过被继承的方法后,再访问被继承的方法就会像访问局部方法一样快了。这种技巧的缺点在于当系统开始运行后修改方法的定义就比价困难了,这是因为这些修改不会沿着继承层次向下传播。
私有性
许多人认为,私有性是一门面向对象语言不可或缺的一部分:每个对象的状态都应该由它自己控制。在一些诸如C++和java的面向对象中,我们可以控制一个字段(也被称为实例变量)或一个方法是否在对象之外可见。另一种非常流行的面向对象语言Smalltalk,则规定所有的变量都是私有的。而所有的方法都是公有的。第一种面向对象语言Simula,则不提供任何形式的私有性保护。
此前,我们所学习的Lua语言中标准的对象实现方式没有提供私有性机制。一方面,这是使用普通结构来表示对象所带来的后果;另一方面,这也是Lua语言为了避免冗余和人为限制所采取的方法。如果夬不想返回一个对象内的内容,那就不要去访问就是了。一种常见的做法是把所有私有名称的最后加上一个上下画线,这样就能立刻区分出全局名称了。
不过,尽管如此,Lua原因的另外一项设计目标是灵活性,它为程序员提供能够模拟许多不同机制的元机制。虽然在Lua语言中,对象的基本设计没有提供私有性机制,但可以用其他方式来实现具有具有访问控制能力的对象。尽管程序员一般不会用到这种实现,但是了解这种实现还是有好处的,因为这种实现即探索了Lua语言中某些有趣的方面,又可以成为其他更具体问题的良好解决方案。
这种做法的基本思想是通过两个表来表示一个对象:一个表用来保存对象的状态,另一个表用于保存对象的操作(或接口)。我们通过二个表来访问对象本身,即通过组成其接口的操作来访问。为了避免未授权的访问,表示对象状态的表不保存在其他表的字段中,而只保存在方法的闭包中。例如,如果要用这种设计来表示银行账户,那么可以通过下面的工厂函数创建新的对象:
1 | function newAccount (initialBalance) |
首先,这个函数创建了一个用于保存对象内部状态的表,并将其存储在局部变量self中。然后,这个函数创建了对象的方法。最后,这个函数会创建并返回一个外部对象,该对象将方法名与真正的方法实现映射起来。这里的关键在于,这些方法不需要额外的self参数,而是直接访问self变量。由于没有了额外的参数,我们也就无须使用冒号语法来操作这些对象,而是可以像普通函数那样来调用这些方法:
1 | acc1 = newAccount(100.00) |
这种设计给予了存储在表self中所有内容完成的私有性。当newAccount返回后,就无法直接访问这个表了,我们只能通过在newAccount中创建的函数来访问它。虽然我们的示例只把一个实例变量放到了私有表中,但还有可以将一个对象中的所有私有部分都存入这个表。我们也可以定义私有方法,它们类似于公有方法但不放入接口中。例如,我们的账户可以给余额大于某个值的用户额外的10%的信用额度,但是又不想让用户访问到这些计算细节,就可以将这个功能按以下方法实现:
1 | function newAccount(initialBalance) |
单方法对象(Single-method Object)
上述面向对象编程实现的一个特例是对象只有一个方法的情况。在这种情况下,可以不用创建接口表,只要将这个单独的方法以对象的表示形式返回即可。
单方法对象的另一种有趣的情况是,这个方法其实是一个根据不同的参数完成不同任务的分发方法。这种对象的一种原型实现如下:
1 | function newObject (value) |
其使用方法很简单:
1 | d = newObject(0) |
这种非传统的对象实现方式是很高效的。虽然d(“set”,10)这样的语法有些奇怪,但也不过只是比传统的d:set(10)多出了两个字符而已。每个对象使用一个闭包,要比使用一个表的开销更低。虽然使用这种方式不能继承,但我们却可以拥有完全的私有性:访问单方法对象中某个成员只能通过该对象所具有的唯一方法进行。
Tcl/Tk对它的窗口部件使用了类似的做法。在Tk中,一个窗口部件的名称就是一个函数,这个函数可以根据它的第一个参数完成所有针对该部件的操作。
对偶表示(Dual Repressentation)
实现私有性的另一种有趣方式是使用对偶表示。让我们先看一下什么是对偶表示。
通常我们使用键来把属性关联到表,例如:
1 | table[key] = value |
不过,我们可以使用对偶表示:把表当作键,同时又把对象本身当作这个表的键:
1 | key = {} |
这里的关键在于:我们不仅可以通过数值和字符串来索引一个表,还可以通过任何值来索引一个表。尤其是可以使用其他的表来索引一个表。
例如,我们在银行账户的实现中,可以把所有账户的余额放在表balance中,而不是把余额放在每个账户里。我们的withdraw方法会变成:
1 | function Account.withdraw(self,v) |
这样做的好处在于私有性。即使一个函数可以访问一个账户,但是除非它能够同时访问表balance,否则也不能访问余额。如果表balance是一个在模块Account内部保存的局部变量,那么只有模块内部的函数才能访问它。因此,只有这些函数才能操作账户余额。
示例 使用对偶表示实现账户
1 | local balance = {} |
我们可以像使用其他类一样使用这个类:
1 | a = Account:new{} |
不过,我们不能恶意修改账户余额。这种实现通过让表balance为模块所私有,保证了它的安全性。
对偶表示无须修改即可实现继承。这种实现方式与标准实现方式在内存和时间开销方面基本相同。新对象需要一个新表,而且再每一个被使用的私有表中需要一个新的元素。访问balance[self]会比访问self.balance稍慢,这是因为后者使用了局部变量而前者使用了外部变量。通常,这种区别是可以忽略的。