0%

Lua 中实现类与面向对象

类是创建对象的模板,对象是类的实例,Lua 中没有类的概念,所以我们只能使用现有的支持去模拟类的概念。

table 实现成员变量与成员函数

Lua 的 table 就是一种对象,可以有成员变量(如下例 sound),也可以有成员函数,(如下例 makesound):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Dog = {sound = 'wolf'}

function Dog.makesound(v)
if v ~= nil then
Dog.sound = v
end
print(Dog.sound)
end

dog1 = Dog
dog2 = Dog

dog1.makesound() -- wolf
dog2.makesound() -- wolf
dog1.makesound('wang') -- wang
dog2.makesound('haha') -- haha
dog1.makesound() -- haha 因为dog1,dog2都是Dog的引用,输出是一样的
dog2.makesound() -- haha

dog1 = nil -- 只是解除了dog1的引用,对dog2无影响
dog2.makesound() -- haha

Dog = nil; -- makesound执行时报错
dog1.makesound() -- error attempt to index global 'Dog' (a nil value)

首先 Lua 的 table 默认是引用类型,dog1 与 dog2 都是全局变量 Dog 的引用。

这个例子中,makesound 函数中使用了全局变量 Dog.sound 进行赋值操作,所以当 Dog = nil 的时候,dog1.makesound() 函数执行的时候当然会报错。

改造一下这个例子,makesound 函数定义的时候我们引入一个 self 参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Dog = {sound = 'wolf'}

function Dog.makesound(self, v)
if v ~= nil then
self.sound = v
end
print(self.sound)
end

dog1 = Dog
dog2 = Dog

dog1.makesound(dog1) -- wolf
dog2.makesound(dog2) -- wolf
dog1.makesound(dog1, 'wang') -- wang
dog2.makesound(dog2, 'haha') -- haha
dog1.makesound(dog1) -- haha
dog2.makesound(dog2) -- haha

Dog = nil;
dog1.makesound(dog1) -- haha
dog2.makesound(dog2) -- haha

makesound 函数,使用第一个参数 self 来标识调用者,而不是使用全局变量,self 和 全局变量Dog 引用了同一个 table,这样就可以工作了。

不过对 Dog = nil 赋值以后,Dog 这个全局变量的 table 不就是 nil 了吗,dog1, dog2 都是对 Dog 的引用,dog1, dog2 为何不是 nil?

要解释这个问题,要说 Lua 的 table 不是值而是对象,Dog = {sound = ‘wolf’} 实际上是创建了这个 table 的引用,最终 Dog,dog1,dog2 都在引用这个 table

Dog = nil,只是解除了 Dog 的引用,此时 dog1, dog2 还都在引用它。当对一个 table 的引用为 0 时,Lua 的垃圾收集器最终会删除该 table,并释放它所占用的内存空间。下面这个例子可以很好的解释:

1
2
3
4
5
6
7
8
9
10
local a = {} -- 创建一个table,并将它的引用存储在a
a["x"] = 10
local b = a -- b与a引用同一个table
print(b["x"]) -- 10
b["x"] = 20
print(a["x"]) -- 20

b = nil -- 现在只有a还在引用table
print(a["x"]) -- 20
a = nil -- 现在不存在对table的引用,等待Lua的垃圾回收

好,现在我们回来。使用 self 参数是所有面向对象语言的一个核心。大多数面向对象语言都对程序员隐藏了 self 参数,从而使得程序员不必显示地声明这个参数。毕竟,定义成员函数和调用的时候都多了一个参数好麻烦。

Lua 也可以,当我们在定义函数和调用函数时,使用了冒号代替点号,则能隐藏 self 参数,这实际是个语法糖,我们重写上面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Dog = {sound = 'wolf'}

function Dog:makesound(v)
if v ~= nil then
self.sound = v
end
print(self.sound)
end

dog1 = Dog
dog2 = Dog

dog1:makesound() -- wolf
dog2:makesound() -- wolf
dog1:makesound('wang') -- wang
dog2:makesound('haha') -- haha
dog1:makesound() -- haha
dog2:makesound() -- haha

现在我们的对象有一个标识符(名称dog1),有成员变量和成员函数,但是他们都是一个全局对象的引用。我们可以把它当成是一个全局类来使用,它的变量是静态变量,方法是静态方法。

我们如何才能创建拥有相似行为的多个对象呢?

在 Lua 中,要表示一个类,只需创建一个专用作其他对象的原型(prototype)。原型也是一种常规的对象,也就是说我们可以直接通过原型去调用对应的方法。当其它对象(类的实例)遇到一个未知操作时,原型会先查找它。这很像 Javascript。

而利用table 的元表与 __index 特有键就可以实现这种原型表述。对象a调用任何不存在的成员都会到对象b中查找。术语上,可以将b看作类,a看作对象。

1
setmetatable(a, {__index = b})

所以可以这样模拟一个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Dog = {sound = 'wolf'}

function Dog:new(o)
o = o or {} -- 如果没有提供对象,则创造一个
local mt = { __index = Dog } -- 构造metatable元表,__index = Dog 对象
return setmetatable(o, mt) -- 返回创建的对象
end

function Dog:makesound(v)
if v ~= nil then
self.sound = v
end
print(self.sound)
end

dog1 = Dog:new()
dog2 = Dog:new()

dog1:makesound() -- wolf
dog2:makesound() -- wolf
dog1:makesound('wang') -- wang
dog2:makesound('haha') -- haha
dog1:makesound() -- wang
dog2:makesound() -- haha
print(dog1.sound) -- wang
print(dog2.sound) -- haha

由此我们可以理解为,dog1 与 dog2 都是 Dog 类的对象实例,由于 metatable 特性,导致调用 dog1 不存在的方法,会自动查找 metatable 中的 __index 键,最终调用了 Dog 的方法。另外 sound 可以看成类的 public 属性

dog1.sound 实际上 getmetatable(dog1).__index.sound

dgo1:makesound() 实际上 getmetatable(dog1).__index:makesound()

这里还有一个小优化:

1
2
3
4
5
function Dog:new(o)
o = o or {}
self.__index = self
return setmetatable(o, self) -- 不在需要创建一个额外的表,可以用Dog表本身作为metatable
end

最后,我们给出 Lua 实现一个类的最终姿势:

1
2
3
4
5
6
7
8
9
local _M = {}

function _M:new(o)
o = o or {}
self.__index = self
return setmetatable(o, self)
end

return _M