20 元表和方法

元表是 Lua 语言最重要的特性,必须要好好学习

通常,Lua 语言中的每种类型的值都有一套可预见的操作集合。例如,我们可以将数字相加,可以连接字符串,还可以在表中插入键值对等。但是,我们无法将两个表相加,无法对函数作比较,也无法调用一个字符串,除非使用元表。
元表可以修改一个值在面对一个未知操作时的行为。例如,假设 a 和 b 都是表,那么可以通过元表定义 Lua 语言如何计算表达式 a + b。当 Lua 语言试图将两个表相加时,它会先检查两者之一是否有 元表 且该元表中是否有 __add 字段。如果 Lua 语言找到了该字段,就调用该字段对应的值,即所谓的 元方法(是一个函数),在本例中就是用于计算表的和的函数。
可以认为,元表是面向对象领域中的受限制类。像类一样,元表定义的是实例的行为。不过,由于元表只能给出预先定义的操作集合的行为,所以元表比类更受限;同时,元表也不支持继承。不过尽管如此,我们还是会在第 21 章 中看到如何基于元表构建一个相对完整的类系统。
Lua 语言中的每一个值都可以有元表。每一个表和用户数据类型都具有独立的元表,而其他类型的值则共享对应类型所属的同一个元表。Lua 语言在创建新表时不带元表:

t = {}
print(getmetatable(t))    -->    nil

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

t = {}
t1 = {}
setmetatable(t, t1)
print(getmetatable(t) == t1)    -->    true

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

print(getmetatable("hi"))
print(getmetatable("xuxu"))
print(getmetatable(10))
print(getmetatable(print))
--------------------------
table: 0000000000dd90d0
table: 0000000000dd90d0
nil
nil

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


20.1 算术运算相关的元方法

在本节中,我们将介绍一个解释元表基础的示例。假设有一个用来表示集合的模块,该模块还有一些用来计算集合并集和交集等的函数,可以参见示例 20.1

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

local Set = {}

-- 使用指定的列表创建一个新的集合
function Set.new(l)
    local set = {}
    for _,v in pairs(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

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

local mt = {}

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

function Set.new(l)
    local set = {}
    setmetatable(set, mt)
    for _,v in pairs(l) do
        set[v] = true
    end
    return set
end

在此之后,所有由 Set.new 创建的集合都具有了一个相同的元表:
``lua
s1 = Set.new{10, 20,30, 50}
s2 = Set.new{30, 1}
print(getmetatable(s1)) --> table: 0000000000d98dd0
print(getmetatable(s2)) --> table: 0000000000d98dd0


  最后,向元表中加入元方法 __add,也就是用于描述如何完成加法的字段:
```lua
mt.__add = Set.union

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

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

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

mt.__mul = Set.intersection

print(Set.tostring((s1 + s2) * s1))    -->    {30, 10, 50, 20}

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

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 内部就会发生错误:

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

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

function Set.union(a, b)
    local res = Set.new {}

    if getmetatable(a) ~= mt or getmetatable(b) ~= mt then
        error("attempt to 'add' a set with a non-set value", 2)
    end

    for k in pairs(a) do
        res[k] = true
    end
    for k in pairs(b) do
        res[k] = true
    end
    return res
end

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


20.2 关系运算相关的元方法

元表还允许我们指定关系运算符的含义,其中的元方法包括等于__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 代表未定义的值,例如 0/0 结果就是 NaN。标准规定任何涉及 NaN 的比较都应返回假,这就意味着 NaN <= x 永远为假, x < NaN 也为假。因此,在这种情况下,a <= b 到 not (b > a) 的转化也就不合法了。
在集合的示例中,我们也面临类似地问题,<= 显而易见且有用的含义是集合包含:a <= b 通常意味着 a 是 b 的一个子集。然而,根据部分有序的定义,a <= b 和 b < a 可能同时为假。因此,我们就必须实现 __le 和 __lt:

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)

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

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)    -->    trus
print(s1 > s1)    -->    false
print(s1 == s2 * s1)    -->    true

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


20.3 库定义相关的元方法

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

print({})    -->    table: 00749600

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

mt.__tostring = Set.tostring

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

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

函数 setmetatable 和 getmetatable 也用到了元方法,用于保护元表。假设想要保护我们的集合,就要使用用户既不能看到也不能修改集合的值,而 setmetatable 则会引发一个错误:

mt.__metatable = "not your business"

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

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


20.4 表相关的元方法

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

20.4.1 __index 元方法

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

prototype = {x = 0, y = 0, width = 100, height = 100}

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

mt = {}

function new(o)
    setmetatable(o, mt)
    return o
end

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

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

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

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

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

mt.__index = prototype

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

20.4.2 __newindex 元方法

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

20.4.3 具有默认值的表

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

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

在调用 setDefault 为所有需要默认值的表创建了一个新的闭包和一个新的元表。如果我们有很多需要默认值的表,那么开销会比较大。然而,由于具有默认值 d 的元表是与元方法关联在一起的,所以我们不能把同一个元表用于具有不同默认值的表。为了能够使所有的表都使用同一个元表,可以使用一个额外的字段将每个表的默认值存放到表自身中。如果不担心命中冲突的话,我们可以使用形如 "___" 这样的键作为额外的字段:

local mt = {__index = function (t) return t.___ end}
function setDefault(t, d)
    t.___ = d
    setmetatable(t, mt)
end

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

local key = {}
local mt = {__index = function (t) return t[key] end}
function setDefault(t, d)
    t[key] = d
    setmetatable(t, mt)
end

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

20.4.4 跟踪对表的访问

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

示例 20.2 跟踪对表的访问

function track(t)
    local proxy = {}

    local mt = {
        __index = function (_, k)
            print("*access to element ".. tostring(k))
            return t[l]
        end,

        __newindex = function(_,k, v)
            print("*update of 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,    }

    setmetatable(proxy, mt)

    return proxy
end

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

t = {}
t = track(t)
t[2] = "hello"    -->    *update of element 2 to hello
print(t[2])    -->    *access to element 2
                  -->    hello

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

t = track({10, 20})
print(#t)    -->    2
for k, v in pairs(t) do
    print(k, v)
end

 -->    *traversing element 1
-->    1    10
-->    *traversing element 2
-->    2    20

以上程序在 Lua 5.1 中得到的是 0 t 表为空

如果想要同时监控几个表,并不需要为每个表创建不同的元表。相反,只要以某种形式将每个代理与其原始表映射起来,并且让所有的代理共享一个公共的元表即可。这个问题与上节所讨论的把表与默认值关联起来的问题类似,因此可以采用相同的解决方法。例如,可以把原来的表保存在代理表的一个特殊的字段中,或者使用一个对偶表示建立代理与对应表的映射。

20.4.5 只读的表

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

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

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

days = readOnly { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }

print(days[1])    -->    Sunday
days[2] = "Noday"    -->    attempt to update a read-only table

20.5 练习

  • 练习 20.1:请定义一个一个元方法 __sub,该元方法用于计算两个集合的差集(集合a - b是位于集合 a 但不位于集合 b 中的元素)。

要找到在 a 不在 b 中的元素,首先另一个辅助数组获取 a 的所有值,并将值作为键存储起来,值设为 true,然后再遍历 b,若以 b 中的值为键的字段存在,且值为true就将其置为 false,最后,遍历辅助数组,将所有值为 true 的键作为值插入到返回数组中即可。程序遍历了一次a、遍历了一次 b,还有一次辅助数组,开销相对比较小。不知道还有没有效率更高的方法,暂时只想到这样。跟下一题两题合一起写

  • 练习 20.2:请定义一个元方法 __len,该元方法用于实现使用 #s 计算集合 s 中的元素个数。
-- Set.lua
local Set = {}

local mt = {}
-- 使用指定的列表创建一个新的集合
function Set.new(l)
    local set = {}
    setmetatable(set, mt)
    for _,v in pairs(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

mt.__add = Set.union

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

mt.__mul = Set.intersection

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

mt.__tostring = Set.tostring

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

    for k,_ in pairs(b) do
        ret[k] = nil
    end

    return ret
end

mt.__sub = Set.SetDiff

function Set.Length(t)
    local i = 0
    for _,_ in pairs(t) do
        i = i + 1
    end
    return i
end

mt.__len = Set.Length

return Set

--------------------------------------------------

local Set = require "Set"

local a = Set.new({ 1, 2, 3 })
local b = Set.new({ 3, 4 })

local c = a - b

print(c)
print(#a)
print(#b)
print(#c)
  • 练习 20.3:实现只读表的另一种方式是将一个函数用作 __index 元方法。这种方式使得访问的开销更大,但是创建只读表的开销更小(因为所有的只读表只能共享同一个元表)。请用这种方式重写函数 readOnly。
function readOnly(t)
    local proxy = {}
    local mt = {
        __index = function(_,k) return t[k] end,
        __newindex = function (t, k ,v)
            error("attempt to update a read-only table", 2)
        end
    }
    setmetatable(proxy, mt)
    return proxy
end


local t = readOnly({1,2,3})
print(t[1])
print(t[2])
print(t[3])
t[1] = 5
  • 练习 20.4:代理表可以表示除表外的其他类型的对象。请编写一个函数 fileAsArray,该函数以一个文件名为参数,返回值为对应文件的代理,当执行 t = fileAsArray("myFile")后,访问 t[i] 返回指定文件的第 i 个字节,而对 t[i] 的赋值更新第 i 个字节。
---@return table
---@param file string
function fileAsArray(file)
    local proxy = {}

    local mt = {
        __index = function(_, k)
            local f = io.open(file,"r+")
            f:seek("set", k)
            local c = f:read(1)
            f:close()
            return c
        end,
        __newindex = function(_, k, v)
            local f = io.open(file, "r+")
            f:seek("set", k)
            f:write(v)
            f:close()
        end,
        __tostring = function()
            local f = io.open(file,"r+")
            f:seek("set")
            local content = f:read("*a")
            return content
        end
    }

    setmetatable(proxy, mt)
    return proxy
end

local t = fileAsArray("ex20_4.txt")
print(t)    -->    Hello
print(t[0])    -->    H
t[3] = " "
print(t)    -->    Hel o
  • 练习20.5:扩展之前的示例,使得我们能够使用 pairs 遍历一个文件中所有的字节,并使用 #t 来获得文件的大小

考虑到程序执行过程中还有可能会改动文件,所以没有办法提前将文件读出来存到表里面,所以使用 tostring 来代替 pairs。

---@return table
---@param file string
function fileAsArray(file)
    local proxy = {}

    local mt = {
        __index = function(_, k)
            local f = io.open(file,"r+")
            f:seek("set", k)
            local c = f:read(1)
            f:close()
            return c
        end,
        __newindex = function(_, k, v)
            local f = io.open(file, "r+")
            f:seek("set", k)
            f:write(v)
            f:close()
        end,
        __tostring = function()
            local f = io.open(file,"r+")
            f:seek("set")
            local content = f:read("*a")
            return content
        end,
        __len = function()
            local f = io.open(file, "r")
            local length = f:seek("end")
            return length
        end
    }

    setmetatable(proxy, mt)
    return proxy
end

local t = fileAsArray("ex20_4.txt")
print(#t)
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容