lua / 2024.08.07

用 C++ 的方式学习 Lua

花了一晚上浅学了一下 Lua 脚本语言。作为 C++ Coder,对我而言,学一门新语言的最容易接受的方式就是将它和 C++ 进行对比,看看能不能实现 C++ 相关特性,或者额外的 C++ 做不到的事,观摩了网上的资料以及骚扰 GPT 后,彻底顿悟,写下心得。

花了一晚上浅学了一下 Lua 脚本语言。作为 C++ Coder,对我而言,学一门新语言的最容易接受的方式就是将它和 C++ 进行对比,看看能不能实现 C++ 相关特性,或者额外的 C++ 做不到的事,观摩了网上的资料以及骚扰 GPT 后,彻底顿悟,写下心得。

基础概念

Hello World

作为一门「脚本语言」,它本身是解释性的,对应的解释器就是 lua。可以新建一个 .lua 文件,然后以 lua <filename>.lua 的方式运行。比如

1print("Hello") -- 控制台输出函数
2print("World") -- 这个也是
3--[[
4  执行 lua HelloWorld.lua,能看到输出如下:
5    Hello
6    World
7
8]]

那么就从上面这个例子开始,进入到对 Lua 的探索中。

注释

-- 后跟单行注释,--[[]] 之间允许跨行注释。

对应 C++ 的 ///**/

变量作用域

所有变量默认为「全局变量」,如果要声明一个仅在当前作用域下生效的局部变量,需要用 local 关键词进行修饰。并且在内部作用域声明的局部变量,访问优先级高于全局变量。

不难发现 Lua 中「作用域」的概念和 C++ 是基本一致的。

 1a = 5           -- 全局变量
 2local b = 5     -- 局部变量
 3
 4function foo()
 5    local a = 6 -- 该变量优先级高于 line 1 的 a
 6    b = nil     -- 上层作用域的局部变量亦能在本作用域中访问,将其设置为 nil 相当于删除该变量
 7    c = 5       -- 全局变量
 8    local d = 6 -- 仅在 foo() 的作用域内生效
 9    print(a, b) --> 6       nil
10end
11
12foo()
13print(a, b)     --> 5       nil
14print(c, d)     --> 5       nil

根据最后一行执行结果,我们得出一个额外的结论:一个不在本作用域生效的变量的值为 nil

同时还应该知道的是:应该尽可能使用局部变量,因为既能避免命名冲突,访问速度也比全局变量更快

基本数据类型

Lua 有 8 大基本类型:

数据类型描述
nil本作用域内无效的变量均为该类型,打印结果也是 nil
boolean只有 false 和 nil 算 false,其余都是 true
number双精度浮点数,允许隐式转为 string
string字符串,用成对的单引号/双引号包裹,也可以用 [[]] 包裹跨行字符串,允许隐式转为 number
functionLua 或 C 的函数
userdataC 中的数据结构
thread线程
table支持任意类型的 key-value 映射

可以通过 type() 函数获取某个变量的类型,函数返回值为 string 类型。

1x = {}               -- 可以通过 {} 建表
2print(type(nil))     --> nil
3print(type(x))       --> table
4print(type(type(x))) --> string
5x = 1
6print(type(x))       --> number

我们又可以得出一个额外的结论:Lua 中的变量可以绑定到任意类型的值,并非像 C++ 那样一经声明就确定了变量名与类型的强绑定关系

Lua 最初是用 C 语言编写的,这使得 Lua 可以很容易地与 C 语言进行交互,所以一些数据类型也与 C 存在关系。

运算符

 1--[[
 2  用 = 赋值,并支持多变量同时多赋值
 3  如果 #(变量) < #(值),则多出来的值被舍弃,比如下面第二行的 4
 4  如果 #(变量) > #(值),则多出来的变量被丢弃(不会被定义),比如下面第三行的 e
 5  支持第四行这样的 swap 行为
 6]] 
 7a = 1
 8b, c = 2, 3, 4
 9d, e = 5
10b, c = c, b
11print(a, b, c, d, e) --> 1 3 2 5 nil
12
13--[[
14  支持 + - * / 四则运算
15  其中如果 + 的操作变量类型为 string,则会尝试将 string 转为 number
16]]
17print("123" + 456)   --> 579
18print("123" + '456') --> 579
19
20--[[
21  % 是取模,^ 是求幂(这个是和 C/C++ 不一样的)
22]]
23print(3 % 4, 2 ^ 2)  --> 3       4
1--[[
2  除了不等于(~=)和 C/C++ 不同之外,其余比较符都是一样的,就不多讲了
3]]
 1--[[
 2  Lua 中用 or(或)、and(与)、not(非)实现逻辑运算,对应 C/C++ 中的 ||、&&、!,就不多讲了
 3]]
 4
 5--[[
 6  支持 .. 进行 string 拼接操作,如果操作变量类型为 number,则会尝试将 number 转为 string
 7  支持 ## 取 string 的长度
 8]]
 9print(#("Hello" .. "World")) --> 10
10print(114 .. 514)            --> 114514
11print(19.19 .. 8.10)         --> 19.198.1

循环控制语句

一共有 4 种循环控制语句

  1. while
  2. for
  3. repeat until
  4. 基于以上三种的嵌套循环;
 1-- while 循环。如果 condition 为 true,则一直运行 statement。
 2while (condition) do
 3    -- statement
 4end
 5
 6-- for 循环分 数值循环 和 泛型循环 两种。
 7--[[
 8  数值循环语法如下:
 9
10  for var = val1, val2 [, val3=1] do
11      statement
12  end
13
14  其中 var 会在区间 [val1, val2] 内以步长 val3(默认为 1)迭代
15  等价于 for (auto var = val1; var <= val2; var += val3) {}
16]] 
17for i = 0, 10, 2 do
18    print(i) --> 0 2 4 6 8 10(这里的空格应该是换行)
19end
20
21--[[
22  泛型循环语法如下:
23
24  for var in iterator do
25      statement
26  end
27
28  Lua 内置的迭代器方法有 pair() 和 ipair() 两种,都需要配合 table 进行使用
29  前者是返回 table 中所有的 key-value 对,后者则限制 key 为从 1 开始递增的整型 number,直到不存在该 key
30]]
31a = {}
32a[1] = 1
33a[3] = 3
34for key, value in pairs(a) do
35    print(key, value) --> 1       1
36                      --> 3       3
37end
38for key, value in ipairs(a) do
39    print(key, value) --> 1       1
40end
41
42-- repeat until 循环。一直运行 statement 直到 condition 为 true。
43repeat
44    -- statement
45until(condition)

上面的 for 迭代循环还会在后文提到,如何自定义迭代器。

流程控制语句

其实就是 ifelseifelse 那一套,只不过语法上稍微有些区别,需要用 thenend 代替大括号。

1a = 100
2if (a < 10) then
3    print("a < 10")
4elseif (a < 50) then
5    print("10 <= a < 50")
6else
7    print("a >= 50")
8end

字符串(string)操作

之前说过可以用单双引号,或者中括号来包裹 string,也说到可以用 ..# 运算符操作 string,但这里的「string」指的都是类型。事实上 Lua 内置了一个名为 string 的对象(本质上是 table),并为其实现了许多成员函数,如下所示:

⚠️注意字符串的位置默认从 1 开始

函数描述
string.upper(str)将 str 转为大写。
string.upper(str)将 str 转为小写。
string.gsub(str, substr, replace, num)将 str 中前 num 个 substr 子串替换为 replace。
string.find(str, pattern, init=1, plain=true)找到 str 从 init 开始第一次出现的 pattern,并返回其起始位置与结束位置。如果 plain 为 true 则禁用正则匹配。没找到就返回 nil。
string.reserve(str)将 str 反转。
string.char(…)可变参函数,输入若干 number,并将 number 转成对应的字符然后拼成字符串(如 97 98 -> “ab”)。
string.byte(str, i=1, j=i)返回 str[i:j] 的所有字符对应整数(如 ‘a’ -> 97)。
string.length(str)求长度,等价于 #str。
string.rep(str, n)将 str 重复 n 次。
string.match(str, pattern, init=1)找到 str 从 init 开始第一次出现的 pattern,并返回匹配到的结果串。支持正则匹配。匹配失败就返回 nil。
string.gmatch(str, pattern)迭代器函数。不断对 str 进行 pattern 的匹配,并返回匹配到的结果串。支持正则匹配。直到返回 nil。

数组

Lua 的数组依托 table 实现,对下标 i 的访问实际上就是访问 table 中 key=i 的那一项。不再赘述。

函数(function)

Lua 的函数定义本质上是定义一个类型为 function 的变量,与变量相关的大部分性质也都适用。我们可以通过以下方式定义一个 function:

 1local function foo()
 2    print("local")
 3end
 4
 5function bar()
 6    print("global")
 7end
 8
 9foo()            --> local
10bar()            --> global
11print(type(foo)) --> function

多返回值

和 C/C++ 不同,Lua 的函数支持多返回值,当然也可以利用赋值的性质,用个数不等的变量去接收值。

1function foo()
2    return 1, 2
3end
4a, b, c = foo()
5print(a, b, c) --> 1       2       nil

参数包

甚至支持参数包。

 1function add(base, ...)         -- 可以有固定参数,但是必须在参数包之前
 2    local s = base
 3    print("get " .. select("#", ...) .. " elements")
 4    for i, v in ipairs {...} do -- {...}表示一个用参数包构成的数组
 5        s = s + v
 6    end
 7    return s
 8end
 9
10print(add(0, select(3, 1, 2, 3, 4, 5))) -- 相当于调用 add(0, 3, 4, 5)
11                                        --> 12

上面我们用了一个叫 select 的函数,这个函数实际上也支持可变参数,它有以下两种用法:

  1. select("#", ...): 获取参数包的参数个数;
  2. select(i, ...): 返回参数包从第 i 个元素开始到最后一个元素的切片;

事实上,当我们以参数包的形式传参时,函数内部会为我们隐式添加一个局部 table 变量 arg = {...},将参数包内的所有参数都加入该 table 中,这样我们就能以数组的形式访问参数包了。同时 arg 还有一个名为 n 的 key,表示参数包的参数个数,比如下面这样。

 1--[[
 2    假设以 lua arg.lua 1 2 3 的方式去执行
 3]]
 4function aa(a)
 5    print(arg.n, arg)
 6end
 7function bb(a, b)
 8    print(arg.n, arg)
 9end
10function cc(a, b, ...)
11    print(arg.n, arg, arg[1])
12end
13
14aa(1, 2, 3)                      --> nil     table: 0x6c9e50
15cc(4, 5, 6)                      --> 1       table: 0x6cae50        6
16bb(7, 8, 9)                      --> nil     table: 0x6c9e50
17cc(0, 0)                         --> 0       table: 0x6ca420        nil
18print(arg.n, arg)                --> nil     table: 0x6c9e50
19for key, value in pairs(arg) do
20    print(key, value)            --> 1       1
21                                 --> 2       2
22                                 --> 3       3
23                                 --> -1      lua
24                                 --> 0       arg.lua
25end

这就为我们提供了两种访问参数包的方法。

同时不难发现其实也有一个全局 table 变量 arg,当一个函数未使用参数包时,就不会生成 local arg,所以引用的是全局的 arg。全局 arg 不具有成员 n,但是具有执行该脚本时通过命令行传递给脚本的所有参数。

其中 -10 分别是 lua 和文件名,之后的就是用户自定义传入的参数了。

实现具有默认值的函数参数

Lua 也可以利用一些基本性质实现 C++ 中的具有默认值的函数参数,当然这些参数必须在参数列表的最右侧。

1function foo(a, b, c)
2    b = b or 2 -- 如果不传入第二个参数,则 b 就会被视为 nil,通过这个语句可以令其具有默认值 2
3    c = c or 3 -- 同理
4    return a + b + c
5end
6
7print(foo())         -- 会报错,因为入参 a 相当于 nil,而 nil 不支持参与四则运算
8print(foo(1))        --> 6
9print(foo(114, 514)) --> 631

表(table)

Lua 中的 table 可以说是应用扩展性最高的类型了,因为其支持任意类型的 key 和 value,就可以玩出很多花样来。

先说说最基础的用法。

 1key0 = 0
 2tab = {key1 = "value1", key2 = "value2", key3 = "value3", "value4"}
 3tab[key0] = "value0"
 4
 5print(tab.key0, tab.key1, tab[key2], tab["key3"]) --> nil value1 nil value2
 6for key, value in pairs(tab) do
 7    print(key, type(key), value, type(value)) --> 1       number  value4  string
 8                                              --> key1    string  value1  string
 9                                              --> key3    string  value3  string
10                                              --> 0       number  value0  string
11                                              --> key2    string  value2  string
12end

简要分析一下: 首先我们通过 {k = v, ...} 的方式构造了一个 table,并且通过 table[] 往里加入新元素,后续的访问既可以通过 . 的方式,也可以通过 [] 的方式。但是根据输出结果来看,我们能得出以下结论:

  1. 若以 [k] = v 方式添加元素,则会保留 k 的源类型;

  2. 若以 {k = v, ...} 方式构造 table,则 k 会视为 string 类型,比如上面 key1 = “value1” 实际上是生成了一个 “key1” -> “value1” 的映射关系;

  3. 若上面这种方式不指定 k,则会默认 k 为从 1 开始自增的 number;

    通过这种方式可以进行数组的构造,即 nums = {1, 2, 3, 4, 5}。注意下标默认从 1 开始。但 Lua 中事实上是没有「数组」的,是通过 table 模拟的。

  4. 若以 [k] 方式访问,则基于 k 的源类型,比如上面 tab[key2] 中,key2 变量不生效,为 nil,所以返回一个 nil

  5. 若以 . 方式访问,则 k 会视为 string 类型,比如上面 tab.key0 实际上等价于 tab["key0"],同理 tab.key1 等价于 tab["key1"],这也就是为什么前者输出 nil 而后者输出 value1 的原因了;

元表(metatable)

所谓 metatable 本质上其实就是一个普通的 table,并不是什么特殊的数据类型,只不过表现为 table 中某个特殊的 key 对应的 value,为 table 提供若干功能。目前只能通过以下两种方式设置元表。

 1metatable = {}
 2
 3tab1 = {}
 4setmetatable(tab1, metatable)      -- 第一种方式
 5
 6tab2 = setmetatable({}, metatable) -- 第二种方式
 7
 8getmetatable(tab1)                 -- 获取 metatable 的方式
 9
10-- 其实就是为传入的第一个参数设置 metatable 为第二个参数,然后将其返回。
11-- 设置的元表并不会通过 key value 公开给用户,比如下面的脚本就啥也不输出。
12for key, value in pairs(tab1) do
13    print(key, value)
14end

上面说了 metatable 可以为 table 提供若干功能,具体有以下功能:

__index

__index 提供了访问表中不存在 key 的处理逻辑。

当通过 table[key] 访问且 key 在 table 中不存在时,如果设置了 metatable,则会用 metatable 的 __index 进行查找。反之返回 nil。

__index 可以是 function,也可以是 table。

  • __index 是 function 时,相当于调用 metatable.__index(table, key)
  • __index 是 table 时,相当于调用 metatable.__index[key]
 1tab1 =
 2    setmetatable(
 3    {},
 4    {
 5        __index = function(table, key)
 6            print(table, key)
 7            return 1
 8        end
 9    }
10)
11tab2 =
12    setmetatable(
13    {},
14    {
15        __index = tab1
16    }
17)
18print(tab1, tab2)         --> table: 0x189d6d0        table: 0x189d080
19print(tab1[0], tab2[1])   --> table: 0x189d6d0        0
20                          --> table: 0x189d6d0        1
21                          --> 1       1

上面的示例中,因为 tab1 不存在 key 0,所以访问 tab1[0] 相当于调用了 metatable.__index(tab1, 0),将其打印后并返回 1。

同时,因为 tab2 不存在 key 1,所以访问 tab2[1] 相当于调用了 metatable.__index[1]tab1[1],所以传入的第一个参数是 tab1 而非 tab2

因为 Lua 函数调用的特性,如果我们用不到 table 和 key 这两个参数,也可以不设置形参,这样传入的实参就会被舍弃。同理,也可以设置参数列表为 (table, key1, key2, …, keyn),只不过从 key2 开始后面的都会是 nil 了。

__newindex

__newindex 提供了添加表中不存在 key 的处理逻辑。

当通过 table[key] = valuetable.key = value 赋值且 key 在 table 中不存在时,如果设置了 metatable,则会用 metatable 的 __newindex 进行操作。反之进行正常的添加。

同样,__newindex 可以是 function,也可以是 table。

  • __newindex 是 function 时,相当于调用 metatable.__newindex(table, key, value)
  • __newindex 是 table 时,相当于调用 metatable.__newindex[key] = value
 1tab1 =
 2    setmetatable(
 3    {},
 4    {
 5        __newindex = function(table, key, value)
 6            print(table, key, value)
 7            return 1
 8        end
 9    }
10)
11tab2 =
12    setmetatable(
13    {},
14    {
15        __newindex = tab1
16    }
17)
18print(tab1, tab2)       --> table: 0x20ec130        table: 0x20ec200
19tab1[0] = 1
20tab2[0] = 2
21print(tab1[0], tab2[0]) --> table: 0x20ec130        0       1
22                        --> table: 0x20ec130        0       2
23                        --> nil     nil

道理和 __index 一样的,就不赘述了。

__call

__call 提供了 table 作为 function 进行函数调用的处理逻辑。

当通过 table(...) 的形式执行类似于函数调用的操作时,如果设置了 metatable,则会用 metatable 的 __call 进行操作。反之报错。

用 C++ 的话描述,其实就是重载了 operator()

 1tab =
 2    setmetatable(
 3    {},
 4    {
 5        __call = function(self, ...)
 6            for key, value in pairs({...}) do
 7                self[key] = value
 8            end
 9        end
10    }
11)
12
13tab(1, 2, 3)
14for key, value in pairs(tab) do
15    print(key, value) --> 1       1
16                      --> 2       2
17                      --> 3       3
18end

__tostring

__tostring 提供了被 print 调用时的处理逻辑。

当通过 print(table) 的形式执行类似于函数调用的操作时,如果设置了 metatable,则会用 metatable 的 __tostring 尝试获取一个能被 print() 函数接受的类型的值。反之报错。

 1tab =
 2    setmetatable(
 3    {1, 2, 3},
 4    {
 5        __tostring = function(self)
 6            local sum = 0
 7            for k, v in pairs(self) do
 8                sum = sum + v
 9            end
10            return "表所有元素的和为 " .. sum
11        end
12    }
13)
14
15print(tab) --> 表所有元素的和为 6

运算符

我们可以通过 __add__sub 这些字段重载 table 的运算符(就像 C++ 中那样!),比如下面这样:

 1tab =
 2    setmetatable(
 3    {},
 4    {
 5        __add = function(self, other) -- 用 C++ 的话来说就是重载了 tab 的 operator+()
 6            for key, value in pairs(other) do
 7                self[key] = value
 8            end
 9            return self
10        end
11    }
12)
13
14res = tab + {1, 2, 3}
15for key, value in pairs(res) do
16    print(key, value) --> 1       1
17                      --> 2       2
18                      --> 3       3
19end
20
21for key, value in pairs(tab) do
22    print(key, value) --> 1       1
23                      --> 2       2
24                      --> 3       3
25end

上面 tab 的值也被更改了,说明 table 类型的传值是以指针/引用的形式。

其余运算符也可以重载,如下:

元方法对应运算符
__add+
__sub-
__mul*
__div/
__mod%
__unm-
__concat..
__eq==
__lt<
__le<=

进阶玩法

迭代器

之前我们提到可以用 pairsipairs 去遍历一个 table,但实际上迭代器(iter)并不限于此。以 ipairs() 为例,我们先尝试获取这个函数的返回值是什么。

1tab = {key1 = 1, 2, 3}
2print(ipairs(tab)) --> function: 0x1a8d080     table: 0x1a94020        0
3print(tab)         --> table: 0x1a94020

第一个返回值是一个 function,应该是一个闭包;第二个返回值是 tab 对象本身;第三个返回值是 0,可能会用于索引。

1a, b, c = ipairs(tab)
2-- print(a())       --> bad argument #1 to 'a' (table expected, got no value)
3-- print(a(b))      --> bad argument #2 to 'a' (number expected, got no value)
4print(a(b, 0))      --> 1       2
5print(a(b, 1))      --> 2       3
6print(a(b, 2, 123)) -->
7print(a(b, 3))      -->
8-- 是的,最后两个就是打印了空行,但实际上应该返回 nil

从上面可以得到,ipairs() 的第一个返回值 function 对象接受两个参数,且第一个参数要求是 table,第二个参数要求是 number(但是传入 0 却实现了返回 1 tab[1],比较莫名其妙)。那么下面是一个可能的 ipairs 的实现:

 1function iter(tbl, i)
 2    i = i + 1
 3    local v = tbl[i]
 4    if v then
 5        return i, v
 6    end
 7end
 8
 9function myIpairs(table)
10    return iter, table, 0
11end
12
13for key, value in ipairs(tab) do
14    print(key, value) --> 1       1
15                      --> 2       2
16end
17
18for key, value in myIpairs(tab) do
19    print(key, value) --> 1       1
20                      --> 2       2
21                      -- 根据这一输出结果,猜想得到证实
22end

进一步分析「泛型循环」的行为:

  1. 首先执行 in 左侧的表达式,期望得到个返回值,记为 funcparam1param2
  2. 调用 func(param1, param2),可以得到若干返回值 value1value2,…;
  3. 这些返回值会被 in 左侧的变量接收,多余的丢弃,不足的用 nil 补充;
  4. 如果 value1 为 nil,终止循环,否则执行循环体;
  5. param1 不变,param2 = value1,重复步骤 2;

其中只有 func 是必要的。另外,由于 param1 不变,所以又被称为「状态常量」,param2 仅用于第一次的 func() 调用,在循环过程中会发生变化,故又被称为「初始变量」。

也可以实现一个只返回 func 的迭代器函数:

 1function square(iteratorMaxCount, currentNumber)
 2    if currentNumber < iteratorMaxCount then
 3        currentNumber = currentNumber + 1
 4        return currentNumber, currentNumber ^ 2
 5    end
 6end
 7
 8for i, n in square, 3, 0 do
 9    print(i, n) --> 1       1
10                --> 2       4
11                --> 3       9
12end

这种只利用状态常量初始变量两个值就可以获取下一个元素的迭代器函数称为「无状态的迭代器」。那么当然也有「有状态的迭代器」了。

 1array = {"Jack", "Mike"}
 2function elementIterator(collection)
 3    local index = 0
 4    local count = #collection
 5    return function()
 6        index = index + 1
 7        if index <= count then
 8            return collection[index]
 9        end
10    end
11end
12
13for element in elementIterator(array) do
14    print(element) --> Jack
15                   --> Mike
16end

这里通过定义局部变量,并将其赋给闭包函数,使得在整个循环中这些局部变量都能生效,这样也就为 func 提供了额外的信息,因而为有状态的

实现面向对象

回顾一下面向对象三大特性:封装、继承、多态。下面分别讲一下 Lua 如何实现这几个特性。

封装

我们知道 table 可以通过 . 的方式访问 key,不难发现这和 C++ 中访问成员变量的方式如出一辙。事实上我们完全可以通过 table 的这一特性实现成员变量。又因为 value 支持任意类型,当然也包括 function 类型,同理也可以实现成员函数。

 1obj = {member = 123} -- 定义成员变量
 2print(obj)           --> table: 0x1449880
 3
 4function obj:foo()   -- 定义成员函数
 5    print(self)
 6    print("obj.foo")
 7end
 8
 9obj:foo()            --> table: 0x1449880
10                     --> obj.foo
11
12obj.foo = function() -- 也可以通过这种方式定义成员函数
13    print(self)
14    print("obj.foo - new!")
15end
16
17obj.foo()            --> nil
18                     --> obj.foo - new!

我们发现了两种成员函数的定义方式,同时也发现了两种调用方式!虽然两种定义方式是等价的,但是调用方式却不等价。区别就在于,使用 : 调用成员函数会隐式传入 self 对象,也就是自身,而 . 的方式却不行。同时,我们不能通过 : 访问非 function 类型的 value,会报错,因为解释器默认在 : 后面的是 function 对象,是要跟圆括号的。所以既然 Lua 为我们专门提供了一种访问方式,那就不要跟他作对,用就是了。

继承

指定 metatable 其实就可以看作「继承自基类」。在 C++ 中,「继承」(假设以 public 方式)能够使派生类访问基类的所有非私有成员变量和成员函数,那么在 Lua 中,这种性质由「指定 metatable.__index = metatable」的方式实现。这很合理,当我们尝试访问一个 table(派生类)中不存在的 key-value 时,就会通过 metatable.__index 去查找,也就相当于查找 metatable(基类)中的 key-value。

同时也允许派生类覆盖基类的成员变量/函数,只需要添加同名 key 即可(就不会走 metatable.__index 了)。

下面给出一个工厂函数示例,用于生成派生 table 对象。

 1RectangleFactory = {}                          -- 基类
 2RectangleFactory.__index = RectangleFactory    -- !这一步很关键,允许派生类访问基类的成员函数和成员变量
 3
 4function RectangleFactory:new(length, breadth) -- 创建派生类的函数
 5    return setmetatable(
 6        {
 7            length = length or 0,
 8            breadth = breadth or 0,
 9            area = length * breadth
10        },
11        self
12    )
13end
14
15function RectangleFactory:printArea()
16    print(self, "面积为", self.area)
17end
18
19rec = RectangleFactory:new(1, 2) -- 此时 new() 中的 self 是 RectangleFactory
20print(rec)                       --> table: 0x243c740
21rec:printArea()                  --> table: 0x243c740        面积为  2

在调用 rec:printArea() 时,由于其本身没有对应的 key,所以去 metatable(也就是 RectangleFactory)的 __index 中查找,最终调用了 RectangleFactory:printArea()

这里我们发现,在调用 metatable 的成员函数时,传入的 selfrec 本身。从而得出一个额外的结论:对于一个 function 而言,如果它作为 table 的成员函数被调用,则内部的 self 取决于调用者,即 : 左侧的对象

多态

只需要在 table 中定义 metatable 的同名函数即可实现多态。

模块(module)与包(package)

我们可以在一个 module.lua 文件中通过 return 返回若干变量,其它 .lua 文件可以在开头通过 require("module") 语句获得这些变量的使用权。

 1mod = {foo = 1}
 2
 3function mod:func()
 4    print("call mod:func " .. self.foo)
 5end
 6
 7function moduleFunc()
 8    print("call moduleFunc")
 9end
10
11return mod, moduleFunc
1require("module") -- 会根据一定规则找到 module.lua 文件
2
3mod:func()        --> call mod:func 1
4moduleFunc()      --> call moduleFunc

加载规则是:先找当前文件夹,再去环境变量 LUA_PATH 中查找。

⚠️需要注意的是,使用的变量必须在 module.lua 文件中为全局变量。这很好理解,我们把一个文件看成一个单独的作用域,那么声明为 local 的变量也就只能在该文件中生效,即便被其它文件 require,相当于作用域发生变化,也就无法使用了。这很容易联想到 C 中的 #includestaticextern 相关用法。

进阶玩法是,可以在某个 package.luarequire 许多 module,这样如果我们需要使用这些模块的时候,只需要 require("package") 即可(相当于形成了一种 table 的树状结构)。

1package = {}
2
3package.module1 = require("mypackage.module1") -- 引入 ./mypackage/module1.lua
4package.module2 = require("mypackage.module2") -- 引入 ./mypackage/module2.lua
5
6return package

通过模块和包,我们可以更好地组织代码,提高代码的可重用性,并降低代码之间的耦合度。

Lua & C 交互

之前说到 Lua 是 C 写的,可以容易地与 C 语言进行交互,下面讲讲是如何做到的。

Lua 中调用 C 函数

具体方式是将 .c 文件生成动态链接库,令 Lua 执行相应的函数注册与调用行为。

 1#include <lauxlib.h>
 2#include <lua.h>
 3#include <lualib.h>
 4
 5// 所有要注册的函数都必须为 int (*)(lua_State *L) 类型
 6static int cAdd(lua_State *L) {
 7  double lhs = luaL_checknumber(L, 1);
 8  double rhs = luaL_checknumber(L, 2);
 9  double result = lhs + rhs;
10  lua_pushnumber(L, result);
11  return 1;  // 返回值的数量
12}
13
14// 如果链接成 *.so,那么就需要设置函数名为 luaopen_*
15int luaopen_mylib(lua_State *L) {
16  luaL_Reg mylib[] = {   // 注册函数名与函数指针,用 {NULL, NULL} 作为结束标识符
17    {"add", cAdd},
18    {NULL, NULL}
19  };
20  luaL_newlib(L, mylib); // 注册函数
21  return 1;
22}
1$ gcc mylib.c -fPIC -shared -o mylib.so -Wall
2$ ls
3mylib.c mylib.so test.lua
1local mylib = require("mylib")   -- 查找 mylib.so,并调用其中的 luaopen_mylib()
2                                 -- 注册 add() 函数对应到 cAdd()
3
4local result = mylib.add(10, 20) -- 相当于调用 cAdd(10, 20)
5print(result)                    --> 30

C 中执行 Lua 脚本

这个比较简单,关键在于 luaL_dofile() 函数。

 1#include <lauxlib.h>
 2#include <lua.h>
 3#include <lualib.h>
 4#include <stdio.h>
 5
 6int main() {
 7  lua_State *L = luaL_newstate();                         // 创建 Lua 虚拟机
 8  luaL_openlibs(L);                                       // 打开标准 Lua 库
 9
10  int error = luaL_dofile(L, "myLua.lua");                // 执行 Lua 脚本
11  if (error) {
12    fprintf(stderr, "Error: %s\n", lua_tostring(L, -1));  // 打印异常信息
13    lua_pop(L, 1);                                        // 弹出异常信息
14  }
15
16  lua_close(L);                                           // 关闭 Lua 虚拟机
17  return 0;
18}

C 中执行调用了 C 函数的 Lua 脚本

这就是把上面两个合起来了。

当然此时也可以不生成动态链接库,直接执行一段字符串即可。

 1#include <lauxlib.h>
 2#include <lua.h>
 3#include <lualib.h>
 4#include <stdio.h>
 5
 6static int cAdd(lua_State *L) {
 7  double lhs = luaL_checknumber(L, 1);
 8  double rhs = luaL_checknumber(L, 2);
 9  double result = lhs + rhs;
10  lua_pushnumber(L, result);
11  return 1;  // 返回值的数量
12}
13
14int main() {
15  lua_State *L = luaL_newstate(); // 创建 Lua 虚拟机
16  luaL_openlibs(L);               // 打开标准 Lua 库
17
18  /**
19   * 下面这个其实是一个宏
20   * #define lua_register(L,n,f) (lua_pushcfunction(L, f), lua_setglobal(L, n))
21   *
22   * 其中:
23   * lua_pushcfunction(L, cAdd); 将函数 cAdd() 转换为 Lua 的 function 并压入虚拟栈
24   * lua_setglobal(L, "add");    弹出栈顶元素,并在 Lua 中用名为 add 的全局变量存储
25   */
26  lua_register(L, "add", cAdd);
27
28  int error = luaL_dostring(L, "add(10, 20)");            // 执行 Lua 脚本
29  if (error) {
30    fprintf(stderr, "Error: %s\n", lua_tostring(L, -1));  // 打印异常信息
31    lua_pop(L, 1);                                        // 弹出异常信息
32  }
33
34  lua_close(L);                                           // 关闭 Lua 虚拟机
35  return 0;
36}

Lua 协程

Lua 协程和 C++20 的协程用法基本一致,由 coroutine 模块提供支持。这里我们直接通过一个生产者-消费者问题来了解什么是协程。

 1local newProductor
 2
 3function productor()
 4    print("productor", coroutine.running()) -- 获取正在运行的 coroutine
 5    local i = 0
 6    repeat
 7        send(i)
 8        i = i + 1
 9    until i > 3
10end
11
12function consumer()
13    print("consumer", coroutine.running())  -- 获取正在运行的 coroutine
14    local i = receive()
15    while i do
16        i = receive()
17    end
18end
19
20function receive()
21    local status, value = coroutine.resume(newProductor) -- 启动 coroutine 并获取返回值
22    print("receive", value, status)
23    return value
24end
25
26function send(x)
27    print("send", x)
28    coroutine.yield(x)                     -- 返回一个值并挂起
29end
30
31-- 这里都是由 main thread 负责的
32newProductor = coroutine.create(productor) -- 创建但不启动 coroutine
33consumer()
34
35-->  consumer        nil
36-->  productor       thread: 0xc81020
37-->  send    0
38-->  receive 0
39-->  send    1
40-->  receive 1
41-->  send    2
42-->  receive 2
43-->  send    3
44-->  receive 3
45-->  receive nil
46
47print(coroutine.resume(newProductor)) --> false    cannot resume dead coroutine
序号描述控制权属于 main控制权属于 newProductor
1首先,我们通过 coroutine.create() 创建了一个 thread 类型的对象 newProductor,绑定到函数 productor()。根据输出结果可以看到,此时其并未启动。
2main 继续调用 consumer() 函数,并通过 coroutine.resume() 的方式启动 newProductor,并等着获取抛出的值。
3newProductor 启动,在 productor() 内部通过 coroutine.yield(i) 抛出一个值的同时将自身挂起;如果函数结束,则抛出 nil。
4.1如果 main 在 receive() 中获取到了非 nil 值,则重复第二步。
4.2如果 main 在 receive() 中获取到了 nil 值,这意味着 newProductor 已经正常结束,那 main 也需结束
5截至目前 status 都是 true,但如果后续再次通过 resume() 尝试启动 newProductor,则会报错

不难看出,其实协程就是通过用户编码的方式来将协程的切换行为委托给用户而非操作系统。虽然说是说 thread 类型的变量,但其实还是以用户态的形式运作的,和需要操作系统调度的「线程」有本质区别。

跟 C++ 的协程非常像,但开发效率高了不少。

文件 I/O

基本的文件操作有「打开」、「关闭」、「读」、「写」这四种。Lua 内置了一个全局 table 变量 io,实现了文件相关的成员函数,比如要打开一个文件可以用 io.open(file [, mode = "r"]),其中第一个参数为「文件路径」,第二个参数为「打开方式」。这种打开文件的操作和 C/C++ 几乎一致,就不赘述了。

io.open() 会返回一个文件句柄 file,其实现了以下成员函数:

file 函数描述
file:close()关闭文件。也可以通过 file=nil 使其被垃圾回收,但回收时间随机,不建议。
file:read(…)每个传入的参数为一种读取方式,对应一个返回值。有以下几种读取方式:
"l":(默认)读一行,不包括换行符。只能用于文本文件
"L": 读一行,包括换行符。只能用于文本文件
"n": 读一个 number。
"a": 从当前位置读整个文件。
number: 传入一个整数,读 number 个字节。
如果读取失败则返回 nil。
file:write(…)将所有入参(只能是 string 或 number)写入文件。写入行为取决于 open() 中指定的打开方式。如果失败则返回 nil, errstring。
file:lines(…)迭代器函数,能够指定和 read() 一样的读取方式循环读取文件直至 EOF。
file:flush()将缓冲区内容落盘。
file:seek(whence, offset=0)修改文件当前位置为 base+offset。第一个参数为 string,用于指定 base。
"cur": (默认)base 为文件当前位置。
"set": base 为文件起始位置。
"end": base 为 EOF。
若成功则返回文件当前位置,否则返回 nil, errstring。
file:setvbuf(mode, size)设置文件输出缓冲模式。
"no": 无缓冲,任何写入都会落盘。
"full": 全缓冲,仅当缓冲区满或显示调用 flush() 时落盘。
"line": 行缓冲,遇到换行符时落盘。

一些基本操作如下:

1file = io.open("input.txt", "a+")  -- 以 append & 可读写 方式打开文件
2
3file:write("hello world\n")        -- 此时 file 当前位置为 EOF
4
5file:seek("set")                   -- 重新指向起始位置
6
7for line in file:lines("l") do
8    print(line)                    --> hello world
9end

至于为什么要用 : 方式调用函数呢,可能内部有一个 cur 指向当前位置,需要传入 self 去查改。

虽然 io 对象也实现了若干成员函数,但只支持单文件操作,并不太想去了解。

异常处理

Lua 提供了两个基本异常处理函数:

  • assert(v, message="assertion failed!"): 若表达式 v 不为 true,则程序中断,输出异常信息 message
  • error(message, level=1): 中断程序,并输出异常信息 message。其中 level 用于控制位置信息,默认为 1,附带「文件名+函数内行号」,0 表示不添加位置信息,2 表示附带「文件名+调用函数行号」;
 1function func0()
 2    error("haha", 0)
 3end
 4function func1()
 5    error("haha", 1)
 6end
 7function func2()
 8    error("haha", 2)
 9end
10
11func0() --> lua: haha
12func1() --> lua: error.lua:5: haha
13func2() --> lua: error.lua:13: haha

这两个函数都会在发生异常时中断程序,有些时候可能不是我们想要的行为。Lua 为我们提供了一种保护机制,使得即使某个函数抛出了异常,也能正确处理而非直接使程序终止,就是下面两个函数:

  • pcall(func, ...): 全称「protected call」。第一个返回值为 boolean,表示是否成功运行,后续返回值是要么是 func(...) 的返回值(如果调用成功),要么是异常信息(如果发生错误);
  • xpcall(func, error_handler, ...): 比 pcall() 多了第二个参数,是一个只接受一个 string 参数的 function,当异常发生时会由 error_handler(err) 进行处理。第一个返回值为 boolean,表示是否成功运行,后续返回值是要么是 func(...) 的返回值(如果调用成功),要么是 error_handler(err) 的返回值(如果发生错误);

Lua 内置了一个 debug 库,为我们提供了两个通用的 error_handler:

  • debug.debug():提供一个 Lua 提示符,让用户来检查错误的原因;
  • debug.traceback():根据调用栈来构建一个扩展的错误消息;

下面是基本用法

 1function func()
 2    n = n / nil
 3end
 4
 5success, res = pcall(func)
 6print(res)  --> test.lua:2: attempt to perform arithmetic on global 'n' (a nil value)
 7
 8success, res = xpcall(func, debug.traceback)
 9print(res)  --> test.lua:2: attempt to perform arithmetic on global 'n' (a nil value)
10            --> stack traceback:
11            -->         test.lua:2: in function <test.lua:1>
12            -->         [C]: in function 'xpcall'
13            -->         test.lua:12: in main chunk
14            -->         [C]: ?

debug 库提供的 error_handler 也不止上面这两个,用到再学吧。

垃圾回收

C/C++ 中可以通过将对象分配在栈上,从而在生命周期结束时令其自动被回收,也可以通过 free/delete 来显式释放不再需要的堆上对象。但显式释放对象的缺点是,一旦忘记释放,但是丢失了这个对象的引用,就再也无法访问这个对象了,即存在「内存泄漏」。

Lua(在内的其他语言)内置了「垃圾回收」的功能,它天然能够在某一时刻检测哪些对象不再被其他变量/对象引用(这种对象称之为「不可达对象」),然后将它们回收,这就使得我们不需要把心思花在处理内存泄漏问题上,只需要专注于开发即可。

1t = {1, 2, 3}
2t = {4, 5, 6} -- {1, 2, 3} 此时为「不可达」,会在将来的某一时刻被回收
3t = nil       -- 显式指定为不可达对象,之后会将 {4, 5, 6} 回收

≤Lua 5.0:简单标记扫描

Lua 5.0 之前用的是一种简单的标记扫描算法,只有以下两步:

  1. 垃圾回收器从一组根对象开始(通常是全局变量、调用栈中的局部变量等),递归扫描这些根对象,并标记可以访问到的对象(下面称为子对象)为「活跃」(弱引用表除外)。
  2. 垃圾回收器遍历保存所有对象的链表 allgc,回收所有未被标记为活跃的对象,同时清除活跃对象的标记。

不难看出一次 GC 会进行全量扫描,并且每当解释器分配了一定数量的内存时,垃圾回收器也执行一次 GC,这样就会产生大量的开销。

更致命的是,GC 的过程会阻塞主程序的运行。所以基本没人用。

Lua 5.0:小优化

依然是进行全量 GC,只不过将执行 GC 的时刻延长至「内存分配量超过了上次 GC 后的两倍」。

值得一提的是,这个版本支持 __gc 元方法,会在被回收时调用。当一个 table 或 userdata 对象实现了该元方法,那么在创建/设置元表时就会加入到 finobj 链表上。

在回收阶段,将 finobj 上的不可达对象执行一遍 __gc,同时标记为「已经执行过 __gc」,然后将其加入 allgc,这样下一次 GC 周期就能正确回收了。

Lua 5.1:三色扫描——渐进式 GC

渐进式垃圾收集器会在虚拟机的正常指令逻辑间交错分布运行,尽量把每步的执行时间减到合理的范围。同时它使用了一种新的算法,即三色扫描,将每个对象分为三个状态:

  • 白色:不可达(这是所有对象的默认颜色);
  • 灰色:可达,但未进行递归扫描;
  • 黑色:可达,且已完全扫描;

那么以下条件是恒成立的:

  • 灰色对象的子对象可以是白色的;
  • 黑色对象的子对象非黑即灰;

三色标记法的回收过程包括以下步骤:

  1. 将所有根对象标记为灰色;
  2. 对根对象进行递归扫描,将访问到的对象标记为灰色。同时,将已经访问过的对象标记为黑色;
  3. 未被标记为黑色的对象即为不可达对象,可以被回收;

随着 GC 的运作,灰色对象会被转变为黑色对象。一旦所有灰色对象消失,整个 GC 过程也就完成了。

这样还有一个问题,就是当一个 table 被完全扫描并标为「黑色」后,再给它加入一个新的对象,此时这个新对象就是「白色」的,后续依然会被回收,就会出现不一致的问题。

此时有两种解决措施,一种是把新对象 barrier forward 为「灰色」,另一种是把 table barrier backward 为「灰色」。

在 Lua 实现中,对黑色 table 进行修改操作会默认使用 barrier backward 策略,将其改为「灰色」(并认为后续还可能进行修改),并把它单独放在一个独立的链表 grayagain 里,留待后面原子处理,避免它在「黑色」和「灰色」之间反复折腾。

一种特殊情况是在栈上分配的 table,就不会让它变「黑色」,这样对栈的操作就不需要 barrier,提高栈写入的性能。

如果是给对象设置一个 metatable,就可以采用 barrier forward 策略,即将 metatable 置「灰色」。

Lua 5.2/5.4:分代 GC

所谓分代 GC 指的是对象分为「老年代」和「新生代」。

  • 老年代指的是常驻的、长时间不需要 GC 的对象。之前的版本中,大量的时间都是在扫描标记这些对象,因此如果能够减少扫描标记老年代的话,性能就能达到提升;
  • 新生代指的是是刚创建出来的对象,很有可能需要进行清理,比如在栈上创建的对象。针对新生代进行 GC 不只能够提升效率,还能保持内存占用的稳定;

如果一个对象在「新生代」中存活足够长的时间(即在多次垃圾收集后仍然存活),它会被晋升到「老年代」。对「老年代」的回收频率显著低于「新生代」的回收频率。

当然依然存在老年代对象引用新生代对象,该如何修改状态的问题——新对象改为「老年代」,那「老年代」就会有特别多,起不到回收的作用;老对象改为「新生代」也是同理,需要进行更多的回收,起不到优化的作用。

所以引入了新的状态叫「触碰态」,一旦老对象引用了一个新的对象,则认为它处于「触碰态」,加入下次的扫描。如果「新生代」和「触碰态」被多次扫描到,就说明它有可能经常被用到,就将其转为「老年代」。

弱表

弱表指内部元素为「弱引用」的表,是用户用来告诉 Lua 一个引用不应该阻碍对该对象的回收的机制。如果一个对象只被弱引用引用到,垃圾收集器就会回收这个对象。

table 的弱引用类型是通过其元表中的 __mode 字段来决定的。这个字段的值应为一个字符串:

  • 如果包含 'k',那么这个 table 的 key 是弱引用的;
  • 如果包含 'v',那么这个 table 的 value 是弱引用的;
 1a = {}
 2setmetatable(a, {__mode = "k"})
 3
 4k = {}
 5a[k] = "v1"
 6
 7k = {}
 8a[k] = "v2"
 9
10for key, value in pairs(a) do
11    print(key, value) --> table: 0xd45960 v1
12                      --> table: 0xd454a0 v2
13end
14
15collectgarbage()      -- "v1" 对应的 key 未被其他变量引用,会被回收
16                      -- "v2" 对应的 key 仍被 k 引用,所以不会被回收
17
18for key, value in pairs(a) do
19    print(key, value) --> table: 0xd454a0 v2
20end

关键在于忽略了 table 对 key 的引用计数,因而也可以用于解决「循环引用」问题。

 1a = {1, 2, 3}
 2b = {4, 5, 6}
 3
 4-- a.other = b
 5-- b.other = a
 6-- 上面这种写法导致循环引用
 7
 8setmetatable(a, {__mode = "v"})
 9setmetatable(b, {__mode = "v"})
10
11a.other = b
12b.other = a
13
14a = nil
15b = nil
16collectgarbage() -- 此时 {1, 2, 3} {4, 5, 6} 均会被回收