Lua之元表与元方法

前言

元表对应的英文是metatable,元方法是metamethod。我们都知道,在C++中,两个类是无法直接相加的,但是,如果你重载了“+”符号,就可以进行类的加法运算。在Lua中也有这个道理,两个table类型的变量,你是无法直接进行“+”操作的,如果你定义了一个指定的函数,就可以进行了。那这篇文章就是主要讲的如何定义这个指定的函数,这个指定的函数是什么?希望对学习Lua的朋友有帮助。

Lua是怎么做的

通常,Lua中的每个值都有一套预定义的操作集合,比如数字是可以相加的,字符串是可以连接的,但是对于两个table类型,则不能直接进行“+”操作。这需要我们进行一些操作。在Lua中有一个元表,也就是上面说的metatable,我们可以通过元表来修改一个值的行为,使其在面对一个非预定义的操作时执行一个指定的操作。比如,现在有两个table类型的变量a和b,我们可以通过metatable定义如何计算表达式a+b,具体的在Lua中是按照以下步骤进行的:

  1. 先判断a和b两者之一是否有元表;
  2. 检查该元表中是否有一个叫__add的字段;
  3. 如果找到了该字段,就调用该字段对应的值,这个值对应的是一个metamethod;
  4. 调用__add对应的metamethod计算a和b的值。

上述四个步骤就是计算table类型变量a+b的过程。在Lua中,每个值都有一个元表,table和userdata类型的每个变量都可以有各自独立的元表,而其他类型的值则共享其类型所属的单一元表。

元表和值

每个值都可以拥有一个元表。对 userdata 和 table 类型而言,其每个值都可以拥有独立的元表,也可以几个值共享一个元表。对于其他类型,一个类型的值共享一个元表。例如所有数值类型的值会共享一个元表。除了字符串类型,其他类型的值默认是没有元表的。

  • 使用 getmetatable 函数可以获取任意值的元表。
  • 使用 setmetatable 函数可以设置表类型值的元表。

例子

我们使用getmetatable来获取一个值的元表,会发现只有字符串类型的值默认拥有元表,其他类型的值,使用getmetatable去获得元表,将返回nil。

1
2
3
4
5
6
7
8
9
local num = 10
local str = "10"
local bool = true
local tab = {10}

print(getmetatable(num)) --> nil
print(getmetatable(str)) --> table: 00717820
print(getmetatable(bool)) --> nil
print(getmetatable(tab)) --> nil

任何一个table都可以作为任何类型值的元表,而一组相关的table有可以共享一个通用的元表,此元表描述了它们共同的行为。一个table甚至可以作为它自己的元表,用于描述其特有的行为。总之,任何搭配形式都是合法的。
在Lua代码中,我们也可以使用setmetatable去设置一个table或userdata类型变量的元表。若要设置其它类型值的元表,则必须通过C代码来完成。还存在一个特例,对于字符串,标准的字符串程序库为所有的字符串都设置了一个元表,而其它类型在默认情况下都没有元表。

可重新定义的元方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__add(a, b)     --对应的运算符 '+'
__sub(a, b) --对应的运算符 '-'
__mul(a, b) --对应的运算符 '*'
__div(a, b) --对应的运算符 '/'
__mod(a, b) --对应的运算符 '%'
__unm(a) --对应的运算符 '-'
__concat(a, b) --对应的运算符 '..'
__eq(a, b) --对应的运算符 '=='
__lt(a, b) --对应的运算符 '<'
__le(a, b) --对应的运算符 '<='
__pow(a, b) --乘幂
__len(a) --长度
__tostring(a) --字符串输出
__call(a, ...) --执行方法调用
__metatable --保护元表
__index(a, b) --索引查询
__newindex(a, b, c) --索引更新

运算操作符元方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
--元表
local metatable = {}
metatable.__add = function(a, b)
local temp = {}
for k,v in pairs(a) do
table.insert(temp, v)
end
for k,v in pairs(b) do
table.insert(temp, v)
end
return temp
end

--测试
local t1 = {1, 2, 3}
local t2 = {4, 5}
setmetatable(t1, metatable)
setmetatable(t2, metatable)

local result = t1 + t2 --> {1, 2, 3, 4, 5}

在上面列举的那些可以重定义的元方法都可以使用上面的方法进行重定义。现在就出现了一个新的问题,t1和t2都有元表,那我们要用谁的元表?虽然我们这里的示例代码使用的都是一个元表,但是实际coding中,会遇到我这里说的问题,对于这种问题,Lua是按照以下步骤进行解决的:

  1. 对于二元操作符,如果第一个操作数有元表,并且元表中有所需要的字段定义,比如我们这里的__add元方法定义,那么Lua就以这个字段为元方法,而与第二个值无关;
  2. 对于二元操作符,如果第一个操作数有元表,但是元表中没有所需要的字段定义,比如我们这里的__add元方法定义,那么Lua就去查找第二个操作数的元表;
  3. 如果两个操作数都没有元表,或者都没有对应的元方法定义,Lua就引发一个错误。

__tostring元方法

写过Java或者C#的人都知道,Object类中都有一个tostring的方法,程序员可以重写该方法,以实现自己的需求。在Lua中,也是这样的,当我们直接print(a)(a是一个table)时,是不可以的。那怎么办,这个时候,我们就需要自己重新定义__tostring元方法,让print可以格式化打印出table类型的数据。
函数print总是调用tostring来进行格式化输出,当格式化任意值时,tostring会检查该值是否有一个__tostring的元方法,如果有这个元方法,tostring就用该值作为参数来调用这个元方法,剩下实际的格式化操作就由__tostring元方法引用的函数去完成,该函数最终返回一个格式化完成的字符串。例如以下代码:

1
2
3
4
5
6
7
8
9
10
--元表
local metatable = {}
metatable.__tostring = function(t)
return "a table"
end

--测试
local t = {}
setmetatable(t, metatable)
print(t) --> a table

比较类元方法

对于三种比较类操作,需要满足两个操作数为同类型,且关联同一个元表时才能使用元方法。

  1. 对于eq(等于)比较操作,如果操作数所属类型没有原生的等于比较,则调用元方法。
  2. 对于lt(小于)与le(小于等于)两种比较操作,如果两个操作数同为数值或者同为字符串,则直接进行比较,否则使用元方法。
  3. 对于le操作,如果元方法 “le” 没有提供,Lua就尝试”lt”,它假定 a<=b 等价于 not(b<\a) 。
1
2
3
4
5
6
7
8
9
local t1 = {name = "number", 1, 2, 3}
local t2 = {name = "number", 4, 5, 6}
local mt = {__eq = function (a,b)
return a.name == b.name
end}
setmetatable(t1,mt) -- 必须要关联同一个元表才能比较
setmetatable(t2,mt)

print(t1==t2) --> true

__index元方法

__index元方法是metatable中最常用的metamethod,用来对表访问 – table[key]

Lua查找一个表元素时的规则,其实就是如下3个步骤:

  1. 当访问一个table的键时,如果这个键有值,则返回这个值;没有值则继续
  2. 判断这个表是否有元表,如果没有元表,返回nil;有元表则继续
  3. 判断这个元表是否有__index方法,如果__index方法是nil,则返回nil;如果__index方法是一个表,则重复1、2、3;如果__index方法是一个函数,则返回该函数的返回值。

如果__index是一个函数的话,Lua就会调用这个函数,table和键会作为参数传递给函数。代码如下:

1
2
3
4
5
6
7
8
9
10
--__index为function
local metatable = {}
metatable.__index = function(t, key)
return tostring(t)..", key:"..tostring(key)
end

local t = {a="a"}
setmetatable(t, metatable)
print(t["a"]) --> a
print(t["b"]) --> table: 004EC1D0, key:b

如果__index是一个表的话,Lua就以相同的方式来重新访问这个table,代码如下:

1
2
3
4
5
6
7
8
--__idnex为table
local metatable = {}
metatable.__index = {b="b"}

local t = {a="a"}
print(t["b"]) --> nil
setmetatable(t, metatable)
print(t["b"]) --> b

__newindex元方法

__newindex元方法用来对表更新,用于赋值操作 – talbe[key] = value

当对一个table中存在的索引赋值时,则会进行赋值,而不调用元方法 __newindex。
当对一个table中不存在的索引赋值时,在Lua中是按照以下步骤进行的:

  1. Lua解释器先判断这个table是否有元表;如果没有元表,就直接添加这个索引,然后对应的赋值
  2. 如果有元表,则判断元表中是否有__newindex元方法,如果没有__newindex元方法,就直接添加这个索引,然后对应的赋值
  3. 如果有__newindex元方法,Lua解释器就执行它,而不是执行赋值
  4. 如果__newindex元方法对应的不是一个函数,而是一个table时,Lua解释器就在这个table中执行赋值,而不是对原来的table进行操作。

以下实例演示了__newindex元方法的应用:

1
2
3
4
5
6
7
8
9
10
11
local mymetatable = {}
local t = {key1 = "value1"}
setmetatable(t, { __newindex = mymetatable })

print(t.key1) --> value1

t.newkey = "新值2"
print(t.newkey,mymetatable.newkey) --> nil 新值2

t.key1 = "新值1"
print(t.key1,mymetatable.key1) --> 新值1 nil

以上实例中表设置了元方法__newindex,在对新索引键(newkey)赋值时(mytable.newkey = “新值2”),会调用元方法,而不进行赋值。而如果对已存在的索引键(key1),则会进行赋值,而不调用元方法 __newindex。

以下实例使用了 rawset 函数来更新表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
local metatable = {}
metatable.__newindex = function(t, key, value)
rawset(t, key, "\""..value.."\"")
end

local t = {key1="value1"}
setmetatable(t, metatable)

print(t.key1) --> value1

t.key1 = "new value"
t.key2 = 4

print(t.key1, t.key2) --> new value 4

__call元方法

__call元方法用于函数调用 – function(args)
__call是一个很有意思的元方法,当把一个table当成function使用时,Lua解释器就会在table的元表中找一个叫__call的元方法,并且执行它。

1
2
3
4
5
6
7
8
9
10
11
12
13
local metatable = {}
metatable.__call = function(t, ...)
local str
local list = { ... }
for i=1, #list do
str = i==1 and list[i] or str..", "..list[i]
end
return str
end

local t = {}
setmetatable(t, metatable)
print(t(1,2,"a")) --> 1, 2, a

__metatable元方法

我们会发现,使用getmetatable就可以很轻易的得到元表,使用setmetatable就可以很容易的修改元表,那这样做的风险是不是太大了,那么如何保护我们的元表不被篡改呢?
在Lua中,函数setmetatable和getmetatable函数会用到元表中的一个字段,用于保护元表,该字段是__metatable。当我们想要保护元表,使用户既不能看也不能修改元表,那么就需要使用__metatable字段了;当设置了该字段时,getmetatable就会返回这个字段的值,而setmetatable则会引发一个错误;如以下演示代码:

1
2
3
4
5
6
7
8
local metatable = {}
metatable.__metatable = "You can't get the metatable!"

local t = {}
setmetatable(t, metatable)

print(getmetatable(t)) --> You can't get the metatable!
setmetatable(t, {}) --> lua: test.lua:8: cannot change a protected metatable

rawget和rawset

有的时候,我们不想从__index对应的元方法中查询值,也不想更新table时执行__newindex对应的方法,或者__newindex对应的table。那怎么办?
在Lua中,当我们查询table中的值,或者更新table中的值时,不想理那该死的元表,我们可以使用rawget函数,调用rawget(table, key)就是对表table进行了一次“原始的(raw)”访问,也就是一次不考虑元表的简单访问;你可能会想,一次原始的访问,没有访问__index对应的元方法,可能有性能的提升,其实一次原始访问并不会加速代码执行的速度。对于__newindex元方法,可以调用rawset(table, key, value)函数,它可以不涉及任何元方法而直接设置表table中与键key相关联的值value。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
local metatable = {}
metatable.__index = {c=3}
metatable.__newindex = function(t, key, value)
metatable.__index[key] = value
end

local t = {a=1, b=2}
setmetatable(t, metatable)

print(t["c"]) --> 3
print(rawget(t, "c")) --> nil

t["c"] = 4
print(t["c"]) --> 4
print(rawget(t, "c")) --nil

rawset(t, "c", 3)
print(t["c"]) --> 3