
很多人第一次接触 Lua通常不是因为想学一门新语言而是因为某个软件或游戏脚本里写了几行.lua文件被要求改一改、调试一下。看代码能猜出大概意思但真让自己写又不知道该从哪下手。这篇文章我就从 Lua 基本语法说起把这些年在各种嵌入式脚本、游戏客户端脚本、自动化配置里用到的知识梳理一遍。Lua 的语法在主流编程语言里属于非常容易上手的那一类但它的表table、元表metatable、闭包这些概念如果不理解后面很容易写出一堆又长又绕的代码。这篇内容适合两种人一是完全没写过 Lua 的纯新手二是已经能写简单脚本、但想补全细节、改掉坏习惯的人。1. 在开始聊语法之前先说说 Lua 到底是个什么样的语言1.1 Lua 的定位和典型场景Lua 是一个轻量、可嵌入的脚本语言。它的设计目标不是像 Java 或者 C 那样构建大型独立应用而是作为宿主程序的“扩展模块”存在。游戏行业的 UI 脚本、自动化工具、嵌入式设备的控制逻辑、网络服务里的请求处理都会用到它。你可能用过一些鼠标宏软件里面支持的就是 Lua一些游戏里的功能插件、界面定制底层也是 Lua很多网关设备上的过滤规则同样可以用 Lua 来写。正因为这样写 Lua 的时候心态要摆正你不是在写一个从 main 函数开始的完整程序而是在写一个“被别人调用的脚本”。这意味着环境里有哪些全局函数、宿主会调用哪个入口往往比语言标准本身更重要。很多新手一上来就纠结“为什么我写的脚本不能独立运行”其实方向搞错了。1.2 学习基本语法前需要建立的两个认知第一个认知Lua 没有“类”这个关键字但万物皆可表。数组、字典、对象、命名空间甚至连模块本质上都是 table。你不需要寻找 class你需要学会使用 table 来模拟一切。第二个认知Lua 的变量默认是全局的这一点和多数语言差别很大。很多脚本运行不正常不是因为语法写错而是因为函数内部不小心创建了全局变量污染了环境。带着这两个认知去学语法你会发现 Lua 的结构其实特别清晰类型少、控制结构少、规则少。但正因为少每个规则背后的使用习惯就显得格外重要。接下来我从变量、类型、运算符开始一点一点展开。2. 变量、类型和运算符——先把手脚活动开2.1 动态类型与八种基础类型Lua 是动态类型语言变量本身不限制类型保存在变量里的值才有类型。你可以用type(x)来查看一个值的类型。官方把类型分成八种nil、boolean、number、string、function、table、userdata、thread。其中userdata一般由 C/C 宿主创建普通脚本碰得少thread是协程相关的类型。对入门来说先把前六种记熟就够了。这里最容易忽略的是number。在 Lua 5.3 之前number基本就是双精度浮点数5.3 开始区分为整数和浮点数但日常写代码时你并不需要强行区分写3是整数写3.14是浮点数做除法时可能得到浮点数做整除时用//。这种设计让 Lua 在数值处理上既灵活又省心你不用像在 C 里那样到处关注类型转换。nil代表“没有值”。给变量赋nil等同于把这个变量“删除”了。这一点在 Lua 里特别常用尤其是清理 table 中的某个键、停止某个回调时直接赋nil就是最干净的删除方式。2.2 变量的声明、作用域和 nil 的脾气Lua 的变量默认是全局的。哪怕你在一个函数内部写x 10如果前面没有加local这个x就会变成全局变量。刚开始写 Lua 的人非常容易在这里翻车特别是从 C、Java 转过来的人会默认“函数里的变量就是局部变量”。在 Lua 里这个默认习惯不成立。所以我给所有初学者的第一个建议是所有变量默认加local。局部变量作用域从声明处开始到所在代码块结束。这里的代码块可以是一个函数体也可以是一对do...end、一个循环体。局部变量不仅更安全访问速度也比全局变量快。虽然现在脚本规模普遍不大性能差异不明显但养成这个习惯可以避免大量命名冲突。nil有两个容易踩的脾气第一一个变量没赋值时默认就是nil第二table 中访问不存在的键返回nil。所以代码里经常用if not x then来检查“没有”。但注意false不是nilnot false的结果是true。如果你的逻辑里需要区分“存在但是 false”和“根本不存在”就要用x nil来精确判断而不是简单写if not x。2.3 运算符和常见坑算术运算符有 - * / // % ^。其中//是整除5 // 2得到2%是取模^是幂。字符串的连接操作符是..不是。如果你写10 1Lua 会尝试把字符串转换成数字能转就变成11不能转就直接报错。这个行为有时很方便但更多时候是隐患。想明确做字符串拼接就用..并配合tostring转换。比较运算符里最需要注意的是不等于号Lua 写~不是!。写惯了 C 语言的人很容易在这里被语法报错提醒半天。用来比较值但 table 类型的比较是比较引用地址不是比较内容。就算两个 table 里面数据完全一样只要它们是两个不同对象结果也是false。想比较内容必须自己写遍历或者用序列化方式对比。逻辑运算符and、or、not也很有特色。a and b如果a为 false 或 nil返回a否则返回ba or b如果a为真返回a否则返回b。所以你会经常看到local config myConfig or {}这种写法用来给变量设置默认值。这个技巧很灵巧但它有一个陷阱如果myConfig的值是false同样会被默认值替换。如果你把false当成有效值就别偷懒用这个技巧。3. 条件、循环和函数——Lua 流程控制的正确姿势3.1 条件判断的写法与习惯Lua 的条件判断是if 条件 then ... elseif 条件 then ... else ... end。注意三个细节第一elseif是一个词不要写成else if否则语法会立刻出错第二每个if都要有对应的end如果你写了很多层嵌套务必检查end数量第三Lua 没有switch语句多分支逻辑除了用连续的elseif也可以考虑用 table 映射。在 Lua 里只有false和nil会被当作“假”数字0、空字符串、空 table 都是“真”。这点和 JavaScript、C 都不一样。我见过一个典型的错误从 C 语言习惯延续过来写if count then判断数量是否为 0结果当count 0时Lua 依然认为条件成立导致逻辑完全反了。正确的做法是写if count ~ 0 then或者if count 0 then。判断字符串是否为空时也不能用if str then而应该判断str ~ 或str ~ nil。3.2 循环while、repeat、数值 for 和泛型 forLua 里有三种循环while、repeat...until、for。其中for用得最多又分成数值for和泛型for。数值for的语法是for i 1, 10 do ... end默认步长是 1。如果你写for i 10, 1 do ... end它不会执行因为默认步长是 1初始值已经大于结束值了。必须明确写步长for i 10, 1, -1 do ... end。循环变量i在循环体内部是只读的你想在循环里改它来调整流程是做不到的最好改用while循环。泛型for是 Lua 遍历 table 的主要方式。for k, v in pairs(t) do ... end可以遍历 table 中所有键for i, v in ipairs(t) do ... end则从索引 1 开始连续遍历到第一个 nil 为止。pairs不保证遍历顺序ipairs保证顺序但要求数组部分是“连续的”。在pairs遍历过程中尽量不要向 table 插入或删除键否则结果很难预料。while循环很简单while 条件 do ... end。还有一种repeat ... until 条件它先执行一次再判断条件条件为真时退出。要注意until后面不要加多余的分号也不要加end因为repeat...until本身就是一个完整的结构。3.3 函数多返回值、可变参数、闭包Lua 函数的定义方式非常轻量最直观的是function add(a, b) return a b end也支持匿名函数local double function(x) return x * 2 end函数在 Lua 里是一等值可以赋值给变量、存进 table、作为参数传递、作为返回值返回。你可以在函数内部再定义函数内层函数能访问外层函数的局部变量这个机制就是闭包。闭包在实际开发里非常有用比如做一个自增计数器local function createCounter() local count 0 return function() count count 1 return count end end local c createCounter() print(c()) -- 1 print(c()) -- 2Lua 函数支持多返回值return a, b一次返回多个值。接收时通常用local x, y f()。这里有个细节函数调用作为 table 构造的唯一元素时返回值会全部展开但如果不是唯一元素或处于表达式中间就只保留第一个返回值。为了不给自己挖坑最好显式用多个变量接收。可变参数用...表示local function sum(...) local args { ... } local total 0 for _, v in ipairs(args) do total total v end return total end注意...在 Lua 5.1 里有一个特殊的arg表可用但 5.1 之后已经废弃了不要写arg。想要获取可变参数个数可以用select(#, ...)。4. 表table——Lua 唯一的数据结构也是一切的核心4.1 表既数组也是字典Lua 只有一种数据结构就是 table。它可以当数组用也可以当字典用甚至可以同时干两件事。创建一个数组风格的 tablelocal colors { red, green, blue } print(colors[1]) -- red注意下标从 1 开始不是从 0 开始。这一点对新手来说需要一点时间去适应。while 循环遍历数组时很多人会习惯先写local i 0然后colors[0]结果发现是 nil。创建一个字典风格的 tablelocal person { name Lua, age 30, tags { script, fast } } print(person.name) -- Luaperson.name和person[name]是等价写法。如果键名里有特殊字符比如包含空格那只能用person[full name]这种形式。table 的键也可以是数字、布尔值甚至另一个 table但实际项目里用字符串键最多。4.2 表的常用操作和遍历顺序问题往 table 里添加一个键直接赋值即可删除一个键把值设为 nillocal t {} t.a 1 t.b 2 t.a nil判断键是否存在一般也用nilif t.a nil then。但这里又绕回前面那个问题了如果一个键的值本身是 nil那这个键等同于不存在。所以在 Lua 里不要想着“保存一个 nil 作为有效值”。遍历 table 时数组部分用ipairs字典部分用pairs。字典部分如果需要固定顺序输出不能依赖pairs要自己收集键名再排序local keys {} for k in pairs(t) do keys[#keys 1] k end table.sort(keys) for _, k in ipairs(keys) do print(k, t[k]) end#运算符用于取“数组部分”的长度但它只对连续整数键可靠。如果 table 中间有空洞#的结果可能不符合预期。所以判断 table 是否为空我推荐用next(t) nil这个写法在任何情况下都可靠。4.3 面向对象的雏形冒号语法Lua 没有 class但可以用 table 冒号语法模拟出面向对象的样子。先看一个最基础的结构local Account {} function Account:new(balance) local obj { balance balance } setmetatable(obj, { __index Account }) return obj end function Account:deposit(amount) self.balance self.balance amount end local acc Account:new(100) acc:deposit(50) print(acc.balance) -- 150这里的关键是冒号语法。定义function Account:deposit(amount)等价于function Account.deposit(self, amount)调用acc:deposit(50)等价于acc.deposit(acc, 50)。冒号只是自动帮你传了一个self参数并不是什么魔法。用setmetatable(obj, { __index Account })让对象在自身找不到方法时去Account表里找。这实际上模拟了“继承”。理解这个模式你就能理解很多 Lua 项目里class库的实现方式也能自己写简单的 OOP。5. 元表、模块和字符串模式匹配——进阶但绕不开的能力5.1 元表改变 table 行为的钩子元表metatable听起来玄乎其实就是一个普通 table用来定义另一个 table 的特殊行为。给一个 table 设置元表local t {} local mt {} setmetatable(t, mt)元表里最常用的字段是__index。当访问t中不存在的键时Lua 会去找t的元表的__index。如果__index是一个 table就去这个 table 里查如果是一个函数就调用函数并返回结果。很多游戏引擎里的 Lua API 都是利用__index实现回调查找的。__tostring也特别实用。直接print(t)一个 table默认输出类似table: 0x7f8a9a0没有任何信息。你可以重写它local t { name Lua, age 30 } setmetatable(t, { __tostring function(tbl) return tbl.name .. , .. tbl.age end }) print(t) -- Lua, 30其他常用的元表字段还有__newindex赋值不存在的键时触发、__call让 table 可以像函数一样调用、__add重载加法等。对初学者来说先用好__index和__tostring就够了但至少要知道存在这些钩子否则遇到奇怪的语法会一头雾水。5.2 模块与 require组织代码的约定Lua 没有强制性的模块语法但社区里形成了一套约定一个文件就是一个模块文件最后return一个 table。比如新建一个mymath.lualocal M {} function M.add(a, b) return a b end function M.sub(a, b) return a - b end return M在另一个文件里用require加载local mymath require(mymath) print(mymath.add(2, 3)) -- 5require会做两件事搜索指定名字的文件然后执行一次并缓存结果。同一个模块第二次require时不会重新执行而是直接返回缓存的那个 table。如果你想改代码后重新加载需要先把缓存清掉package.loaded[mymath] nil mymath require(mymath)这里有一个容易踩的坑写模块文件时文件名和require的字符串要对应模块内部不要写print一堆加载日志否则在日志系统里会很吵。尽量只返回一个干净的 table。5.3 字符串库和模式匹配比正则更轻量的选择Lua 的string库是日常脚本里使用频率最高的库之一。string.sub(s, i, j)取子串注意下标从 1 开始负数表示从末尾倒数。string.find(s, pattern)返回匹配位置的起始和结束下标找不到就返回 nil。string.match(s, pattern)直接提取匹配内容。string.gmatch用于迭代匹配。string.gsub做全局替换。Lua 的模式匹配不是完整的正则表达式它有自己的轻量语法。常见的模式元素%d匹配数字%a匹配字母%s匹配空白.匹配任意字符%D、%A是大写形式表示取非()表示捕获-表示最短匹配*和表示贪婪匹配举个例子从一行日志里提取时间和级别local line 2025-06-01 10:30:00 ERROR something failed local date, time, level line:match((%d%-%d%-%d) (%d:%d:%d) (%a)) print(date, time, level)line:match(...)等价于string.match(line, ...)这是冒号语法在字符串库上的应用。模式匹配平时解析配置、提取数值、清理日志都非常方便。不用一上来就学完整正则Lua 这套模式已经能覆盖大部分脚本需求。6. 错误处理、协程和调试——写脚本时保命的三个工具6.1 pcall/xpcall 和错误对象Lua 脚本在出错时会抛出错误默认行为是中断。如果你用的是宿主嵌入式环境错误信息可能直接消失非常难排查。所以写正式脚本时我习惯在关键入口外面包一层pcalllocal ok, err pcall(function() -- 你的业务逻辑 end) if not ok then print(脚本执行失败: , err) endpcall返回两个值第一个是是否成功第二个是返回值或错误信息。这样即使内部出错外面也能捕获到。xpcall比pcall更进一步可以传一个错误处理函数一般用来打印更完整的堆栈local ok, err xpcall(function() error(自定义错误) end, function(msg) return debug.traceback(msg) end) print(ok, err)在 Lua 5.2 之后错误对象不一定是字符串可以是任意值。所以打印错误信息时最好调用tostring转一下避免遇到 table 类型的错误对象时只输出一堆地址。6.2 协程的快速入门与适用场景协程coroutine是 Lua 里很有特色的机制不过它不算“基本”内容。简单理解协程是可以暂停和恢复的函数。coroutine.create创建一个协程coroutine.resume启动或继续执行coroutine.yield让出执行权。看个最简单的例子local co coroutine.create(function() print(第一次运行) coroutine.yield() print(第二次运行) end) coroutine.resume(co) -- 第一次运行 coroutine.resume(co) -- 第二次运行协程最常见的用途是生成器。比如逐个产生斐波那契数列的数字local function fibGen() local a, b 0, 1 return coroutine.wrap(function() while true do a, b b, a b coroutine.yield(a) end end) end for v in fibGen() do print(v) if v 100 then break end end在实际项目里协程也适合做状态机、异步任务协调但如果你只是写简单的业务脚本可以先跳过等用到再学也不迟。别因为这个东西看起来高级就把它硬塞到不需要的代码里反而增加理解成本。6.3 调试技巧print 之外的几种方法脚本调试第一反应是print这个没错。但在嵌入环境里print不一定把内容输出到终端。有些宿主会改写print到日志文件有些甚至不提供。你可以先试运行一下看看输出在哪里。如果print不可用就优先用宿主提供的日志接口。还有几个习惯能大大减少调试时间。第一多用assert做前置检查local function process(data) assert(data ~ nil, data 不能为空) end只要条件不满足立即中断并给出错误信息。第二在关键分支里加“哨兵日志”哪怕只输出一个标记也能知道代码走到了哪一步。第三写脚本时给函数加注释说明参数类型和作用。Lua 是动态类型函数不会帮你检查参数一个nil传进算术函数就会报错。注释写得清楚能省掉很多排查时间。7. 常见问题速查表和一点经验总结7.1 新手最容易踩的六个坑我整理了一份新手常见的错误对照表你可以直接当速查用错误现象原因正确做法for i 10, 1 do不执行默认步长是 1初始值已经大于结束值写for i 10, 1, -1 doif x then在x 0时成立Lua 只有false和nil是假0 是真写if x ~ 0 then函数内赋值变成了全局变量没加local所有变量默认加localelse if报错elseif是一个词写elseif不要拆开#t得到的结果和预期不符table 的数组部分有空洞用next(t) nil判断空t[1] nil之后数组坏了Lua 数组不允许中间出现 nil删除中间元素用table.remove补充一个很实际的经验不要在 table 数组部分存nil。table.insert插入nil会直接报错给已有索引赋nil则会让#和ipairs变得不可靠。如果确实需要“空位”可以考虑用false或者一个占位字符串代替原来的 nil这样数组仍然保持连续。7.2 我的一点经验心得我写 Lua 这几年最大的感受是语法简单不等于知识量少真正实用的东西全藏在细节里。local的作用域、nil的语义、table 的遍历差异这些都是考试不会考但项目里天天遇到的点。我的建议是不要光靠看抽一个下午把for、pairs、ipairs、元表的__index、字符串模式匹配这几个内容各写一个小 demo 跑一遍你很快就能掌握 Lua 的语感。还有一个我一直在用的习惯脚本文件开头先把常用库引用到局部变量里比如local print print local tostring tostring local pairs pairs local ipairs ipairs这样写有两个好处。第一访问局部变量比全局变量快一点第二即使宿主环境有人不小心覆盖了全局print你的脚本仍然能用原来的函数。别小看这个习惯我遇到过一次宿主把print改成了带缓冲的日志接口结果脚本里的调试输出全部延迟排查了很久才发现是这个原因。掌握了这些基础接下来再去读具体的宿主 API、项目文档都会顺畅很多。