Lua元表与元方法详解:自定义运算与行为控制 | 南锋

南锋

南奔万里空,脱死锋镝余

Lua元表与元方法详解:自定义运算与行为控制

通常,Lua语言中的每种类型的值都有一套可预见的操作集合。例如,我们可以将数字相加,可以连接字符,还可以在表中插入键值对等。但是,我们无法将两个表相加,无法对函数做比较,也琺调用一个字符串,除非使用元表。

元表可以修改一个值在面对一个未知操作时的行为。例如,假设a和b都是表,那么可以通过元表定义Lua语言如何计算表达式a+b。当Lua语言试图将两个表相加时,它会先检查两者之一是否有元表且该元表中是否有__add字段。如果Lua语言找到了该字段,就调用该字段对应的值,即所谓的元方法,在本例中就是用于计算表的和的函数。
可以认为,元表是面向对象领域中的受限制类。像类一样,元表定义的是实例的行为。不过,由于元表只能给出预先定义的操作集合的行为,所以元表被类更受限;同时,元表也不支持继承。
Lua语言中的每一个值都可以有元表。每一个表和用户数据类型都具有各自独立的元表,而其他类型的值则共享对应类型所属的同一个元表。Lua语言在创建新表时不带元表:

1
2
t = {}
print(getmetatable(t)) -- nil

可以使用函数setmetatable来设置或修改任意表的元表:

1
2
3
t1 = {}
setmetatable(t,t1)
print(getmetatable(t) == t1) --true

在Lua语言中,我们只能为表设置元表;如果要为其他类型的值设置元表,则必须通过C代码或调试库完成(该限制存在的主要原因是为了防止过度使用对某种类型的所有值生效的元表。Lua语言老版本中的经验表明,这样的全局设置经常导致不可重用的代码)。字符串标准库为所有的字符串都设置了同一个元表,而其他类型在默认情况中都没有元表:

1
2
3
4
print(getmetatable("hi"))		-- table:0x80772e0
print(getmetatable("xuxu")) -- table:0x80772e0
print(getmetatable(10)) -- nil
print(getmetatable(print)) -- nil

一个表可以成为任意值的元表;一组相关的表也可以共享一个描述了它们共同行为的通用元表;一个表还可以成为它自己的元表,用于描述其自身特有的行为。总之,任何配置都是合法的。

算术运算相关的元方法

假设有一个用表来表示集合的模块,该模块还有一些用来计算集合并集和交集等的函数。

示例:一个用于集合的简单模块

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
27
28
29
30
31
32
local Set = {}

-- 使用指定的列表创建一个新的集合
function Set.new(l)
for _, v inpairs(l) do set[v] = true end
return set
end

function Set.union(a,b)
local res = Set.new{}
for k in pairs(a) do res[k] = true end
for k in pairs(b) do res[k] = true end
end

function Set.intersection(a,b)
local res = Set.new{}
for k in pairs(a) do
res[k] = b[k]
end
return res
end

-- 将集合表示为字符串
function Set.tostring(set)
local l = {} -- 保存集合中所有元素的列表
for e in pairs(set) do
l[#l + 1] = tostring(e)
end
return "{" .. table.concat(l.",").."}"
end

return Set

现在,假设想使用加法操作符来计算两个集合的并集,那么可以让所有表示集合的表共享一个元表。这个元表中定义了这些表应该如何执行加法操作。首先,我们创建一个普通的表,这个表被用作集合的元表:

1
local mt = {}

然后,修改用于创建集合的函数Set.new。在新版本中只多了一行,即将mt设置为函数Set.new所创建的表的元表:

1
2
3
4
5
6
function Set.new(l)		-- 第二个版本
local set = {}
setmetatable(set,mt)
for _, v in inpairs(l) do set[v] = true end
return set
end

在此之后,所有由Set.new创建的集合都具有了一个相同的元表:

1
2
3
4
s1 = Set.new{10,20,30,50}
s2 = Set.new{30,1}
print(getmetatable(s1)) -- table:0x00672B60
print(getmetatable(s2)) -- table:0x00672B60

最后,向元表中加入元方法__add,也就是用于描述如何完成加法的字段:

1
mt.__add = Set.union

此后,只要Lua语言试图将两个集合相加,它就会调用函数Set.union,并将两个操作数作为参数传入。
通过元方法,我们就可以使用加法运算符来计算集合的并集了:

1
2
s3 = s1 + s2
print(Set.tostring(s3)) --{1,10,20,30,50}

类似地,还可以使用乘法运算符来计算集合的交集:

1
2
mt.__mul = Set.intersection
print(Set.tostring(s1 + s2)*s1) -- {10,20,30,50}

每种算术运算符都有一个对应的元方法。除了加法和乘法外,还有减法(__sub)、除法(__div)、floor除法(__idiv)、负数(__unm)、取模(__mod)和幂运算(__pow)。类似地,位操作也有元方法:按位与(__band)、按位或(__bor)、按位异或(__bxor)、按位取反(__bnot)、向左移位(__shl)和向右位移(__shr)。我们还可以使用字段__concat来定义连接运算符的行为。
当我们把两个集合相加时,使用哪个元素是确定的。然而,当一个表达式中混合了两种具有不同元素的值时,例如:

1
2
s = Set.new{1,2,3}
s = s + 8

Lua语言会按照如下步骤来查找元方法:如果第一个值有元表且元表中存在所需的元方法,那么Lua语言就使用这个元方法,与第二个值无关;如果第二个值有元表且元表中存在所需的元方法,Lua语言就使用这个元方法;否则,Lua语言就抛出异常。因此,上例会调用Set.union,而表达式10+s和”hello”+s同理(由于数值和字符串都没有元方法__add)。
Lua语言不关心这些混淆类型,但我们在实现中需要关心混合类型。如果我们执行了s = s + 8,那么在Set.union内部就会发生错误:

1
bad argument #1 to 'pairs' (table expected , got number)

如果想要得到更明确的错误信息,则必须在试图进行操作前显式地检查操作数的类型,例如:

1
2
3
4
5
function Set.union(a,b)
if getmetatable(a) ~= mt or getmetatable(b) ~= mt then
error("attempt to 'add' a set with a non-set value",2)
end
同前

请注意,函数error的第二个参数说明了出错的原因位于调用该函数的代码中。

关系运算相关的元方法

元表还允许我们制定关系运算符的含义,其中的元方法包括等于(__eq)、小于(__lt)和小于等于(__le)。其他三个关系运算符没有单独的元方法,Lua语言会将a~=b转换为not(a == b),a>b转换为b<a,a>=b转换为b<=a
在Lua语言的老版本中,Lua语言会通过将a<=b转换为not (b<a)来把所有的关系运算符转化为一个关系运算符。不过,这种转化在遇到部分有序时就会不正确。所谓部分有序是指,并非所有类型的元素都能够被正确地排序。例如,由于Not a Number(NaN)的存在,大多数计算机中的浮点数就不是完全可以排序的。根据IEEE 754标准,NaN代表未定义的值,例如O/O的结果就是NaN。标准规定任何涉及NaN的比较都应返回假,这就意味着NaN<=x永远为假,x<NaN也为假。因此,在这种情况下,a<=bnot(b<a)的转化也就不合法了。
在集合的示例中,我们也面临类似的问题。<=显而易见且有用的含义集合包含:a<=b通常意味着a是b的一个子集。然而,根据部分有序的定义,a<=bb<a可能同时为假。因此,我们就必须实现__le(小于等于,子集关系)和__lt(小于,真子集关系):

1
2
3
4
5
6
7
8
9
10
mt.__le = function (a,b) 		-- 子集
for k in pairs(a) do
if not b[k] then return false end
end
return true
end

mt.__lt = function (a,b) -- 真子集
return a<= b and not (b <= a)
end

最后,我们还可以通过集合包含来定义集合相等:

1
2
3
mt.__eq = function (a,b)
return a <= b and b <= a
end

有力这些定义后,我们就可以比较集合了:

1
2
3
4
5
6
7
s1 = Set.new{2,4}
s2 = Set.new{4,10,2}
print(s1 <= s2) -- true
print(s1 < s2) -- true
print(s1 >= s1) -- true
print(s1 > s1) -- false
print(s1 == s2 * s1) -- true

相等比较有一些限制。如果两个对象的类型不同,那么相等比较操作不会调用任何元方法而直接返回false。因此,不管元方法如何,集合永远不等于数字。

库定义相关的元方法

到目前为止,我们见过的所有元方法针对的都是核心Lua语言。Lua语言虚拟机会检测一个操作中设计的值是否有存在对应元方法的元表。不过,由于元表是一个普通的表,所以任何人都可以使用它们。因此,程序库在元表中定义和使用它们自己的字段也是一种常见的时间。
函数tostring就是一个典型的例子。正如我们此前所看到的,函数tostring能将表表示一种简单的文本格式:

1
print({})			--table:0x8062ac0

函数print总是调用tostring来进行格式化输出。不过,当对值进行格式化时,函数tostring会首先检查值是否有一个元方法__tostring。如果有,函数tostring就调用这个元方法来完成工作,将对象作为参数传给该函数,然后把元方法的返回值作为函数tostring的返回值。
在之前集合的示例中,我们已经定义了一个将集合表示为字符串的函数。因此,只需要在元表中设置__tostring字段:

1
mt.__tostring = Set.tostring

之后,当以一个集合作为参数调用函数print时,print就会调用函数tostring,tostring又会调用Set.tostring:

1
2
s1 = Set.new{10,4,5}
print(s1) -- {4,5,10}

函数setmetatable和getmetatable也用到了元方法,用于保护元表。假设想要保护我们的集合,就要使用户既不能看到也不能修改集合的元表。如果在元表中设置__metatable字段,那么getmetatable会返回这个字段的值,而setmetatable则会引发一个错误:

1
2
3
4
5
6
mt.__metatable = "not yopur business"

s1 = Set.new{}
print(getmetatable(s1)) -- not your business
setmetatable(s1,{})
stdin:1:cannot change protected metatable

从Lua5.2开始,函数pairs也有了对应的元方法。因此我们可以修改表被遍历的方式和为非表的对象增加遍历行为。当一个对象拥有__pairs元方法时,pairs会调用这个元方法来完成遍历。

表相关的元方法

算术运算符、位运算符和关系运算符的元方法都定义了各种错误情况的行为,但它们都没有改变语言的正常行为。Lua语言还提供了一种改变表在两种正常情况下的行为的方式,即访问和修改表中不存在的字段。

__index元方法

正如我们此前所看到的,当访问一个表中不存在的字段时会得到nil。这是正确的,但不是完整的真相。实际上,这些访问会引发解释器查找一个名为__index的元方法。如果没有这个元方法,那么像一般情况下一样,结果就是nil;否则,则由这个元方法来提供最终结果。
下面介绍一个关于继承的原型示例。假设我们要创建几个表来描述窗口,每个表中必须描述窗口的一些参数,例如位置、大小及主题颜色等。所有的这些参数都有默认值,因此我们希望在创建窗口对象时只需要给出那些不同于默认值的参数即可。第一种方法是使用一个构造器来填充不存在的字段,第二种方法是让新窗口从一个原型窗口继承所有不存在的字段。首先,我们声明一个原型:

1
2
-- 创建具有默认值的原型
prototype = {x = 0, y = 0 ,width = 100,height = 100}

然后,声明一个构造函数,让构造函数创建共享同一个元表的新窗口:

1
2
3
4
5
local mt = {}
function new(o)
setmetatable(o,mt)
return o
end

现在,我们来定义元方法__index:

1
2
3
mt.__index = function(_,key)
return prototype[key]
end

在这段代码后,创建一个新窗口,并查询一个创建时没有指定的字段:

1
2
w = new{x = 10,y = 20}
print(w.width) -- 100

Lua语言会发现w中没有对应的字段”width”,但却有一个带有__index元方法的元表。因此,Lua语言会以w(表)和”width”(不存在的键)为参数来调用这个元方法。元方法随后会用这个键来检索原型并返回结果。
在Lua语言中,使用元方法__index来实现继承是很普通的方法。虽然被叫作方法,但元方法__index不一定必须是一个函数,它还可以是一个表。当元方法是一个函数时,Lua语言会以表和不存在的键为参数调用该函数,正如我们刚刚所看到的。当元方法是一个表时,Lua语言就访问这个表。因此,在我们此前的示例中,可以把__index简单地声明为如下样式:

1
mt.__index = prototype

这样,当Lua语言查找元表的__index字段时,会发现字段的值是表prototype。因此,Lua语言就会在这个表中继续查找,即等价地执行prototype[“width”],并得到预期的结果。
将一个表用作__index元方法为实现单继承提供了一种简单快捷的方法。虽然将函数用作元方法开销更昂贵,但函数却更加灵活:我们可以通过函数来实现多继承、缓存及其他一些变体。
如果我们希望在访问一个表时不调用__index元方法,那么可以使用函数rawget。调用rawget(t,i)会对表t进行原始的访问,即在不考虑元表的情况下对表进行简单的访问。进行一次原始访问并不会加快代码的执行(一次函数调用的开销就会抹杀用户所作的这些努力),但是,我们后续会看到,有时确实会用到原始访问。

__newindex元方法

元方法__newindex__index类似,不同之处在于前者用于表的更新而后者用于表的查询。当对一个表中不存在的索引赋值时,解释器就会查找__newindex元方法:如果这个元方法存在,那么解释器就调用它而不执行赋值。像元方法__index一样,如果这个元方法时一个表,解释器就在此表中执行赋值,而不是在原始的表中进行复制。此外,还有一个原始函数允许我们绕过元方法:调用rawset(t,k,v)来等价于t[k] =v,但不涉及任何元方法。
组合使用元方法__index和__newindex可以实现Lua语言中的一些强大的结构,例如只读的表、具有默认值的表和面向对象编程中的继承。

具有默认值的表

一个普通表中所有字段的默认值都是nil。通过元表,可以很容易地修改这个默认值:

1
2
3
4
5
6
7
8
9
function setDefualut(t,d)
local mt = {__index = function () return d end}
setmetatable(t,mt)
end

tab = {x = 10, y = 20}
print(tab.x,tab.z) -- 10 nil
setDefualut(tab,0)
print(tab.x,tab.z) -- 10 0

在调用setDefault后,任何对表tab中不存在字段的访问都将调用它的__index元方法,而这个元方法会返回零(这个元方法中的值是d)。
函数setDefault为所有需要默认值的表创阿金一个新的闭包和一个新的元表。如果我们有很多需要默认值的表,那么开销会比较大。然而,由于具有默认值d的元表是于元方法关联在一起的,所有我们不能把同意个元表用于具有不同默认的表。为了能够使所有的表都使用同一个元表,可以使用一个额外的字段将每个表的默认值存放到表自身中。如果不担心命名冲突的话,我们可以使用形如___这样的键作为额外的字段:

1
2
3
4
5
local mt = {__index = function(t) return t.___ end}
function setDefualut(t,d)
t,___ = d
setmetatable(t,mt)
end

请注意,这里我们只在setDefault外创建了一次元表mt及对应的元方法。
如果担心命名冲突,要确保这个特殊键的唯一性也很容易,只需要创建一个新的排除表,然后将它作为键即可:

1
2
3
4
5
6
local key = {}
local mt = {__index = function (t) return t[key] end}
function setDefualut(t,d)
t[key] = d
setmetatable(t,mt)
end

还有一种方法可以将每个表与其默认值关联起来,称为对偶表示,即使使用一个独立的表,该表的键为各种表,值为这些表的默认值。不过,为了正确地实现这种做法,我们还需要一种特殊的表,称为弱引用表。在这里,我们暂时不会使用弱引用表。
另一种为具有相同默认值的表复用同一个元表的方式是记忆元表。不过,这也需要用到弱引用表。

跟踪对表的访问

假设我们要跟踪对某个表的所有访问。由于__index和__newindex元方法都是在表中的索引不存在时才有用,因此,捕获对一个表访问的唯一方式是保持表是空的。如果要监控对一个表的所有访问,那么需要为真正的表创建一个代理。这个代理是一个空的表,具有用于跟踪所有访问并将访问重定向到原来的表格的合理元方法。

示例: 跟踪对标的访问

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
27
28
29
30
31
function track(t)
local proxy = {}

-- 为代理创建元表
local mt = {
__index = function(_,k)
print("*access to element" .. tostring(k))
return t[k] -- 访问原来的表
end,

__newindex = function (_,k,v)
print("*update to element " .. tostring(k) .. " to " ..tostring(v))
t[k] = v -- 更新原来的表
end,

__pairs = function()
return function(_,k) --迭代函数
local nextkey,nextvalue = next(t,k)
if nextkey ~= nil then -- 避免最后一个值
print("*traversing element " .. tostring(nextkey))
end
return nextkey,nextvalue
end
end,

__len = function() return #t end
}

setmetatable(proxy,mt)
return proxy
end

以下展示了上述代码的用法:

1
2
3
4
5
6
t = {}				-- 任意一个表
t = track(t)
t[2] = "hello" -- *update of element 2 to hello
print(t[2])
-- *access to elemetn 2
-- hello

元方法__index和__newindex按照我们设计的规则跟踪每一个访问并将其重定向到原来的表中。元方法__pairs使得我们能够像遍历原来的表一样遍历代理,从而跟踪所有的访问。最后,远方__len通过代理实现了长度操作符:

1
2
3
4
5
6
7
t = track({10,20})
print(#t)
for k,v in pairs(t) do pairs(k,v) end
-- *traversing element 1
-- 1 10
-- *traversing element 2
-- 2 10

如果想要同时监控几个表,并不需要为每个表创建不同的元表。相反,只要以某种形式将每个代理与其原始表映射起来,并且让所有的代理共享一个公共的元表即可。

只读的表

使用代理的概念可以很容易地实现只读的表,需要做的只是跟踪对表的更新操作并抛出异常即可。对于元方法__index,由于我们不需要跟踪查询,所以可以直接使用原来的表来代替函数。这样做比把所有的查询重定向到原来的表上更简单也更有效率。不过,这种做法要求为每个只读代理创建一个新的元表,其中__index元方法指向原来的表:

1
2
3
4
5
6
7
8
9
10
11
function readOnly(t)
local proxy = {}
local mt = {
__index = t,
__newindex = function(t,k,v)
error("attempt to update a read-only table", 2)
end
}
setmetatable(proxy,mt)
return proxy
end

作为示例,我们可以创建一个表示星期的只读表:

1
2
3
4
days = readOnly{"Sunday","Monday","Tuesday","Wednesday","Thrsday","Friday","Saturday"}
print(days[1]) -- Sunday
days[2] = "Nodya"
-- stdin:1:attempt to update a read-only table
+