Lua 速成笔记
简单学一下Lua这个有点过时的轻量级脚本语言吧,因为很多工具(nvim、xmake、MySQL等)都采用了Lua脚本提供配置,
而且直到现在,在c++项目使用Lua脚本提供配置也是一个可以考虑的方案。
关于Lua的教程很多都是速成版的,因为内容实在比较简单,比如Learn Lua in Y minutes。
编译安装
Lua是一种开源的脚本语言,完全使用C语言编写,Lua官网直接提供了源码,
在Linux系统中的下载和源码编译流程如下
1 | curl -L -R -O https://www.lua.org/ftp/lua-5.4.7.tar.gz |
编译命令非常简单,编译完成后可以得到两个产物:lua和luac,两者仍然存放在src/文件夹中,将其移动到其它位置,
然后将路径添加到环境变量即可。其实编译产物中还有一个Lua库,用于嵌入到C语言项目中,但是暂时不需要。
由于Lua的源码编译过程本身非常简单,我们可以迁移到Windows上进行源码编译,为了方便还可以把Makefile改成CMakeLists.txt,
为了简化直接删除了安装和卸载的部分
1 | cmake_minimum_required(VERSION 3.10) |
基本使用
Lua是一种解释型的脚本语言,解释器lua既可以交互式使用,也可以执行整个脚本文件(习惯使用.lua作为文件后缀),
还可以预先使用编译器luac将脚本编译为不可读的字节码文件(习惯使用.luac作为文件后缀,但默认名为luac.out),
解释器可以更高效地执行字节码文件,无需提供原始的脚本文件。
示例如下
1 | # (1) |
注意:
lua解释器的使用方式优点奇怪,需要使用os.exit()才能退出解释器,不支持常见的退出操作和清屏指令,甚至不支持上下方向键和其它常见的快捷键;luac的参数选项不能随便更改顺序,例如-o xxx放在脚本文件名后面会解析错误。
基本语法
Lua的标识符通常包括大小写字母,下划线和数字,不允许以数字开头,区分大小写。
以下划线加大写字母形式的标识符为Lua的保留标识符,建议不要使用。
Lua 的保留关键词如下
1 | and break do else elseif end false for function if in |
Lua支持单行和多行注释,示例如下
1 | --两个减号是单行注释 |
虽然#不被视作注释,Lua脚本对形如#!/path/to/lua的脚本开头行也是支持的。
和Python类似,Lua的语句可以但是不要求使用分号;结尾,但是如果希望把多个语句写在同一行,则必须加上分号。
在Lua中可以使用print()函数进行输出,在解释器中如果一个表达式的结果没有被变量接收,也会立刻显示出来。
变量
Lua的变量机制大体上和Python类似,变量在使用之前不需要声明,直接赋值即可。
在Lua中访问没有经过初始化的变量甚至不会出错,而是会返回nil。
Lua和Python在变量部分的最大语法区别是:默认的变量总是认为是全局的,除非在定义时专门加上local修饰。
涉及作用域的具体规则如下:
- 在默认情况下,所有变量(包括函数)都是全局的,可以在整个程序中访问。
- 使用函数和控制结构可以创建独立的局部作用域。
- 使用
local关键字声明变量,可以将作用域改为局部的。 - 局部变量会遮蔽同名的全局变量,在读取和修改变量时,优先查找局部变量,找不到时会向外查找全局变量。
- 对于找不到定义的变量,赋值意味着初始化,此时会自动创建全局变量而非局部变量。
和Python类似,Lua自带了一套包括垃圾回收的内存管理机制,我们不需要也没有办法直接管理内存。
和Python类似,Lua也没有什么常量的概念,但是也支持通过一些复杂的做法让修改报错,或者只返回副本,从而间接达到不可修改的目的。
基本数据类型
Lua 有八个基本数据类型:nil、boolean、number、string、userdata、function、thread 和 table,
我们主要关注其中最基础的前四个类型。
使用type(X)可以获取变量X的类型字符串,例如
1 | type(x) -- nil |
nil类型只有一个同名的值nil,语义与Python的None类似,但是在Lua中发挥了更大的作用:
- 输出一个未定义的变量,会显示
nil; - 对一个变量赋值为
nil,相对于删除这个变量;
例如判断一个标识符是nil(注意type()返回的结果是字符串)
1 | type(x) == 'nil' -- true |
boolean类型只有两个可选值:true(真) 和 false(假),
需要注意,Lua将其它类型变量转换为boolean类型时的逻辑为:
nil被视作false;- 其它所有都视作
true,包括数字0和空字符串''!
Lua只提供了双精度浮点数这一种数值类型,没有单独提供整数类型。使用tonumber函数可以尝试将变量转换为数值
1 | a = tonumber('123') |
Lua的字符串可以由一对双引号或单引号包裹,例如
1 | string1 = "this is string1" |
也可以用 2 个方括号 [[]] 来表示多行的字符串。
使用..而不是通常的加号来拼接字符串
1 | a = 'hello,' .. 'world.' |
使用#可以计算字符串的长度(也可以用于计算表的长度)
1 | print(#'abc') -- 3 |
字符串对于特殊字符需要使用转义处理,例如回车\n。
使用tostring函数可以尝试将变量转换为字符串
1 | a = tostring(123) |
对一个可以转换为数字的字符串进行算术操作时,Lua会尝试将这个数字字符串转成一个数字(很离谱的语法糖)
1 | print('1'+3) -- 4 |
Lua内置了一个string库,无需导入即可直接使用,里面提供了很多常用的字符串操作,例如
1 | -- 截取字符串 |
基本运算
Lua的赋值语法非常自由,支持多对多的赋值,例如交换两个值
1 | a, b = b, a |
并且两侧的个数可以不相等,对应的处理逻辑为:
- 如果左侧变量的个数少于右侧值的个数,将靠后的多余的值丢弃;
- 如果左侧变量的个数多于右侧值的个数,将多的变量赋值
nil。
Lua支持常见的算术运算,包括取余%和乘方^,除法/和整除//,但是不支持++和+=等简化运算。
Lua支持常见的比较运算,值得注意的是不等号是~=而不是!=。
Lua 以关键词形式提供逻辑运算,包括and、or和not,它们支持短路运算。
流程控制
直接给几个例子即可
if条件语句
1 | local n = 10 |
for循环语句
1 | local result = 0 |
while循环语句
1 | local result = 0 |
在上述结构中支持break和goto控制语句。
表 Table
Lua的table是一种强大且多功能的数据结构,类似于其他编程语言中的数组、字典或结构体。
作为事实上唯一的内置数据结构,Lua 的table可以被用来表示数组、哈希表、集合和记录(结构体)等。
例如Lua的所有全局变量都存储在名为_G的table中。
创建
首先,Lua的表可以当作列表使用,可以直接使用字面量来创建表,{}代表空表
1 | local tb1 = {} |
此时默认的索引从1开始。
其次,Lua的表在本质上更像是字典,可以用键值对的方式来创建表
1 | local tb = { |
这种创建方式中索引通常是字符串,也支持使用数字作为索引
1 | local t = { |
这和前面的数组风格的创建方式实际是等效的,并且这里可以自定义索引的开始,不要求数字是连续的。
两种索引当然可以混合使用
1 | local t2 = { |
table支持嵌套定义,例如
1 | local t = { |
使用
访问table时主要通过键来读写对应的值
1 | tb['key1'] = 'value0' |
对于以数组形式创建的表,自动使用从1开始的连续整数作为索引
1 | print(tb2[1]) |
读取使用不存在的键会返回nil,对不存在的键进行赋值会直接创建对应的项,使用nil进行赋值则代表删除对应的项。
如果键是字符串,在不引起歧义的情况下,Lua还提供如下的语法糖,使得读写类似于C语言中结构体的风格
1 | local tb = { |
在定义时,如果键是字符串并且不会引起歧义,也有类似的语法糖
1 | local tb = { |
遍历
对于使用从1开始连续整数索引的数组型table,可以使用ipairs函数进行遍历
1 | local array = { "a", "b", "c" } |
如果表的索引不满足要求,不适合使用这个函数进行遍历,因为ipairs的原理就是从1开始不断尝试访问元素,遇到nil就会停止。
对于更一般的表,可以使用pairs函数进行遍历
1 | local t = { |
函数
基础
Lua的函数语法和其它语言没什么区别,使用function关键词,需要使用return提供返回值,例如
1 | local function add(a, b) |
函数默认具有全局作用域,建议加上local修改为局部作用域。
Lua的函数传参机制和Python是类似的:
- 对基本数据类型(如数值、字符串、布尔值)相当于值传递;
- 对表(table)和函数等复杂数据类型相当于引用传递。
Lua甚至允许函数的实参和形参个数不匹配:
- 如果实参多于形参,靠后的实参被舍弃;
- 如果实参少于形参,不足的形参被赋值为
nil。
函数可以直接返回多值,不需要额外处理。
1 | local function swap(a, b) |
和Python一样,Lua把函数视为普通类型的一种,可以直接把函数作为变量传递,不需要使用函数指针或句柄的特殊处理。
1 | local function apply(func, a, b) |
Lua对于可变参数使用特殊的...表示,例如
1 | local function sum(...) |
在Lua中定义一个函数实际上等价于把一个匿名函数赋值给一个变量
1 | local add = function(a,b) |
例如我们可以将函数赋值给表中的一项
1 | local t = {} |
它完全等价于下面的语法
1 | local t = {} |
这说明在Lua在没有函数标识符的概念,只有统一的变量标识符。
Lua还提供了一个比较离谱的函数调用语法糖:如果只有一个实参,且这个实参是一个字符串字面量或者是table字面量,则可省略括号,例如
1 | func 'abc' -- func('abc') |
不过相比于MATLAB在无参数调用时可以直接省略括号的离谱语法糖,Lua的这个语法糖还是可以接受的。
进阶
Lua也支持嵌套函数、闭包和高阶函数,基本与Python相同,除了默认作用域的处理:Python的变量默认是局部的,而Lua的变量默认是全局的。
函数嵌套例如
1 | local function outer(a) |
闭包例如
1 | local function createCounter() |
高阶函数例如
1 | local function derivative(f, delta) |
I/O
基本 I/O
print函数用于向标准输出打印信息,支持多个参数,并在输出的各项之间自动添加空格。
1 | print("Hello, world!") -- 输出: Hello, world! |
io.write用于输出到标准输出,不自动添加空格或换行符。
1 | io.write("Hello, world!") -- 输出: Hello, world! |
io.read:用于从标准输入读取数据。可以指定读取模式,如整行读取、按字符读取等。
1 | local line = io.read() -- 读取一行输入 |
文件 I/O
Lua 提供了对文件进行读写的操作,需要通过 io 库的函数实现,文件操作对各个语言都是类似的,直接提供几个例子即可。
io.open函数以各种模式打开文件,并返回文件句柄
1 | local file = io.open("example.txt", "r") |
读取文件内容
1 | local file = io.open("example.txt", "r") |
写入文件内容
1 | local file = io.open("example.txt", "w") |
错误处理
使用error函数可以直接生成一个错误
1 | error("This is an error message!") |
使用assert函数可以创建断言: 如果条件为假,则生成一个错误。
1 | assert(1 == 1, "Condition failed!") |
使用pcall函数来调用一个函数,可以捕获该函数中出现的任何错误
1 | local status, result = pcall(function() |
模块
Lua 提供了模块化编程的支持,可以将代码组织成独立的模块,便于重用和共享。
简单示例
首先考虑一个单文件的简单模块,Lua 要求它的文件名必须以 .lua 结尾,并且需要返回一个表结构,可以在表中定义函数、变量等供外部调用。
例如我们创建一个名为 mymodule.lua 的文件,内容如下
1 | local mymodule = {} |
这里将函数放在了表中,并且将这个表返回,这样就得到了一个名为mymodule的模块。
在其他文件中,可以使用 require 加载并使用模块,在加载模块时会首先将模块的内容执行一遍,然后返回模块的最终返回值。
通常都会使用变量获取 require 的返回值,后续才能顺利调用模块所提供的内容。(否则仍然可以通过更底层的变量获取,但是不建议)
例如
1 | local m = require("mymodule") |
如果模块只需要一次性使用,也可以直接使用链式调用,例如
1 | require("mymodule").sayHello() |
Lua 使用了模块缓存机制,同一个模块只会加载一次,后续的重复加载都是直接返回缓存的模块内容,因此模块的内容只会在第一次加载时执行,例如
1 | require("mymodule").sayHello() |
复杂模块与模块加载
除了简单的单文件作为模块,Lua 还支持更复杂的模块结构,比如下面的目录结构
1 | project/ |
相当于提供:
mylib模块(作为文件夹,提供init.lua)mylib.utils模块(作为单个文件)mylib.math模块(作为文件夹,提供init.lua)mylib.math.extra模块(作为单个文件)
单独加载各个模块例如
1 | local lib = require("mylib") -- load mylib/init.lua |
需要注意的是:模块和子模块之间是几乎完全独立的,不会自动加载对应的子模块,
但是我们可以在 init.lua 中手动导入包含的子模块,以实现这种行为,例如
1 | -- mylib/init.lua |
此时在外部就可以一次性导入并使用模块以及子模块的所有内容,例如
1 | local lib = require("mylib") |
require 加载模块时:
- 对于模块
xxx,首先尝试加载xxx.lua,如果找不到,则尝试加载xxx/init.lua - 对于子模块
xxx.yyy,首先尝试加载xxx/yyy.lua,如果找不到,则尝试加载xxx/yyy/init.lua - 在某些特殊情况下,
xxx.lua和xxx/init.lua都被找到,Lua 会优先选择xxx.lua。
注意:require 接收的始终是模块名而非文件名,因此不能加后缀 .lua,也不能使用 a/b 这样的路径。
模块搜索路径
Lua 使用 package.path 来确定搜索模块的路径,搜索路径通常包括系统固定目录和当前目录。
使用下面的命令可以直接查看
1 | print(package.path) |
输出结果类似
1 | /usr/local/share/lua/5.4/?.lua; |
这里的各个匹配路径之间使用;分隔,每一个匹配路径包含一个占位符?,require 命令会将传入的参数经过处理后(将.换成路径分隔符)直接替换?,从而得到一个完整的搜索路径。
在加载自定义模块时,我们通常需要修改 package.path 来添加自定义路径,确保Lua可以找到对应模块,例如
1 | package.path = package.path .. ";./mylib/?.lua" |
此时 utils 模块就可以被直接导入,不需要使用 mylib.utils
1 | local util = require("utils") |
元表 (Metatable)
元表(metatable)是 Lua 提供的一种机制,用于改变表(table)的行为。
通过对元表(metatable)的设置可以自定义表(table)的操作行为,如算术运算、索引等,甚至可以用来实现面向对象机制。
有两个涉及元表的操作函数:
- 使用
setmetatable函数可以设置一个表的元表,第一个参数为目标的表,第二个参数为提供的元表,返回值是第一个参数,无论是否接收第一个参数,这里的修改都是有效的,因为传参过程相对于引用传递; - 使用
getmetatable函数可以获取一个表的元表,提供的参数就是目标的表。
运算符元方法
对于常见的运算符,有对应的元方法
__add对应运算符+__sub对应运算符-__mul对应运算符*__div对应运算符/__mod对应运算符%__unm对应运算符-__eq对应运算符==__lt对应运算符<__le对应运算符<=__concat对应运算符..
例如用自定义的加法行为实现表的相加
1 | local t1 = { value = 10 } |
还有一个__tostring元方法,用于自定义 tostring 函数行为,这个函数会被print自动调用,例如
1 | local t = { |
重要元方法
有几个重要的涉及表的固有行为的元方法:
__index: 自定义索引的访问行为。__newindex: 自定义新索引的赋值行为。__call:自定义调用行为
Lua 在查找一个表中的元素时,会涉及到__index元方法,按照如下规则处理:
- 在表中查找:如果找到,直接返回该元素;找不到则继续
- 判断该表是否有元表:如果没有元表,返回
nil;如果有元表则继续 - 判断元表有没有设置
__index:- 如果
__index没有定义,则返回nil; - 如果
__index是一个表,则进入这个表,从头重复上述步骤; - 如果
__index是一个函数,则传递原表和键给这个函数,并返回该函数的返回值。
- 如果
关于__index是表的例子如下
1 | local defaults = { |
关于__index是函数的例子如下
1 | local t = {} |
Lua 在处理对表中不存在的键进行赋值的情况,会涉及到__newindex元方法,按照如下规则处理:
- 在表中查找要赋值的键:如果键存在,直接更新表中该键的值;找不到则继续
- 判断该表是否有元表:如果没有元表,直接在表中添加这个键值对;如果有元表则继续
- 判断元表有没有设置
__newindex:- 如果
__newindex没有定义,则回到原表中添加键值对; - 如果
__newindex是一个表,则进入这个表,从头重复上述步骤; - 如果
__newindex是一个函数,则传递原表、键和值作为参数,并返回该函数的返回值。
- 如果
关于__newindex是表的例子
1 | local backup = {} |
关于__newindex是函数的例子
1 | local t = {} |
__call元方法允许表像函数一样被调用,将__call设置为函数的例子如下
1 | local counter = {} |
面向对象
Lua 本身不是一种面向对象的语言,但是我们可以使用已有的语法来模拟面向对象的特性,具体实现有很多种方案:
- 基于表和元表实现面向对象是最常见的方案;
- 基于闭包也可以实现面向对象。
基于元表的示例(一)
一个简单的基于元表的面向对象例子如下
1 | -- 定义 Person 类 |
冒号语法糖
上面使用原生的Lua语法的做法实在是太过繁琐了,Lua使用冒号:为面向对象机制提供了一点语法糖,让面向对象的语句稍微简化了一点。
例如对于方法的调用来说,使用:会自动将调用者自身作为第一个参数传递给函数。
1 | alice.greet(alice) |
对于方法的定义也支持使用:简化,此时约定加上self作为函数的第一个参数,self不需要出现在函数形参列表中,但是仍然可以在函数体中正常使用。
1 | Student.study = function(self) |
这时不能使用Student:study = function ...的语法,不符合约定。
基于元表的示例(二)
使用冒号语法糖对前面的例子进行简化,可以得到如下代码
1 | -- 定义 Person 类 |
基于闭包的示例
基于闭包的面向对象例子如下,这里我们仍然使用了冒号:语法糖
1 | -- 定义 Person 类 |
