第 12 章 Metatables and Metamethods
Lua中的 table 由于定义的行为,我们可以对 key-value 对执行加操作,访问 key 对 应的 value,遍历所有的 key-value。但是我们不可以对两个 table
执行加操作,也不可以比较两个表的大小。
Metatables 允许我们改变 table 的行为,例如,使用 Metatables 我们可以定义 Lua 如 何计算两个 table 的相加操作 a+b。当 Lua试图对两个表进行相加时,他会检查两个表是 否有一个表有 Metatable,并且检查 Metatable 是否有_add 域。如果找到则调用这个_add 函数(所谓的 Metamethod)去计算结果。Lua
中的每一个表都有其Metatable。(后面我们将看到 userdata 也有 Metatable),Lua默认创建一个不带metatable的新表。
t = {} print(getmetatable(t)) --> nil
可以使用 setmetatable 函数设置或者改变一个表的 metatable
t1 = {} setmetatable(t,t1) assert(getmetatable(t) ==t1)
任何一个表都可以是其他一个表的 metatable,一组相关的表可以共享一个metatable(描述他们共同的行为)。一个表也可以是自身的 metatable(描述其私有行为)。
12.1算术运算的 Metamethods
这一部分我们通过一个简单的例子介绍如何使用 metamethods。假定我们使用 table 来描述结合,使用函数来描述集合的并操作,交集操作,like 操作。我们在一个表内定 义这些函数,然后使用构造函数创建一个集合:
Set = {} function Set.new (t) local set = {} for _, l in ipairs(t) do set[l] = 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 return res 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 s = "{" local sep = "" for e in pairs(set) do s = s .. sep .. e sep = ", " end return s .. "}" end function Set.print (s) print(Set.tostring(s)) end
现在我们想 加号运算符(+)执行两个 集合的并操 作,我们将 所有集合共 享一个metatable,并且为这个 metatable 添加如何处理相加操作。
第一步,我们定义一个普通的表,用来作为 metatable。为避免污染命名空间,我们将其放在 set 内部。
Set.mt = {} --metatable for sets
第二步,修改 set.new 函数,增加一行,创建表的时候同时指定对应的 metatable。
function Set.new (t) -- 2nd version local set = {} setmetatable(set, Set.mt) for _ , l in ipairs(t) do set[l] = true end return set end
这样一来,set.new 创建的所有的集合都有相同的 metatable 了:
<pre name="code" class="csharp">s1 = Set.new{10, 20, 30, 50} s2 = Set.new{30, 1} print(getmetatable(s1)) --> table: 00672B60 print(getmetatable(s2)) --> table: 00672B60
第三步,给 metatable 增加 _add 函数。
Set.mt._add = Set.union
当 Lua 试图对两个集合相加时,将调用这个函数,以两个相加的表作为参数。 通过 metamethod,我们可以对两个集合进行相加:
s3 = s1 + s2 Set.print(s3) --> {1, 10, 20,30, 50}
同样的我们可以使用相乘运算符来定义集合的交集操作
Set.mt._mul = Set.intersection Set.print((s1 + s2)*s1) --> {10, 20, 30,50}
对于每一个算术运算符,metatable 都有对应的域名与其对应,除了 _add, _mul 外,还有_sub(减),_div(除), _unm(负),__pow(幂),我们也可以定义_concat 定义连接行为。当我们对两个表进行加没有问题,但如果两个操作数有不同的
metatable 例如:
s = Set.new{1,2,3} s = s + 8
Lua 选择 metamethod 的原则:如果第一个参数存在带有 _add 域的 metatable,Lua使用它作为metamethod,和第二个参数无关;否则第二个参数存在带有
_add域的 metatable,Lua使用它作为 metamethod 否则报错。Lua不关心这种混合类型的,如果我们运行上面的 s=s+8 的例子在 Set.union 发生错误:
bad argument#1 to `pairs' (table expected, got number)
如果我们想得到更加清楚地错误信息,我们需要自己显式的检查操作数的类型:
function Set.union (a,b) if getmetatable(a) ~= Set.mt or getmetatable(b) ~=Set.mt then error("attempt to `add' a set with a non-set value", 2) end ... --same as before
12.2关系运算的 Metamethods
Metatables 也允许我们使用metamethods: _eq(等于),_lt(小于),和 _le(小于 等于)给关系运算符赋予特殊的含义。对剩下的三个关系运算符没有专门的 metamethod, 因为 Lua 将 a ~= b 转换为not
(a == b);a > b 转换为 b < a;a >= b 转换为 b <= a。
(直到 Lua 4.0 为止,所有的比较运算符被转换成一个,a <= b 转为 not (b < a)。然 而这种转换并不一致正确。当我们遇到偏序(partial order)情况,也就是说,并不是所 有的元素都可以正确的被排序情况。例如,在大多数机器上浮点数不能被排序,因为他的值不是一个数字(Not a Number 即 NaN)。根据 IEEE 754 的标准,NaN 表示一个未定
义的值,比如 0/0 的结果。该标准指出任何涉及到 NaN 比较的结果都应为 false。也就是 说,NaN <= x 总是 false,x < NaN 也总是 false。这样一来,在这种情况下 a <= b 转换 为 not(b < a)就不再正确了。)
在我们关于基和操作的例子中,有类似的问题存在。<=代表集合的包含:a <= b 表 示集合 a 是集合 b 的子集。这种意义下,可能 a<= b 和 b < a 都是 false;因此,我们需 要将 _le 和 _lt 的实现分开:
Set.mt._le =function (a,b) --set containment for k in pairs(a) do if not b[k] then return falseend end end return true end Set.mt._lt = function (a,b) return a <= b and not (b <= a) end
最后,我们通过集合的包含来定义集合相等:
Set.mt._eq = function (a,b) return a <= b and b <= a end
有了上面的定义之后,现在我们就可以来比较集合了:
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
与算术运算的 metamethods 不同,关系元算的 metamethods 不支持混合类型运算。 对于混合类型比较运算的处理方法和 Lua的公共行为类似。如果你试图比较一个字符串 和一个数字,Lua 将抛出错误.相似的,如果你试图比较两个带有不同 metamethods 的对 象,Lua 也将抛出错误。
但相等比较从来不会抛出错误,如果两个对象有不同的 metamethod,比较的结果为 false,甚至可能不会调用 metamethod。这也是模仿了 Lua的公共的行为,因为 Lua 总是 认为字符串和数字是不等的,而不去判断它们的值。仅当两个有共同的metamethod的对 象进行相等比较的时候,Lua 才会调用对应的 metamethod。
12.3库定义的 Metamethods
在一些库中,在自己的 metatables 中定义自己的域是很普遍的情况。到目前为止,我们看到的所有 metamethods 都是 Lua 核心部分的。有虚拟机负责处理运算符涉及到的 metatables 和为运算符定义操作的 metamethods。但是,metatable 是一个普通的表,任何 人都可以使用。tostring 是一个典型的例子。如前面我们所见,tostring 以简单的格式表示出
table:
print({}) --> table: 0x8062ac0
(注意:print 函数总是调用 tostring 来格式化它的输出)。然而当格式化一个对象的时候,tostring 会首先检查对象是否存在一个带有 tostring 域的 metatable。如果存在则 以对象作为参数调用对应的函数来完成格式化,返回的结果即为tostring 的结果。
在我们集合的例子中我们己经定义了一个函数来将集合转换成字符串打印出来。因此,我们只需要将集合的 metatable 的 tostring 域调用我们定义的打印函数:
Set.mt._tostring = Set.tostring
这样,不管什么时候我们调用 print 打印一个集合,print 都会自动调用 tostring,而 tostring 则会调用Set.tostring:
s1 =Set.new{10, 4, 5} print(s1) --> {4, 5, 10}
setmetatable/getmetatable 函数也会使 用 metafield ,在这种情 况下,可以 保护metatables。假定你想保护你的集合使其使用者既看不到也不能修改 metatables。如果你 对 metatable 设置了 _metatable
的值,getmetatable 将返回这个域的值,而调用 setmetatable 将会出错:
Set.mt._metatable = "not yourbusiness" s1 =Set.new{} print(getmetatable(s1)) --> not your business setmetatable(s1, {}) stdin:1: cannotchange protected metatable
12.4表相关的 Metamethods
关于算术运算和关系元算的 metamethods 都定义了错误状态的行为,他们并不改变 语言本身的行为。针对在两种正常状态:表的不存在的域的查询和修改,Lua 也提供了 改变 tables 的行为的方法。
12.4.1The __index Metamethod
前面说过,当我们访问一个表的不存在的域,返回结果为nil,这是正确的,但并不 一致正确。实际上,这种访问触发 lua 解释器去查找 _index metamethod:如果不存在, 返回结果为 nil;如果存在则由_index metamethod
返回结果。
这个例子的原型是一种继承。假设我们想创建一些表来描述窗口。每一个表必须描 述窗口的一些参数,比如:位置,大小,颜色风格等等。所有的这些参数都有默认的值,当我们想要创建窗口的时候只需要给出非默认值的参数即可创建我们需要的窗口。第一 种方法是,实现一个表的构造器,对这个表内的每一个缺少域都填上默认值。第二种方 法是,创建一个新的窗口去继承一个原型窗口的缺少域。首先,我们实现一个原型和一个构造函数,他们共享一个
metatable:
-- create anamespace Window = {} -- create theprototype with defaultvalues Window.prototype ={x=0, y=0, width=100, height=100, } -- create ametatable Window.mt = {} -- declare theconstructor function function Window.new (o) setmetatable(o,Window.mt) return o end
现在我们定义_index metamethod:
Window.mt._index = function (table, key) return Window.prototype[key] end
这样一来,我们创建一个新的窗口,然后访问他缺少的域结果如下:
w = Window.new{x=10, y=20} print(w.width) --> 100
当Lua 发现 w 不存在域width 时,但是有一个 metatable 带有 _index 域,Lua 使用 w(the table)和 width(缺少的值)来调用 _index metamethod,metamethod 则通过访问 原型表(prototype)获取缺少的域的结果。
_index metamethod 在继承中的使用非常常见,所以 Lua 提供了一个更简洁的使用方式。_index metamethod 不需要非是一个函数,他也可以是一个表。但它是一个函数 的时候,Lua 将 table 和缺少的域作为参数调用这个函数;当他是一个表的时候,Lua 将 在这个表中看是否有缺少的域。所以,上面的那个例子可以使用第二种方式简单的改写 为:
Window.mt._index = Window.prototype
现在,当 Lua 查找 metatable 的__index 域时,他发现 window.prototype 的值,它是 一个表,所以 Lua 将访问这个表来获取缺少的值,也就是说它相当于执行:
Window.prototype["width"]
将一个表作为 _index metamethod 使用,提供了一种廉价而简单的实现单继承的方法。一个函数的代价虽然稍微高点,但提供了更多的灵活性:我们可以实现多继承,隐藏,和其他一些变异的机制。
当我们想不通过调用 indexmetamethod 来访问一个表,我们可以使用 rawget函数。Rawget(t,i)的调用以 raw access 方式访问表。这种访问方式不会使你的代码变快Cthe overhead of a function call kills any gain you could have),但有些时候我们需要他,在后面我们将会看到。
12.4.2The __newindex Metamethod
_newindex metamethod 用来对表更新, _index 则用来对表访问。当你给表的一个缺少的域赋值,解释器就会查找_newindex metamethod:如果存在则调用这个函数而不 进行赋值操作。像 _index 一样,如果 metamethod 是一个表,解释器对指定的那个表, 而不是原始的表进行赋值操作。另外,有一个 raw 函数可以绕过 metamethod:调用
rawset(t,k,v)不掉用任何 metamethod 对表 t 的 k 域赋值为 v。 index 和 newindex metamethods的混合使用提供了强大的结构:从只读表到面向对象编程的带有继承默认 值的表。
12.4.3有默认值的表
在一个普通的表中任何域的默认值都是 nil。很容易通过 metatables 来改变默认值:
function setDefault (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 setDefault(tab, 0) print(tab.x, tab.z) --> 10 0
现在,不管什么时候我们访问表的缺少的域,他的_index metamethod 被调用并返 回 0。setDefault函数为每一个需要默认值的表创建了一个新的 metatable。在有很多的表 需要默认值的情况下,这可能使得花费的代价变大。然而 metatable 有一个默认值 d 和它 本身关联,所以函数不能为所有表使用单一的一个 metatable。为了避免带有不同默认值
的所有的表使用单一的 metatable,我们将每个表的默认值,使用一个唯一的域存储在表 本身里面。如果我们不担心命名的混乱,我可使用像" "作为我们的唯一的域:
local mt = { index =function (t) return t._<span style="font-family: Arial, Helvetica, sans-serif;">end}</span> function setDefault (t, d) t._ = d setmetatable(t, mt) end
如果我们担心命名混乱,也很容易保证这个特殊的键值唯一性。我们要做的只是创 建一个新表用作键值:
local key = {} -- unique key local mt = {_index =function (t) return t[key] end} function setDefault (t, d) t[key] = d setmetatable(t, mt) end
另外一种解决表和默认值关联的方法是使用一个分开的表来处理,在这个特殊的表 中索引是表,对应的值为默认值。然而这种方法的正确实现我们需要一种特殊的表:weak table,到目前为止我们还没有介绍这部分内容。为了带有不同默认值的表可以重用相同的原表,还有一种解决方法是使用
memoize metatables,以后说。
12.4.4 监控表
_index和 _newindex 都是只有当表中访问的域不存在时候才起作用。捕获对一个表的所有访问情况的唯一方法就是保持表为空。因此,如果我们想监控一个表的所有访 问情况,我们应该为真实的表创建一个代理。这个代理是一个空表,并且带有 _index和 _newindexmetamethods,由这两个方法负责跟踪表的所有访问情况并将其指向原始的表。假定,t 是我们想要跟踪的原始表,我们可以:
t = {} --original table (createdsomewhere) -- keep aprivate access tooriginal table local _t = t -- create proxy t = {} -- create metatable local mt = { _index = function (t,k) print("*access to element " .. tostring(k)) return _t[k] -- access the originaltable end, _newindex = function (t,k,v) print("*update of element" .. tostring(k) .." to ".. tostring(v)) _t[k] = v --update original table end } setmetatable(t, mt)
这段代码将跟踪所有对 t 的访问情况:
<pre name="code" class="csharp">> t[2] = 'hello' *update of element 2 to hello > print(t[2]) *access to element 2 hello
(注意:不幸的是,这个设计不允许我们遍历表。Pairs 函数将对 proxy 进行操作,而不是原始的表。)如果我们想监控多张表,我们不需要为每一张表都建立一个不同的 metatable。我们只要将每一个 proxy 和他原始的表关联,所有的 proxy
共享一个公用的 metatable 即可。将表和对应的 proxy 关联的一个简单的方法是将原始的表作为 proxy 的 域,只要我们保证这个域不用作其他用途。一个简单的保证它不被作他用的方法是创建 一个私有的没有他人可以访问的 key。将上面的思想汇总,最终的结果如下:
-- create privateindex local index = {} -- create metatable local mt = { _index = function (t,k) print("*access to element" .. tostring(k)) return t[index][k] -- access theoriginal table end _newindex = function (t,k,v) print("*update of element" .. tostring(k) .. " to ".. tostring(v)) t[index][k] = v --update original table end } function track (t) local proxy = {} proxy[index] = t setmetatable(proxy, mt) return proxy end
现在,不管什么时候我们想监控表 t,我们要做得只是 t=track(t)。
12.4.5只读表
采用代理的思想很容易实现一个只读表。我们需要做得只是当我们监控到企图修改 表时候抛出错误。通过 _index metamethod,我们可以不使用函数而是用原始表本身来使 用表,因为我们不需要监控查寻。这是比较简单并且高效的重定向所有查询到原始表的方法。但是,这种用法要求每一个只读代理有一个单独的新的 metatable,使用 _index 指向原始表:
function readOnly (t) local proxy = {} local mt = { -- create metatable _index = t, _newindex = function (t,k,v) error("attempt to updatea read-only table", 2 end ) setmetatable(proxy, mt) return proxy end
(记住:error的第二个参数 2,将错误信息返回给企图执行 update 的地方)作为一 个简单的例子,我们对工作日建立一个只读表:
days = readOnly{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} print(days[1]) --> Sunday days[2] = "Noday" stdin:1: attemptto update aread-only table