Wax是一个不怎么热门的App脚本化的框架,基于Lua引擎与Objc运行时特性来实现。苹果对JSPatch着力打压,而刚好我之前有过Wax相关的实践,正好可以介绍下。Wax相关内容主要包括(一)Lua语言基础(二)Wax的实现原理。
本文主要介绍Lua的一些基础概念与入门知识。
需要说明的是,Wax是基于Lua的v5.1版本的(Wax第一次提交是在2009年6月),v5.2是在2011年末发布,C API有变动,且不兼容。本文中主要基于v5.1的manual对与wax框架相关的内容进行介绍。
写在前面
Lua是一个开源的脚本语言,官网是www.lua.org。但除了语言作者的Lua实现之外,还有LuaJIT这样的非官方实现。我们讨论的是Lua官网的标准实现。
Lua跟JavaScript有着很多相似的地方。比如JavaScript的prototype对应Lua的元表、元方法,还有变量对象与执行环境。当然Lua其实更早出现,v1.0在1993年发布(公众版本是’94年v1.1),一直至今遵循其简洁高效的设计目标。且作为一门TIOBE前30、核心库只有100KB(v5.1),且广泛应用于游戏编程、嵌入脚本的语言,也是非常值得学习的。
Lua语法简单且有强大的数据描述结构。相比而言,JavaScript要复杂很多——至少看上去specification也是多很多。Lua是动态类型的,通过一个基于寄存器的虚拟机来解析字节码,并且是支持递进式垃圾回收的自动内存管理;被实现为一个用纯C编写的代码库(Lua核心,其他都是基于这些库来开发)。
Lua通常是作为一个拓展语言被嵌入到宿主程序中,宿主程序可以调用Lua函数、读写Lua变量以及注册C函数提供给Lua调用。Lua核心库的C-API不多,能方便的被嵌入主程序提供脚本化支持,这也是Lua的设计目标及优点所在。
需要说明的是,Lua编程其实涉及两方面:除了Lua脚本编程,还有与宿主程序的对接(基于Lua的C API编程)。像Wax这样的框架,主要是处理与宿主程序的对接,也就是Objc与Lua的互调与数据通信。
学习线路
对于希望了解Lua的童鞋,建议根据需要选择学习路线:
- 入门了解:可以参考Coolshell的“简明Lua教程”;
- Lua语法与C API:“Programming in Lua”(简称PIL,Lua作者编写),主要介绍语法及Lua标准库、C接口API等细节;
- Lua的语言设计:语言手册,manual,是Lua的语言规范,可以理解为原理介绍;
- Lua5.0的实现:作者的语言设计论文。
学习Lua脚本编程的话1、2就够了。
Lua基本概念
基本数据类型
Lua中有8中基本类型:nil
, boolean
, number
, string
, function
, userdata
, thread
, 以及 table
。Lua是动态类型语言,这意味着变量没有类型,值才有类型。所有的值都是一等公民(first-class values),也就是包括function
、thread
等在内的所有类型值都可作为参数或返回值。
nil
是一个值,表示一个有意义的值不存在时的状态。这个得提一下作者关于值的数据结构设计:
1 | typedef struct { |
称之为tagged-union
,打了标签的联合体,(t, v)
标签-值对。
在Lua的实现中,用t一个标志位即可表示nil
值。具体的值则通过Value
这个联合体表征,可以是由GC管理的对象GCObject
*gc、C指针p
表示的轻量用户数据、lua_Number
表示double
类型(number
)、整型b
表示boolean
。GCObject
可表示string
、table
、function
、heavy userdata
及thread
。
string
是有明确大小的字节数组,因此可以包含任意二进制数据,包括”\0”。
userdata
(通常译为”用户数据“,若非特别指明,指的是”完全用户数据“)类型允许将 C 中的数据保存在 Lua 变量中。 用户数据类型的值是一个内存块。有两种用户数据:完全用户数据,指一块由 Lua 管理的内存对应的对象;轻量用户数据,则指一个简单的 C 指针,内存由宿主程序负责管理。用户数据在 Lua 中除了赋值与相等性判断之外没有其他预定义的操作。 通过使用 元表 (元表在下文介绍),程序员可以给完全用户数据定义一系列的操作。 你只能通过 C API 而无法在 Lua 代码中创建或者修改用户数据的值,这保证了数据仅被宿主程序所控制。wax有使用userdata
将Objc中的值传递给Lua中使用。
table
类型是Lua中主要的数据结构(通常译为”表“,下文中如无歧义”表“指代table
类型的值),也是Lua脚本中唯一的可自定义的数据结构。table
在lua中被实现为关联数组,它的索引可以是任意非nil值,值可以是任意值,table
的大小动态增减。在Lua5.0之前table被实现为哈希表,v5.0中则针对数组做了优化:由哈希及数组组成,数组可以减少key的存储,且访问更直接,参考图:
thread
则代表协程,协程是一种并发编程方式,高大上的内容Wax框架中没有涉及,不做介绍。
function
表示Lua中的方法或者遵循Lua虚拟机交互接口的C函数(因为可以注册C函数到Lua中使用)。
table
、function
、thread
以及userdata
完全用户数据在 Lua 中被称为对象: 变量并不持有这些对象的值(value),而仅保存了对这些对象的引用(reference)。 赋值、参数传递、函数返回,都是针对引用而不是针对值的操作, 不会做任何形式的拷贝。
使用type
库函数可以返回一个string
格式的值类型的描述。
关键词
主要是以下这些:
1 | and, break, do, else, elseif, end, false, for, function, if, in, |
真的是很少的了!
错误处理
作为嵌入式语言,Lua中所有行为始于宿主程序中C代码对Lua库函数的调用,因此它也将错误处理的主导权交给宿主程序。
在Lua中可以使用error
来显式抛出错误,若希望在Lua中捕获错误,则使用pcall
或xpcall
在保护模式下调用函数。出现错误时,会抛出一个错误对象。使用 xpcall
或 lua_pcall
时, 需提供一个 消息处理函数用于错误抛出时调用。该函数需接收原始的错误消息,并返回一个新的错误消息。它在错误发生后栈尚未展开时调用, 因此可以利用栈来收集更多的信息, 比如通过探知栈来创建一组栈回溯信息。
若需在C中捕捉异常,使用lua_atpanic
指定一个回调方法。
垃圾回收
Lua自动内存管理,运行GC(Garbage Collector,垃圾收集器)来回收内存,所有由Lua使用的内存都遵循自动内存管理,包含table
、function
、string
等。使用两个参数来控制垃圾收集循环:垃圾收集间歇率及步进率,具体参数意义可参考manual中说明。可以通过在 C 中调用 lua_gc
或在 Lua 中调用 collectgarbage
来改变参数。主要介绍垃圾回收元方法及弱表,这在wax中都有使用。
垃圾回收元方法
对于userdata
用户数据类型,可以通过C API来设置垃圾回收器元方法,这些元方法也被称之为“终结器(finalizer)“。”终结器“允许你来协调Lua的GC与外部的资源管理(如文件管理、网络、数据库连接或内存释放等)。
对于元表中包含__gc
元方法的userdata
用户数据类型,GC不做立即回收,而是将userdata
放到一个列表中,等当前回收循环接收后,以LIFO的方式,分别调用其__gc
垃圾回收元方法,参数为userdata
,此步骤中交由宿主程序处理其内存释放问题,而userdata
用户数据类型值本身将在下一轮垃圾回收循环中被释放。
弱表
弱表指的是内部元素是弱引用的表。GC会忽略弱引用的对象,除非该对象只被弱引用。
弱表可以有弱键、弱值或键值都是弱引用。弱键的表允许GC回收它的键而阻止回收它的值;键值都是弱引用的表则允许GC回收其键值。在任何情况下,只要键或值被回收,该键值对也会从表中移除。
表的键值弱属性,由其元表的__mode
域进行控制,弱键或弱值分别对应k
或v
。在将一个表指定为元表后,其中的__mode
域就不应再修改,否则由这张元表控制的表的弱属性行为是未定义的。
语法规则
主要提供完整的扩展BNF范式描述的语法规则(v5.3)。BNF中,{}表示0或多个,[]表示可选,|表示或者,:=表示定义。详细看PIL介绍,不展开介绍语法规则了。对于一些关键的概念则在下文中介绍。
chunk ::= block
block ::= {stat} [retstat]
stat ::= ‘;’ |
varlist ‘=’ explist |
functioncall |
label |
break |
goto Name |
do block end |
while exp do block end |
repeat block until exp |
if exp then block {elseif exp then block} [else block] end |
for Name ‘=’ exp ‘,’ exp [‘,’ exp] do block end |
for namelist in explist do block end |
function funcname funcbody |
local function Name funcbody |
local namelist [‘=’ explist]
retstat ::= return [explist] [‘;’]
label ::= ‘::’ Name ‘::’
funcname ::= Name {‘.’ Name} [‘:’ Name]
varlist ::= var {‘,’ var}
var ::= Name | prefixexp ‘[’ exp ‘]’ | prefixexp ‘.’ Name
namelist ::= Name {‘,’ Name}
explist ::= exp {‘,’ exp}
exp ::= nil | false | true | Numeral | LiteralString | ‘...’ | functiondef |
prefixexp | tableconstructor | exp binop exp | unop exp
prefixexp ::= var | functioncall | ‘(’ exp ‘)’
functioncall ::= prefixexp args | prefixexp ‘:’ Name args
args ::= ‘(’ [explist] ‘)’ | tableconstructor | LiteralString
functiondef ::= function funcbody
funcbody ::= ‘(’ [parlist] ‘)’ block end
parlist ::= namelist [‘,’ ‘...’] | ‘...’
tableconstructor ::= ‘{’ [fieldlist] ‘}’
fieldlist ::= field {fieldsep field} [fieldsep]
field ::= ‘[’ exp ‘]’ ‘=’ exp | Name ‘=’ exp | exp
fieldsep ::= ‘,’ | ‘;’
binop ::= ‘+’ | ‘-’ | ‘*’ | ‘/’ | ‘//’ | ‘^’ | ‘%’ |
‘&’ | ‘~’ | ‘|’ | ‘>>’ | ‘<<’ | ‘..’ |
‘<’ | ‘<=’ | ‘>’ | ‘>=’ | ‘==’ | ‘~=’ |
and | or
unop ::= ‘-’ | not | ‘#’ | ‘~’
元表及元方法
Lua中的每一个值都可以有一个元表(metatable),这个元表是一个普通的Lua表,其中定义了原始值在特定操作下的行为。可以通过修改元表的相应的域,来改变这些行为。例如,当一个非数值的值作为加法操作的操作数时,Lua会检查其元表中对应”__add“域的方法,若存在则调用该方法来执行加法操作。
元表中的这些键对应被称之为事件,对应的值则称之为元方法(metamethod)。在上面的例子中,”add“是事件而元方法则是执行加法操作的函数(function)。在Lua中可以通过getmetatable
方法来获取任何值的元表;对于table
类型的值可以通过setmetatable
方法来替换其元表,但对于其他类型除非使用debug库不能修改其元表,如要改则需要调用C类型API。gc
也是一个特定的事件,其对应的元方法用于配合GC进行内存处理等。
table
及userdata
(完全用户数据)类型的值可以有独立的元表(当然也可以多个table或userdata共享同一张元表),而其他类型的值每种类型共享一个元表。
元表中以”前缀+事件名“作为对应的键,用来存放对应事件操作的元方法。如对应”add“操作(事件)的key为”__add“。元方法的访问不会触发另外的元方法,访问一个没有元表的对象的元方法不会失败(仅返回nil)。在Lua或C中可以通过rawset
或rawget
的方式,在不触发访问元表的情况下进行访问。
有诸多内置的操作和元方法,如算术操作add
等、比较操作eq
等、索引index
、索引赋值newindex
以及函数调用call
等。如果需要比较的话,大概相当于操作符重载的概念。具体可查看manual,这里主要介绍比较重要的三个:
index
index
操作主要是对值进行索引(或者称为subscript),对table
类型相当于调用 table[key],也就是一个查找操作。 当 table 不是表或是表 table 中不存在 key 这个键时,index
被触发。 此时,会调用__index
相应的元方法,可以是一个函数也可以是一张表。
如果是一个函数,则以 table 和 key 作为参数调用它。 如果它是一张表,最终的结果就是以 key 取索引这张表的结果。 (这个索引过程是走常规的流程,而不是直接索引, 所以这次索引有可能引发另一次元方法。)
需要先指出的是,通过C API可以向Lua注册函数,因此同样可以在C中注册__index
这些域对应的函数,在宿主程序中接收相应的事件并将处理结果回传到Lua。这也是Wax中所使用到的,用来索引Objc类及创建对象实例。
newindex
newindex
操作对值相应的域进行赋值,相当于 table[key] = value,赋值操作。和索引事件类似,当table 不是表或是表 table 中不存在 key 这个键的时候,会触发newindex
事件,调用__newindex
元方法(若存在的话)。
call
call
操作是函数调用, 当 Lua 尝试调用一个非函数的值的时候会触发这个事件 (即 func 不是一个函数)。 查找 func 的元方法, 如果找得到,就调用这个元方法, func 作为第一个参数传入,原来调用的参数(args)后依次排在后面。
Lua的元表跟JavaScript的prototype有几分相像,在JavaScript中可以通过prototype原型链支持面向对象等特性,其实在Lua中同样可以利用元表及执行环境来实现。
执行环境
除了元表,thread
、function
、usertable
这些类型的对象还绑定了另外一张表,称之为其执行环境(environment)。与元表类似,执行环境也是一张普通的table
类型的表,多个对象也可以共享同一个表。
对thread
类型,协程创建时,得到的thread
对象与创建时的协程共享执行环境。
userdata
类型及C函数与其创建函数共享执行环境。非嵌套Lua函数(由loadfile
、loadstring
、load
创建)于创建时的协程共享执行环境。内嵌的Lua函数与其创建时的Lua函数共享执行环境。userdata
绑定的执行环境对Lua是无意义的。
Lua函数绑定的执行环境被用于访问函数中的全局变量,也作为其内部创建的内嵌Lua函数的默认执行环境。
可以通过调用setfenv
来改变Lua函数或者当前运行协程的执行环境,同理通过getfenv
来获取其执行环境。对于其他类型的对象(userdata
、C函数及其他协程),必须通过C的API来操作修改执行环境。
另外,Lua 中含有一个被称为全局环境的表,它被保存在 C 注册表(后面还有介绍)的一个特别索引LUA_RIDX_GLOBALS
下。 在 Lua 中,全局变量 _G
被初始化为这个值。
但是,执行环境表有什么用,有什么必要修改?先来看一个实际的例子:
1 | -- Security.lua |
1 | -- iPhone.lua |
Security:verify(pwd)
以及self:checkPwd(pwd)
这些方法调用为什么能正常?明明verify
并不是Security
这个值的一个域,它仅是模块中定义的一个全局function变量吧?实际上,这个正是通过替换执行环境来实现的。(注:function
及self
的说明见后文)
因此,执行环境可以简单理解为包含其可访问全局变量的表。下例中会报错“attempt to call global ‘print’ (a nil value)”,因为当前执行环境被替换一张空表,并没有print
的域。
1 | -- 定义全局变量a |
通过setfenv
可以指定函数的执行环境,第一个参数1表示当前执行环境,2表示上层调用方的执行环境,以此类推。Wax实现的正是将waxClass定义类所在的模块中的所有function的执行环境指定为对应class的userdata相关表了。
另外,还需要指出的是,新版本的Lua已经废弃了setfenv
的接口,而是采用了直接设置_ENV
的方式来指定执行环境。
脚本加载及require
package
库提供了Lua中加载与编译模块的基本工具,导出了两个方法require
及module
到全局环境。
require
require (modname)
用来加载模块代码,首先会查找缓存表package.loaded
,若modname模块被加载过,require
返回 package.loaded[modname]
中保存的值,否则尝试为该模块查找加载器 。
加载器的查找,require
是由package.loaders
数组来引导的,默认配置的查找如下:
首先 require
查找 package.preload[modname]
。 如果存在一个值,那它(应当是一个函数)就是那个加载器。 否则 require
使用package.path
配置的路径来查找Lua加载器。 如果也查找失败了,则使用package.cpath
配置的路径去查找一个C加载器。 如果都失败了,就是用默认的all-in-one加载器 。
一旦找到一个加载器,require
将调用这个加载器(参数为modname
),若加载器返回任何值(非空),则require
将赋值到package.loaded[modname]
;若没有返回值且package.loaded[modname]
为空,则require
将其赋值为true
。require
返回package.loaded[modname]
最终的值。
那么加载器会返回什么值呢?其实一般就取决于当前Lua模块的返回,加载器读入代码进行解析,当前模块可以返回一个function
、table
或者不返回等。在使用waxClass时,其实一般也没有返回。
package.loaders
这是一张用来控制模块加载的表。这张表中每一项都是一个搜索函数(接受一个modname
的参数),require
按升序调用其中的搜索器函数,调用结果返回另一个函数(也就是模块加载器函数)或者一个错误描述字符串。Lua初始配置了4个搜索函数,正如上面require
中阐述那样。(注:v5.3修改为package.searchers)
module
module (name [, ···])
创建一个模块。按以下顺序创建模块:
package.loaded[name]
包含一个表,否则如果有一个同名的全局表,否则创建一个表t
并赋值给全局同名变量及package.loaded[name]
。
module
这个方法还初始化了t.Name,t.M以及t._PACKAGE(分别是参数name、模块自身以及包名),最后,模块设置t
作为当前函数的新的执行环境及package.loaded[name]
,使得require
返回t
。
初次之外还提供了dofile
、loadfile
相关的函数。
那可以自定义加载器吗?因为默认的加载器执行的是默认的文件读取并作为一个chuck读入执行,假如我们需要加解密脚本,这是不够的。答案也是明确的,可以自定义,这个将在wax中再介绍。另外,wax中还没有用到module
。
function与self
闭包
首先,什么是闭包?摘录Wikipedia上的定义如下:
In programming languages, closures (also lexical closures or function closures) are techniques for implementing lexically scoped name binding in languages with first-class functions.
中文解释是,引用了自由变量的函数称之为闭包。闭包最初是函数式编程语言实现的,现在如C这样的命令式编程语言其实也已经支持(Apple以block
的形式为C添加了闭包的特性,由LLVM编译器支持)。
当Lua编译一个function
时,它生成了一个包含该函数的虚拟机指令、常量值以及一些调试信息的原型。在运行时,当Lua执行一个function...end
表达式的时候,它创建一个新的闭包,每个闭包包含对相应函数原型的引用、环境表(查找全局变量)的引用以及一组upvalue
(用于访问外部局部变量)的引用。
对于访问外部局部变量,词法作用域与一等公民函数(闭包)的组合制造了众所周知的难题。下面的例子中,当add2
函数调用时,其函数体访问了外部局部变量x
,然而此时创建add2
的函数add
已经返回了,如果x
是存在栈中的,这个变量所在栈幁理应已被回收导致访问出错。那Lua是怎样实现闭包捕获外部局部变量的?
1 | function add (x) |
Lua使用了一个称之为upvalue
的结构来实现的。任意的外部局部变量都是通过一个upvalue
间接访问的,该upvalue
最初指向局部变量所在的栈元素,当变量离开作用域后,它会从栈移到upvalue
中。因为所有对这个变量的访问是间接通过指向这个upvalue
的指针来实现的,因此读写对Lua是透明的。不同于内部的函数,声明该变量的函数是直接访问栈来访问局部变量的。
熟悉Objc中的block
的实现的,是不是觉得有些似曾相识(关于block
的实现另有说明 )。对于堆中的对象,不做说明,因为它始终都是通过指向堆上的指针来访问的,前后一致。简单提一下,对于需捕获的栈中的局部变量,用__block
修饰后,事实上编译器已经将其修改为__Block_byref_{$var_name}_{$index}
命名的结构体了,也是通过间接访问来实现,该结构体中保存了原始的变量值。另外该结构体中包含一个指向本身类型的指针__forwarding
,用来支持__block
变量从栈转移到堆内存中,其访问方式为 __blockVar->__forwording->localVar
。所以,原理是相通的,增加间接层。
Lua通过保存一个仍指向栈中变量(称之为pending var)的链表来索引upvalue
以供重用保证每个局部变量的upvalue
唯一性。当Lua创建一个闭包时,会扫描它所有的外部变量,通过重用索引链表中的upvalue
或新建一个来捕获外部局部变量。
self
self
是Lua中的一个关键字,用法请参照上面的 iPhone.lua。但上面的self
用法其实只是个语法糖,下面的调用是一样的:
1 | Person = {"x"=0,"y"=0} |
在以冒号:
调用的语法中, self
是将调用者作为第一个隐含参数传递给函数。
C编程接口
这里主要是描述与宿主程序通信的C编程接口。主要关注几个关键的点:栈、索引、C闭包、注册表以及一些关键的API。
Lua中的虚拟机状态机:
1 | struct lua_State { |
这是个线程独立的参数,所有的接口调用都基于这个状态变量,这是所有C接口函数都必须接受第一个参数。
栈与索引
Lua脚本与C之间的互调必须能进行参数传递、数据通信,Lua 使用一个虚拟栈来和C数据通信,栈上的的每个元素都是一个 Lua 值(nil,数字,字符串,等等)。
当一个C函数被Lua中调用时,将得到一个新的独立于C函数的调用栈及原先Lua调用C的栈,包含了Lua传递给C函数的所有参数,且C函数可以将返回结果放入栈中返回给调用者。
C接口中对栈的操作,通常可以通过一个索引参数来指定操作栈中特定位置的元素。索引可以是1~n的正索引(n为栈大小),也可以是-n~-1的负索引,负索引表示从栈顶开始的偏移量。举例,lua_isnumber(L, i)
是读取栈中第i个元素判断是否数值类型。
另外,一些接口方法隐含了入栈或出栈的操作,如lua_gettable (lua_State *L, int index);
会读取index索引位置的表t,并以栈顶的值作为key,读取t[key]并将该值入栈。具体行为需要仔细阅读API说明。
lua_CFunction
typedef int (*lua_CFunction) (lua_State *L);
这是能提供Lua调用的C 函数的类型定义,除此之外还约定了参数及返回值传递方法:C函数通过上述所提的栈来接受参数,参数以正序入栈,在函数开始时可以通过lua_gettop(L)
来获取函数接收到的参数个数;返回值同样正序分别压入堆栈,并返回返回值的个数。在返回值之下的栈元素将被Lua丢弃。
下面这个例子中的函数将接收若干数字参数,并返回它们的平均数与和:
1 | static int foo (lua_State *L) { |
C闭包
上面提到的,绑定自由变量的函数就是闭包。当一个C函数创建后,可以将其与某些值(称之为upvalue
)绑定,生成C闭包。当C函数被调用时,它的那些upvalue
位于伪索引处,这些索引由lua_upvalueindex
产生,其中第n个upvalue
位于lua_upvalueindex(n)
的索引位置。该函数参数超过函数本身的总上值个数(但<=256)时,产生一个可接受但无效的索引。
void lua_pushcclosure (lua_State *L, lua_CFunction fn, int n);
这个是将C闭包压栈的接口函数。为了将值绑定到函数,需要先将这些值正序压入堆栈,然后调用上述函数创建出闭包并将这个C函数压到栈中。参数n告知需绑定的上值总数,该方法也会将这些值弹出栈。n最大值为255,当n为0时将创建一个轻量C函数,也就是一个指向C函数的指针。
void lua_pushcfunction (lua_State *L, lua_CFunction f);
将一个 C 函数压栈。 这个函数接收一个C函数指针,并将一个类型为 function 的 Lua 值压栈。当这个栈顶的值被调用时,将触发对应的 C 函数。注册到 Lua 中的任何函数都必须遵循正确的协议来接收参数和返回值 (参见 lua_CFunction )。该函数作为一个宏:#define lua_pushcfunction(L,f) lua_pushcclosure(L,f,0)
。
注册表(registry)
Lua提供了一个预定义的表,称之为注册表(registry),提供给C来存放任意Lua值。该表可以通过伪索引LUA_REGISTRYINDEX
来访问。因为是全局的,所以应当注意选择合适的key以防碰撞/覆盖。
注册表中的整数键用于引用机制,因此不应将整数键用作其他用途。
LUA_RIDX_GLOBALS
则是全局表在注册表中的伪索引。
相关API
简单列举几个常用的API如下:
1 | int luaL_newmetatable (lua_State *L, const char *tname); |
其他说明
可以考虑阅读Lua的源码!
Author: Jason
Permalink: http://blog.knpc21.com/ios/lua-basic/
文章默认使用 CC BY-NC-SA 4.0 协议进行许可,使用时请注意遵守协议。
Comments