欢迎光临
专注android技术,聚焦行业精粹,我们一直在努力

小刚带你深入浅出理解Lua语言

前言

这篇文章并不是针对某个知识点深入剖析,而是聚焦在Lua语言的关键知识点覆盖和关键使用问题列举描述。能够让学习者对Lua整体有个认识(使用一门新的语言不仅仅在用的时候适应它,而是知道怎么善于使用它),同时也可以作为一个工具文档在Lua中遇到具体问题的时候能从这里索引到相应的知识点和Lua的一些原理,得到启发。

 

1、Lua语言的特点

简单的说Lua语言是一个可扩展的嵌入型的脚本语言。它具有以下的特点:

  • 嵌入式语言: 它是ANSI C实现,在大多数 ANSI C 编译器中无需更改即可编译,包括 gcc(在 AIX、IRIX、Linux、Solaris、SunOS 和 ULTRIX 上)、Turbo C(在 DOS 上)、Visual C++(在 Windows 3.1/95/NT 上)、Think C (MacOS) 和 CodeWarrior (MacOS)。基本上每种编程语言都有调用 C 函数的方法,因此您可以在所有这些语言中使用 Lua。 这包括 C++、Go、Rust、Python、……
  • 解释型语言:Lua脚本会先编译成字节码,然后在Lua虚拟机上解释执行这些字节码。保证了它的可移植性
  • 动态类型语言:Lua语言本身没有定义类型,不过语言中的每个值都包含着类型信息
  • 简洁轻量,运行速度快:它所有的实现不到6000行 ANSI C代码。只包括一个精简的核心和最基本的库,较新的5.4.3版本解释器编译后283kB(Linux,amd64)。同时Lua通常被称为市场上最快的脚本级 HLL 语言
  • 设计原则遵循尽量使用机制来代替规则约定 Lua语言中包含的机制有模块管理、自动垃圾收集、元表和元方法、引用机制等。这些机制下面会详细介绍

基于这些特点我们会很愿意将Lua嵌入到我们的应用中,用于拓展应用的能力。

1.1、Lua与宿主程序的关系

以下图显示了Lua与宿主程序之间的关系:可以嵌入到宿主程序,并为宿主程序提供脚本能力,同时可以帮助拓展宿主程序。另外Lua也提供了一些工具帮助编译Lua文本(luac),执行lua脚本(lua)

1.1.1、Lua语言的组成

  • Lua C-api:正如上面所说Lua的所有的能力都是在C层实现的,并通过基础的C-api暴露出来。同时Lua也提供auxlib辅助库,它是基于基础C-api的更高一层的抽象封装。与基础API不同,基础API接口的设计力求经济性和正交性,而auxlib力求对于通用任务的实用性。
  • 标准库:Lua语言也包含标准库(io, math, string等),不过语言设计者为了保证Lua尽量的小,这些标准库是独立分开的。如果应用不需要用到这些标准库可以不需要加载,如果需要则可以通过luaopen_io等方法加载具体的库,或者>=5.1版本时通过luaL_openlibs来加载所有标准库。
  • 拓展三方库:此外Lua还可以扩展其他三方库,方式有3种:
  1. 在lua中 require “模块名”。require机制下面会介绍,简单说lua的require加载机制会在package.cpath查找”模块名”的动态库并加载,同时找到luaopen_模块名的函数执行,并把执行结果缓存并返回
  2. 把C方法添加到Lua标准库列表中,例如把luaopen_模块名 添加到由luaL_openlibs打开的标准库列表中
  3. 使用Lua C api的luaL_requiref方法将模块添加到package.loaded中,该方法同lua的 require方法
  • Lua内置的机制:Lua内置了很多的机制让开发过程尽量的简单,程序尽量的高效。其中包括:模块加载机制(require),自动垃圾回收机制,元表和元方法的元机制,错误处理机制(pcall),引用机制(自动管理table的key)等。下面会详细介绍这些机制。
  • Lua编译器:将Lua脚本编译成字节码
  • Lua虚拟机:Lua虚拟机会维护两个状态——global_state和Lua_state。
  • lua_state包含两个栈,Callinfo 栈(方法调用栈) 和  TValue 栈(数据栈,关于TValue的介绍)。分别用于缓存函数的调用信息的链表和参数传递。在Lua内部,参数的传递是通过数据栈,同时Lua与C等外部进行交互的时候也是使用的栈。
  • global_state: 负责全局的状态,比如GC相关的,注册表,内存统计等等信息

1.1.1.1、lua_state、call_info调用栈、数据栈之间的关系

参考链接:链接

图1.1

callinfo 结构组成一个双向链表,它的结构如下:

图1.2

其中lua_State的base_ci指向第一层调用,而ci则记录着当前的调用。

CallInfo会占用栈的一部分,用来保存函数参数,本地变量,和运算过程的临时变量。如图1中callinfo到lua_stack的部分空间映射。

1.1.1.2、global_state全局状态

global_state包含以下信息:

  1. stringtable:全局字符串表, 字符串池化,使得整个虚拟机中短字符串只有一份实例。
  2. gc相关的信息
  3. l_registry : 注册表(管理全局数据) ,Registry表可以用debug.getregistry获取。注册表 就是一个全局的table(即整个虚拟机中只有一个注册表),它只能被C代码访问,通常,它用来保存 那些需要在几个模块中共享的数据。比如通过luaL_newmetatable创建的元表就是放在全局的注册表中。
  4. 主lua_State, 在一个独立的lua虚拟机里, global_State是一个全局的结构, 而lua_State可以有多个。 lua_newstate会创建出一个lua_State, 绑在 lua_State *mainthread.可以说是主线程、主执行栈。
  5. Meta table :tmname (tag method name) 预定义了元方法名字数组;mt 每一个Lua 的基本数 据类型都有一个元表。 global_mt可以用debug.getmetatable获取。

下图描述了gloable_state里面比较主要的一个部分——注册表,Lua脚本的的上值_ENV就是注册表里面的全局表_G,它是通过LUA_RIDX_GLOBALS这个索引从注册表里面索引过来的。

Lua脚本中的所有对全局变量的引用都是对_G的引用,不过不是直接操作_G,而是指向_G的另一个参数_ENV。

1.1.1.3、_ENV 和 _G

如上图所示,Lua脚本中访问全局变量实际上是访问的_ENV table, 脚本中对全局变量访问的代码会被Lua编译器加上_ENV前缀。那么_ENV究竟是什么呢?按照Lua的书籍里面提到的,Lua语言把所有的代码段都当作匿名函数,而同时也会把_ENV作为该匿名函数的上值绑定到该匿名函数,所以Lua里面的脚本实际会做如下的转换:

普通写法:

编译器实际转换后的做法:

这里引出另2个概念:上值(upvalues),能支持上值的闭包(closure)。下面会详细提到。

 

2、Lua语言基础

上一章了解了Lua是一门什么样的语言,以及能为宿主应用提供嵌入式脚本能力。接下面我们从一个新手开发者的角度去了解这一门语言。

2.1、词法规范

作为一个开发者在Lua编码时需要遵循它的词法规范,保证一致的编码风格。这里列举一下:

  • 标识符(或名称)是由任意字母、数字和下划线组成的字符串(注意:不能以数字开头)
  • “下划线+大写字母”(例如_VERSION)组成的标识符通常被Lua语言用作特殊用途
  • Lua语言是大小写敏感的,例如:And和AND是两个不同的标识符

2.1.1、注释

单行注释:

多行注释:

–[[注释内容]],或 –[[ 注释内容 –]]

或者

注释代码时建议用这种方式: –[[ 注释内容 –]] ,这样在第一行补一个 ‘-’ 字符就可以取消注释了,会非常方便。例如:

2.1.2、变量

Lua里面默认是全局变量,即直接写变量名即为全局变量。相反局部变量的定义需要加 local 关键字来修饰,例如:

 

2.2、Lua 基本类型

了解了Lua的词法规范后我们可以着手开始写Lua代码了,我们先了解一下Lua有哪些基本类型。Lua是一门动态类型的语言,主要体现在Lua没有类型定义,不过它的每个值都带有类型信息。

Lua有8种基本类型:nil, number, string ,table, function, boolean, userdata, thread。下面一一介绍一下:

2.2.1、nil

nil代表空,变量定义出来在第一次赋值之前是空的,目的用于告诉lua它是没有初始化的。由于nil在lua里面有赋值操作=,和判断操作==,~=才有效,其它操作符都会报错,所以这种类型是无法使用的。我们在处理算数运算符或字符串连接符号使用时需要特别注意这个类型的检查,不然程序会出错。例如我们在处理字符串连接建议如下处理:

2.2.2、number

在Lua5.2及之前的版本中,所有的数值都以双精度浮点格式表示。从Lua5.3版本开始,Lua语言为数值格式提供了两种选择:integer ——64位整型和float —— 双精度浮点类型。我们在编译Lua库时也可以将Lua 5.3 编译为精简Lua模式,在该模式中使用32位整型和单精度浮点类型。

2.2.3、string

Lua 语言中字符串是一串字节组成的序列,Lua的核心不关心这些字节以何种方式编码文本,它使用8个比特位来存储。Lua语言中的字符串可以存储包括空字符在内的所有数值代码,这意味着我们可以在字符串中存储任意的二进制数据。同样,也可以使用编码方法(UTF-8,UTF-16)来存储unicode字符串。

2.2.3.1、string常使用的方式

> 获取长度:

Lua中获取字符串的长度有两种方式:#字符串和string.len(字符串)。这里推荐使用#操作符,因为string.len()需要先查找string再找其下的len,再传参再调用,至少需要4条 lua vm bytecode;而#直接被翻译为LEN指令,一条指令就可以算出来。

以下列举了几种获取字符串长度的方式,源文件是用的utf-8编码:

 

> 字符串连接:

另一个用的比较多的是字符串连接操作符——”..”,需要关注2个点:

  1. .. 操作符不能操作 nil类型。如前面在介绍nil类型时提到的,nil是告诉系统这个变量没有初始化时的类型,只对判断符号和赋值符号不会出错,其他操作符号都会出错。所以在使用“..”连接操作符时检查它不为nil
  2. 字符串连接操作符在连接多个操作符时实际会创建字符串的多个副本,所以为了节省内存和CPU的开销,我们可以以类似Java中的StringBuilder的方式来处理较多的字符串连接。代码示例如下:

从以上代码的执行结果来看table.concat的方式处理字符串连接非常高效。也是在其他语言开发中经常用到的一种手段。

 

> 长文本字符串定义

在其他语言例如Java中定义长文本需要关注文本中的换行和字符转义,Lua提供了一种非常方便的定义方式—— [[长字符串]]。代码示例如下:

 

2.2.3.2、字符串标准库常用的方法

string标准库中公开的一些常用的方法包括:string.rep, string.reverse, string.lower, string.upper,string.len等。这里主要介绍一下string.gsub, string.pack, string.unpack,主要是因为gsub在一些关键的逻辑使用比较多,string.pack对于二进制字符串打包用于传输的场景使用比较多。

> gsub

是字符串替换函数,它的第3个参数可以是一个表(table)或者一个function,代码示例如下:

上面的示例展示了利用gsub的第3个参数为function时可以将一个字符串解析为另一种table查找的表达方式。这种用法在一些场景确实非常有用。这也正是Lua的强大之处。

> string.pack和string.unpack

这两个函数用于在二进制数据和Lua的基本类型值之间进行转换的函数。string.pack会把值“打包”成二进制字符串,而函数string.unpack是从二进制字符串中提取这些值。关于这个API的参数介绍参考这里。举个例子:

对于函数里面的format字段的介绍,可以参考这篇文章:链接

s[n]: 长度加内容的字符串,其长度编码为一个 n 字节(默认是个 size_t) 长的无符号整数。从上面的输出可以看出在我们逐个打印每个字节的值,第一行是是5 2,表示长度是5,并且2代表下一个未读的字节的索引,因为这里是逐个字节读取,所以下一个未读的字节的索引是2了。

string.pack和string.unpack在网络传输中经常使用到,另外在不同的系统之间传递数据也经常使用。将各种类型的数据打包成字节数组来传递,非常的高效和方便。

> utf8库

从Lua5.3开始,Lua语言引入了一个用于操作UTF-8编码的Unicode字符串的标准库。当然,在引入这个标准库之前,Lua语言也提供了对UTF-8字符串的合理支持。

还是以算字符串长度的代码为例:

utf8库能正确计算出utf8字符串中字符的个数。

 

> 模式匹配

谈到模式匹配还是挺有意思,为啥Lua搞个自己的模式匹配,为啥不用业内公认的正则表达式呢?原因还是考虑到Lua库的大小。一个典型的POSIX正则表达式实现超过4000行代码,比所有lua语言标准库的总大小的一半还大,lua语言的模式匹配实现代码不到600行。别小看区区600行代码,基本功能一应俱全,已经非常强大了。下面挑了2个我觉得挺不错的点介绍一下:

  • 最短匹配符“-”

Lua里面使用了较简单的符号“-”来相对应“+”实现了最短匹配。这个很有用,而在正则表达式中这个字符不是这个含义。这个最短匹配可以用在一段字符串里面出现相同的多个匹配时,匹配最短的场景。例如下面这个例子:

  • 匹配捕获

捕获在正则表达式里面常用“()”括号圈出来。在Lua中会经常用到,例如:匹配并获取到捕获的分组内容,匹配并替换捕获到的分组内容。下面分别举这两个例子:

获取捕获的分组示例

Lua语言中支持返回多个结果的特性真的非常方便,省去了像其他语言中需要对数据打包一下,然后用的地方解包的过程。上面的例子中把捕获的结果直接获取并直接赋值给多个结果,代码是不是非常简洁易懂。

替换捕获分组示例:

Lua中的字符串替换函数是 gsub,下面的例子用到了gsub,且用到了%n这样的匹配具体的第几个捕获,可以使用的场景非常多。

另一个经常用到的替换用途,string.trim去除字符串前后多余的空格,也用到了%n这样的方式,代码示例如下:

再举一个使用场景的例子,替换字符串中的占位符:

 

2.2.4、table

这个就厉害了,它是Lua里面主要的数据结构,他可以用来实现其它语言中的常见的数据结构:Map,集合,数组,记录等。下面列举了table使用中的几个点:

2.2.4.1、table中的key

table的key可以是除了nil意外的任意值,也就是说它可以是字符串,数值,甚至是table。当用到table作为key时,就出现了一个非常有用的唯一key的用法。为了避免table中的key冲突,可以使用table对象作为key(因为table对象的地址是唯一的),代码示例如下:

我们还可以通过把值设置为nil来从table中删除一条记录。

2.2.4.2、table中元素的安全访问

在其它语言中在访问某个可能为空的对象里面的属性时会用到三目运算符,但是Lua中没有这个运算符。Lua作者推荐我们另一种方式来实现类似的操作:

2.2.4.3、table的长度

计算table的长度也可以用计算string长度的”#”操作符来完成,不过需要记住的是对于table计算长度,这个table最好是个数组,而不是包含其他非连续整形类型的集合,如果对这样的集合算长度会得到不可预知的结果。下面列举了3个计算table长度的示例:

我们知道table里面把值设置为nil代表从table中删除该元素,对于上面示例中的table2场景,究竟是之前有10000个值,后面把数组中间的值设置为了nil,还是本身这个table就只有包含2个key的元素呢,我们看到的结果是这个table的长度返回了1,是不是比较难理解。所以还是那句话计算table的长度时,我们最好能确认这个table是一个包含连续整形索引的数组。Lua中的数组是从1开始的,不过你可以定义任意数值的key。

2.2.4.4、使用pairs和ipairs遍历一个table

Lua中遍历一个table可以用 in pairs 或者 in ipairs,还可以用下面这个,具体怎么使用参考这里

这里我主要介绍一下pairs和ipairs,因为lua里面的table结构比较强大,可以用来表示其它语言里面多种结构,包括:数组,map,集合,记录等。

当我们的table表示一个数组时可以用ipairs,不过使用ipairs有一些限制:

  • ipairs的key需要是数值类型,非数值类型会被忽略
  • 索引的顺序是确定的从1开始的连续增序分布

所以需要注意:不要用ipairs遍历一个非连续整数索引的table

当一个table为一个集合或map时,可以使用pairs。它没有ipairs这些限制,不过也要注意:使用pairs遍历集合里面的nil记录会被跳过。

下面这个示例是分别使用ipairs和pairs遍历一个包含任意类型的集合:

上面的示例印证了我上面提到的限制。这说明对于一个自由度较高的table我们也并非可以任意发挥,我们要知道table是什么样的结构,我们可以采用什么方式来遍历。

2.2.4.5、table的标准库

lua也提供了标准库用于操作table,常用的api有:table.insert, table.remove, table.move, table.sort等,这些api对于table的操作都非常有用。这里列举一个table排序的例子,我们知道table结构的自由度很高,所以对个table排序可以自定义:

 

另外想重点介绍一下另外两个api: table.pack 和 table.unpack,为什么想介绍这两个api,因为他们在其它语言是没有的,所以比较特别,但是它们在Lua中应运而生。我理解由于Lua的函数入参支持可变参数,函数的返回也支持返回多个,因为这些强大的能力,所以孕育出了table.pack和table.unpack这样的api。它们的主要用途是对一个包含任意类型的集合拆成单个项,也可以反过来打包成一个集合结构。

以一个示例来描述:

上面这个示例展示了包含任意类型的集合可以打散成一个个的项,也可以反过来把这些项打包到一个table里面。这个api适用于lua语言的可变参数和多结果返回的特性。

另外上面的示例中local params_table = table.pack(…)的写法也可以简单的换位local params_table = {…},这样的语法糖对于编程是非常便利的。这里也可以看出Lua在处理编程细节方面考虑了很多,这也体现出它对这个语言设计的初衷:简洁轻量,机制重于规则约束。

2.2.4.6、table.pack和unpack的使用特别注意

这里标红一下,在使用table的pack和unpack时容易踩到一个坑,我们看一个例子:

上面的示例中table2和table3其实表达的意思相同,但是unpack的结果却不一样。主要计算table的长度时我们需要明确的知道它是一个数组,中间不要有nil,不然结果会匪夷所思。可以从这篇文章了解一下:链接

推荐做法

将key明确的设置为字符串可以避免上面的问题

 

 

2.2.4.6、元表和元方法

Lua设计了元表和元方法,它的目的在于:用于拓展任意值在面对一个未知操作时的行为。这个机制不仅仅可以作用于table,甚至可以作用于基本类型,但是在Lua脚本中我们改变不了基本类型的元操作行为,不过可以在C层实现。这里先提一下,抛出这个概念,让我们提前有个印象。因为table除了结构强大,他还能实现类似其他脚本或面向对象语言的方法原型、继承等概念,这些概念依赖元机制,用到table必然会用到元机制。更详细的我们在下面的元机制中介绍。

下面展示了元机制实现的两个table相加,table和其它类型数值相加的示例:

上面的示例中为table添加了元表(setmetatable),这个元表里面添加了元方法_add(Lua语言里面定义了一些操作符的元方法名称例如_div表示除法)。该元方法的入参是对应”+”的两个操作数,我们可以自定义实现我们想要怎么做,例如这里我们假设第一个操作数是table, 希望如果第二个操作数是table则合并到第一个table,第二个操作数是其他类型则直接插入到第一个table中。看到输出结果能满足我们这个想法。我们再试试将一个table和一个number类型的数据相加会怎么样:

是不是也是符合预期的。

这里简单的过了一下元机制,以及它在table结构中的应用。详细了解元机制可以看下面的元机制的介绍。

 

2.2.5、function

function在Lua语言是lua是“第一类值”,Lua中所有的函数都是匿名的,不仅可以存储在全局变量中,还可以存储在表字段和局部变量中。同时上面介绍全局变量的时候有提到Lua语言把所有的代码段都当作匿名函数,而同时也会把_ENV作为该匿名函数的上值绑定到该匿名函数。

lua中有三类函数:大类都是LUA_TFUNCTION,变体分别是LUA_VLCL(Lua closure)LUA_VLCF(light C function)LUA_VCCL(C closure)

lua的closure是下面这个样子的,它包括:函数原型(编译后的包含指令信息的字节码块)和上下文环境。上下文环境包括:upvalues(上值)和 env(所在环境)。

 

env就是上面提到的_ENV,它可以在loadfile时指定,不指定的时候默认会设置为全局表_G。想要指定某个closure的_env可以这么做:

loadfile会返回一个function类型的closure,所以会再加上一个()用于执行该closure。在上面这个示例中我们指定了环境为一个空table,修改了closure的环境。这个在某种意义上起到了沙盒的作用,也就是这个加载的lua脚本的环境是空的,它无法访问C层的库,也就是脚本只能执行自己的业务。

那上值是啥?简单的说它是closure特有的,当创建一个closure时可以给他绑定一些值,这些值在该closure内可以访问,这些值就是上值。其实看c function的宏定义可以知道它其实也是closure类型,只是上值固定为0。所以作者说lua中只有闭包没有函数,总结来说闭包 = 函数+若干个上值(up value)。更多上值相关的介绍看官方的这篇文章:链接

 

2.2.5.1、lua function和 c function的区别

lua function脚本会预编译成字节码,然后存储为function类型值,而c_function需要通过lua c api注册给lua使用。详细见这篇文章:链接

对于CClosure数据结构(C闭包函数):

  • lua_CFunction f:函数指针,指向自定义的C函数
  • TValue upvalue[1]:C的闭包中,用户绑定的任意数量个upvalue

对于LClosure数据结构(Lua脚本函数):

  • Proto *p:Lua的函数原型,在下面会有详细说明
  • UpVal *upvals:Lua的函数upvalue,这里的类型是UpVal,这个数据结构下面会详细说明,这里之所以不直接用TValue是因为具体实现需要一些额外数据。

2.2.5.2、重定义函数

了解了Lua中的function是什么,我们了解一下function在Lua语言中使用上的一些特点。从上面我们了解到我们Lua脚本中引用的全局变量和全局方法实际是从上下文环境_ENV的table中获取的。我们可以把这个全局table中的方法替换成任意其它值,这里介绍一种在Lua中经常用的hook方式编程。以下示例展示了脚本hook了全局方法,并在方法里面额外做了一些事情:

 

2.2.5.3、多参数返回和函数可变入参

Lua的function可以有多个返回值,入参支持可变参数。这个特性挺巧妙的,在使用方面会带来很多的便利,主要体现在以下几个方面:

  • 多返回值省去了对结果的包装:在Java语言中多个返回结果我们会用Pair, Object, Map, Json等进行包装,Lua中只要直接返回多个数据值就好。
  • 变参入参巧妙的达到了方法重载的目的。如果一个同名方法想要再拓展一个或多个参数,在Java语言中需要用重载多个方法来实现。Lua语言中可以直接往后面加。如果有不确定的多个参数,那么Lua可以让你使用”…”来替代参数定义。
  • 多个参数返回和可变参数的函数入参这两个特性结合在一起,又省去了处理函数返回解析逻辑的这一开发工作,提升了效率。这些语言中的细节点,或者语法糖,体现出了作者从语言设计之初到完成整个设计过程中的始终不忘的初心——简洁和轻量。程序员能不爱不释手吗?

下面对这几个特性分别列举几个示例:

1.多参数返回:

这个示例体现出的一个使用方法是,你可以根据你关心的某几个返回值来决定定义的容纳这些数值的变量,其它的不取则默认丢弃。在其他的一些语言中你要首先解析出所有的参数,然后再决定想取某几个。

  1. 可变入参

根据参数的不同值来决定其他数据是什么,在可变参数这个特性出现后变得更容易实现了。

  1. 两者的结合

lua模块加载方法load,标准库里面的file,io等提供的API返回值都遵循共同的一个约定,如果成功则返回对应内容,如果失败则返回nil + 错误信息。看的出来这个约定会根据不同的情况返回不同的内容,那么就是作为函数返回值是动态的,并可能有多个返回值,作为另一个函数的入参它是动态的,并可能有多个参数传入。而assert函数的执行会先判断第一个参数如果不为true(下一节会介绍Lua中除了nil和false的值其它都是true),则会认为第二个参数为错误信息。如果第一个参数校验为true则返回函数的执行结果,不作处理。这个assert方法就巧妙的结合了函数的多结果返回,和函数的可变参数入参实现了断言。设计细节如此精妙,不经让人拍案叫绝~

 

2.2.5.4、Lua C function

所有在Lua中注册的函数都必须使用一个相同的原型,该原型就是定义在 lua.h 中lua_CFunction:

从C语言的角度看,这个函数只有一个指向Lua状态类型的指针作为参数,返回值是一个整型数,代表压入栈中的返回值的个数。因此,该函数在压入结果前无需清空栈。在该函数返回后,Lua会自动保存返回值的并清空整个栈

如果我们有多个lua c function,我们希望把这些lua c function放在一个table里面一起返回给Lua。Lua语言中提供了luaL_Reg类型,该类型是由两个字段组成的结构体,这两个字段分别是函数名(字符串)和函数指针。

在上面的例子中,只声明了一个函数(log)。数组的最后一个元素永远是{NULL, NULL},并以此标识数组的结尾。最后,我们使用函数luaL_newlib声明一个主函数:

对函数luaL_newlib的调用会新创建一个表,并使用由数组reg指定的“函数名-函数指针”填充这个新创建的表。当luaL_newlib返回时,它把这个新创建的表留在了栈中。然后,函数 luaopen_mylib返回1,表示将这个表返回给lua。

 

2.2.6、boolean

boolean类型中true在Lua里面是除false和nil之外的值,例如:0和空字符串是真

2.2.7、userdata

userdata 表示一个原始的内存块,可以有自己的metatable,这个metatable可以绑定到该userdata(内存块),然后在Lua脚本中通过元表来操作这个userdata。本篇文章下面介绍C接口时会有一个userdata的示例,想了解的可以继续往下阅读。

2.2.8、thread

在Lua 中thread代表独立执行的任务,在Lua中是通过协程来实现的。它和操作系统的线程不同,协程可以在所有的系统上面支持协程,即使是那些不支持线程的系统。这里就不做详细的介绍了,想了解的可以可以参考官方的介绍:链接

 

2.3、Lua常用到的语法

2.3.1、goto

可以从定义的标签的地方继续执行,虽然说goto在编程语言中不被人推荐,主要是因为它破坏代码逻辑的结构。这里介绍它在Lua脚本中的一个有用的使用方法。

我们知道Lua中的迭代器没有continue字段,我们可以通过goto来实现continue。Lua中的标签是通过被两个“::”包围的标签名字符串来表示的,代码示例如下,他是通过goto让Lua的代码执行直接切换到该标签处:

 

2.3.2、三目运算

Lua中没有三目运算符,也就是它没有提供诸如Java中这样的写法:String s = a > b ? “s1” : “s2”; 但是Lua有一种替代的写法:

之所以能这样做是因为Lua中的and和or逻辑运算符也遵循最短路径。例如这样的判断逻辑是不会出错的(不会因为i的值为0了导致算数运算符出现除0的错误):

在Lua中使用上面的三目运算方式需要注意一点——lua中nil和false值会判断为false,其它的任意值会判断为true,所以我们需要留意and 前面这个判断不能出现歧义。例如下面这种写法就不应该用这样方式实现三目运算符:

 

2.4、Lua虚拟机

前面1.1.1章节介绍了Lua虚拟机在Lua语言中的依赖关系以及它自身的组成。我们再回顾一下,它包括2个部分:global_state(全局状态)和lua_state(负责Lua脚本和Lua c function的执行,包括它们之间的数据交换)。 lua_state包括方法调用栈和数据栈。那么Lua虚拟机在Lua语言中的角色是什么呢?

首先我们先了解什么是虚拟机,”虚拟机”就是使用代码实现的用于模拟计算机运行的程序. 每一门脚本语言都会有自己定义的opcode(operation code,中文一般翻译为”操作码”),可以理解为这门程序自己定义的”汇编语言”。我们了解到对于编译型语言,比如C等,经过编译器编译之后生成的都是与当前硬件环境相匹配的汇编代码;而脚本型的语言,经过编译器的处理之后,生成的就是opcode,再将该opcode放在这门语言的虚拟机中逐个执行.。可见,虚拟机是个中间层,它处于脚本语言前端和硬件之间的一个程序(有些虚拟机是作为单独的程序独立存在,例如Java。而Lua由于是一门嵌入式的语言是附着在宿主环境中的,它的Lua虚拟机是C代码实现的,被编译在宿主程序中,由宿主程序加载和启动).

Lua虚拟机的依赖关系图:

lua对外通过lua.h, lauxlib.h对外公开API接口。虚拟机部分主要包括global_state和 lua_state两个部分,其中lua_state包括stack数据栈和base_ci、ci指向的函数调用链。

lua虚拟机的主要职责是执行字节码中的指令,管理全局状态(global_state)和函数调用链状态(base_ci, ci),在stack中处理指令执行过程中的数据(TValue数组)。

Lua虚拟机具体的工作流程:

步骤一:从C层开始,我们通常加载一个Lua脚本是通过luaL_dofile来执行一个脚本文件。这个dofile操作包括load_file和pcall两部分组成。我们看到它的宏定义是这样的:

load_file实际是解析Lua脚本文件,转换成字节码chunk并作为一个C的closure返回,源码如下(ldo.c):

步骤二:执行前面步骤通过setclvalue压在栈里面的closure

步骤三:接下来luaD_call会执行luaD_precall,再到luaV_execute;

luaD_precall: 会检查CallInfo调用链表,如果首次调用,那么该链表为空会创建第一个CallInfo

luaV_execute: 这里是虚拟机执行代码的主函数。这里会一直循环从ci的指令列表中取出指令逐个执行

整体的流程如下:

 

2.4.1、栈

为啥会有栈呢?它是C和Lua之间调用的通道。当我们想在Lua和C之间交换数据时,会面对两个问题:

  1. 动态类型和静态类型体系之间不匹配
  2. 自动内存管理和手动内存管理之间不匹配

此外Lua语言不仅能方便的与C/C++交互,而且还能与Java、Fortran、C#等其他语言方便的交互。其次,Lua会做垃圾收集,由于Lua语言引擎并不知道Lua中的一个表可能被保存在一个C语言变量中,因为它可能会错误的认为这个表可以被回收。

栈的存在形式是做为一个StkId,实际是一个TValue的指针。TValue是个包含type信息和union类型的一个结构。用于表示lua中的数据类型。

CAPI使用索引来引用栈中的元素,第一个被压入栈的元素索引为1, 第二个被压入的元素索引为2,依次类推。我们还可以以栈顶为参照,使用负数索引来访问栈中的元素。此时,-1表示栈顶元素(即被最后压入的元素),-2表示在它之前被压入栈的元素,依此类推。例如:调用lua_tostring(L, -1)会将栈顶的值作为字符串返回。

当Lua调用C注册的方法时,Lua的参数会先压栈,例如一个Lua调用C的例子:

lua示例:

该Lua调用C方法时传入了4个参数,分别对应3个string类型,第4个是字节数组(在lua里面的字符串可以存储任意编码的字节数组)。在调用到C方法时Lua会分配一个新的栈,并把这些参数按照顺序逐个压栈,例如:M.JAVA_CALL_STATIC_CLASS会压入到栈索引1的位置,type字符串会放在栈索引2的位置,依此类推。

在C方法接收到调用时我们想要获取传入的参数,可以从栈里面按照索引逐个弹出参数,例如下面方法体的前5行代码:

C示例:

我们注意到方法的最后两行是压入了一个number类型的数值,同时返回了1。这是告诉Lua在收到C方法返回时栈里面留下了1个返回结果。Lua脚本可以直接从C方法调用返回值赋值给lua变量。

2.4.1.1、栈平衡

编写C代码时保持栈平衡是一个非常好的习惯,不然操作完栈后由于一些数据不小心留在了栈中,在其他逻辑处理时从栈中pop出来的数据就不是我们意料之中的,那么会出现奇奇怪怪的问题。下面举一个保持栈平衡的例子:

方法调用开始前我们获得了栈的当前位置,处理了一系列的压栈操作后我们通过lua_settop方法设置回之前的栈的位置,保持了栈平衡。

 

2.5、Lua中的机制

正如Lua提到它的设计原则遵循尽量使用机制来代替规则约定,Lua内置了一些非常有用的机制来帮助管理代码模块,内存分配等这些繁琐,且容易出错的内容。

2.5.1、模块和包管理机制

谈到模块和包,我们一般想到的是我们对代码逻辑的划分,然后这些划分之后的独立模块,能支持不同路径动态查找、按需加载和卸载、更新替换等。Lua中的模块包括:Lua脚本模块使用C接口扩展3方模块。用一个图描述一下如下:

流程描述:

  1. 首先,函数require在表package.loaded中检查模块是否已经被加载。如果模块已经加载,函数require就返回相应的值。因此,一旦一个模块被加载过,后续对于同一模块的所有require调用都将返回同一个值,而不会再运行模块里面的代码。
  2. 如果package.loaded里面没有(即没有加载),那么函数require会去检查package.preload,这个里面定义了模块名和加载函数的映射(这里再统一一下概念加载函数是一个函数类型,可以被执行,能返回模块需要返回给外部的公开数据,由模块自己决定返回内容,通常会是一个table,也可以是其他值。如果返回nil,为了保证package.loaded里面能记录该模块被加载过,会填补一个true值,例如:package.loaded[模块名]=true )。使用到的场景有:当我们使用静态链接到Lua的C库,可以将其luaopen_函数注册到preload里面,这样luaopen_函数只有当用户加载这个模块时才会被调用。
  3.  前面2部没有找到时,Lua会去搜索该模块的文件,首先尝试搜索lua文件路径。该搜索路径是由变量package.path指定。通过该搜索路径如果找到了相应的文件,那么就用函数loadfile进行加载,该函数的返回结果是加载函数。接下来简单介绍一下package.path,在ISO C并没有目录的概念。所以package.path使用的是一组模版(template),例如:

    lua会用require的模块名参数替换template里面的‘?’字符,然后在‘;’分隔的多个路径中逐个找到。下面举一个例子说明我们怎么通过C代码修改这个查询路径,将我们自己的目录追加进去:
  4. 如果找不到指定模块的Lua文件,那么它就会搜索相应名称的C标准库(此时,搜索路径由变量package.cpath指定)。如果找到一个C标准库,则会使用底层函数package.loadlib来进行加载,这个底层函数会查找名为luaopen_模块名的函数。此时的加载函数就是loadlib的执行结果,也就是一个被表示为Lua函数的C语言函数luaopen_模块名。
  5. 以上都是Lua系统的默认的查找方式,如果程序有一些特别的查找方式,例如需要解开一个zip包后拿到文件等,Lua提供了一个搜索器的概念。Lua提供的搜索器是由package.searchers提供。函数require会传入模块名并调用该列表中的每一个搜索器直到它们其中的一个找到了指定模块的加载器(即能正常返回加载函数)。如果所有的搜索器都被调用完后还是找不到,那么函数require就抛出一个异常。自定义searcher搜索器比较简单,我们只需要往package.searchers表里面插入一个function,然后该function的实现需要接收一个模块名的参数,并能返回一个加载函数。示例如下:

    我们知道load方法会加载Lua脚本并返回一个function类型的值,这个值就是加载函数。

 

2.5.1.1、Lua模块和C模块

我们先搞清楚模块是啥。从用户的观点来看,一个模块(module)就是一些代码(要么是Lua语言编写的,要么是C语言编写的),这些代码可以通过函数require加载,然后创建和返回一个表。这个表就像是某个命名空间,其中定义的内容是模块中导出的东西,比如函数和常量。

以Lua举例,通常Lua的模块是什么样的:

lua module例子:

外部调用该模块示例:

上面例子可以看出我们在module的代码中定义了一个table M,这个table用于定义该模块可以公开的一些内容,例如模块中定义了get_a方法,然后该模块将M表作为require函数加载过程中的加载函数执行结果返回,按照上面讲解的require机制的流程,该结果也会存储一份到package.loaded,用于其他代码require时能直接返回,而不需要再次执行模块代码。

这种Lua模块的包装方式是Lua推荐的一种比较简单的方式,从模块的包装我们可以清晰的知道定义在M表结构里面的方法和常量是模块希望对外的,而没有在M里面local变量则可以认为是私有变量,他们是不希望对外的。例如上面示例中的local a。

另外有时我们遇到一种场景,就是M中定义的函数访问另一个私有函数时,该函数已经定义在文件的末尾,也就是没有提前申明,该M里面的函数就找不到那个私有函数,如果把这个私有函数挪动位置放在最前面那么会动到之前的代码,这样就不是很好。我们可以把私有函数前面也加上M.这样可以不挪动私有函数的位置从而被定义在前面的M.的函数访问。但是为了表明该函数是私有的,我们可以在私有名称的最前面或者最后加上一个下划线,用于约定区分全局名称

 

接下来介绍一下C模块的包装示例:

Lua提供了4种方式:

方式1动态链接库的方式。路径中添加“模块名”.so(Android环境),so中定义了函数luaopen_模块名,这样Lua可以在脚本中直接require “模块名”得到luaopen_模块名返回的值。下面举个例子:

上面的例子中我们将Lua的cjson库下载下来(下载地址:链接),编译后生成cjson.so文件,然后放在Lua工程的目录下面命名也是cjson.so,这命名和require “cjson”的cjson是相同的,从上面的加载机制我们了解到require函数会通过模版的方式在当前的路径下面找到cjson.so,并加载进来,同时查找luaopen_cjson方法并执行它得到的结果返回Lua同时存放在package.loaded。从上面的示例我们可以看到Lua脚本就能正确调用到cjson模块暴露的decode和encode方法了。

我们简单看一下cjson的luaopen_cjson方法:

我们看到luaopen_cjson方法返回了一个table,这个table就是lua脚本获得的值。lua中调用decode和encode就是调用的这个table里面的对应的两个方法了。

此时我们把模块名改成cjson2会发生什么呢?就会报一个找不到的错误了:

 

方式二添加到lua标准库列表

如果解释器不支持动态链接,就必须连同新库一起从新编译Lua语言。除了重新编译,还需要以某种方式告诉独立解释器,它应该在打开一个新的状态时打开这个库。一个简单的做法是把luaopen_模块名 添加到由luaL_openlibs打开的标准库列表中,这个列表位于文件linit.c中。我们看到linit.c的代码很少,直接贴出来:

它的实现是将将一些标准库的加载函数的返回值通过luaL_requiref(同lua脚本中的require)保存到package.loaded中。这样lua中require时能直接得到缓存的返回值了。这里的返回值是公开的表结构。

方式三使用luaL_requiref

从方式二中我们已经知道可以使用luaL_requiref将模块添加到package.loaded中。那么我们可以不用那么麻烦去修改lua的c api了。我们可以直接使用luaL_requiref方法来达到此目的。举个例子:

我们在C方法的某个初始化阶段调用一下上面的方法,把自己定义的lua c function通过table注册到package.loaded里面,让lua能直接使用。

C代码中注册lua c function,用到了luaL_Reg结构,它的结构如下,包含字符串类型的name和一个lua_CFunction。

方式四保存到全局表

我们再回顾一下cjson的luaopen_cjson加载函数的实现:

我们看到有个ENABLE_CJSON_GLOBAL宏定义,在这个宏里面它先通过lua_pushvalue将位置-1(即栈顶)的值复制了一份并压栈,然后通过lua_setglobal从栈顶pop出那个复制的表然后设置到全局表中(即_G中)。我们从上面的讲解中知道_G会赋值给Lua匿名函数的上值_ENV中,所有对全局变量的访问会访问到_ENV,所以Lua就可以直接通过键值CJSON_MODNAME访问到刚刚push到全局表的那个表了。

在Lua5.1版本也可以使用luaL_register,按照官方对该方法的描述,如果带了libname参数,那么会在全局表和package.loaded里面注册那些方法。

 

2.5.1.2、子模块

Lua脚本也是支持具有层次结构的模块名的,例如当我们 require “a.b”时,lua会自动将点符号自动转化为操作系统的分隔符,这里之前package.path的模版会被替换为以下的路径搜索列表:

模版:./?.lua;/usr/local/lua/?.lua;/usr/local/lua/?/init.lua

搜索列表:

  1. ./a/b.lua
  2. /usr/local/lua/a/b.lua
  3. /usr/local/lua/a/b/init.lua

C 子模块:举个例子,我们require C层的a.b.c,会写成 require “a.b.c”,搜索器会搜索文件a(例如a.so),然后Lua会在该库中搜索对应的加载函数luaopen_a_b_c。

2.5.1.3、模块的不同版本

我们可以为每个so的不同版本命令不同so,例如我们把cjson的so改名为cjson-v5.so,然后require时可以用这个带版本的so的名字来查找so文件,不过会用不带版本号的luaopen_cjson来查找加载函数。所以我们可以通过这种方式来发布cjson的不同版本的so库了。上面方式一加载动态库so文件的代码修改后示例如下:

 

2.5.1.4、卸载模块

参考这篇链接,我们无法简单的卸载一个C模块。不过我们可以简单的卸载一个lua模块。我们知道lua模块的加载函数结果保存在package.loaded中,我们可以显示的将package.loaded[模块名] = nil的方式来实现卸载。这样在下一次require这个模块时会重新查找和执行该模块的加载函数了,并将新的返回值保存在package.loaded中。

 

2.5.2、元机制

通常,Lua语言中的每种类型的值都有一套可预见的操作集合。例如,我们可以将数字相加,可以连接字符串,还可以在表中插入键值对等。但是,我们无法将两个表相加,无法对函数作比较,除非使用元表。

元表可以修改一个值在面对一个未知操作时的行为,例如,假设a和b都是表,那么可以通过元表定义来实现如何计算表达式 a + b。Lua中识别到试图将两个表相加时,它会检查两者之一是否有元表(metatable),且该元表中是否有_add字段。如果Lua语言找到了该字段,就调用该字段对应的值,即所谓的元方法(metamethod),用于计算表的和。

注意:lua 脚本中的setmetatable只能给表设置元表,如果需要改变其他类型的元表只能在C语言中实现

下面就以两个表相加写一个代码示例,两个表相加时我们想把他们两个集合合并在一起:

同样的上面的实现也满足一个表和一个数值相加:

Lua预置了一些元方法定义,包括:

  • 算术运算元方法:__add, __div(除法), __mod(取模)等
  • 关系运算相关的元方法:__eq(等于),__lt(小于), __le(小于等于)
  • 库定义相关的元方法:__tostring, __metatable(保护元表)等

Lua语言会按照如下的步骤来查找元方法:如果第一个值有元表且元表中存在所需的元方法,那么Lua语言就使用这个元方法,与第二个值无关(如上面的代码示例);如果第二个值有元表且元表中存在所需的元方法,Lua语言就使用这个元方法;否则Lua语言就抛出异常。

接下来我们介绍一下_metatable元方法,它是用于保护元表。假设想要保护我们的集合,就要使用户既不能看到也不能修改集合的元表。如果在元表中设置__metatable字段,那么getmetatable会返回这个字段的值,而setmetatable则会引发一个错误,我们继续举一个例子:

 

2.5.2.1、元表的关键字__index和__newindex

lua中当访问一个表中不存在的字段时会返回nil。这是正确的,但不是完整的真相。实际上,这些访问会引发解释器查找一个名为__index的元方法。如果没有这个元方法,那么像一般情况下一样,结果就是nil; 否则,则由这个元方法来提供最终的结果。

如果我们希望在访问一个表时不调用__index元方法,那么可以使用函数rawget,调用rawget(t, i)会对表t 进行原始访问,即在不考虑元表的情况下对表进行简单的访问。

元方法__newindex与__index类似,不同之处在于前者用于表的更新而后者用于表的查询。当对一个表中不存在的索引赋值时,解释器就会查找__newindex元方法。同样的它也有一个原始函数允许我们绕过元方法:rawset(t, k, v),它等价于t[k] = v,但不涉及任何元方法。

在只读表的应用:

以一个例子来实践一下元表的__index和__newindex操作,熟悉一下lua中强大的元机制:

这个示例中readonly_t本身是一张空表,这样对空表的取值和赋值操作都不会找到对应的键值key,那么必然会走到元表的__index和__newindex,而我们在示例中对__index放开了,存放了一些数据,这样readonly_t就可以读取到数据里面的值。但是当我们想要修改readonly_t的值时,也只会触发到__newindex,而这里我们会直接返回一个错误。从示例的执行结果看出达到了这个目的。我们看到readonly_t[“a”]能正确返回1,而readonly_t[“a”] = 123会提示错误

 

2.5.2.2、userdata类型设置元表

Lua里面userdata类型表示的是一个C指针。我们以一个示例演示如何在Lua中使用C代码中的一个结构体,要知道Lua中没有结构体这样的直接对应的数值类型,但是有userdata,可以表示任意一块C的内存空间,要操作这样一个内存空间,Lua提供了一种方法可以为一个数值设置元表,然后我们在元表中可以定义方法来操作这个内存空间了。先了解几个关键的方法:

这个方法是从栈顶弹出一个table类型的数值,然后把它设置为指定index索引位置的数值作为它的元表。我们前面介绍过Lua数据栈里面的存放的都是TValue类型的结构,这个TValue实际是一个union类型,里面表示了Lua里面所有基本类型。所以也就是说lua_setmetatable可以为任意的Lua数值类型设置一个元表。在元表中我们可以定义_index键值,这里示例中是一个table(示例代码参考的这篇文章)。

luaL_checkudata用于校验userdata是否是我们预想的类型。Lua中通过为userdata绑定一个元表,然后通过C方法检查userdata是否有指定的元表的方式来实现的。

lua中使用

下面解释一下这个代码:

  1. require “foo”,会走Lua的模块加载机制(前面介绍了),找到foo动态库,然后执行luaopen_foo加载函数。
  2. c 中 luaopen_foo函数调用luaL_register(L,”testuserdata”,arraylib_f);在全局表中注册了testuserdata键值,以及和它对应的lua c function列表
  3. lua 中 testuserdata. new(100) 中testuserdata在lua转换成字节码时被识别为一个全局变量(前面介绍过)。所以会自动添加前缀,结果为_ENV.testuserdata.new(100)。我们现在知道了_ENV对应全局表中的_G。那么这段代码实际的意思是会到全局表中查找testuserdata关键字,那么此时的new方法就是C里面的newArray方法了
  4. newArray方法会构建一个NumArray结构体,并绑定元表myarray
  5. lua代码中接下来array:size()方法调用又是怎么样呢?“:”符号的方式和 “.” 使用方式不同, 它转化为”.”的方式会变为array.size(array), 也就是它的第一个参数为调用的table它自己。那么这段代码的意思是调用array这个userdata类型数值的 size方法,该方法会在元表中_index中查找于是找到了键值size对应的之前注册的getSize 这个 lua c function。
  6. getSize C function中首先会在栈顶找到userdata它自己(前面介绍的array.size(array)),然后转换为 NumArray 指针赋值给变量a。对应的代码是 —— NumArray* a = (NumArray*)luaL_checkudata(L,1,”myarray”);
  7. 最后从该结构实例中取出size成员,并把该结果压栈返回给lua。return 1 是告诉Lua栈里面留了一个值给它,lua中调用返回后可以直接赋值给Lua变量。

注意:Lua调用C时会在Lua虚拟机的数据栈中分配一块新的范围作为它们之间交互的栈空间。调用到C时栈里面的索引从1开始依次存放了Lua调用时传递过来的参数,c function中可以直接从栈中pop出对应的参数使用。

 

2.5.2.3、继承

Lua中的table和元机制的确很强大。它们结合在一起可以实现其它语言中的继承的行为。不过它的继承和Java语言的继承不同,它和javascript类似,是基于原型的。简单说就是只有对象,没有类;对象继承对象,而不是类继承类

“原型对象”是基于原型语言的核心概念。原型对象是新对象的模板,它将自身的属性共享给新对象。一个对象不但可以享有自己创建时和运行时定义的属性,而且可以享有原型对象的属性。

写一个Lua中继承的典型的例子:

这个示例中定义了一个table Account作为父对象,里面定义了一个公共方法get_name,用于打印name属性。然后在Account里面定义了new方法,这个方法里面给传进来的table实例(子对象)设置一个元表(元表就是Account自己),这个元表的__index也设置为Account自己,这样在子对象调用get_name时(obj1:get_name()和obj2:get_name())会访问到父对象的方法了。这个示例展示了继承的一个特性——多个对象共享行为

作为一个“类”,除了多对象共享行为,它还需要具备其它的特点,这里总结为3个:

  • 多个对象共享行为
  • 继承
  • 私有性
Lua是基于原型的继承,可以实现对象继承对象,代码示例如下:

上面这个示例中,基于Account我们构建了TomClass实例, 然后tom实例是基于TomClass。我们看到在这个示例中TomClass实例复写了Account的get_name方法,并修改了print打印的内容,当我们使用TomClass new一个新的实例时此时调用get_name方法不再使用Account中的get_name,而是找到了TomClass里面的get_name了。

 

2.5.2.3.1、多重继承

再来看Lua中一个多重继承的示例:

在这个示例中我们定义了两个table——Account, Named,用于多重继承的2个“父类”,它们分别提供了方法操作各自的属性account和name。

提供了一个createClass方法,这个方法返回一个table,它包含这些父类的列表,同时它提供了new方法用于构建新的子对象。子对象设置该table为它的元表,并指定__index也为这个table,让子对象和多个父对象关联起来。这个table也设置了一个元方法,它的__index我们设置了一个function,这个函数会调用search方法在父类列表中(parents)查找对应的方法名。通过这种方式实现了多重继承的目的。

我们看到子对象account在使用方法get_name和get_account时分别使用到父对象的方法来执行的。

 

2.5.2.3.2、私有性

继承中另一个重要的特性是私有性。总结起来有以下3种方式来保证私有性:

  • 在一个table返回时只返回需要公开的方法或字段。可以是两个table的方式,一个table放对象状态,一个table放操作;也可以是返回一个方法(单方法对象)。
  • 继承出来的table它的元表不实现__newindex,则自己的属性修改不会影响到公共的父类,实现了私有性
  • 对于共享的数据,可以用对偶表示法实现私有性,即用对象唯一地址来存储数据,只有持有该对象才能访问对应的数据

示例一:返回公开方法和字段,屏蔽私有字段的方式:

上面示例中newAccount方法返回了一个新的对象,它公开了两个方法:printName和addAge,这个新的对象绑定了一个上值(upvalue)

_self,它也是一个table,里面放了2个属性——name和age,外部在使用这个对象时只能通过公开的方法来操作这个私有table(_self)的值。这样有效的保证了_self的私有性。
示例二:对偶表示法

对偶表示法将对象私有属性值保存在以该对象为键值的一张表中统一管理。后续需要访问这些私有属性时必须持有相应的对象才能访问。这样有效的保证了私有性。

 

2.5.3、lua中的错误处理机制

由于Lua是一门可扩展的嵌入型的脚本语言,所有的Lua操作起源于宿主的C代码,通过lua_pcall来调用的,任何的Lua执行错误都会返回到C层,我们可以在这一层做一些错误的处理。下面会介绍一种全局捕获Lua异常的做法。

lua中关于错误的处理有以下的方法:

  • 产生一个错误:Lua脚本中使用error,在C层可以用lua_error,也有一个辅助库的luaL_error(可以打印格式化的字符串)
  • assert(v [, message]): 断言,如果v是false(前面提到在lua里面,false和nil都认为是false)就会产生一个错误。不然的话就会返回v的所有返回信息。message是出错时的错误信息,如果没有,则默认为“assertion failed”
  • pcallxpcall:  pcall (f, arg1, ···)和xpcall (f, err)。都是在保护模式下执行function。这意味着如果f中执行有异常pcall会抓住这个异常然后返回错误信息。xpcall和pcall的差异在于xpcall可以带一个err handler function。如果f中有异常,xpcall能捕获异常,并把错误信息传给err function来处理
  • lua_atpanic: 如果应用调用了Lua API中的函数,就可能发生错误。Lua语言通常通过长跳转来提示错误,但是如果没有相应的setjmp, 解释器就无法进行长跳转。此时,API中的任何错误都会导致Lua调用紧急函数(panic function),当这个函数返回后,应用就会退出。我们可以通过函数lua_atpanic来设置自己的紧急函数,但作用不大。可以用做统计收集错误信息。
  • debug.traceback, LuaL_traceback: 会返回一个崩溃时调用栈的字符串类型信息。
  • luaL_argerror, luaL_check*,luaL_typeerror, luaL_typeerror: 这些方法也是用于抛出一个错误,不过是关于参数或类型检查的。

 

2.5.3.1、C语言的setjmp机制

C语言没有C++或Java的异常机制,但可以通过setjmp/longjmp实现类似的效果,Lua的pcall就是利用的setjmp来实现异常捕获的。

举个C代码实现的例子:

输出结果为:

代码执行流程如下:

  • 使用setjmp保存当前执行环境到jmp_buf,然后默认返回0。
  • 程序继续执行,到某个地方调用longjmp,传入上面保存的jmp_buf,以及另一个值。
  • 此时执行点又回到调用setjmp的返回处,且返回值变成longjmp设置的值。

在Lua中使用pcall时也是同样的效果,任意在pcall中的异常最终会通过longjmp跳转到之前调用pcall的位置,然后带上出错的信息。我们代码可以在pcall返回时判断执行的结果来处理异常时的逻辑。

 

2.5.3.2、Lua中的全局错误捕获

有了上面的知识作为铺垫我们不难实现Lua脚本中的全局错误捕获了。我们知道所有的Lua操作起源于宿主的C代码,所以如果要全局捕获lua脚本的异常当然要在C层来处理了。

开始着手前我们再自己看下C层的lua_pcall 函数定义(链接):

我们重点看下第四个参数errfunc,这个值是个整形值它代表错误处理函数在数据栈中的位置。我们可以利用这个错误处理函数来接收pcall返回的错误信息。

下面是一个Android里面通过JNI调用到C层,然后调用Lua API的lua_pcall来执行Lua二进制chunk字节码,并捕获异常的代码。

上面的代码中lua_pcall的第四个参数我们传了1,代表数据栈中索引1的位置我们放了一个错误处理函数。这个函数就是通过lua_pushcfunction push到栈中的,代码中push了一个msghandler的lua c function。

我们在看下msghandler的实现:

这段代码捕获了pcall中的异常信息,它位于栈索引1的位置,不过这个异常信息不一定是字符串类型,如果不是我们尝试从它的元表中调用__tostring方法来获得错误信息。拿到错误信息我们把它通过luaL_traceback拼接到栈回溯信息前,并通过return 1将结果返回。

这个地方我们可以把这些错误信息通过自己的异常上传逻辑收集起来,从而实现了全局Lua异常捕获、收集和上传了。

 

2.5.4、引用机制

一些情况下C函数需要保存一些非局部数据,即生存时间超出C函数执行时间的数据。在C语言中,我们通常使用全局变量(extern)或静态变量来满足这种需求。然而,当我们为Lua编写库函数时,这并不是一个好办法。首先,我们无法在一个C语言变量中保存普通的Lua值。其次,使用这类变量的库无法用于多个Lua状态。Lua的CAPI提供了两个类似的地方来存储非局部数据,即注册表(registry)和上值(upvalue)。

然而注册表是一个普通的Lua表,它的键很有可能冲突。引用机制主要是解决在Lua中拓展多个C库时可能会在全局表中定义相同的key,带来潜在的问题。这个机制简单说引用机制就是Lua自动管理key,开发者不应该手动指定key,否则就可能破坏内部的Key重用机制。

引用机制提供了两个方法:

luaL_ref会从栈中弹出一个值,然后分配一个新的整形的键,使用这个键将从栈中弹出的值保存到注册表中,最后返回该整形键,而这个键就被称为引用(reference)。最后想要释放值和引用,我们可以调用 luaL_unref。

 

2.5.5、垃圾回收机制

云风大神有一篇关于Lua垃圾回收机制的讲解,非常深入,详细了解可以参考这篇文章——链接。这里简单介绍一下Lua的垃圾收集器。

一直到Lua 5.0,Lua语言使用的都是一个简单的标记-清除(mark-and-sweep)式垃圾收集器。这种收集器又被称为“stop-the-world”(全局暂停)式的收集器,意味着Lua语言会时不时的停止主程序的运行来执行一次完整的垃圾收集周期。每一个垃圾收集周期由四个阶段组成:标记(mark)、清理(cleaning)、清除(sweep)和析构(finalization)。

标记阶段会把根结点集合标记为活跃,根节点就是由Lua语言可以直接访问的对象组成。在Lua语言中,这个集合只包括C注册表(主线程和全局环境都是在这个注册表中预定义的元素)。

Lua5.1使用了增量式垃圾收集器。这种垃圾收集起像老版的垃圾收集器一样执行相同的步骤,但是不需要在垃圾收集期间停止主程序的运行。相反,它与解释器一起交替运行。每当解释器分配了一定数量的内存时,垃圾收集器也执行一小步(这意味着,在垃圾收集器工作期间,解释器可能会改变一个对象的可达性。为了保证垃圾收集器的正确性,垃圾收集器中的有些操作具有发现危险改动和纠正所涉及的对象标记的内存屏障)。

Lua 5.2引入了紧急垃圾收集。当内存分配失败时,Lua语言会强制进行一次完整的垃圾收集,然后再次尝试分配。

 

2.5.5.1、一些辅助垃圾回收机制的工具

2.5.5.1.1、弱引用表

所谓弱引用(weak reference)是一种不在垃圾收集器考虑范围内的对象引用。如果对一个对象的所有引用都是弱引用,那么垃圾收集器将会回收这个对象并删除这些弱引用。

一个表是否为弱引用表是由其元表中的__mode字段所决定的。当这个字段存在时,其值应为一个字符串:如果这个字符串为“v”,那么代表这个表的值是弱引用的;如果这个字符串是“kv”,那么这个表的键和值都是弱引用的。举个栗子:

上面的示例中虽然我们在table a 中创建了两个key,分别对应值1和2。但是由于设置了a的元表中的__mode的弱引用表的模式为“k”。我们看到当key被赋值2次时,第一次赋给key的值就不再被强引用了,这样在强制垃圾回收后a 表中第一个键值对就被垃圾回收器从表中移除了。

谈到弱引用表,这里需要提一下“瞬表”的概念:

一种棘手的情况是,一个具有弱引用键的表中的值又引用了对应的键。一个典型的示例是敞亮函数工厂。这种工厂的参数是一个对象,返回值是一个被调用时返回传入对象的函数:

不过,这里另有玄机。请注意,表mem中与一个对象关联的值(函数)回指向了它自己的键(对象本身)。虽然表中的键是弱引用的,但是表中的值却不是弱引用的。从一个弱引用表的标准理解看,这个表里面没有任何东西会被移除。由于值不是弱引用的,所以对于每一个函数来说都存在一个强引用。每一个函数都指向其对应的对象,因而对于每一个键来说都存在一个强引用。因此,即使有弱引用的键,这些对象也不会被回收。

Lua语言通过瞬表的概念来解决这个问题。在Lua语言中,一个具有弱引用键和强引用值的表是一个瞬表。在一个瞬表中,一个键的可访问性控制着对应值的可访问性。更确切的说,考虑瞬表中的一个元素(k, v),指向的v的引用只有当存在某些指向k的其他外部引用存在时才是强引用,否则即使v (直接或间接地)引用了k,垃圾收集器最终会收集k并把元素从表中移除。

 

2.5.5.1.2、析构器

垃圾收集器不仅可以回收对象,也可以帮助程序释放资源。出于这个目的,几种编程语言都提供了析构器。析构器是一个与对象关联的函数,当该对象即将被回收时该函数会被调用。

Lua 语言通过元方法__gc实现析构器,例如:

在本例中,我们首先创建一个带有__gc元方法元表的表,然后把这个变量o赋值为nil,也就是抹去了与这个表的唯一联系(全局变量),再强制进行一次完整的垃圾回收。在垃圾回收期间,Lua 语言发现表已经不再是可访问的了,因此会调用表的析构函数,也就是元方法__gc。

 

 

赞(1) 打赏
未经允许不得转载:花花鞋 » 小刚带你深入浅出理解Lua语言
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

国内精品Android技术社区

联系我们

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏