面向对象编程

  Lua中的table就是一种对象,这句话可以从3个方面来证实。首先,table与对象一样可以拥有状态。其次,table也与对象一样拥有一个独立于其值的标识(一个self)。例如,两个具有相同值的对象(table)是两个不同的对象。最后,table与对象一样具有独立于创建者和创建地的生命周期。

  对象有其自己的操作。同样table也有这样的操作:

  1. Account = {balance = 0}
  2. function Account.withdraw(v)
  3. Account.balance = Account.balance - v
  4. end

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

  1. Account.withdraw(100.00)

  这种函数就是所谓的“方法(Method)”。不过,在函数中使用全局名称Account是一个不好的编程习惯。因为这个函数只能针对特定对对象工作,并且,这个特定对象还必须存储在特定的全局变量中。如果改变了对象的名称,withdraw就再也不能工作了:

  1. a = Account;Account = nil
  2. a.withdraw(100.00) -- 错误!❌

  这种行为违反了前面提到的对象特性,即对象拥有独立的生命周期。

  有一种灵活的方法,即指定一项操作所作用的“接受者”。因此需要一个额外的参数来表示该接受者。这个参数通常称为selfthis

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

  此时当调用该方法时,必须指定其作用的对象:

  1. a1 = Account; Account = nil
  2. ...
  3. a1.withdraw(a1, 100.00) -- OK

  通过对self参数的使用,还可以针对多个对象使用同样的方法:

  1. a2 = {balance=0, withdraw=Account.withdraw}
  2. ...
  3. a2.withdraw(a2, 260.00)

  使用self参数是所有面向对象语言的一个核心。大多数面向对象语言都能对程序员隐藏部分self参数,从而使得程序员不必显式地声明这个参数。Lua只需使用冒号,则能隐藏该参数。即可将上例重写为:

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

  调用时可写为:

  1. a:withdraw(100.00)

  冒号的作用是在一个方法定义中添加一个额外的隐藏参数,以及在一个方法调用中添加一个额外的实参。冒号只是一种语法便利,并没有引入任何新的东西。例如,用点语法来定义一个函数,并用冒号语法调用它。反之,只要能正确地处理那个额外参数即可:

  1. Account = {balance=0,
  2. withdraw=function(self, v)
  3. self.balance = self.balance - v
  4. end
  5. }
  6. function Account:deposit(v)
  7. self.balance = self.balance + v
  8. end
  9. Account.deposit(Account, 200.00)
  10. Account:withdraw(100.00)

  现在的对象已有一个标识、一个状态和状态之上的操作。不过还缺乏一个类(class)系统、继承和私密性(privacy)。首先解决第一个问题,如何创建多个具有类似行为的对象?更准确地说,如何创建多个account账户对象?

  

  一个类就像是一个创建对象的模具。有些面向对象语言提供了类的概念,在这些语言中每个对象都是某个特定类的实例。Lua则没有类的概念,每个对象只能自定义行为和形态。不过,要在Lua中模拟类也并不困难,可以参照一些基于原型的语言,例如SelfNewtonScript。在这些语言中,对象是没有“类型”的(objects have no classes)。而是每个对象都有一个原型(prototype)。原型也是一种常规的对象,当其他对象(类的实例)遇到一个未知操作时,原型会先查找它。在这种语言中要表示一个类,只需创建一个专用作其他对象(类的实例)的原型。类和原型都是一种组织对象间共享行为的方式。

  在Lua中实现原型很简单,使用后续章节所述的继承即可。更准确地说,如果有两个对象a和b,要让b作为a的一个原型,只需输入如下语句:

  1. setmetatable(a, {__index = b})

  在此之后,a就会在b中查找所有它没有的操作。若将b称为是对象a的类,只不过是术语上的一个变化。

  回到先前银行账号的示例。为了创建更多与Account行为类似的账号,可以让这些新对象从Account行为中继承这些操作。具体做法就是使用__index元方法。可以应用一项小优化,则无须创建一个额外的table作为账户对象的元表。而是使用Account table自身作为元表:

  1. function Account:new(o)
  2. o = o or {} -- 如果用户没有提供table,则创建一个
  3. setmetatable(o, self)
  4. self.__index = self
  5. return o
  6. end

  当调用Account:new时,self就等于Account。因此可以直接使用Account来代替self。不过,当引入类继承时,使用self则会更为准确。在这段代码之后,创建一个新账户或调用一个方法时会发生什么呢?

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

  当创建新账户时,a会将Account(Account:new调用中的self)作为其元表。而当调用a:deposit(100.00)时,就是调用了a.deposit(a, 100.00)。因此冒号只不过是一个“语法糖”。当Lua无法在table a中找到条目“deposit”时,它会进一步搜索元表的__index条目。最终的调用情况为:

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

  a的元表是AccountAccount.__index也是Account。因此,上面这个表达式可以简化为:

  1. Account.deposit(a, 100.00)

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

  继承不仅可以作用于方法,还可以作用于所有其他在新账户中没有的字段。因此,一个类不仅可以提供方法,还可以为实例中的字段提供默认值。回忆一下,在第一个Account定义中,有一个balance字段为0。如果在创建新账户时没有提供balance的初值,那么它就会继承这个默认值:

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

  在b上调用deposit方法时,self就是b,就相当于执行了:

  1. b.balance = b.balance + v

  在第一次调用deposit时,对表达式b.balance的求值结果为0,然后一个初值被赋予了b.balance。后续对b.balance的访问就不会再涉及到__index元方法了,因为此时b已有自己的balance字段。

  

继承

  由于类也是对象,它们也可以从其他类获得方法。这种行为就是一种继承,可以很容易地在Lua中。

  假设有一个基类Account

  1. Account = {}
  2. function Account:new(o)
  3. o = o or {}
  4. setmetatable(o, self)
  5. self.__index = self
  6. return o
  7. end
  8. function Account:deposit(v)
  9. self.balance = self.balance + v
  10. end
  11. function Account:withdraw(v)
  12. if v > self.balance then error "insufficient funds" end
  13. self.balance = self.balance - v
  14. end

  若想从这个类派生出一个子类SpecialAccount,以使客户能够透支。则先需要创建一个空的类,从基类继承所有的操作:

  1. SpecialAccount = Account:new()

  直到现在,SpecialAccount还只是Account的一个实例。如下所示:

  1. s = SpecialAccount:new{limit=1000.00}

  SpecialAccountAccount继承了new,就像继承其他方法一样。不过这次new在执行时,它的self参数表示为SpecialAccount。因此,s的元表为SpecialAccountSpecialAccount中字段__index的值也是SpecialAccounts继承自SpecialAccount,而SpecialAccount又继承自Account。当执行:

  1. s:deposit(100.00)

  Lua在s中找不到deposit字段时,就会查找SpecialAccount。如果仍找不到deposit字段,就查找Account。最终会在那里找到deposit的原始实现。

  SpecialAccount之所以特殊是因为可以重定义那些从基类继承的方法。编写一个方法的新实现只需:

  1. function SpecialAccount:withdraw(v)
  2. if v - self.balance >= self:getLimit() then
  3. error "insufficient funds"
  4. end
  5. self.balane = self.balance - v
  6. end
  7. function SpecialAccount:getLimit()
  8. return self.limit or 0
  9. end

  现在,当调用s:withdraw(200.00)时,Lua就不会在Account中查找了。因为Lua会在SpecialAccount中先找到withdraw方法。由于s.limit为1000.00,程序会执行取款,并使s变成一个负的余额。

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

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

  在这段代码后,调用s:withdraw(200.00)还是会执行SpecialAccountwithdraw。但withdraw所调用的self:getLimit则是上面这个定义。

  

多重继承

  由于Lua中的对象不是原生的(Primitive),因此在Lua中进行面向对象编程时有几种方法。上面介绍了一种使用__index元方法的做法,这是集简易、性能和灵活性于一体的做法。另外还有一些其他的做法,可能更适用于某些特殊的情况。在此将介绍另一种做法,可以在Lua中实现多重继承。

  这种做法的关键在于同一个函数作为index元字段。例如,若在一个table的元表中,index字段为一个函数。那么只要Lua在原来的table中找不到一个key,就会调用这个函数。基于这点,就可以让__index函数在其他地方查找缺失的key

  多重继承意味着一个类可以具有多个基类。因此无法使用一个类中的方法来创建子类,而是需要定义一个特殊的函数来创建。下面的createClass就是这样的函数,它会创建一个table表示新类,其中一个参数表示新类的所有基类。创建时它会设置元表中的index元方法,而多重继承正是在这个index元方法中完成的。虽然是多重继承,但每个对象实例仍属于单个类,并且都在这个类中查找所有的方法。因此,类和基类之间的关系不同于类和实例之间的关系。尤其是一个类不能同时作为其实例和子类的元表。在以下代码中,将类作为其实例的元表,并创建了另一个table作为类的元表。

  1. -- table 'plist'中查找'k'
  2. local function search(k, plist)
  3. for i=1, #plist do
  4. local v = plist[i][k] -- 尝试第i个基类
  5. if v then return v end
  6. end
  7. end
  8. function createClass(...)
  9. local c = {} -- 新类
  10. local parents = {...}
  11. -- 类在其父类列表中的搜索方法
  12. setmetatable(c, {__index = function(t, k)
  13. return search(k, parents)
  14. end})
  15. -- 'c'作为其实例的元表
  16. c.__index = c
  17. -- 为这个新类定义一个新的构造函数(construction
  18. function c:new(o)
  19. o = o or {}
  20. setmetatable(o, c)
  21. return o
  22. end
  23. return c -- 返回新类
  24. end

  接下来是一个使用createClass的例子。假设有两个类,一个是前面提到的Account类;另一个是Named类,它有两个方法setnamegetname

  1. Named = {}
  2. function Named:getname()
  3. return self.name
  4. end
  5. function Named:setname(n)
  6. self.name = n
  7. end

  要创建一个新类NamedAccount,同时从AccountNamed派生,那么只需调用createClass

  1. NamedAccount = createClass(Account, Named)

  如下要创建并使用实例:

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

  现在,来研究最后代码是如何工作的。首先,Lua在account中无法找到字段“getname”。因此,就查找account元表中的index字段,该字段为NamedAccount。由于在NamedAccount也无法提供字段“getname”。因此,Lua查找NamedAccount元表中的index字段。由于这个字段也是一个函数,Lua就调用了它。该函数则先在Account中查找“getname”。未找到后,继而查找Named。最终在Named中找到了一个非nil的值,即为搜索的最终结果。

  由于这项搜索具有一定的复杂性,则多重继承的性能不如单一继承。有一种改进性能的简单做法是将继承的方法复制到子类中。通过这种技术,类的__index元方法如下所示:

  1. setmetatable(c, {__index = function(t, k)
  2. local v = search(k, parents)
  3. t[k] = v -- 保存下来,以备下次访问
  4. return v
  5. end})

  用了这种技术后,访问继承的方法就能像访问局部方法一样快了。但缺点是当系统运行后就较难修改方法的定义,因为这些修改不会沿着继承体系向下传播。

  

私密性

  许多人认为私密性应成为面向对象语言不可或缺的一部分,每个对象的状态都应该是由它自己掌握。在一些面向对象语言中,例如C++Java,能控制对象中的字段或方法是否在对象之外可见。而对于其他语言,例如Smalltalk,规定所有的变量都是私有的,但所有的方法却都是公有的。第一个面向对象语言Simula则不提供任何形式的私密性保护。

  Lua在设计对象时,没有提供私密性机制,这具体章节已看到了。一方面这是因为使用了普通的结构(table)来表示对象,另一方面也反映了Lua某些基本的设计决定。Lua并不打算构建需要许多程序员长期投入的大型程序。相反,Lua定位于开发中小型程序,这些程序通常是一个更大系统的一部分。而参与编程的程序员一般只有一名或几名,甚至还可以是非程序员。因此,Lua尽量避免过多冗余和人为限制。如果不想访问一个对象中的内容,则无须进行操作。

  Lua的另外一项设计目标是灵活性。Lua提供给程序员各种元机制,以使他们能模拟许多不同的机制。虽然在Lua对象的基础设计中没有提供私密性机制。但可以用其他方法来实现对象,从而获得对象的访问限制。这种实现不常用,只做基本的了解,它既探索了Lua中的某些知识又可以成为其他问题的解决方案。

  这种做法的基本思想是,通过两个table来表示一个对象。一个table用来保存对象的状态;另一个用于对象的操作,或称为“接口”。对象本身是通过第二个table来访问的,即通过其接口的方法来访问。为了避免未授权的访问,表示状态的table不保存在其他table中,而只是保存在方法的closure中。例如,若使用这种设计来表示一个银行账户,可以调用下面这个工厂函数来创建新的账户对象:

  1. function newAccount(initialBalance)
  2. local self = {balance = initialBalance}
  3. local withdraw = function(v)
  4. self.balance = self.balance - v
  5. end
  6. local deposit = function(v)
  7. self.balance = self.balance + v
  8. end
  9. local getBalance = function() return self.balance end
  10. return {
  11. withdraw = withdraw,
  12. deposit = deposit,
  13. getBalance = getBalance
  14. }
  15. end

  这个函数先创建了一个table,用于保存对象的内部状态,并将其存储在局部变量self中。然后再创建对象的方法。最后,函数创建并返回一个供外部使用的对象,其中将方法名与真正的方法实现匹配起来。区别关键在于,这些方法不需要额外的self参数,因为它们可以直接访问self变量。由于没有了额外的参数,也就无须使用冒号语法来操作对象。则可以像普通函数那样来调用这些方法:

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

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

  1. function newAccount(initialBalance)
  2. local self = {
  3. balance = initialBalance,
  4. LIM = 10000.00
  5. }
  6. local extra = function()
  7. if self.balance > self.LIM then
  8. return self.balance * 0.10
  9. else
  10. return 0
  11. end
  12. end
  13. local getBalance = function()
  14. return self.balance + extra()
  15. end
  16. <如前>

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

  

单一方法(single-method)做法

  上述面向对象编程的做法有一种特殊情况,就是当一个对象只有一个方法时,可以不用创建接口table,但要将这个单独的方法作为对象表示来返回。如果无法理解,请参阅前面章节。前面章节介绍了如何构造一个迭代器函数,那个函数将状态保存为closure。一个具有状态的迭代器是一个单一方法对象。

  单一方法对象还有一种情况,若这个方法是一个调度(dispatch)方法,它根据某个参数来完成不同的操作。则可以这样来实现一个对象:

  1. function newObject(value)
  2. return function(action, v)
  3. if action == "get" then return value
  4. elseif action == "set" then value = v
  5. else error "invalid action"
  6. end
  7. end
  8. end

  如下所示:

  1. d = newObject(0)
  2. print(d("get")) --> 0
  3. d("set", 10)
  4. print(d("get")) --> 10

  这种非传统的对象实现方式是很高效的。语句d("set", 10)虽然有些奇特,但只比传统的d:set(10)多出两个字符。每个对象都用一个closure,这比都用一个table更高效。虽然无法实现继承,却拥有了完全的私密性控制。访问一个对象状态只有一个方式,那就是通过它的单一方法。

  Tcl/Tk对它的窗口部件使用了类似的做法。在Tk中,一个窗口部件的名称就是一个函数名,通过这个函数就可以完成所有针对该部件的操作。

?