Lua重要知识点总结
前言
本篇文章主讲三个方面:Lua的性质,Lua的基础理论和重要知识点。尝试言简意赅的呈现各个模块的知识点,并且把一些平时开发可能不会注意到的地方呈现出来。
Lua的性质
Lua的设计目的
Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,从一开始就把轻量化,可移植性,可嵌入型,可扩展性等作为自己的设计目标,作为胶水语言来辅助像是C,C++这样的主角来更好地完成工作。
特性
轻量型
非常小的尺寸,5.1版本的压缩包仅有208KB,解压后也不过是835KB,Lua解释器只有17000多行的C代码,编译后二级制库文件仅有143KB。
可移植性
使用clean C 编写的解释器,可以在Mac,Unix,Windows等多个平台轻松编译通过。
可嵌入型
Lua提供非常丰富的API,可以提供宿主程序与Lua脚本之间进行通信和数据交换。
优势
- 语法简单,容易上手,学习成本低。在有其他语言基础而完全未接触过Lua的情况下,能在3天内快速上手并投入业务开发。
- 轻量,占用小。
Lua与游戏开发
对从事游戏开发行业中的人来说,Lua语言可能并不陌生。因为它满足了占中国游戏市场半壁江山的手游的热更需求,且在经历过国内网易,西山居,腾讯等行业头部的公司的使用,形成多个成熟的Lua热更方案后,Lua作为热更新脚本语言已经成为一时的主流。
虽然今年随着更多热更方案的兴起,部分人认为Lua相关的热更方案已经过时了,但在一些老的项目以及游戏框架万年不变的换皮公司中,Lua仍旧是不二之选。
Lua的基础理论
这里不会从数据类型开始一一介绍,而是会着重讲解一些比较重要以及可能会混淆的知识点。
pairs和ipairs
简单直接的说两者在遍历上的差距在于
- pairs的遍历顺序是随机的,但是一定会遍历整个表;ipairs遍历是从索引1开头按顺序遍历的,中途索引不能断开,否则终止遍历;
- pairs是先按照索引值遍历,然后按照键值对。
接下来通过一个例子来说明差异,这个例子包含3个样例。
1 |
|
其打印结果如下:
1 |
|
首先看第一个样例,orderTable。
pairs和ipars遍历结果一致,都是1,2,3。
这是因为ipairs满足从索引1开始按顺序遍历的条件,而无论跑多少次,你都可以发现两者的结果永远一致。这时候你可能会有疑问,上面不是说pairs的遍历结果是随机的吗?这个我们放在mixTable的时候一起说。
接下来看第二个样例,keyTable。
pairs遍历结果是0,1,3 ,而ipairs遍历结果是1。
ipairs遍历结果因为需要从1开始且出现了索引不连续,因此断开了,只打印了1。
最后看第三个样例,mixTable。
pairs遍历结果是3,hello world,8,4,6,而ipairs遍历结果只有一个3。
这里你可能会局的奇怪,按照上面的理论,总成员明明有8个,而pairs出去nil却只打印了5个成员,而ipars按照顺序明明看起来有3个,却只打印出了一个3,。
结合上面的的共同点,可以发现 mixTable前面的两个元素:[1] = 1 和 [2] = 2 通过这两种方式都没有被打印出来。这又是为什么呢?
我们先拆分一下mixTable里面的组成:
显式键值对形式部分: {
[1] = 1,
[2] = 2,
[4] = 4,
[6] = 6,
[8] = 8,
}
隐式形式部分:{
3,
“hello world”,
nil,
}
再把后者中的元素替换前者中对应冲突的键值对
首先,后者中的元素形式可以表现为:
{
[1] = 3,
[2] = “hello world”,
[3] = nil
}
此时可以明显发现冲突,前者中已有的键值对 [1] 和 [2] 与数组中的冲突了,因此需要进行重新匹配,把前者中的替换为后者中的,最终表的形式可以表示为:
{
[1] = 3,
[2] = “hello world”,
[3] = nil,
[4] = 4,
[6] = 6,
[8] = 8,
}
这才是真正遍历的表。再通过初步的理论,推导出结果并对比遍历的结果,便不难理解了。
最后再来说一下pairs的“随机性”,出现这个问题的原因是因为对于lua中的表来说,其遍历标的次序取决于key的hash值,而lua中很多数据类型的哈希值都是随机的。
如果有需要可以重写pairs迭代器,实现顺序遍历。
1 |
|
其结果为:
1 |
|
注意这里本质上是根据key值进行排序,并不是按照表中定义的顺序,无法处理key值无法对比排序的情况,更无法解决冲突的问题,且效率较低。
loadfile,dofile,require
- loadfile ——只编译,不运行。loadfile只会加载对应路径的文件,编译其代码,但是不会运行文件里的代码。
它会返回一个函数,可以接收后,通过调用并执行里面的代码。 - dofile ——编译并运行。dofile会加载对应路径的文件,编译其代码,并运行文件里的代码。
- require ——加载并运行,之后进行缓存。缓存之后再次require同一文件不会再执行。
rawset和rawget
顾名思义,raw表示原始的,则rawset和rawget表示对“原始的”表进行直接的赋值/取值操作,不会触发元方法。
也就是,通过rawset和rawget可以忽略表对应的metatable,绕过元表的行为约束,强制对原始的表进行操作,不需要考虑元表的简单更新。
格式:
rawset(table, key, value) , 它会绕过 __newindex元方法。
rawget(table, key) , 它会绕过__index元方法。
应用:
一个典型的应用,在__newindex中通过rawset对表进行赋值,可以避免陷入死循环而爆栈。
pcall,xpcall,debug
pcall
Lua中处理错误,可以使用pcall(protected call)来包装需要执行的代码。
类似于C#里面的try-catch提供一种保护机制,当pcall执行的内容发生错误的时候,会返回false,成功时返回true,通过这种形式可以用来捕获函数执行中的任何错误。
格式:
1 | local result , errorMsg = pcall(functionName, arg1, arg2,...) |
这里的errorMsg就是发生的错误信息,它会简单显示问题发生的原因。
xpcall与debug
通常在错误发生时,希望落得更多的调试信息,而不只是发生错误的位置。但pcall返回时,它已经销毁了调用桟的部分内容。这种时候就可以使用xpcall函数。
xpcall接收第二个参数——一个错误处理函数,当错误发生时,Lua会在调用桟展开前调用错误处理函数。
格式:
1 | function myErrorHandler(err) |
通过这种形式,我们能供直接使用自定义的方法处理错误信息。此时,我们还可以在此自定义方法中使用debug库来获取错误的调用栈等额外信息。
debug库提供了两个通用处错误处理函数:
- debug.debug:提供一个Lua提示符,让用户来检查错误的原因
- debug.traceback:根据调用桟来构建一个扩展的错误消息
assert
在 Lua 中,assert 函数用于检查一个条件是否为真。如果条件为真,assert 会继续执行后续代码;如果条件为假,assert 会抛出一个错误并终止程序执行。
格式:
1 | assert(condition, message) |
message 是可选参数,用于自定义错误信息,如果返回为false,则会抛出这个错误信息,默认为:”assertion failed!”。
应用:
- 输入验证:在函数开始时,验证输入参数是否符合预期。
- 调试:在开发过程中,验证某些假设条件是否成立。
- 错误处理:在某些关键步骤中,确保条件满足,否则立即终止程序。
1 | function divide(a, b) |
os.time与os.date
Lua标准库中提供了关于时间的两个方法os.time()与os.date()。
os.time()
os.time()方法可以把一个日期字符串转换为时间戳,它返回一个整数,表示从1970年1月1日8点0分0秒到指定日期的秒数(又名 Unix时间戳)。
- 如果传参为空,则返回当前时间转化为秒数的结果。
- 传参不为空,可以传入指定的时间,将其转化为秒数。传参格式为table,如果传参的话至少要传入年月日才能确定一个具体的时间。如果小于上述指定时间,则秒数为负数,会得到空值。
格式:
1 |
|
注意键值不能写错,必须是year,month,day,hour,min,sec。
os.date()
os.date()方法可以把一个时间戳(Unix时间戳)转换为固定格式的时间,它返回一个字符串,表示指定时间的日期和时间。
格式:
1 | local timeTable = { |
此外,要特别注意的是时区问题。
一般情况下,如果要联调处理时间功能,往往是服务器端发过来一个时间戳,客户端操作后转化为指定的可读样式。
转换之中如果时区不同,则应当进行时区转换。如果有夏令时,则需要额外加上3600秒。
Lua元表
元表(MetaTable)的主要作用是用于改变表的行为,每个行为有其对应的元方法。通过这些元方法的赋值,可以将两个table的对应行为进行关联。
设置和获取元表
1 | -- 对指定table设置元表 |
__index元方法
这是 metatable 最常用的键。也是后面实现面向对象的关键的元方法。
当你通过键来访问 table 的时候,如果这个键没有值,那么Lua就会寻找该table的metatable(假定有metatable)中的__index元方法所指向的table或者方法,从而搜寻对应的值。
格式有两种:
直接赋值一个table:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
myTable = {
name = "Alice",
}
myMetatable = {
__index = { age = 18 },
}
print("----------------设置元表前------------------")
print(myTable.name) -- "Alice"
print(myTable.age) -- nil
setmetatable(myTable, myMetatable)
print("----------------设置元表后------------------")
print(myTable.name) -- "Alice"
print(myTable.age) -- 18这种情况下,如果元表里面的__index指向的是表,而此表还有元表,且实现了__index仍然指向表,则会以此类推查找,直到找到值或者返回nil。
赋值一个函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24myTable = {
name = "Alice",
}
myMetatable = {
__index = function(table, key)
if key == "age" then
return 18
else
return nil
end
end
}
print("----------------设置元表前------------------")
print(myTable.name) -- "Alice"
print(myTable.age) -- nil
setmetatable(myTable, myMetatable)
print("----------------设置元表后------------------")
print(myTable.name) -- "Alice"
print(myTable.age) -- 18
Lua 查找一个表元素时的规则,其实就是如下 3 个步骤:
- 在表中查找,如果找到,返回该元素,找不到则继续
- 判断该表是否有元表,如果没有元表,返回 nil,有元表则继续。
- 判断元表有没有 __index 方法,如果 __index 方法为 nil,则返回 nil;如果 __index 方法是一个表,则重复 1、2、3;如果 __index 方法是一个函数,则返回该函数的返回值。
__newindex元方法
当你给 table 中的某个键赋值的时候,如果这个键没有值,那么Lua就会寻找该table的metatable(假定有metatable)中的__newindex元方法所指向的table或者函数,从而设置对应的值。
格式同__index元方法。
- 赋值为table
1 | mymetatable = {} |
- 赋值为函数
1 |
|
这里的赋值不应当直接赋值,而应当使用rawset,上面已经说过,这里不再赘述。
表操作符
元方法 | 对应的运算符 |
---|---|
__add | + |
__sub | - |
__mul | * |
__div | / |
__mod | % |
__unm | - |
__concat | … |
__eq | == |
__lt | < |
__le | <= |
__call元方法
__call 元方法在 Lua 把一个表当作函数调用时,会调用该元方法。支持自定义传参。
其赋值就是一个函数。
格式:
1 | local mytable = { 1, 2, 3 } |
输出结果为:
1 | 1 |
_tostring元方法
__tostring 元方法用于修改表的输出行为。也就是print(table)时,会调用该元方法,其指向一个函数,有着对应的返回值。
如果不实现这个元方法的话,默认的输出行为是table的地址。
格式:
1 | mytable = { 1, 2, 3 } |
Lua实现面向对象
众所周知面向对象的三大特性,继承,多态,封装。
继承
继承的实现主要是通过元表的__index元方法来实现。因为继承里面一个很重要的性质就是派生类可以直接使用基类里面的方法和属性,从而减少代码的复用。
单继承
1 | -- 基类Base |
多继承
不错,Lua是可以实现多继承的。在类的基础上实现多继承的方式中__index指向的就不再是单一的table了,而是一个函数。
1 |
|
Lua重要知识点
这个部分的每个知识点都能拓展很多,这里挑一些要点简要说明,把一些以重要且易混淆的地方呈现出来,对此更深层的实现原理和进一步的拓展感兴趣的可以在参考文档中找到对应的模块自行查阅。
require加载机制
模块
模块类似于一个封装库,从 Lua 5.1 开始,Lua 加入了标准的模块管理机制,可以把一些公用的代码放在一个文件里,以 API 接口的形式在其他地方调用,有利于代码的重用和降低代码耦合度。
Lua 的模块是由变量、函数等已知元素组成的 table,因此创建一个模块很简单,就是创建一个 table,然后把需要导出的常量、函数放入其中,最后返回这个 table 就行。
在实际使用lua的开发中,无论是管理器,功能通用功能类,已经mvc各层都是通过模块的形式去进行封装的。必要时,其他地方可以通过require进行引入。
require加载策略的优化
lua中require的加载,它本身其实是具有懒加载的特性的,当一个模块被require之后,会缓存到package.loaded里面,它会一直保存在内存中,直到显式地将其从package.loaded里面移除。
这里要引入两个情况 循环加载 和 冗余占用
- 循环引用。在Lua中,模块之间的循环引用是指两个或多个模块相互require,会形成一个闭环。
- 冗余占用。在Lua中,在声明引入了一个模块后,可能根据功能使用情况,并不会用到这个模块的功能,这就照成了没有必要的消耗。
Lua自身的处理机制:
针对循环加载,Lua的模块加载机制是可以处理这种情况的。当一个模块处于加载中,尚未加载完成的时候,会设置一个Flag标识符,来表示当前模块尚未加载完成。
因此,即使模块A中包含了加载模块B的代码,而模块B中又有加载模块A的代码。当我们加载A时,A会加载模块B,而B中则会开始加载模块A,但此时得到了模块A还没加载完的标识,便不再继续,从而避免了循环引用。
而针对冗余占用,有些可能是程序在开发中不规范导致的;而有些则是没有办法,因为有些功能分支本来就是需要应对可能需要用到的情况。
在这样情况下,我们还能进一步进行优化。
1 | ---懒加载自定义,我们可以在初始化定义的时候定义一下 |
Lua的懒加载模块:
1 |
|
从这里我们看到,加了一层tableLazyRequire来进行缓存,也就是说在初步定义的时候并没有去真正加载,返回的也并不是对应的模块,而是指向对应模块的缓存表;当这个模块的功能真正被使用的时候,再去真正的加载对应的模块,然后置空其在tableLazyRequire里面的缓存。
以此便解决了循环引用和冗余占用的问题。
Lua的浅拷贝与深拷贝
浅拷贝
浅拷贝发生在直接使用 赋值运算符”=”的时候。
对于基本类型,会直接进行复制,创建出一个新的对象,两者互不影响。这里的基本类型也就是Lua中除了table以外的类型。
对于table类型,则是进行引用,只是增加了一个指针指向已经存在的内存地址,如果原地址内容发生变化,则浅拷贝出来的对象也会发生变化。
1 |
|
深拷贝
深拷贝主要是应用于table类型。它会增加一个新的指针并申请新的内存,把原来table中的所有值拷贝一份到新内存中,并让这个新增的指针指向这个新开的内存,从而实现深拷贝。
1 | function DeepCopy(object) |
userdata
full userdata 表示一个原始的内存块,可以存储任何东西,它是一个类似于table的object,必须事先创建(也可以被垃圾收集器回收),它也有自己的metatable,它只等于其自身。
可以为每种full userdata 创建一个唯一的元表,来辨别不同类型的userdata,每当创建了一个userdata后,就用相应的元表(放在Registry中)来标记它,而每得到一个userdata后,就检查它是否拥有正确的元表。
Lua在释放full userdata所关联的内存时,若发现userdata对应的元表还有__gc元方法,则会调用这个方法,并以userdata自身作为参数传入。利用该特性,可以再回收userdata的同时,释放与此userdata相关联的资源。
轻量级userdata是一种表示C指针的值(即void*),要将一个轻量级userdata放入栈中,只需要调用lua_pushlightuserdata即可。轻量级userdata只是一个指针而已。它没有元表,就像数字一样,轻量级userdata无须受垃圾收集器的管理。
Lua在释放完全userdata所关联的内存时,若发现userdata对应的元表还有__gc元方法,则会调用这个方法,并以userdata自身作为参数传入。利用该特性,可以再回收userdata的同时,释放与此userdata相关联的资源。
它在和其他语言交互时候,起到了很关键的作用,这个到时候可以细讲。
闭包(closure)与upvalue
闭包(closure)指的是在运行期间,任何时候只要Lua执行了一个function…end表达式,就会创建于一个新的数据对象,这个数据对象,就是闭包。
每个闭包由两部分组成,分别是对其函数的引用,对一个元素是对upvalue引用的数组的引用。在5.2版本之前,其实还包含着一个对环境的引用。
其最大的特征在于包含了成员对upvalue引用的数组的引用,如果没有这一点的话,闭包的行为更像是一个普通的函数引用,因为它没有捕获任何额外的局部环境。这也是函数和闭包的区别,有时候我们获取或者定义函数的时候,如果其包含了对upvalue的引用则严格意义上讲,它表示的其实不是函数,而是闭包。要说明这一点首先要说一下Lua函数的性质。
Lua函数的性质
函数其实是算作第一类型值(First-Class Value),这种类型具有特定的词法作用域(Lexical Scoping)。前者意味着函数能像数组,字符串,表那样被操作(传参,赋值,运行时创建),而我们讨论函数的时候,实际上讨论的是指向对应函数的变量。后者则表示函数在其定义的时候可以访问其定义时所在环境中的变量,这种作用域是静态的,即在编译时确定,而不是运行时,这使得函数能够记住其特定的上下文,包括局部变量。
这也说明了,函数其实是编译时期的概念,是静态的;而闭包是运行时的概念,是动态的。
Upvalue
闭包中,对于任何外层局部变量的存取都是间接通过upvalue来进行。upvalue最初指向栈中变量活跃的地方,当其离开变量作用域(超过变量生存期)的时候,此变量会被复制到upvalue中。注意,这里的“复制”指的是upvalue的指针会指向对应的变量。
通过为每个变量创建一个upValue并按照需要重复利用这个upvalue,保证了未决状态(未超过生命周期)的局部变量都能够在闭包之前正确地共享,没错,这里说的是共享。
结构上,Lua会使用并维护一条链表,该链表的每一个节点都会对应一个打开(open)的upvalue,这里的打开指的是当前正指向栈局部变量的upvalue。
流程中,当Lua创建一个新的闭包,Lua会遍历当前函数所有的外部局部变量,对于每一个外部的局部变量,若在上面的链表中能找到该变量,则会重复使用对应打开的upvalue,这也是能共享的原因;如果不能找到,则会创建一个新的打开的upvalue,并把它插入链表中。当局部变量离开作用域的时候,这个的upvalue就会变成关闭状态(closed upvalue),并会被移除链表,此时闭包中对应的数组还是引用着对应的upvalue的,这也是离开作用域后,我们任然能获取的原因。一旦某个关闭的upvalue不再被任何闭包引用,那么它的存储空间就会被回收。
特殊情况
一个函数可能存取其更外层函数而非直接外层函数的局部变量。在这种情况下,当创建闭包的时候,这个局部变量便可能不在栈中(离开了作用域),也就是进入了close状态,只收到更外层函数闭包里数组的引用,从而导致无法获取。
Lua使用flat闭包(flat closures)来处理这种情况,其核心思想就是将所有被捕获的外部局部变量都存储在闭包的直接外层函数的闭包中,即使这些变量位于更外层的函数。flat闭包的时候,无论何时一个函数访问一个外部的局部变量并且该变量不再在直接外部函数中,该变量也会进入直接外部函数的闭包中。
简结
我们可以通过一个例子来说明上面的各种情况:
1 | function Test(n) |
通过这个例子,我们可以发现之前知识点的应用:
- 首先作为第一类型值,函数可以作为参数传递,也可以作为返回值返回。上面的test,foo的返回值都是函数,而foo,inner1,inner2则被当做返回值。
- 分析 Test 函数结构我们能得知。n作为传参传入Test函数中,foo函数的直接外部变量只有n,而inner1和inner2作为foo函数的内部变量,都没有直接外部变量,但其更外层有一个n,且两个函数中都有对于n的使用。
- 在执行Test的过程中,Lua链表中会创建并打开一个upvalue,让其指向n,此时此upvalue的状态为open,它最先被Test闭包引用;在执行foo函数中的inner1和inner2时候,这两个闭包在创建时堆栈上无法找到n的踪影,则会以flat闭包的形式来处理这种情况,直接使用闭包foo的upvalue来引用n。而执行 t = Test(2015) 之后,t这个闭包已经把n拓展保管好了,之后f1和f2用n的时候,就从外层闭包的upvalue中去找,直到找到对应引用并拷贝到自己的upvalue引用数组中,也正是因为如此,f1,f2,g1,g2所引用的upvalue其实都是指向的同一个变量。
- t2 = Test(2001)的过程是创建一个新的闭包和原来的t闭包没有关系,因此其执行和结果自然也不相干。
table的底层实现
在上面pairs和ipairs里面提了一嘴,Lua中table的存储主要是由两部分组成,数组和哈希表,对于数组的下表是从1开始;而对于哈希表而言,只要非整数键和超过数组范围n的整数键对应的值将被存入哈希表部分。
查找算法的实现
当我们往table中传入key值,尝试获取其值的时候,就会触发其查找流程。
如果当前key为整数且key大于等于0且小于等于数组的大小,则会首先从数组部分进行查找,否则就会从哈希表部分进行查找。后者会计算出该key值的哈希值,然后遍历哈希表找到对应的值。
举个简单的例子:
1 | t = {}, |
根据上面的规则,我们可以清楚明白的确定,key为1是从数组里面进行查找,key为100则是从哈希表中查找(因为1小于数组的大小,100明显大于数组的大小)。
新增元素的实现原理
给Lua中添加新元素的时候,对于key值超过数组大小,都会存储于哈希表部分,从而引发哈希表的rehash。
rehash过程中,会对table重新规划hash和数组部分的大小。首先会清空数组,然后计算数组部分的大小和范围,再计算哈希表中key的数量和范围,最后进行重新分配。
上面过程中两部分值得注意的点是:
- 新的数组部分大小是满足以下条件的最大n值:1到n之间至少一半的空间会被利用,这是为了避免像稀疏数组一样浪费空间。并且n/2 + 1到n之间的空间需要至少有一个被利用,这是避免n/2个个空间能容纳所有数组的时候申请n个空间造成浪费。
- 哈希部分则会采用闭散列的算法,它会把有冲突的key存于空闲槽位,从而避免了额外分配内存。
举个简单的例子
1 | t = {} |
根据上面的规则我们就可以得到其分配情况:
1 | array_part = { |
此外,还有一个重点,是关于表中隐式key值数组成员,这一点在遍历关键字的内容中已经初步说过了,这里为了方便说明,我们再用一下那个例子:
1 |
|
上面也讲过,此表真正的形式可以表示为:
1 | { |
这个表根据结构可以得知,它是完全存于数组部分的,没有哈希部分的内容。然而,对于原来的 [1] = 1, [2] = 2 是和隐式的 3 和 “hello world ” 冲突,受到了替换。这是因为在键值对定义和隐式数组成员的情况下,Lua会优先考虑数组部分的定义,以这种形式来解决掉冲突。
结构设计的优点
- 存取整数数组部分的键值很快,因为无需计算哈希值。
- 操作简单,容易上手。无需开发者手动干预,Lua则会自动根据键值特性进行处理。
- 灵活性强。当表只需要被当做数组使用的时候,它就具备数组的性能,且无需承担哈希表部分的开销,反之亦然。
Lua编译器,解释器和虚拟机
为了达到较高的执行效率,Lua代码并不是直接被Lua解释器解释执行,而是会先编译为字节码,然后再交给Lua虚拟机去执行,这是一个将高级语言转化为低级指令并执行的过程。
Lua编译器
Lua编译器的主要任务是将Lua源代码(就是纯文本文件)转换为字节码或者某种中间表示形式。编译器再编译的过程中会进行词法分析,语法分析,语义分析等,最终生成可以执行的字节码。这些字节码不是直接由机器执行的机器码,而是由Lua虚拟机解释执行的指令集。
Lua代码称为chunk,编译成的字节码则称为二进制chunk(Binary chunk)。编译器以函数为单位对源代码进行编译,每个函数会被编译成一个称之为原型(Prototype)的结构。
在整个将高级语言转化为低级指令的过程中,目的是使得源代码能够被虚拟机理解和执行。编译器在这里为源代码和虚拟机之间进行连接打开了通道。
Lua解释器
Lua解释器则负责执行由编译器生成的字节码。解释器会逐条读取字节码,并将其转换为虚拟机可以执行的指令。 解释器与虚拟机紧密配合,确保字节码的正确执行。
Lua虚拟机
虚拟机相当于物理机,通过借助于操作系统对物理机器(CPU等硬件)的一种模拟、抽象,主要扮演CPU和内存的作用。
它主要是是用来执行解释器传递的字节码中的指令,管理全局状态(global_state)、数据栈(StackValue)和函数调用链状态(CallInfo)。
当解释器读取并解释字节码时,它会调用虚拟机的指令来执行相应的操作。虚拟机的存在使得Lua代码可以在不同的操作系统和硬件平台上运行,而无需进行大量的修改。
此外,虚拟机可以分为基于寄存器VM(Register Base 和栈VM两种)和栈VM(Stack Base VM)两种。前者包括操作码和操作数,指令只有一条;后者只有操作码,操作数需要从栈里面获取,处理完再压入栈。中。也正是因此,前者可以减少出入栈的数据拷贝操作和生成指令,虽说其单条指令长度较长,但其效率还是相对高些。
gc方式与原理
gc也就是垃圾回收机制,是指自动释放不再使用的内存,以节省内存开销的过程。GC 的性能表现对整个系统的性能表现影响重大。Go 语言早期就是因为 GC 问题而饱受诟病。如果我们把 GC 关闭,那么 CPU 就完全没有额外开销,但是会有极大的内存开销;如果我们每次分配新对象都运行一遍 GC ,那么就不会有任何额外的内存开销,但是 CPU 开销会完全不可接受。
在 Lua 5.0 以前,Lua 使用的是一个非常简单的标记扫描算法。它从根集开始遍历对象,把能遍历到的对象标记为活对象;然后再遍历通过分配器分配出来的对象全集链表,把没有标记为活对象的其它对象都删除。
而Lua 5.0 支持 userdata ,它可以有 __gc 方法,当 userdata 被回收时,会调用这个方法。所以,一遍标记是不够的,不能简单的把死掉的 userdata 简单剔除,那样就无法正确的调用 __gc 了。所以标记流程需要分两个阶段做,第一阶段把包括 userdata 在内的死对象剔除出去,然后在死对象中找回有 __gc 方法的,对它们再做一次标记复活相关的对象,这样才能保证 userdata 的 __gc 可以正确运行。执行完 __gc 的 userdata 最终会在下一轮 gc 中释放(如果没有在 __gc 中复活)。 userdata 有一个单向标记,标记 __gc 方法是否有运行过,这可以保证 userdata 的 __gc 只会执行一次,即使在 __gc 中复活(重新被根集引用),也不会再次分离出来反复运行 finalizer 。也就是说,运行过 finalizer 的 userdata 就永久变成了一个没有 finalizer 的 userdata 了。
针对CPU和内存开销的取舍情况,Lua 5.0 采用的是一个折中的方案:每当内存分配总量超过上次 GC 后的两倍,就跑一遍新的 GC 流程。
从 Lua 5.1 开始,Lua 实现了一个步进式垃圾收集器。这个新的垃圾收集器会在虚拟机的正常指令逻辑间交错分布运行,尽量把每步的执行时间减到合理的范围。
而此版本采用了一种叫做三色标记的算法。每个对象都有三个状态:
- 白色。无法被访问到的对象是白色
- 灰色。可访问到,但没有递归扫描完全的对象
- 黑色。完全扫描过的对象
我们可以假定在任何时间点,下列条件始终成立:
- 所有被根集引用的对象要么是黑色,要么是灰色的。
- 黑色的对象不可能指向白色的。
- 白色对象集就是日后会被回收的部分;黑色对象集就是需要保留的部分;灰色对象集是黑色集和白色集的边界。
随着收集器的运作,通过充分遍历灰色对象,就可以把它们转变为黑色对象,从而扩大黑色集。一旦所有灰色对象消失,收集过程也就完成了。
步进式 GC 比全量 GC 复杂,不能再只用一个量来控制 GC 的工作时间。对于全量 GC ,我们能调节的是 GC 的发生时机,对于 lua 5.0 ,就是 2 倍上次 GC 后的内存使用量;在 5.1 以后的版本中,这个 2 倍可以由 LUA_GCSETPAUSE 调节。另外增加了 LUA_GCSETSTEPMUL 来控制 GC 推进的速度,默认是 2 ,也就是新增内存速度的两倍。lua 用扫描内存的字节数和清理内存的字节数作为衡量工作进度的标准,有在使用的内存需要标记,没在使用的内存需要清理,GC 一个循环大约就需要处理所有已分配的内存数量的总量的工作。这里 2 是个经验数字,通常可以保证内存清理流程可以跑的比新增要快。
在Lua5.2中,曾经引入分代gc,以一个试验特性提供,后来因为没有收到太多正面反馈,又在Lua5.3中移除。事实上Lua5.2提供的分代GC过于简单,的确有设计问题,未能很好的解决问题,在还没有发布的Lua5.4中,分代GC被重新设计实现。
Lua热更新(热重载)
这里的热更新指的是游戏运行中Lua虚拟机启动后,对代码逻辑修改后直接生效,不需重启游戏,常用于开发过程中调试,不是指游戏启动时的版本更新。
Lua侧热更新的主要用途在本人这里主要是用于方便重写Lua呈逻辑后,不重启编辑器就可以看到效果,方便快速验证代码和确认效果。比如在制作动效,调试功能BUG等相关场景的时候能够提高效率。但在使用中,upvalue的丢失和框架模块引用嵌套层太多需要重新确定引用过于繁琐,也确定了目前这方面的应用目前只能写作一个小工具便捷开发。
回归上面在讲require的时候,我们说到,require加载一个模块(lua文件)后,会存放到package.loaded中,如果再次require这个模块,就会直接从package.loaded中取出,而不会再次冗余加载。
package.loaded本身就是一个Table,其主要包含了:
- _G全局大G表。
- 默认加载的模块(string,debug,package,io,os,table,math,coroutine)
- 用户加载的模块。
根据这种情况,可以直接得到一个简单暴力的重载方式:将package.loaded中对应的模块删除,然后再require,就能实现模块的初步更新;之后只需要把其他引用了旧模块的地方进行更新,就可以实现一个简单的热更新效果。
这里
1 | function reload_module(module_name) |
通过上面的写法可以实现初步的跟新,但实际上还需要把对应引用的地方也进行更新才行。
总结
本篇文章就是这样,希望能够对你有所帮助,当然如果你发现有任何错误的地方,欢迎指出。
话说回来,随着游戏行业的发展以及越来越多热更技术的兴起和成熟,Lua仿佛确实越来越过时了,就连 xLua 作者、司内大佬 johnche 也从 Lua 转向 JavaScript/TypeScript,开发了新一代热更新框架 PuerTS。而如果不是其历史包袱,IOS平台限制反射等因素,也许Lua热更新方案的使用趋势可能会更糟糕一些。
但无论是从一开始质疑和细数lua的“十宗罪”,到Lua热更方案成为一时主流,再到一部分人高呼“Lua已经过时了”。
但Lua本身始终也只是发挥着其能发挥的作用的,正如codedump在《Lua的设计与实现》中所说的,Lua专注做一个配角,作为胶水语言来辅助像是C,C++这样的主角来更好地完成工作,当其他语言在功前面攻城拔寨的时候,它在后方完成自己辅助的作用,Lua始终恪守本分地做好自己胶水语言的本职工作,可谓“上善若水,水善万物而不争”。
最后,新年快到了,祝大家新年快乐,工作和学习的同时,也要身体健健康康的。