表( Table )是 Lua 语言中最主要(事实上也是唯一的)和强大的数据结构。 使用表,Lua语言可以以一种简单、统一且高效的方式表示数组、集合、记录和其他很多数据结构。 Lua语言也使用表来表示包( package )和其他对象。 当调用函数 math.sin 时 ,我们可能认为是“调用了 math 库中函数 sin ” ;而对于 Lua 语言来说,其实际含义是“以字符串sin ” 为键检索表 math ”。
Lua 语言中的表本质上是一种辅助数组( associative array ),这种数组不仅可以使用数值作为索引,也可以使用字符串或其他任意类型的值作为索引( nil 除外)。
Lua 语言中的表要么是值要么是变量 ,它们都是对象 ( object ) 。 如果读者对 Java 或
Scheme 中的数组比较熟悉 ,那么应该很容易理解上述概念。 可以认为,表是一种动态分配的对象,程序只能操作指向表的引用(或指针) 。 除此以外, Lua 语言不会进行隐藏的拷贝( hidden copies)或创建新的表。
我们使用构造器表达式( constructor expression )创建表,其最简单的形式是 {} :
> 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"]
11
表永远是匿名的,表本身和保存表的变量之间没有固定的关系:
> a={}
> a["x"]=10
> b=a --'b'和'a'引用同一张表
> b["x"]
10
> b["x"]=20
> a["x"]
20
> a=nil --只有'b'仍然指向表
> b=nil --没有指向表的引用了
> a["x"]
stdin:1: attempt to index a nil value (global 'a')
> b["x"]
stdin:1: attempt to index a nil value (global 'b')
对于一个表而言,当程序中不再有指向它的引用时,垃圾收集器会最终删除这个表并重用其占用的内存。
5.1 表索引
同一个表中存储的值可以具有不同的类型索引 , 并可以按需增长以容纳新的元素 :
> a={} --空的表
> --创建1000个新元素
> for i=1,1000 do a[i]=i*2 end
> a[9]
18
> a["x"]=10
> a["x"]
10
> a["y"]
nil
请注意上述代码的最后一行 :如同全局变量一样,未经初始化的表元素为 nil ,将 nil 赋值给表元素可以将其删除。 这并非巧合,因为 Lua 语言实际上就是使用表来存储全局变量的。
当把表当作结构体使用时,可以把索引当作成员名称使用( a. name 等价于 a["name"] ) 。
因此 ,可以使用这种更加易读的方式改写前述示例的最后几行 :
> a={} --空白表
> a.x=10 --等价于a["x"]=10
> a.x --等价于a["x"]
10
> a.y --等价于a["y"]
nil
对 Lua 语言而言,这两种形式是等价且可以自由混用的;不过,对于阅读程序的人而言,这两种形式可能代表了不同的意图 。 形如 a.name 的点分形式清晰地说明了表是被当作结构体使用的,此时表实际上是由固定的、预先定义的键组成的集合;而形如a["name"] 的字符串索引形式则说明了表可以使用任意字符串作为键,并且出于某种原因我们操作的是指定的键。
初学者常常会混淆 a.x 和 a [ x ] 。 实际上, a.x 代表的是 a["x"] ,即由字符串” x ”索引的
表;而 a[x]则是指由变量 x 对应的值索引的表,例如 :
> a={}
> x="y"
> a[x]=10
> a[x]
10
> a.x --字段"x"的值(未定义)
nil
> a.y
10
>
由于可以使用任意类型索引表,所以在索引表时会遇到相等性比较方面的微妙问题。 虽然确实都能用数字 0 和字符串 ” 0 ” 对同一个表进行索引,但这两个索引的值及其所对应的元素是不同的 。 同样,字符串”+ 1 ” 、 ” 01 ” 和” 1 ” 指向的也是不同的元素 。 当不能确定表索引的真实数据类型时,可以使用显式的类型转换,强制类型转换函数为 tonumber():
> i=10;j="10";k="+10"
> a={}
> a[i]="number key"
> a[j]="string key"
> a[k]="another string key"
> a[i]
number key
> a[j]
string key
> a[k]
another string key
> a[tonumber(j)]
number key
> a[tonumber(k)]
number key
如果不注意这一点,就会很容易在程序中引人诡异的 Bug 。
整型和浮点型类型的表索引则不存在上述问题。 由于 2 和 2.0 的值相等,所以当它们被当作表索引使用时指向的是同一个表元素:
> a={}
> a[2.0]=10
> a[2.1]=20
> a[2]
10
> a[2.1]
20
更准确地说,当被用作表索引时,任何能够被转换为整型的浮点数都会被转换成整型数。 例如,当执行表达式 a[2.0]=10 时,键 2.0 会被转换为 2 。 相反,不能被转换为整型数的浮点数则不会发生上述的类型转换。
5.2 表构造器
表构造器( Table Constructor )是用来创建和初始化表的表达式,也是 Lua 语言中独有的也是最有用、最灵活的机制之一。
最简单的构造器是空构造器 {} 。 表构造器也可以被用来初始化列表,例如,下例中使用字符串" Sunday ” 初始化了 days[1] (构造器第一个元素的索引是 1 而不是 0 )、使用字符串 ” Monday ” 初始化了 days[2] ,依此类推 :
> days={"Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"}
> print(days[4])
Wednesday
Lua 语言还提供了一种初始化记录式( record-like )表的特殊语法 :
> a={x=10,y=20}
> a["x"]
10
上述代码等价于:
> 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增加到表‘x’中
> print(w["x"])
0
> print(w[1])
another field
> print(x.f[1])
another field
> w.x=nil --删除字段‘x’
> x.f.x
nil
> x.f.y
0
> x.f.label
console
在同一个构造器中,可以棍用记录式( record-styl e ) 和列表式( list-style )写法:
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 = {color="blue",
>> thickness=2,
>> npoints=4,
>> {x=0,y=0},
>> {x=-10,y=0},
>> {x=-10,y=1},
>> {x=0,y=1}
>> }
> 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"}
在最后一个元素后总是可以紧跟一个逗号。 虽然总是有效,但是否加最后一个逗号是可选的:
a = {[1]="r",[2]="g",[3]="b",}
这种灵活性使得开发人员在编写表构造器时不需要对最后一个元素进行特殊处理。
最后,表构造器中的逗号也可以使用分号代替,这主要是为了兼容 Lua 语言的旧版本,目前基本不会被用到 。
5.3 数组、列表和序列
如果想表示常见的数组(array )或列表( list ),那么只需要使用整型作为索引的表即可。
同时,也不需要预先声明表的大小,只需要直接初始化我们需要的元素即可:
-- 读取10行,然后保存在一个表中
a = {}
for i=1,10 do
a[i] = io.read()
end
鉴于能够使用任意值对表进行索引,我们也可以使用任意数字作为第一个元素的索引 。 不过,在 Lua 语言中,数组索引按照惯例是从 1 开始的(不像 C 语言从 0 开始), Lua 语言中的其他很多机制也遵循这个惯例 。
当操作列表时,往往必须事先获取列表的长度 。 列表的长度可以存放在常量中,也可以存放在其他变量或数据结构中 。 通常,我们把列表的长度保存在表中某个非数值类型的字段中(由于历史原因,这个键通常是” n" ) 。 当然,列表的长度经常也是隐式的。 请注意,由于未初始化的元素均为 nil ,所以可以利用 nil 值来标记列表的结束。 例如, 当向一个列表中写入了 10 行数据后,由于该列表的数值类型的索引为 1,2, .. ., 10 ,所以可以很容易地知道列表的长度就是 10 。 这种技巧只有在列表中不存在空洞 ( hole )时(即所有元素均不为 nil )才有效,此时我们把这种所有元素都不为 nil 的数组称为序列 ( sequence )。
Lua 语言提供了获取序列长度的操作符 # 。 正如我们之前所看到的,对于字符串而言,该
操作符返回字符串的字节数;对于表而言,该操作符返回表对应序列的长度。 例如,可以使用如下的代码输出上例中读入的内容:
for i = 1, #a do
print(a[i])
end
长度操作符也为操作序列提供了几种有用的写法 :
print(a[#a]) --输出序列‘a’的最后一个值
a[#a] = nil --移除最后一个值
a[#a + 1] = v --把v加到序列的最后
对于中间存在空洞( nil 值)的列表而言,序列长度操作符是不可靠的,它只能用于序列(所有元素均不为 nil 的列表)。 更准确地说,序列 ( sequence ) 是由指定的 n 个正数数值类型的键所组成集合 { 1, ... , n }形成的表(请注意值为 nil 的键实际不在表中)。 特别地 ,不包含数值类型键的表就是长度为零的序列
将长度操作符用于存在空洞的列表的行为是 Lua 语言 中最具争议的内容之一。在过去几年中,很多人建议在操作存在空洞的列表时直接抛出异常 , 也有人建议扩展长度操作符的语义。 然而,这些建议都是说起来容易做起来难。 其根源在于列表实际上是一个表,而对于表来说,“长度”的概念在一定程度上是不容易理解的 。 例如,考虑如下的代码 :
a = {}
a[1] = 1
a[2] = nil --什么也没有做,因为a[2]已经是nil了
a[3] = 1
a[4] = 1
我们可以很容易确定这是一个长度为 4 、在索引 2 的位置上存在空洞的列表。 不过,对于下面这个类似的示例是否也如此呢?
a = {}
a[1] = 1
a[10000] = 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的值。
尽管讨论了这么多,程序中的大多数列表其实都是序列(例如不能为nil的文件行)。 正因如此, 在多数情况下使用长度操作符是安全的。 在确实需要处理存在空洞的列表时,应该将列表的长度显式地保存起来。
5.4 遍历表
我们可以使用 pairs 迭代器遍历表中的键值对 :
> t = {10, print, x = 12, k = "hi"}
> for k, v in pairs(t) do
>> print(k, v)
>> end
1 10
2 function: 0000000063bdb1d0
x 12
k hi
受限于表在 Lua 语言中的底层实现机制,遍历过程中元素的出现顺序可能是随机的 ,相同的程序在每次运行时也可能产生不同的顺序。 唯一可以确定的是,在遍历的过程中每个元素会且只会出现一次。
对于列表而言,可以使用 ipairs 迭代器 :
> t = {10, print, 12, "hi"}
> for k, v in ipairs(t) do
>> print(k, v)
>> end
1 10
2 function: 0000000063bdb1d0
3 12
4 hi
此时 , Lua 会确保遍历是按照顺序进行的 。
另一种遍历序列的方法是使用数值型 for 循环 :
> t = {10, print, 12, "hi"}
> for k = 1, #t do
>> print(k, t[k])
>> end
1 10
2 function: 0000000063bdb1d0
3 12
4 hi
5.5 安全访问
考虑如下的情景:我们想确认在指定的库中是否存在某个函数。 如果我们确定这个库确实存在,那么可以直接使用 if lib.too then ... ;否则,就得使用形如 if lib and lib. foo then ... 的表达式 。
当表的嵌套深度变得比较深时,这种写法就会很容易出错,例如 :
zip = company and company.director and
company.director.address and
company.director.address.zipcode
这种写法不仅冗长而且低效,该写法在一次成功的访问中对表进行了 6 次访问而非 3 次访问 。
对于这种情景,诸如 C#的一些编程语言提供了 一种安全访问操作符( safe navigation
operator )。 在 C#中,这种安全访问操作符被记为“?. ” 。 例如,对于表达式 a ?.b ,当 a 为
nil 时,其结果是 nil 而不会产生异常。 使用这种操作符,可以将上例改写为:
zip = company?.director?.address?.zipcode
如果上述的成员访问过程中出现 nil ,安全访问操作符会正确地处理 nil 并最终返回 nil 。
Lua 语言并没有提供安全访问操作符,并且认为也不应该提供这种操作符。 一方面, Lua语言在设计上力求简单;另一方面,这种操作符也是非常有争议的,很多人就无理由地认为该操作符容易导致无意的编程错误。 不过,我们可以使用其他语句在 Lua 语言中模拟安全访问操作符。
对于表达式 a or {} ,当 a 为 nil 时其结果是一个空表。 因此 ,对于表达式 ( aor{}).b ,当 a 为 nil 时其结果也同样是nil 。 这样,我们就可以将之前的例子重写为:
zip = (((company or {}).director or {}).address or {}).zipcode
再进一步,我们还可以写得更短和更高效:
E = {} --可以在其他类似表达式中复用
...
zip = (((company or E).director or E).address or E).zipcode
确实 , 上述的语法比安全访问操作符更加复杂。 不过尽管如此 ,表中的每一个字段名都只被使用了一次,从而保证了尽可能少地对表进行访问(本例中对表仅有 3 次访问);同时,还避免了向语言中引入新的操作符。
5.6 表标准库
表标准库提供了操作列表和序列的一些常用函数。
函数 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 删除并返回序列指定位置的元素,然后将其后 的元素 向前移动填充删除元素后造成的空洞 。 如果在调用该函数时不指定位置,该函数会删除序列的最后一个元素。
借助这两个函数,可以很容易地实现栈( Stack )、 队列( Queue )和双端队列( Double
queue )。 以栈的实现为例,我们可以使用 t ={} 来表示栈, Pus h 操作可以使用table.insert(t,x ) 实现 ,Pop 操作可以使用 table.remove(t)实现,调用 table.insert(t, 1, x )可以实现在栈的顶部进行插入,调用 table.remove(t, 1 ) 可以从栈的顶部移除。 由于后两个函数涉
及表中其他元素的移动,所以其运行效率并不是特别高 。 当然,由于table 标准库中的这些函数是使用 C 语言实现的,所以移动元素所涉及循环的性能开销也并不是太昂贵。 因而,对于几百个元素组成的小数组来说这种实现已经足矣 。
Lua 5.3 对于移动表中的元素引入了一个更通用的函数 table.move(a, f, e, t ) ,调用该函数可以将表 a 中从索引 f 到 e 的元素(包含索引 f 和索引 e 对应的元素本身)移动到位置 t 上。 例如,如下代码可以在列表 a 的开头插入一个元素:
table.move(a, 1, #a, 2)
a[1] = newElement
如下的代码可以删除第一个元素 :
table.move(a, 2, #a, 1)
a[#a] = nil
应该注意,在计算机领域,移动 ( move)实际上是将一个值从一个地方拷贝 ( copy ) 到另一个地方。 因此 ,像上面的例子一样,我们必须在移动后显式地把最后一个元素删除。
函数 table.move 还支持使用一个表作为可选的参数。 当带有可选的表作为参数时 ,该函数将第一个表中的元素移动到第二个表中 。 例如, table.move(a, 1 ,#a, 1, {})返回列表 a 的一个克隆( clone )(通过将列表 a 中的所有元素拷贝到新列表中), table .move(a, 1, #a,#b + 1, b )将列表 a 中的所有元素复制到列表 b 的末尾。