跳到主要内容
信息
· 文章中可能会出现一些错误,希望大佬们可以在评论区指出;

04-表

表(Table)是Lua语言中唯一的数据结构,Lua可以通过表实现各种常用的数据结构(例如,数组,集合等)。Lua也使用表来表示包(Package)和其他对象,例如在调用math.sin()函数时,实际上是以字符串"sin"为键检索表math。

要想正确使用表,需记住以下 核心思想 :

  • Lua语言中的表本质上是一种关联数组(Associative Array),能使用除了nil外任意类型的值作为索引。
  • 表永远是匿名的,表本身和保存表的变量之间没有固定的关系。可以这样认为,表是一种动态分配的对象,程序只能操作指向表的引用/指针,当程序中没有指向表的引用时,垃圾收集器GC最终会删除该表并重用它占用的内存。

下例简要解释了这些核心思想:

示例:表的核心思想点击展开/折叠代码
a = {} -- 创建一个表,将其引用赋值给a

-- 表本质是一种关联数组, 也就是键值对
k = "x"
a[k] = 10 -- 给表添加新元素, 键为"x", 值为10
a[20] = "great" -- 给表添加新元素, 键为20, 值为"great"
print(a["x"]) --> 10
k = 20
print(a[k]) --> "great"

-- 表本身是匿名的, 程序只能操作指向表的引用
b = a -- 'b'和'a'引用同一张表, 并非深拷贝
print(b["x"]) --> 10
b["x"] = 1145
print(a["x"]) --> 1145

-- 没有对象引用表后, 该表占用的内存会被垃圾回收
a = nil
b = nil

基本操作​

表的构造​

Lua语言使用构造器{}来创建和初始化表,共有如下初始化方式:

  • 列表式初始化:如days = {"Sunday", "Monday"},索引为数值类型且为1起点;
  • 记录式初始化:config = {color="blue", thickness=2},索引为字符串类型;
  • 通用初始化:opnames = {["+"] = "add", ["-"] = "sub"},索引类型由自定义的键值类型决定;

这些初始化方式可以被同时用于初始化一个表。

表的索引​

从上一小节内容可以得出,同一个表中存储的值可以具有不同数据类型的索引。可以通过表名[键]获取到对应的值。需要注意的是,如果表中某值的索引类型为字符串类型,也能通过表名.键获取到对应的值。

安全访问​

假设要确认指定的库中是否存在某函数,如果库表嵌套程度比较深就会发生下列情况:

zip = company and company.director and
company.director.address and
company.director.address.zipcode

这种写法冗长低效,而且对表company访问了6次而不是3次。可以仿照C#中安全访问操作符?.的做法,编写出如下改良版:

zip = (((company or {}).director or {}).address or {}).zipcode

对于表达式a or {},当a = nil时返回空表,这样会将nil一直传递到最后并返回nil。上例只需访问3次表即可验证函数是否存在。

表的遍历​

使用迭代器 + 泛型for循环​

可以使用pairs迭代器遍历表中的键值对,这也是最通用的遍历方法:

t = {10, print, x = 12, k = "hi"}
for k,v in pairs(t) do
print("Key: " .. k .. ", Value: " .. tostring(v))
end

--[[ 可能的输出结果:
Key: 1, Value: 10
Key: 2, Value: function: 0000000065b9cff0
Key: k, Value: hi
Key: x, Value: 12
--]]

受限于表在Lua语言中的底层实现机制,虽然能全部遍历一次表中的键值对,但元素的出现顺序可能是随机的,相同的程序在每次运行时也可能产生不同的顺序。

如果当前的表是 没有nil元素的数组(只有整数索引/纯列表初始化),那么就可以用ipairs迭代器进行顺序遍历:

arr = {10, print, 12, "hi"}
for k,v in ipairs(arr) do
print("Key: " .. k .. ", Value: " .. tostring(v))
end

--[[ 输出结果:
Key: 1, Value: 10
Key: 2, Value: function: 0000000065b9cff0
Key: 3, Value: 12
Key: 4, Value: hi
--]]

如果在不符合上述条件的表中使用ipairs迭代器遍历,可能会出现未知错误。

使用长度操作符 + 数值for循环​

如果当前的表是 没有nil元素的数组(只有整数索引/纯列表初始化),还可以通过长度操作符#和数值for循环进行表的遍历:

arr = {10, print, 12, "hi"}
for k = 1, #arr do
print(k, arr[k])
end

--[[ 输出结果:
1 10
2 function: 0000000065b9cff0
3 12
4 hi
--]]

如果在不符合上述条件的表中使用长度操作符#遍历,可能会出现未知错误。

表标准库​

table.insert()​

table.insert(list, [pos,] value)向表list的指定位置pos插入元素value,剩余元素依次后移。位置pos是可选的,如果不指定位置则会在表末尾插入。

t = { 10, 20, 30 }

table.insert(t, 1, 15) --> 15 10 20 30
table.insert(t, 66) --> 15 10 20 30 66

table.remove()​

table.remove(list [, pos])移除表list指定位置pos的元素,剩余元素依次前移。位置pos是可选的,如果不指定位置则会删除表末尾的元素。

table.remove(t, 2) --> 15 20 30 66
table.remove(t) --> 15 20 30

table.sort()​

table.sort(list [, comp])默认升序排序表list内的元素。comp为可选的匿名函数,参数为两个列表元素,返回符合排序条件的boolean类型值以实现自定义排序。

table.sort(t, function (a, b)
return a > b
end) --> 30 20 15

table.sort(t) --> 15 20 30

table.concat()​

table.concat(list [, sep [, i [, j]]])将表list的元素(数值或字符串类型)按照list[i] .. sep .. list[i + 1] ...... sep .. list[j]的格式拼接成一个字符串并返回。其中:

  • sep:分隔符,可选,默认为空字符串;
  • i:起始元素索引,可选,默认为1;
  • j:终止元素索引,可选,默认为#list;
  • 如果i > j,返回空字符串;
print(table.concat(t)) --> 152030
print(table.concat(t, ", ", 2, 3)) --> 20, 30

表的应用​

实现类​

Lua 语言没有提供面向对象编程的相关组件,需要我们自己实现。这里来实现 OOP 最基本的概念——类:

Account = {
-- 成员变量
balance = 0,

-- 内部声明的成员函数/方法
withdraw = function (self, v)
self.balance = self.balance - v
end,
printBalance = function (self)
print(self.balance)
end
}

-- 外部声明的成员函数/方法
function Account:deposit(v)
self.balance = self.balance + v
end

Account:printBalance() --> 0
Account.deposit(Account, 200.00)
Account.printBalance(Account) --> 200.0
Account:withdraw(100.00)
Account:printBalance() --> 100.0

Account.printBalance(1) -- 错误

上述代码简单实现了一个类 Account,它拥有成员变量 balance 和若干成员函数。其中需要注意的是冒号关键字 : 和参数 self:

  • 冒号关键字 ::冒号的作用是在一个方法中增加一个额外的实参,或在方法的定义中增加一个额外的隐藏形参。
  • 参数 self:类似 this,但和它完全不一样,仅作为冒号定义方法的 第一个参数。

冒号与点号的等价关系​

冒号本质上只是语法糖,下面两种写法完全等价:

-- 定义时
function Account:deposit(v) -- 冒号定义,隐含 self
self.balance = self.balance + v
end

function Account.deposit(self, v) -- 点号定义,显式声明 self
self.balance = self.balance + v
end

-- 调用时
Account:deposit(200.00) -- 冒号调用,自动传入 Account 作为 self
Account.deposit(Account, 200.00) -- 点号调用,手动传入 Account 作为 self
场景点号 .冒号 :
定义方法function T.f(self, ...)function T:f(...)
调用方法T.f(obj, ...)T:obj(...)
访问字段T.field不适用

常见错误​

Account.printBalance(1) -- 错误:self 被赋值为 1,函数内访问 1.balance 会报错

点号调用时,Lua 不会自动传入 self,第一个实参会被当作 self。因此如果写成 Account.printBalance(1),函数体里的 self.balance 就相当于 1.balance,会触发 attempt to index a number value 之类的错误。

同样,用点号定义方法却用冒号调用,也会多传一个参数:

function Account.foo(a, b) -- 没有 self
print(a, b)
end

Account:foo(1) -- 等价于 Account.foo(Account, 1),输出 table 和 1

目前我们利用表的知识简单实现了一个类,但它远远不是 OOP 所要求的类(实例化、封装、继承和多态),这需要在后续学习元表(Metatable)的相关知识才能继续深入实现,详见后续文章 TODO。

实现数据结构​

二维数组/矩阵​

在Lua语言中,简单地用整数类型的索引表即可实现数组,这里讨论二维数组的实现,有两种方式来表示矩阵:

  • 嵌套数组:

    -- 全0元素的NxM矩阵,嵌套数组实现
    local mt = {} -- 创建矩阵
    for i = 1, N do
    local row = {} -- 创建新的一行
    mt[i] = row
    for j = 1, M do
    row[j] = 0
    end
    end
  • 公式映射:

    -- 全0元素的NxM矩阵,公式映射实现
    local mt = {}
    for i = 1, N do
    local aux = (i - 1) * M
    for j = 1, M do
    mt[aux + j] = 0
    end
    end

常用到的实现为第一种。

链表​

由于表是动态对象,所以在Lua语言中可以很容易地实现链表。可以把每个节点用一个表来表示,通过存储其他表的引用实现链接,例如单链表的实现如下:

-- 根节点
list = nil

-- 在表头插入一个值为v的元素
list = {next = list, value = v}

-- 遍历单链表
local l = list
while l do
-- 操作l.value ......
l = l.next
end

队列​

可以借助表标准库中的insert()和remove()实现队列,但开销可能比较大。因此更高效的实现是维护两个索引,一个指向队头元素,另一个指向队尾元素:

双端队列的实现点击展开/折叠代码
-- 双端队列
-- 创建一个队列
function listNew()
return {first = 0, last = -1}
end

-- 队头插入元素
function pushFirst(list, value)
local first = list.first - 1
list.first = first
list[first] = value
end

-- 队尾插入元素
function pushLast(list, value)
local last = list.last + 1
list.last = last
list[last] = value
end

-- 队头删除元素
function popFirst(list)
local first = list.first
if first > list.last then
error("队列为空")
end
local value = list[first]
list[first] = nil
list.first = first + 1
return value
end

-- 队尾删除元素
function popLast(list)
local last = list.last
if list.first > last then
error("队列为空")
end
local value = list[last]
list[last] = nil
list.last = last - 1
return value
end

set和multiset​

在Lua语言中,可以通过将集合元素作为索引放入表中,查表看结果是否为nil实现集合:

-- 构造函数
local function Set(list)
local set = {}
for _, v in ipairs(list) do
set[v] = true
end
return set
end

local reservedWords = Set{"while", "end", "function"}
print(reservedWords["while"]) --> true
print(reservedWords["foo"]) --> nil

对于multiset,仅需在集合的基础上多维护一个索引的计数器即可:

local function Multiset(list)
local mset = {}
for _, v in ipairs(list or {}) do
mset[v] = (mset[v] or 0) + 1
end
return mset
end

local function mset_insert(mset, element)
mset[element] = (mset[element] or 0) + 1
end

local function mset_remove(mset, element)
local count = mset[element]
if count == nil then return end
if count > 1 then
mset[element] = count - 1
else
mset[element] = nil
end
end

local m = Multiset{"a", "b", "a", "c", "a"}
print(m["a"], m["b"], m["c"]) --> 3 1 1

mset_insert(m, "b")
print(m["b"]) --> 2

mset_remove(m, "a")
print(m["a"]) --> 2
mset_remove(m, "a")
mset_remove(m, "a")
print(m["a"]) --> nil

实现深拷贝​

Lua中没有深拷贝(这里只讨论table类型的对象),因此只能通过我们自己实现它。 进行深拷贝的整体思路是递归遍历表的每一个元素,并且在遇到子表时,对子表也进行深拷贝。这样可以确保拷贝后的新表与元表完全独立,任何对新表的修改都不会影响到旧表。

参考代码如下:

local function clone(object)
-- 记录已经拷贝过的表, 防止出现循环引用(无限递归)
local lookupTable = {}
-- 递归拷贝函数
local function _copy(object)
-- 非表类型, 直接默认复制
if type(object) ~= "table" then
return object
-- 直接返回拷贝过的表
elseif lookupTable[object] then
return lookupTable[object]
end

-- 新表, 需要深拷贝
local newTable = {}
lookupTable[object] = newTable
-- 递归拷贝每个键值对, 键也有可能是一个表
for k, v in pairs(object) do
newTable[_copy(k)] = _copy(v)
end
return setmetatable(newTable, getmetatable(object))
end
return _copy(object)
end

local tb1 = { x = 1, y = 2, z = 3 }
local tb2 = clone(tb1)
tb2.x = 4
print(tb1.x, tb2.x) --> 1 4
local tb3 = tb1
tb3.y = 5
print(tb1.y, tb2.y, tb3.y) --> 5 2 5

参考资料​