表是Lua中最主要和强大的数据结果。使用表,Lua语言可以以一种简单、统一且高效的方式表示数组、集合、记录和其他很多数据结果。Lua语言也使用表来表示包和其他对象。当调用函数math.sin时,我们可能认为是“调用了math库中函数sin”;而对于Lua语言来说,其实际含义是“以字符串sin”为键检索表math。
Lua语言中的表本质上是一种辅助数组。这种数组不仅可以使用数值作为索引,也可以使用字符串或其他任意类型的值作为索引(nil除外)。 我们使用构造器表达式创建表,其最简单的形式是{}:
> a = {} --创建一个表然后用表的引用赋值
> k = "x"
> a[k] = 10 -- 新元素,键是"x",值是10
> a[20] = "great" -- 新元素,键是20,值是"great"
> a["x"] -- 10
> k = 20
> a[k] -- "great"
> a["x"] = a["x"] + 1 -- 增加元素"x"的值
> a["x"]
表永远是匿名的,表本身和保存表的变量之间没有固定的关系:
> a = {}
> a["x"] = 10
> b = a -- 'b'和'a'引用同一张表
> b["x"] = 20
> a["x"] -- 20
> a = nil -- 只有'b'仍然指向表
> b = nil -- 没有指向表的引用了
对于一个表而言,当程序中不再有指向它的引用时,垃圾收集器会最终删除这个表并重用其占用的内存。
表索引
同一个表中存储的值可以具有不同的类型索引,并可以按需增长以容纳新的元素:
> a = {} -- 空的表
> -- 创建1000个新元素
> for i = 1 , 1000 do a[i] = 1 * 2 end
> a[9] -- 18
> a["x"] = 10
> a["x"] -- 10
> a["y"] -- nil
请注意上述代码的最后一行:如同全局变量一样,未经初始化的表元素为nil,将nil赋值给表元素可以将其删除。
当把表当做结构体使用时,可以把索引当作成员名称使用。因此,可以使用这种更加易读的方式改写前述示例的最后几行:
> a = {}
> x = "y"
> a[x] = 10 -- 把10放在字段"y"中
> a[x] -- 10 字段"y"的值
> a.x -- nil 字段"x"的值(未定义)
> a.y -- 10 字段"y"的值
由于可以使用任意类型索引表,所以在索引表时会遇到相等性比较方面的微妙问题。虽然确实都能使用数字0和字符串”0”对同一个表进行索引,但这两个索引的值及其所对应的元素是不同的。同样,字符串”+1”、”01”和”1”指向的也是不同的元素。当不能确定表索引的真实数据类型时,可以使用显式的类型转换:
> i = 10; j = "10"; k = "+10"
> a = {}
> a[i] = "nubmer key"
> a[j] = "string key"
> a[k] = "another string key"
> a[i] -- 数值类型的键
> a[j] -- 字符串类型的键
> a[k] -- 另一个字符串类型的键
> a[tonumber(j)] -- 数值类型的键
> a[tonumber(k)] -- 数值类型的键
如果不注意这一点,就会很容易在程序中引入诡异的Bug。
整型和浮点型类型的表索引则不存在上述问题。由于2和2.0的值相等,所以当它们被当作表索引使用时指向的是同一个表元素:
> a = {}
> a[2.0] = 10
> a[2.1] = 20
> a[2] -- 10
> a[2.1] -- 20
更准确地说,当被用作表索引时,任何能够被转换为整型的浮点数都会被转换为整型数。
表构造器
表构造器是用来创建和初始化表的表达式,也是Lua语言中独有的也是最有用、最灵活的机制之一。 正如我们此前已经提到的,最简单的构造器是空构造器{}。表构造器也可以被用来初始化列表,例如,下例中使用字符串”Sunday”初始化了day[1]、使用字符串”Monday”初始化了day[2],依次类推:
days = {"Sunday","Monday","Tuesday","Wndnesday","Thursday","Friday","Saturday"}
print(days[4]) -- Wednesday
Lua语言还提供一种初始化记录式表的特殊语法:
a = {x = 10, y = 20 }
上述代码等价于:
a = {};
a.x = 10;
a.y = 20;
不过在第一种写法中,由于能够提前判断表的大小,所以运行速度更快。
无论哪种方式创建,都可以随时增加或删除元素:
w = {x = 0 ,y = 0, label = "console"}
x = {math.sin(0),math.sin(1),math.sin(2)}
w[1] = "another field" -- 把键1增加到表"w"中
x.f = w -- 把键"f"增加到表"w"中
print(w["x"]) -- 0
print(w[1]) -- another field
print(x.f[1]) -- another field
w.x = nil -- 删除字段"x"
不过,正如此前所提到的,使用合适的构造器来创建表会更加高效和易读。
在同一个构造器中,可以混用记录式和列表式写法:
polyline = {color = "blue",
thickness = 2,
npoints = 4,
{x = 0 ,y = 0 }, -- polyline[1]
{x = -10, y = 0}, -- polyline[2]
{x = -10, y = 1}, -- polyline[3]
{x = 0 , y = 1} -- polyline[4]
}
上述的示例也同时展示了如何创建嵌套表以表达更加复杂的数据结构。每一个元素polyline[i]都是代表一条记录的表:
print(polyline[2].x) -- -10
print(polyline[4].y) -- 1
不过,这两种构造器都有各自的局限。例如,使用这两种构造器时,不能使用负数索引舒适化表元素,不能使用不符合规范的标识符作为索引。对于这类需求,可以使用另一种更加通用的构造器,即通过方括号括起来的表达式显式地指定每一个索引:
opnames = {["+"] = "add", ["-"] = "sub" ,
["*"] = "mul", ["/"] = "div"}
i = 20
s = "-"
a = {[i + 0] = s, [i + 1] = s..s, [i + 2] = s..s..s}
print(opnames[s]) -- sub
print(a[22]) -- ---
这种构造器虽然冗长,但非常灵活,不管是记录式构造器还是列表式构造器军师其特殊形选的。例如,下面的几种表达式就相互等价:
{x = 0, y = 0} -- {["x”] = 0, ["y"] = 0}
{"r", "g", "b"} -- {[1] = "r", [2] = "g", [3] = "b"}
在最后一个元素后总是可以紧跟一个逗号。虽然总是有效,但是否加最后一个逗号是可选的。
这种灵活性使得开发人员在编写表构造器时不需要对最后一个元素进行特殊处理。
最后,表构造器中的逗号也可以使用分号代替,这主要是为了兼容Lua语言的旧版本。
数组、列表和序列
如果想表示常见的数组或列表,那么只需要使用整型作为索引的表即可。同时,也不需要预先声明表的大小,只需要直接初始化我们需要的元素即可:
a = {}
for i = 1, 10 do
a [i] = io.read()
end
鉴于能够使用任意值对表进行索引,我们也可以使用任意数字作为第一个元素的索引。不过,在Lua语言中,数组索引按照惯例是从1开始的(不像C语言从0开始),Lua语言中的其他很多机制也遵循这个惯例。
在操作表时,往往必须事先获取列表的长度。列表的长度可以存放在常量中,也可以存放在其他变量或数据结构中。通常,我们把列表的长度保持在表中某个非数值类型的字段中。当然,列表的长度经常也是隐形的。请注意,由于为初始化的元素均为nil,所以可以利用nil值来标记列表的结束。例如,当向一个列表中写入了10行数据后,由于该列表的数值类型的索引为1,2,…,10,所以可以很容易地知道列表的长度就是10.这种技巧只有在列表中不存在空洞时才有效,此时我们把这种所有元素都不为nil的数组称为序列。
Lua语言提供了获取序列长度的操作符#。正如我们之前所看到的,对于字符串而言,该操作符返回字符串的字节数;对于表而言,该操作符返回表对应序列的长度。例如,可以使用如下代码输出上例中读入的内容:
for i = 1 ,10 do
print(a[i])
end
长度操作符也操作序列提供了几种有用的写法:
print(a[#a]) -- 输出序列'a'的最后一个值
a[#a] = nil -- 移除最后一个值
a[#a + 1] = v -- 把'v'加到序列的最后
对于中间存在空洞的列表而言,序列长度操作符是不可靠的,它只能用于序列。更准确地说,序列是由指定的n个正数数值类型的键所组成集合[1,…,n]形成的表。特别地,不包含数值类型键的表就是长度为零的序列。
将长度操作符用于存在空洞的列表的行为是Lua语言中具有争议的内容之一。在过去几年中,很多人建议在操作存在空洞的列表时直接抛出异常,也有人建议扩展长度操作符的语义。然而,这些建议都是说起来容易做起来难。其根源在于列表实际上是一个表,而对于表来说,“长度”的概念在一定程度上是不容易理解的。例如,考虑如下的代码:
a = {}
a[1] = 1
a[2] = nil --什么也没有做
a[3] = 1
a[4] = 1
我们这里可以很容易确定这是一个长度为4、在索引2的位置上存在空洞的列表。不过,对于下面这个类似的示例是否也如此呢?
a = {} a[1] = 1 a[1000] = 1
是否应该认为a 是一个具有10000个元素,9998个空洞的列表?如果代码进行了如下的操作:
a[10000] = nil
那么该列表的长度会变成多少?由于代码删除了最后一个元素,该列表的长度是不是变成了9999?或者由于代码只是将最后一个元素变成了nil,该列表的长度仍然是10000?又或者该列表的长度缩成了1?
另一种常见的建议是让#操作符返回表中全部元素的数量。虽然这种语义听起来清晰且定义明确,但并非特别有用和符合直觉。请考虑下我们在此讨论过的所有例子,然后思考一下这些例子而言,为什么让#操作符返回表中全部元素的数量并非特备有用。
更复杂的列表是以nil结尾的情况。请问如下的列表的长度是多少:
a = {10,20,30,nil,nil}
请注意,对于Lua语言而言,一个为nil的字段和一个不存在的元素没有区别。因此,上述列表与{10,20,30}是等价的,其长度为3,而不是5.
可以将以nil结尾的列表当作一种非常特殊的情况。不过,很多列表时通过逐个添加各个元素创建出来的。任何按照这种方式构造出来的带有空洞的列表,其最后一定存在为nil的值。
尽管讨论了这么多,程序中的大多数列表其实都是序列。正因如此,在多数情况下使用长度操作符是安全的。在确实需要处理存在空洞的列表时,应该将列表的长度显式地保存起来。
遍历表
我们可以用是pairs迭代器遍历表中的键值对:
t = {10, print, x = 12, k = "hi"}
for k ,v in pairs(t) do
print(k,v)
end
-- 1 10
-- k hi
-- 2 function:0x420610
-- x 12
受限于表在Lua中的底层实现机制,遍历过程中元素的出现顺序可能是随机的,相同的程序在每次运行时也可能产生不同的顺序。唯一可以确定的是,在遍历的过程中每个元素会且只会出现一次。
对于列表而言,可以使用ipairs迭代器:
t = {10, print, x = 12, k = "hi"}
for k ,v in pairs(t) do
print(k,v)
end
-- 1 10
-- 2 hi
-- 3 function:0x420610
-- 4 12
此时,Lua会确保遍历是按照顺序进行的。
另一种遍历序列的方法是使用数值型for循环:
t = {10, print, x = 12, k = "hi"}
for k = 1 , #t do
print(k , t[k])
end
-- 1 10
-- 2 hi
-- 3 function:0x420610
-- 4 12
安全访问
考虑如下的情景:我们想确认在指定的库中是否存在某个函数。如果我们确定这个库确实存在,那么可以直接使用if lib.foo then ...;
否则,就得使用形如if lib and lib.foo then ...
的表达式。 当表的嵌套深度变得比较深时,这种写法就会很容易出错,例如:
zip = company and company.director and company.director.address and company.director.address.zipcode
这种写法不仅冗长而且低效,该写法在一次成功的访问中对表进行了6次访问而非3词访问。
对于这种情景,诸如C#的一些编程语言提供了一种安全访问操作符。在C#中,这种安全访问操作符被记为?.
。例如,对于表达式a?.b
,当a为nil时,其结果是nil而不会产生异常。使用这种操作符,可以将上例改为:
zip = company?.director?.address?.zipcode
如果上述的成员访问过程中出现nil,安全访问操作符会正确地处理nil并最终返回nil。
Lua语言并没有提供安全访问操作符,并且认为也不应该提供这种操作符。一方面,Lua语言在设计上力求简单;另一方面,这种操作符也是非常有争议的,很多人就无理由认为该操作符容易导致无意的编程错误。不过,我们可以使用其他语句在Lua语言中模拟安全访问操作符。
对于表达式 a or {}
,当a为nil时其结果是一个空表。因此,对于表达式(a or {}).b
,当a为nil时其结果也同样是nil。这样,我们就可以将之前的例子重写为:
zip = (((company or {}).director or {}).address or {}).zipcode
再进一步,我们还可以写得更短更高效:
E = {} ... zip = (((company or E).director or E).address or E).zipcode
确实,上述的语法比安全访问操作符更加复杂。不过尽管如此,表中的每一个字段名都只被使用了一次,从而保证了尽可能少地对表进行访问;同时,还避免了向语言中引入新的操作符。
表标准库
表标准库提供了操作列表和序列的一些常用函数。 函数table.insert
向序列的指定位置插入一个元素,其他元素依次后移。例如,对于列表t = {10,20,30},在调用table.insert(t,1,15)后它会变成{15,10,20,30},另一种特殊但常见的情况是调用insert时不指定位置,此时函数会在序列的最后插入指定的元素,不会移动任何元素。例如,下述代码从标准输入中安行读入内容并将其保存到一个序列中:
t = {}
for line in io.lines() do
table.insert(t,line)
end
print(#t)
函数table.remove
删除后并返回序列指定位置的元素,然后将其后的元素向前移动填充删除元素后造成的空洞。如果在调用该函数时不指定位置,该函数会删除序列的最后一个元素。
借助这个函数,可以很容易地实现栈、队列和双端队列。以栈的实现为例,我们可以使用t = {}来表示栈,Push操作可以使用table.insert(t,x)实现,Pop操作可以使用table.remove(t)实现,调用table.insert(t,1,x)可以实现在栈的顶部进行插入,调用table.remove(t,1)可以从栈的顶部移除。由于后两个函数设计表中其他元素的移动,所以其运行效率并不是特别的高。当然,由于table标准库中的这些函数是使用C语言实现的,所以移动元素所涉及循环的性能开销也并不是太昂贵。因而,对于几百个元素组成的小数组来说这种实现已经够了。