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

1、前言

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

2、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嵌入到我们的应用中,用于拓展应用的能力。

2.1、Lua与宿主程序的关系

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

2.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。
    1. lua_state:包含两个栈,Callinfo 栈(方法调用栈) 和 TValue 栈(数据栈,关于TValue的介绍)。分别用于缓存函数的调用信息的链表和参数传递。在Lua内部,参数的传递是通过数据栈,同时Lua与C等外部进行交互的时候也是使用的栈。
    2. global_state: 负责全局的状态,比如GC相关的,注册表,内存统计等等信息
2.1.1.1 lua_state、call_info调用栈、数据栈之间的关系

参考链接:链接
图1

callinfo 结构组成一个双向链表,它的结构如下:
图2
其中lua_State的base_ci指向第一层调用,而ci则记录着当前的调用。
CallInfo会占用栈的一部分,用来保存函数参数,本地变量,和运算过程的临时变量。如图1中callinfo到lua_stack的部分空间映射。

2.1.1.2 global_state全局状态

从Lua 源码lstate.h中定义的global_State的结构我们可以了解global_state包含的信息:

/*
** 'global state', shared by all threads of this state
*/
typedef struct global_State {
  lua_Alloc frealloc;  /* function to reallocate memory */
  void *ud;         /* auxiliary data to 'frealloc' */
  l_mem totalbytes;  /* number of bytes currently allocated - GCdebt */
  l_mem GCdebt;  /* bytes allocated not yet compensated by the collector */
  lu_mem GCmemtrav;  /* memory traversed by the GC */
  lu_mem GCestimate;  /* an estimate of the non-garbage memory in use */
  stringtable strt;  /* hash table for strings */
  TValue l_registry;
  unsigned int seed;  /* randomized seed for hashes */
  lu_byte currentwhite;
  lu_byte gcstate;  /* state of garbage collector */
  lu_byte gckind;  /* kind of GC running */
  lu_byte gcrunning;  /* true if GC is running */
  GCObject *allgc;  /* list of all collectable objects */
  GCObject **sweepgc;  /* current position of sweep in list */
  GCObject *finobj;  /* list of collectable objects with finalizers */
  GCObject *gray;  /* list of gray objects */
  GCObject *grayagain;  /* list of objects to be traversed atomically */
  GCObject *weak;  /* list of tables with weak values */
  GCObject *ephemeron;  /* list of ephemeron tables (weak keys) */
  GCObject *allweak;  /* list of all-weak tables */
  GCObject *tobefnz;  /* list of userdata to be GC */
  GCObject *fixedgc;  /* list of objects not to be collected */
  struct lua_State *twups;  /* list of threads with open upvalues */
  unsigned int gcfinnum;  /* number of finalizers to call in each GC step */
  int gcpause;  /* size of pause between successive GCs */
  int gcstepmul;  /* GC 'granularity' */
  lua_CFunction panic;  /* to be called in unprotected errors */
  struct lua_State *mainthread;
  const lua_Number *version;  /* pointer to version number */
  TString *memerrmsg;  /* memory-error message */
  TString *tmname[TM_N];  /* array with tag-method names */
  struct Table *mt[LUA_NUMTAGS];  /* metatables for basic types */
  TString *strcache[STRCACHE_N][STRCACHE_M];  /* cache for strings in API */
} global_State;

global_state包含以下信息:

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

下图描述了gloable_state里面比较主要的一个部分——注册表

ENV: 图中Lua脚本的的上值_ENV就是注册表里面的全局表 _G ,它是通过LUA_RIDX_GLOBALS这个索引从注册表里面索引过来的。Lua脚本中的所有对全局变量的引用都是对 _G 的引用,不过不是直接操作 _G ,而是指向 _G 的另一个参数 _ENV

2.1.1.3 _ENV 和 _G

如上图所示,Lua脚本中访问全局变量实际上是访问的_ENV 表(table类型), 脚本中对全局变量访问的代码在编译后会被Lua编译器加上_ENV前缀。那么_ENV究竟是什么呢?在Lua语言里,Lua会把所有的代码段都当作匿名函数来处理,而同时也会把_ENV作为该匿名函数的上值绑定到该匿名函数,所以Lua里面的脚本实际会做如下的转换:
普通Lua代码写法:

x = 10
local y = 20
z = x + y
 
print("z:" .. tostring(z))
 
--output
--[[
    z:30
]]

经过编译器实际转换后会变成这样:

local _ENV = _G
local func = function(...)
    _ENV.x = 10
    local y = 20
    _ENV.z = _ENV.x + y
    
    print("z:" .. tostring(_ENV.z))
end
 
func()
 
 
--output
--[[
    z:30
]]

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

3、Lua语言基础

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

3.1、词法规范

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

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

3.1.1、注释

单行注释:

-- 这个是注释内容

多行注释:

--[[注释内容]]

--[[
this is multiline annotatiaon
-–]]

--[[
this is multiline annotatiaon
]]

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

---[[
local f = function()
    print("method in annotation")
end
--]]
-- 这里可以继续调用函数 f
f()

3.1.2、变量

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

g_var = "this is glocal variable"
local loc_var = "this is local variabl"
print("g_var:" .. tostring(g_var) .. ", loc_var:" .. tostring(loc_var))
 
--output
--[[
    g_var:this is glocal variable, loc_var:this is local variabl
]]

3.2、 Lua 基本类型

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

3.2.1、nil

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

local a = "aaa" .. tostring(b)

3.2.2、number

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

3.2.3、string

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

3.2.3.1、string常使用的方式举例
  • 获取长度
    Lua中获取字符串的长度有两种方式:**#** 字符串和 string.len(字符串) 。这里推荐使用 “#” 操作符,因为 string.len() 实际需要先查找 string(table)再找其下的len,然后传参调用,至少需要 4 条 lua vm bytecode;而#直接被翻译为LEN指令,一条指令就可以算出来。
local a = "aaa"
local a_len = utf8.len(a)
 
local b = "你好"
local b_len1 = utf8.len(b)
local b_len2 = #b
local b_len3 = string.len(b)
 
print("a_len:" .. a_len .. ", b_len1:" .. b_len1 .. ", b_len2:" .. b_len2 .. ", b_len3:" .. b_len3)
 
-- 输出
-- a_len:3, b_len1:2, b_len2:6, b_len3:6
  • 字符串连接
    另一个用的比较多的是字符串连接操作符—— “ .. ”,这里需要关注2个点:
    1. .. 操作符不能操作 nil 类型。如前面在介绍nil类型时提到的,nil是告诉系统这个变量没有初始化时的类型,只有在使用判断符号和赋值符号不会出错,其他操作符号都会出错。所以在使用 “..” 连接操作符时务必要检查它不为nil
    2. 字符串连接操作符在连接多个操作符时实际会创建字符串的多个副本,会大量使用该操作符时会带来内存和CPU的开销,在Lua中也可以像其他语言一样使用字符串缓存的方式来处理(例如:Java中的 StringBuilder,Lua中可以用 table.concat)。代码示例如下:
local str = ""
 
local begin_time = os.time()
for i=1, 300000 do
    str = str  .. "[xxxxxxxxxxx],"
end
local delay = os.time() - begin_time
 
print("first delay:" .. delay)
 
str = ""
begin_time = os.time()
local buffer = {}
for i=1, 300000 do
    table.insert(buffer, "[xxxxxxxxxxx]")
end
 
str = table.concat(buffer, ",")
delay = os.time() - begin_time
print("second delay:" .. delay)
 
--output
--[[
first delay:87
second delay:1
]]

从以上代码的执行结果来看table.concat的方式处理字符串连接非常高效的,普通连接符的方式花了87秒,而table.concat只花了1秒。在实际的开发过程中,我们也需要牢记这一点。

  • 长文本字符串定义
    在其他语言例如Java中定义长文本需要关注文本中的换行和字符转义,Lua提供了一种非常方便的定义方式—— [ [ 长字符串 ] **]**。
    代码示例如下:
local a = [[
adfadfadfadf,
hello world
{
    "conio":1,
    "b":2,
    "c": [
        1,2,3
    ]
 
}
]]
 
print(a)

也就是说Lua作为一门嵌入式脚本语言它在处理数据上提供了很多便利的语法糖的。

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

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

  • gsub
    是字符串替换函数,它的第3个参数可以是一个表(table)或者一个function,用于查找和处理替换内容。接下来举一个🌰:
    下面的示例展示了利用gsub的第3个参数为function时可以将一个字符串解析为另一种table查找的表达方式。这种用法在一些结合 查找table元素 的场景确实非常有用。这也正是Lua的强大之处。
local share_module = {
    qq = {
        qzone = {
            img = function()
            end,
            text = function(message)
                print("share with content:" .. tostring(message))
            end
        },
        chat = {
 
        }
    }
}
 
local share_protocal = function(module, method, text)
    local invoke_fun = module
    string.gsub(method,'[^\\.]+',function(w)
        invoke_fun=invoke_fun[w]
    end)
 
    print("share_protocal after gsub:" .. tostring(invoke_fun))
    invoke_fun(text)
end
 
share_protocal(share_module, "qq.qzone.text", "hello world")
 
-- output
--[[
    share with content:hello world
]]
  • string.pack和string.unpack
    这两个函数用于在 二进制数据和Lua的基本类型值之间进行转换 的函数。string.pack 会把值“打包”成二进制字符串,而函数 string.unpack 是从二进制字符串中提取这些值。关于这个API的参数介绍参考这里。举个栗子:
local s = string.pack("s1", "hello")
for i=1, #s do
    print(string.unpack("B", s, i))
end
 
-- output
--[[
5   2
104 3
101 4
108 5
108 6
111 7  
]]

对于函数里面的format字段的介绍,可以参考这篇文章:链接
s[n]: 长度加内容的字符串,其长度编码为一个 n 字节(默认是个 size_t) 长的无符号整数。上面的示例中 s1 中的 1 代表用一个字节来存放字符串的长度。输出中看出在我们逐个打印每个字节时,第一行是是 “5 2”,表示长度是 “5”,并且 “ 2 “代表下一个未读的字节的索引,因为这里是逐个字节读取,所以下一个未读的字节的索引是 2 了。
string.pack和string.unpack在网络传输中打包传输字节数组经常使用到,另外在不同的系统之间传递数据也经常使用。将各种类型的数据打包成字节数组来传递,非常的高效和方便。