Lua程序设计 | 模块和包、泛型迭代器和for、元表和元方法
From《Programming in Lua》 by Roberto Ierusalimschy
文章目录
- 模块和包
- 函数 require
- 模块重命名
- 搜索路径
- 搜索器
- Lua语言中编写模块的基本方法
- 子模块和包
- 迭代器和泛型for
- 迭代器和闭包
- 泛型for的语法
- 无状态迭代器
- 按顺序遍历表
- 迭代器的真实含义
- 元表和元方法
- 算术运算相关的元方法
- 关系运算相关的元方法
- 库定义相关的元方法
- 表相关的元方法
- __index 元方法
- __newindex 元方法
- 具有默认值的表
- 只读的表
模块和包
通常,Lua语言不会设置规则(policy)。相反,Lua语言提供的是足够强大的机制供不同的开发者实现最适合自己的规则。然而,这种方法对于模块(module)而言并不是特别适用。模块系统的主要目标之一就是允许不同的人共享代码,缺乏公共规则就无法实现这样的共享。
Lua语言从5.1版本开始为模块和包(package,模块的集合)定义了一系列的规则。这些规则不需要从语言中引入额外的功能,可以使用目前为止我们学习到的机制实现这些规则,也可以自由地使用不同的策略。当然,不同的实现可能会导致程序不能使用外部模块,或者模块不能被外部程序使用。
从用户观点来看,一个模块(module)就是一些代码(要么是Lua语言编写的,要么是C语言编写的),这些代码可以通过函数require加载,然后创建和返回一个表。这个表就像是某种命名空间,其中定义的内容是模块中导出的东西,比如函数和常量。例如,所有的标准库都是模块。我们可以按照如下的方法使用数学库:
local m = require "math"
print(m.sin(3.14)) --> 0.0015926529164868
独立解释器会使用跟如下代码等价的方式提前加载所有标准库:
math = require "math"
string = require "string"
···
使用表来实现模块的显著优点之一是,让我们可以像操作普通表那样操作模块,并且能利用Lua语言的所有功能实现额外的功能。在大多数语言中,模块不是第一类值(即它们不能被保存在变量中,也不能被当作参数传递给函数等),所以那些语言需要为模块实现一套专门的机制。而在Lua语言中,我们则可以轻易地实现这些功能。
例如,用户调用模块中的函数就有几种方法。其中常见的方法是:
local mod = require "mod"
mod.foo()
用户可以为模块设置一个局部名称:
local m = require "mod"
m.foo()
也可以为个别函数提供不同的名称:
local m = require "mod"
local f = m.foo
f()
还可以只引入特定的函数:
local f = require "mod".foo -- (require("mod")).foo
f()
上述这些方法的好处是无须语言的特别支持,它们使用的都是语言已经提供的功能。
函数 require
尽管函数require也只是一个没什么特殊之处的普通函数,但在Lua语言的模块实现中扮演着核心角色。要加载模块时,只需要简单地调用这个函数,然后传入模块名作为参数。请记住,当函数的参数只有一个字符串常量时括号是可以省略的,而且一般在使用require时按照惯例也会省略括号。不过尽管如此,下面的这些用法也都是正确的:
local m = require('math')local modname = 'math',
local m = require(modname)
函数require尝试对模块的定义做最小的假设。对于该函数来说,一个模块可以是定义了一些变量(比如函数或者包含函数的表)的代码。典型地,这些代码返回一个由模块中函数组成的表。不过,由于这个动作是由模块的代码而不是由函数require完成的,所以某些模块可能会选择返回其他的值或者甚至引发副作用(例如,通过创建全局变量)。
首先,函数require在表package.loaded中检査模块是否已被加载。如果模块已经被加载,函数require就返回相应的值。因此,一旦一个模块被加载过,后续的对于同一模块的所有require调用都将返回同一个值,而不会再运行任何代码。
如果模块尚未加载,那么函数require则搜索具有指定模块名的Lua文件(搜索路径由变量package.path指定,我们会在后续对其进行讨论)。如果函数require找到了相应的文件,那么就用函数loadfile将其进行加载,结果是一个我们称之为加载器(loader)的函数(加载器就是一个被调用时加载模块的函数)。
如果函数require找不到指定模块名的Lua文件,那么它就搜索相应名称的C标准库(在这种情况下,搜索路径由变量package.cpath指定)。如果找到了一个C标准库,则使用底层函数package.loadlib进行加载,这个底层函数会查找名为luaopen_modename的函数。在这种情况下,加载函数就是loadlib的执行结果,也就是一个被表示为Lua函数的C 语言函数 luaopen_modename
不管模块是在Lua文件还是C标准库中找到的,函数require此时都具有了用于加载它的加载函数。为了最终加载模块,函数require带着两个参数调用加载函数:模块名和加载函数所在文件的名称(大多数模块会忽略这两个参数)。如果加载函数有返回值,那么函数require会返回这个值,然后将其保存在表package.loaded中,以便于将来在加载同一模块时返回相同的值。如果加载函数没有返回值且表中的package.loaded[@rep{modename}]为空,函数require就假设模块的返回值是true。如果没有这种补偿,那么后续调用函数require时将会重复加载模块。
要强制函数require加载同一模块两次,可以先将模块从package.loaded中删除:
package.loaded.modename = nil
下一次再加载这个模块时,函数require就会重新加载模块。
对于函数require来说,一个常见的抱怨是它不能给待加载的模块传递参数。例如,数学模块可以对角度和弧度选择增加一个选项:
-- 错误的代码
local math = require("math", "degree")
这里的问题在于,函数require的主要目的之一就是避免重复加载模块。一旦一个模块被加载,该模块就会在后续所有调用require的程序部分被复用。这样,不同参数的同名模块之间就会产生冲突。如果读者真的需要具有参数的模块,那么最好使用一个显式的函数来设置参数,比如:
local mod = require "mod”
mod.init(0, 0)
如果加载函数返回的是模块本身,那么还可以写成:
local mod = require "mod”
请记住,模块在任何情况下只加载一次;至于如何处理冲突的加载,取决于模块自己。
模块重命名
通常,我们通过模块本来的名称来使用它们,但有时,我们也需要将一个模块改名以避免命名冲突。一种典型的情况就是,出于测试的目的而需要加载同一模块的不同版本。对于一个Lua语言模块来说,其内部的名称并不要求是固定的,因此通常修改.lua文件的文件名就够了。不过,我们却无法修改C标准库的二进制目标代码中luaopen_*函数的名称。
为了进行这种重命名,函数require运用了一个连字符的技巧:如果一个模块名中包含连字符,那么函数require就会用连字符之前的内容来创建luaopen_*函数的名称。例如,如果一个模块的名称为mod-v3.4,那么函数require会认为该模块的加载函数应该是luaopen_mod而不是luaopen_mod-v3.4 (这也不是有效的C语言函数名)。因此,如果需要使用两个名称均为mod的模块(或相同模块的两个不同版本),那么可以对其中的一个进行重命名,如mod-v1。当调用m1=require "mod-v1"时,函数require会找到改名后的文件mod-v1并将其中原名为luaopen_mod的函数作为加载函数。
搜索路径
在搜索一个Lua文件时,函数require使用的路径与典型的路径略有不同。典型的路径是很多目录组成的列表,并在其中搜索指定的文件。不过,ISO C (Lua语言依赖的抽象平台)并没有目录的概念。所以,函数require使用的路径是一组模板,其中的每项都指定了将模块名(函数require的参数)转换为文件名的方式。更准确地说,这种路径中的每一个模板都是一个包含可选问号的文件名。对于每个模板,函数require会用模块名来替换每一个问号,然后检查结果是否存在对应的文件;如果不存在,则尝试下一个模板。路径中的模板以在大多数操作系统中很少被用于文件名的分号隔开。例如,考虑如下路径:
?;?.lua;c:\windows\?;/usr/local/lua/?/?.lua
在使用这个路径时,调用require "sql"将尝试打开如下的Lua文件:
sql
sql.lua
c:\windows\sql
/usr/local/lua/sql/sql.lua
函数require只处理分号(作为分隔符)和问号,所有其他的部分(包括目录分隔符和文件扩展名)则由路径自己定义。
函数require用于搜索Lua文件的路径是变量package.path的当前值。当package模块被初始化后,它就把变量package.path置成环境变量LUA_PATH_5_3的值。如果这个环境变量没有被定义,那么Lua语言则尝试另一个环境变量LUA.PATH。如果这两个环境变量都没有被定义,那么Lua语言则使用一个编译时定义的默认路径。在使用一个环境变量的值时,Lua语言会将其中所有的";;“替换成默认路径。例如,如果将LUA_PATH_5_3设为"mydir/?.lua;;”,那么最终路径就会是模板"mydir/?.lua"后跟默认路径。
搜索C标准库的路径的逻辑与此相同,只不过C标准库的路径来自变量package。cpath而不是package.path。类似地,这个变量的初始值也来自环境变量LUA_CPATH_5_3或LUA_CPATH。在POSIX系统中这个路径的典型值形如:
./?.so;/usr/local/lib/lua/5.2/?.so
请注意定义文件扩展名的路径。在上例中,所有模板使用的都是.so,而在Windows操作系统中此典型路径通常形如:
.\?.dll;C:\Program Files\Lua502\dll\?.dll
函数package.searchpath中实现了搜索库的所有规则,该函数的参数包括模块名和路径,然后遵循上述规则来搜索文件。函数package.searchpath要么返回第一个存在的文件的文件名,要么返回nil外加描述所有文件都无法成功打开的错误信息,如下:
path = ".\\?.dll;C:\\Program Files\\Lua502\\dll\\?.dll"
print(package.searchpath("X", path))-- 结果
nilno file '.\X.dll'no file 'C:\Program Files\Lua502\dll\X.dll'
搜索器
在现实中,函数require比此前描述过的稍微复杂一点。搜索Lua文件和C标准库的方式只是更加通用的搜索器(searcher)的两个实例。一个搜索器是一个以模块名为参数,以对应模块的加载器或nil (如果找不到加载器)为返回值的简单函数。
数组package.searchers列出了函数require使用的所有搜索器。在寻找模块时,函数require传入模块名并调用列表中的每一个搜索器直到它们其中的一个找到了指定模块的加载器。如果所有搜索器都被调用完后还找不到,那么函数require就抛出一个异常。
用一个列表来驱动对一个模块的搜索给函数require提供了极大的灵活性。例如,如果想保存被压缩在zip文件中的模块,只需要提供一个合适的搜索器(函数),然后把它增加到该列表中。在默认配置中,我们此前学习过的用于搜索Lua文件和C标准库的搜索器排在列表的第二、三位,在它们之前是预加载搜索器。
预加载(preload)搜索器使得我们能够为要加载的模块定义任意的加载函数。预加载搜索器使用一个名为package.preload的表来映射模块名称和加载函数。当搜索指定的模块名时,该搜索器只是简单地在表中搜索指定的名称。如果它找到了对应的函数,那么就将该函数作为相应模块的加载函数返回;否则,则返回nil。
预加载搜索器为处理非标场景提供了一种通用的方式。例如,一个静态链接到Lua中的C标准库可以将其luaopen_函数注册到表preload中,这样luaopen_函数只有当用户加载这个模块时才会被调用。用这种方式,程序不会为没有用到的模块浪费资源。
默认的package.searchers中的第4个函数只与子模块有关,我们会在后文中对其进行介绍。
Lua语言中编写模块的基本方法
在Lua语言中创建模块的最简单方法是,创建一个表并将所有需要导出的函数放入其中,最后返回这个表。下例是一个用于复数的简单模块。
local M = {} -- 模块-- 创建一个新的复数
local function new(r, i)return {r = r, i = i}
endM.new = new -- 把'new'加到模块中-- constant 'i'
M.i = new(0, 1)function M.add(c1, c2)return new(c1.r + c2.r, c1.i + c2.i)
endfunction M.sub(c1, c2)return new(c1.r - c2.r, c1.i - c2.i)
endfunction M.mul(c1, c2)return new(c1.r * c2.r - c1.i * c2.i, c1.r * c2.i + c1.i * c2.r)
endfunction M.inv(c)local n = c.r^2 + c.i^2return new(c.r/n, -c.i/n)
endfunction M.tostring(c)return string.format("(%g,%g)", c.r, c.i)
endreturn M
请注意我们是如何通过简单地把new和inv声明为局部变量而使它们成为代码段的私有函数的。
关于最后的返回语句,一种将其省略的方式是直接把模块对应的表放到package.loaded 中:
local M = {}
package.loaded[...] = M -- 跟之前一样,但没有返回语句
请注意,函数require会把模块的名称作为第一个参数传给加载函数。因此,表索引中的可变长参数表达式…其实就是模块名。在这一赋值语句后,我们就不再需要在模块的最后返回M了:如果一个模块没有返回值,那么函数require会返回package.loaded[modname]的当前值(如果不是nil的话)。不过,在模块的最后加上return语句更清晰。
另一种编写模块的方法是把所有的函数定义为局部变量,然后在最后构造返回的表,下例是使用导出表的模块:
local function new(r, i) return {r=r, i=i} end-- 定义常量'i'
local i = complex.new(0, 1)-- 跟之前一样的其他函数 ···return {new = new,i = i,add s= add,sub = sub,mul = mul,div = div,tostring = tostring
}
这种方式的优点在于,无须在每一个标识符前增加前缀M.或类似的东西。通过显式的导出表,我们能够以与在模块中相同的方式定义和使用导出和内部函数。这种方式的缺点在于,导出表位于模块最后而不是最前面(把前面的话当作简略文档的话更有用),而且由于必须把每个名字都写两遍,所以导出表有点冗余。
不管怎样,无论怎样定义模块,用户都能用标准的方法使用模块:
local cpx = require "complex"
print(cpx.tostring(cpx.add(cpx.new(3, 4), cpx.i)))--> (3, 5)
子模块和包
Lua支持具有层次结构的模块名,通过点来分隔名称中的层次。例如,一个名为mod.sub的模块是模块mod的一个子模块(submodule)。一个包(package)是一棵由模块组成的完整的树,它是Lua语言中用于发行程序的单位。
当加载一个名为mod.sub的模块时,函数require依次使用原始的模块名"mod.sub"作为键来查询package.loaded和表package.preload。这里,模块名中的点像模块名中的其他字符一样,没有特殊含义。
然而,当搜索一个定义子模块的文件时,函数require会将点转换为另一个字符,通常就是操作系统的目录分隔符(例如,POSIX操作系统的斜杠或Windows操作系统的反斜杠)。转换之后,函数require会像搜索其他名称一样搜索这个名称。例如,假设目录分隔符是斜杠并且有如下路径:
./?.lua;/usr/local/lua/?.lua;/usr/local/lua/?/init.lua
调用require "a.b"会尝试打开以下文件:
./a/b.lua
/usr/local/lua/a/b.lua
/usr/local/lua/a/b/init.lua
这种行为使得一个包中的所有模块能够放到一个目录中。例如,一个具有模块p、p.a和p.b的包对应的文件可以分别是p/init.lua、p/a.lua和p/b.lua,目录p又位于其他合适的目录中。
Lua语言使用的目录分隔符是编译时配置的,可以是任意的字符串。例如,没有目录层次的系统可以使用下画线作为“目录分隔符”,因此调用 require "a.b"会搜索文件 a_b.lua。
C语言中的名称不能包含点,因此一个用C语言编写的子模块a.b无法导出函数luaopen_a.b。这时,函数require会将点转换为其他字符,即下画线。因此,一个名为a.b的C标准库应将其加载函数命名为luaopen_a_b。
作为一种额外的机制,函数require在加载C语言编写的子模块时还有另外一个搜索器。当该函数找不到子模块对应的Lua文件或C文件时,它会再次搜索C文件所在的路径,不过这次将搜索包的名称。例如,如果一个程序要加载子模块a.b.c,搜索器会搜索文件a。如果找到了 C标准库a,那么函数require就会在该库中搜索对应的加载函数luaopen_a_b_c。这种机制允许一个发行包将几个子模块组织为一个C标准库,每个子模块有各自的加载函数。
从Lua语言的视角看,同一个包中的子模块没有显式的关联。加载一个模块并不会自动加载它的任何子模块。同样,加载子模块也不会自动地加载其父模块。当然,只要包的实现者愿意,也可以创造这种关联。例如,一个特定的模块可能一开始就显式地加载它的一个或全部子模块。
迭代器和泛型for
我们在之前博文中的几个需求中使用过泛型for,比如读取一个文件的所有行或遍历一个对象所有匹配的模式。然而,我们仍然不知道如何创建迭代器。接下来,我们先从简单的迭代器入手,再学习如利用泛型for的所有功能来编写各种各样的迭代器。
迭代器和闭包
迭代器(iterator)是一种可以让我们遍历一个集合中所有元素的代码结构。在Lua语言中,通常使用函数表示迭代器:每一次调用函数时,函数会返回集合中的“下一个”元素。一个典型的例子是io.read,每次调用该函数时它都会返回标准输入中的下一行,在没有可以读取的行时返回nil。
所有的迭代器都需要在连续的调用之间保存一些状态,这样才能知道当前迭代所处的位置及如何从当前位置步进到下一位置。对于函数io.read而言,C语言会将状态保存在流的结构体中。对于我们自己的迭代器而言,闭包则为保存状态提供了一种良好的机制。
请注意,一个闭包就是一个可以访问其自身的环境中一个或多个局部变量的函数。这些变量将连续调用过程中的值并将其保存在闭包中,从而使得闭包能够记住迭代所处的位置。当然,要创建一个新的闭包,我们还必须创建非局部变量。因此,一个闭包结构通常涉及两个函数:闭包本身和一个用于创建该闭包及其封装变量的工厂。
作为示例,让我们来为列表编写一个简单的迭代器。与ipairs不同的是,该迭代器并不是返回每个元素的索引而是返回元素的值:
function values (t)local i = 0return function () i = i + 1; return t[i] end
end
在这个例子中,values就是工厂。每当调用这个工厂时,它就会创建一个新的闭包(即迭代器本身)。这个闭包将它的状态保存在其外部的变量t和i中,这两个变量也是由values创建的。每次调用这个迭代器时,它就从列表t中返回下一个值。在遍历完最后一个元素后,迭代器返回nil,表示迭代结束。
我们可以在一个while循环中使用这个迭代器:
t = {10, 20, 30}
iter = values (t) -- 创建迭代器
while true dolocal element = iter() -- 调用迭代器if element == nil then break endprint(element)
end
不过,使用泛型for更简单。毕竟,泛型for正是为了这种迭代而设计的:
t = {10, 20, 30}
for element in values(t) doprint(element)
end
泛型for为一次迭代循环做了所有的记录工作:它在内部保存了迭代函数,因此不需要变量iter;它在每次做新的迭代时都会再次调用迭代器,并在迭代器返回nil时结束循环。
下面是一个更高级的示例,展示了一个迭代器,它可以遍历来自标准输入的所有单词。
function allwords()local line = io.read() -- 当前行local pos = 1 -- 当前行的当前位置return function() -- 迭代函数while line do -- 当还有行循环时local w, e = string.match(line, "(%w+)()", pos)if w then -- 发现一个单词?pos = e -- 下一个位置位于单词后return w -- 返回该单词elseline = io.read() -- 没找到单词,尝试下一行pos = 1 -- 从第一个位置重新开始endendreturn nil -- 没有行了:迭代结束end
end
为了完成这样的遍历,我们需要保存两个值:当前行的内容(变量line)及当前行的当前位置(变量pos)。有了这些数据,我们就可以不断产生下一个单词。这个迭代函数的主要部分是调用函数string.match,以当前位置作为起始在当前行中搜索一个单词。函数string.match使用模式’%w+'来匹配一个“单词”,也就是匹配一个或多个字母/数字字符。如果函数string.match找到了一个单词,它就捕获并返回这个单词及该单词之后的第一个字符的位置(一个空匹配),迭代函数则更新当前位置并返回该单词;否则,迭代函数读取新的一行,然后重复上述搜索过程。在所有的行都被读取完后,迭代函数返回nil以表示迭代结束。
尽管迭代器本身有点复杂,但allwords的使用还是很简明易懂的:
for word in allwords() doprint(word)
end
泛型for的语法
上述那些迭代器都有一个缺点,即需要为每个新的循环创建一个新的闭包。对于大多数情况而言,这或许不会有什么问题。例如,在之前的allwords迭代器中,创建一个闭包的开销相对于读取整个文件的开销而言几乎可以忽略不计。但是,在另外一些情况下,这样的开销可能会很可观。在这类情况中,我们可以通过使用泛型for自己保存迭代状态。接下来,我们会详细说明泛型for提供的用来保存状态的机制。
泛型for在循环过程中在其内部保存了迭代函数。实际上,泛型for保存了三个值:一个迭代函数、一个不可变状态和一个控制变量。泛型for的语法如下:
for var-list in exp-list dobody
end
其中,var-list是由一个或多个变量名组成的列表,以逗号分隔;exp-list是一个或多个表达式组成的列表,同样以逗号分隔。通常,表达式列表只有一个元素,即一句对迭代器工厂的调用。例如,在如下代码中,变量列表是k、v,表达式列表只有一个元素pairs(t):
for k, v in pairs(t) do print(k, v) end
我们把变量列表的第一个(或唯一的)变量称为控制变量,其值在循环过程中永远不会是nil,因为当其值为nil时循环就结束了。
for做的第一件事情是对in后面的表达式求值。这些表达式应该返回三个值供for保存:迭代函数、不可变状态和控制变量的初始值。类似于多重赋值,只有最后一个(或唯一的)表达式能够产生不止一个值;表达式列表的结果只会保留三个,多余的值会被丢弄,不足三个则以nil补齐;例如,在使用简单迭器时,工厂只会返回迭代函数,因此不可变状态和控制变量都是nil。在上述的初始化步骤完成后,for使用不可变状态和控制变量为参数来调用迭代函数。
从for代码结构的立足点来看,不可变状态根本没有意义。for只是把从初始化步骤得到的状态值传递给所有迭代函数。然后,for将迭代函数的返回值赋给变量列表中声明的变量。如果第一个返回值(赋给控制变量的值)为nil,那么循环终止;否则,for执行它的循环体并再次调用迭代函数,再不断地重复这个过程。
更确切地说,形如
for var_1, ···, var_n in explist do block end
这样的代码结构与下列代码等价:
dolocal _f, _s, _var = explistwhile true dolocal var_1, ..., var_n = _f(_s, _var)_var = var_1if _var == nil then break endblockend
end
无状态迭代器
顾名思义,无状态迭代器就是一种自身不保存任何状态的迭代器。因此,可以在多个循环中使用同一个无状态迭代器,从而避免创建新闭包的开销。
正如刚刚所看到的,for循环会以不可变状态和控制变量为参数调用迭代函数。一个无状态迭代器只根据这两个值来为迭代生成下一个元素。这类迭代器的一个典型例子就是ipairs,它可以迭代一个序列中的所有元素:
a = {"one", "two", "three"}
for i, v in ipairs(a) doprint(i, v)
end
迭代的状态由正在被遍历的表(一个不可变状态,它不会在循环中改变)及当前的索引值(控制变量)组成。ipairs和迭代器都非常简单,我们可以在Lua语言中将其编写出来:
local function iter (t, i)i = i + 1local v = t[i]if v thenreturn i, vend
endfunction ipairs (t)return iter, t, 0
end
当调用for循环中的ipairs(t)时,ipairs(t)会返回三个值,即迭代函数iter、不可变状态表t和控制变量的初始值0。然后,Lua语言调用iter(t, 0),得到1,t[1](除非t[1]已经变成了 nil)。在第二次迭代中,Lua语言调用iter(t, 1),得到2,t[2],依此类推,直至得到第一个为nil的元素。
函数pairs与函数ipairs类似,也用于遍历一个表中的所有元素。不同的是,函数pairs的迭代函数是Lua语言中的一个基本函数next:
function pairs (t)return next, t, nil
end
在调用next(t, k)时,k是表t的一个键,该函数会以随机次序返回表中的下一个键及k对应的值(作为第二个返回值)。调用next(t, nil)时,返回表中的第一个键值对。当所有元素被遍历完时,函数next返回nil。
我们可以不调用pairs而直接使用next:
for k, v in next, t doloop body
end
请注意,for循环会把表达式列表的结果调整为三个值,因此上例中得到的是next、t和nil,这也正与pairs(t)的返回值完全一致。
关于无状态迭代器的另一个有趣的示例是遍历链表的迭代器(链表在Lua语言中并不常见,但有时也需要用到)。我们的第一反应可能是只把当前节点当作控制变量,以便于迭代函数能够返回下一个节点:
local function getnext (node)return node.next
endfunction traverse (list)return getnext, nil, list
end
但是,这种实现会跳过第一个节点。所以,我们需要使用如下的代码:
local function getnext (list, node)if not node thenreturn listelsereturn node.nextend
endfunction traverse (list)return getnext, list, nil
end
这里的技巧是,除了将当前节点作为控制变量,还要将头节点作为不可变状态(traverse返回的第二个值)。第一次调用迭代函数getnext时,node为nil,因此函数返回list作为第一个节点。在后续的调用中,node不再是nil,所以迭代函数会像我们所期望的那样返回node.next。
按顺序遍历表
一个常见的困惑发生在开发人员想要对表中的元素进行排序时。由于一个表中的元素没有顺序,所以如果想对这些元素排序,就不得不先把键值对拷贝到一个数组中,然后再对数组进行排序。
假设我们要读取一个源文件,然后构造一个表来保存每个函数的名称及其声明所在的行数,形式如下:
lines = {["luaH_set"] = 10,["luaH_get"] = 24,["luaH.present"] = 48,
}
现在,我们想按照字母顺序输出这些函数名。如果使用pairs遍历表,那么函数名会按照随机的顺序岀现。由于这些函数名是表的键,所以我们无法直接对其进行排序。不过,如果我们把它们放到数组中,那么就可以对它们进行排序了。
首先,我们必须创建一个包含函数名的数组,然后对其排序,再最终输出结果。
a = {}
for n in pairs(lines) do a[#a + 1] = n end
table.sort(a)
for _, n in ipairs(a) do print(n) end
当我们使用有序的索引访问数组时,就实现了有序。这正是应该总是使用ipairs而不是pairs来遍历数组的原因。第一个函数通过有序的键1、2等来实现有序,然而后者使用的则是天然的随机顺序。
现在,我们实现一个按照键的顺序来遍历表的迭代器:
function pairsByKeys (t, f)local a = {}for n in pairs(t) do -- 创建一个包含所有键的表a[#a + 1] = nendtable.sort(a, f) -- 对列表排序local i = 0 -- 迭代变量return function () -- 迭代函数i = i + 1 return a[i], t[a[i]] -- 返回键和值end
end
工厂函数pairsByKeys首先把键放到一个数组中,然后对数组进行排序,最后返回迭代函数。在每一步中,迭代器都会按照数组a中的顺序返回原始表中的下一个键值对。可选的参数f允许指定一种其他的排序方式。
使用这个函数,可以很容易地解决开始时提出的按顺序遍历表的问题:
for name, line in pairsByKeys(lines) doprint(name, line)
end
像通常的情况一样,所有的复杂性都被隐藏到了迭代器中。
迭代器的真实含义
“迭代器”这个名称多少有点误导性,这是因为迭代器并没有进行实际的迭代:真正的迭代是for循环完成的,迭代器只不过为每次的迭代提供连续的值。或许,称其为“生成器(generator)“更好,表示为迭代生成元素;不过,“迭代器“这个名字已在诸如Java等其他语言中被广泛使用了。
然而,还有一种创建迭代器的方式可以让迭代器进行实际的迭代操作。当使用这种迭代器时,就不再需要编写循环了。相反,只需要调用这个迭代器,并传入一个描述了在每次迭代时迭代器需要做什么的参数即可。更确切地说,迭代器接收一个函数作为参数,这个函数在循环的内部被调用,这种迭代器就被称为真正的迭代器。
举一个更具体的例子,让我们使用这种风格再次重写allwords迭代器:
function allwords (f)for line in io.lines() dofor word in string.gmatch(line, "w+") dof(word) -- 调用函数endend
end
使用这个迭代器时,我们必须传入一个函数作为循环体。如果我们只想输出每个单词,那么简单地使用函数print即可。
allwords(print)
通常,我们可以使用一个匿名函数作为循环体。例如,以下的代码用于计算单词“hello”在输入文件中出现的次数:
local count = 0
allwords(function (w)if w == "hello" then count = count + 1 endend)print(count)
同样的需求,如果釆用之前的迭代器风格,差异也不是特别大:
local count = 0
for w in allwords() doif w == "hello" then count = count + 1 end
endprint(count)
真正的迭代器与生成器风格的迭代器相比怎么样呢?
这两种风格都有大致相同的开销,即每次迭代都有一次函数调用。一方面,编写真正的迭代器比较容易。另一方面,生成器风格的迭代器则更灵活。首先,生成器风格的迭代器允许两个或更多个并行的迭代。其次,生成器风格的迭代器允许在循环体中使用break和return语句。使用真正的迭代器,return语句从匿名函数中返回而并非从进行迭代的函数中返回。基于这些原因,一般使用生成器风格的迭代器。
元表和元方法
通常,Lua语言中的每种类型的值都有一套可预见的操作集合。例如,我们可以将数字相加,可以连接字符串,还可以在表中插入键值对等。但是,我们无法将两个表相加,无法对函数作比较,也无法调用一个字符串,除非使用元表。
元表可以修改一个值在面对一个未知操作时的行为。例如,假设a和b都是表,那么可以通过元表定义Lua语言如何计算表达式a + b。当Lua语言试图将两个表相加时,它会先检查两者之一是否有元表(metatable)且该元表中是否有_add字段。如果Lua语言找到了该字段,就调用该字段对应的值,即所谓的元方法,在本例中就是用于计算表的和的函数。
可以认为,元表是面向对象领域中的受限制类。像类一样,元表定义的是实例的行为。不过,由于元表只能给出预先定义的操作集合的行为,所以元表比类更受限;同时,元表也不支持继承。不过尽管如此,我们还是会在后续博文中看到如何基于元表构建一个相对完整的类系统。
Lua语言中的每一个值都可以有元表。每一个表和用户数据类型都具有各自独立的元表,而其他类型的值则共享对应类型所属的同一个元表。Lua语言在创建新表时不带元表:
t = {)
print(getmetatable(t)) --> nil
可以使用函数setmetatable来设置或修改任意表的元表:
t1 = (}
setmetatable(t, t1)
print(getmetatable(t) == t1) --> true
在Lua语言中,我们只能为表设置元表;如果要为其他类型的值设置元表,则必须通过C代码或调试库完成(该限制存在的主要原因是为了防止过度使用对某种类型的所有值生效的元表。Lua语言老版本中的经验表明,这样的全局设置经常导致不可重用的代码)。字符串标准库为所有的字符串都设置了同一个元表,而其他类型在默认情况中都没有元表:
print(getmetatable("hi")) --> table: 0x7fb6ffc08340
print(getmetatable("xuxu")) --> table: 0x7fb6ffc08340
print(getmetatable(10)) --> nil
print(getmetatable(print)) --> nil
一个表可以成为任意值的元表;一组相关的表也可以共享一个描述了它们共同行为的通用元表;一个表还可以成为它自己的元表,用于描述其自身特有的行为。总之,任何配置都是合法的。
算术运算相关的元方法
接下来,我们将介绍一个解释元表基础的示例。假设有一个用表来表示集合的模块,该模块还有一些用来计算集合并集和交集等的函数,参见下例:
local Set = {}-- 使用指定的列表创建一个新的集合
function Set.new (l)local set = {}for _, v in ipairs(l) do set[v] = true endreturn set
endfunction Set.union (a, b)local res = Set.new{}for k in pairs(a) do res[k] = true endfor k in pairs(b) do res[k] = true endreturn res
endfunction Set.intersection (a, b)local res = Set.new{}for k in pairs(a) dores[k] = b[k]endreturn res
end-- 将集合表示为字符串
function Set.tostring (set)local l = {} -- 保存集合中所有元素的列表for e in pairs(set) dol[#l + 1] = tostring(e)end ,return "{" .. table.concat(l, ", ") .. "}"
endreturn Set
现在,假设想使用加法操作符来计算两个集合的并集,那么可以让所有表示集合的表共享一个元表。这个元表中定义了这些表应该如何执行加法操作。首先,我们创建一个普通的表,这个表被用作集合的元表:
local mt = {} -- 集合的元表
然后,修改用于创建集合的函数Set.new。在新版本中只多了一行,即将mt设置为函数Set.new所创建的表的元表:
function Set.new (l) -- 第二个版本local set = {}setmetatable(set, mt)for _, v in ipairs(l) do set[v] = true endreturn set
end
在此之后,所有由Set.new创建的集合都具有了一个相同的元表:
s1 = Set.new{10, 20, 30, 50}
s2 = Set.new{30, 1}
print(getmetatable(sl)) --> table: 0X00672B60
print(getmetatable(s2)) --> table: 0X00672B60
最后,向元表中加入元方法__add,也就是用于描述如何完成加法的字段:
mt.__add = Set.union
此后,只要Lua语言试图将两个集合相加,它就会调用函数Set.union,并将两个操作数作为参数传入。
通过元方法,我们就可以使用加法运算符来计算集合的并集了:
s3 = s1 + s2
print(Set.tostring(s3)) --> (1, 10, 20, 30, 50}
类似地,还可以使用乘法运算符来计算集合的交集:
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来定义连接运算符的行为。
当我们把两个集合相加时,使用哪个元表是确定的。然而,当一个表达式中混合了两种具有不同元表的值时,例如:
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)if getmetatable(a) ~= mt or getmetatable(b) ~= mt thenerror("attempt to 'add' a set with a non-set value", 2)end-- 同前···
请注意,函数error的第二个参数(上例中的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) doif not b[k] then return false endendreturn true
endmt.__lt = function (a, b) -- 真子集return a <= b and not (b <= a)
end
最后,我们还可以通过集合包含来定义集合相等:
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 >= s2) --> true
print(s1 > s2) --> false
print(s1 == s2 * s1) --> true
相等比较有一些限制。如果两个对象的类型不同,那么相等比较操作不会调用任何元方法而直接返回false。因此,不管元方法如何,集合永远不等于数字。
库定义相关的元方法
到目前为止,我们见过的所有元方法针对的都是核心Lua语言。Lua语言虚拟机会检测一个操作中涉及的值是否有存在对应元方法的元表。不过,由于元表是一个普通的表,所以任何人都可以使用它们。因此,程序库在元表中定义和使用它们自己的字段也是一种常见的实践。
函数tostring就是一个典型的例子。正如我们此前所看到的,函数tostring能将表表示为一种简单的文本格式:
print({}) --> table: 0x8062ac0
函数print总是调用tostring来进行格式化输出。不过,当对值进行格式化时,函数tostring会首先检查值是否有一个元方法__tostring。如果有,函数tostring就调用这个元方法来完成工作,将对象作为参数传给该函数,然后把元方法的返回值作为函数tostring的返回值。
在之前集合的示例中,我们已经定义了一个将集合表示为字符串的函数。因此,只需要在元表中设置__tostring字段:
mt.__tostring = Set.tostring
之后,当以一个集合作为参数调用函数print时,print就会调用函数tostring,tostring又会调用 Set.tostring:
s1 = Set.new(10, 4, 5}
print(sl) --> {4, 5, 10}
函数setmetatable和getmetatable也用到了元方法,用于保护元表。假设想要保护我们的集合,就要使用户既不能看到也不能修改集合的元表。如果在元表中设置__metatable字段,那么getmetatable会返回这个字段的值,而setmetatable则会引发一个错误:
mt.__metatable = "not your 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;否则,则由这个元方法来提供最终结果。
下面介绍一个关于继承的原型示例。假设我们要创建几个表来描述窗口,每个表中必须描述窗口的一些参数,例如位置、大小及主题颜色等。所有的这些参数都有默认值,因此我们希望在创建窗口对象时只需要给出那些不同于默认值的参数即可。第一种方法是使用一个构造器来填充不存在的字段,第二种方法是让新窗口从一个原型窗口继承所有不存在的字段。首先,我们声明一个原型:
-- 创建具有默认值的原型
prototype = {x = 0, y = 0, width = 100, height = 100}
然后,声明一个构造函数,让构造函数创建共享同一个元表的新窗口:
local 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”],并得到预期的结果。将一个表用作__index元方法为实现单继承提供了一种简单快捷的方法。虽然将函数用作元方法开销更昂贵,但函数却更加灵活:我们可以通过函数来实现多继承、缓存及其他一些变体。我们将会在后续博文中学习面向对象编程时讨论这些形式的继承。
如果我们希望在访问一个表时不调用__index元方法,那么可以使用函数rawget。调用rawget(t, i)会对表t进行原始的访问,即在不考虑元表的情况下对表进行简单的访问。
__newindex 元方法
元方法__newindex与__index类似,不同之处在于前者用于表的更新而后者用于表的查询。当对一个表中不存在的索引赋值时,解释器就会查找__newindex元方法:如果这个元方法存在,那么解释器就调用它而不执行赋值。像元方法__index—样,如果这个元方法是一个表,解释器就在此表中执行赋值,而不是在原始的表中进行赋值。此外,还有一个原始函数允许我们绕过元方法:调用rawset(t, k, v)来等价于t[k]=v,但不涉及任何元方法。
组合使用元方法__index和__newindex可以实现Lua语言中的一些强大的结构,例如只读的表、具有默认值的表和面向对象编程中的继承。在本篇博文中,我们会介绍其中的一些应用,面向对象编程会在后续的博文中进行介绍。
具有默认值的表
一个普通表中所有字段的默认值都是nil。通过元表,可以很容易地修改这个默认值:
function setDefault (t, d)local mt = {__index = function() return d end}setmetatable(t, mt)
endtab = {x=10, y=20}
print(tab.x, tab.z) --> 10 nil
setDefault(tab, 0)
print(tab.x, tab.z) --> 10 0
在调用setDefault后,任何对表tab中不存在字段的访问都将调用它的__index元方法,而这个元方法会返回零(这个元方法中的值是d )。
函数setDefault为所有需要默认值的表创建了一个新的闭包和一个新的元表。如果我们有很多需要默认值的表,那么开销会比较大。然而,由于具有默认值d的元表是与元方法关联在一起的,所以我们不能把同一个元表用于具有不同默认值的表。为了能够使所有的表都使用同一个元表,可以使用一个额外的字段将每个表的默认值存放到表自身中。如果不担心命名冲突的话,我们可以使用形如"___"这样的键作为额外的字段:
local mt = {__index = function (t) return t.___ end}
function setDefault (t, d)t.___ = dsetmetatable(t, mt)
end
请注意,这里我们只在setDefault外创建了一次元表mt及对应的元方法。
如果担心命名冲突,要确保这个特殊键的唯一性也很容易,只需要创建一个新的排除表,然后将它作为键即可:
local key = {} -- 唯一的键
local mt = {__index = function (t) return t[key] end}
function setDefault (t, d)t[key] = dsetmetatable(t, mt)
end
还有一种方法可以将每个表与其默认值关联起来,称为对偶表示,即使用一个独立的表,该表的键为各种表,值为这些表的默认值。不过,为了正确地实现这种做法,我们还需要一种特殊的表,称为弱引用表。在这里,我们暂时不会使用弱引用表,后续博文中再讨论这个话题。
另一种为具有相同默认值的表复用同一个元表的方式是记忆元表。不过,这也需要用到弱引用表,我们会在后续博文中继续学习。
只读的表
使用代理的概念可以很容易地实现只读的表,需要做的只是跟踪对表的更新操作并抛出异常即可。对于元方法__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"--> stdin:1: attempt to update a read-only table
Lua程序设计 | 模块和包、泛型迭代器和for、元表和元方法相关推荐
- lua学习笔记之元表和元方法
元表允许当遇到未知操作时,改变值的行为.例如,使用元表,可以定义表a与表b的关系运算a+b.当lua尝试两个表相加时,会检查是否其中一个有元表并且元表是否有__add字段. 元表在面向对象的术语中是一 ...
- Lua 学习元表,元方法
前言 Lua本身没有面向对象的思想,但是可以根据表.元表.元方法来靠近它 一.元表与元方法的概念 Lua中每个值都可具有元表.元表是普通的Lua表,定义了原始值在某些特定操作下 的行为.例如,当tab ...
- lua元表和元方法 《lua程序设计》 13章 读书笔记
lua中每个值都有一个元表,talble和userdata可以有各自独立的元表,而其它类型的值则共享其类型所属的单一元表.lua在创建table时不会创建元表. t = {} print(getmet ...
- Lua语言编程学习之路02----第13章 元表与元方法
前言 在Lua中我们无法直接对两个table进行相加,无法对函数进行比较,也无法调用一个函数. 于是Lua可以通过修改一个值的行为,使其在面对一个非预定义的操作时执行一个自己实现的操作.比如两个t ...
- lua的元表metatable及元方法
前言 元表对应的英文是metatable,元方法是metamethod.我们都知道,在C++中,两个类是无法直接相加的,但是,如果你重载了"+"符号,就可以进行类的加法运算.在Lu ...
- 【Lua进阶系列】lua元方法
[Lua进阶系列]之Lua元方法案例+字段 大家好,我是Lampard~~ 欢迎来到Lua进阶系列的博客 前文再续,书接上一回.今天和大家讲解一 ...
- Lua元表(__index方法个人理解)
什么是Lua元表: 原表可理解为"一个方法表(类似函数表)",里面包含了一些解决方案.当一个table设置元表之后,相当于关联了这个方法表 setmetatable(table,m ...
- lua学习笔记之模块、包
模块需要通过函数require来加载,创建返回表.模块导出的所有如函数.常量作为一个工作空间. 1.require函数 require首先检查表package.loaded是否模块已经加载,如果已经加 ...
- 【Lua 入门基础篇(七)】表模块与包
文章目录 一.table表 1. 表的构造 2. 表的操作 (1) concat(连接) (2) insert(插入) (3) remove(移除) (4) sort(排序) (5) maxn(最大值 ...
最新文章
- Python同步文件
- 面试阿里,被一大总监全程质疑前公司
- SAP WM初阶LQ02报错 - Movement Type 901 for manual transfer orders does not exist -
- 诗歌rails之 定时任务 rufus-scheduler
- 实习总结之jquery实例
- mxnet基础到提高(10)--读写文件
- 【Linux网络编程】网络字节序和地址转换
- 【终于等到你】7种策略解除云风险警报
- 音视频开发(4)---Windows下Red5安装与使用入门
- python thread 多线程
- 201571030139/201571030134 小学生四则运算软件结对编程
- foreach进不去报错java_为什么阿里巴巴Java开发手册中强制要求不要在foreach循环里进行元素的remove和add操作...
- 安卓rom制作教程_MIUI官方ROM(卡刷包、线刷包)合集
- 命令行工具恢复文件 foremost 和 extundelete 简介
- 如何获取微信应用appid
- 网吧会员管理系统c语言,常用的网吧会员管理系统哪个比较好|纳客软件
- html页面js跨域获取json数据,JS跨域获得Json的应用
- Offcie 安装出现1706的错误
- 4行代码 超级简单 html/css 实现平移动画
- java 扩展名读取_java 读取excel文件,根据文件后缀名