🧭 编程学习站

Lua 入门 + 进阶训练

公网版 · 手机/电脑自适应

纯新手先看这一框

1

Lua 适合做什么?游戏脚本(魔兽/WoW 插件、Roblox、Love2D)、嵌入式与配置文件、Redis/Nginx 脚本、Neovim 插件、OpenResty 网关。它是"胶水 + 嵌入式"语言,常被嵌进 C/C++ 程序当配置和逻辑引擎。

2

为什么学?极简(核心语言不到 30 个关键字)、极易嵌入 C、支持热更新(改脚本不重启服务)、学习曲线平缓。会 Lua,你能给很多大型系统写"可插拔逻辑"。

3

怎么跑?本站每节"动手实验室"点 ▶ 直接出结果;本地装 Lua:官网 lua.org 下载,或终端 brew install lua / apt install lua5.4,然后 lua hello.lua

4

新手最常踩的坑:① 数组默认从 1 开始,不是 0;② 没写 local 的变量全是全局,会悄悄污染;③ nil 索引会报错;④ 只有一种数据结构 table,数组/字典/对象/类全靠它。把这几条记牢,能少绕三小时。

1. Lua 是什么与怎么跑先建立全局认识

Lua(葡萄牙语"月亮")是 1993 年由巴西 PUC-Rio 推出的轻量级、可嵌入脚本语言。它的设计哲学是"用 C 做繁重的事,用 Lua 做灵活的事"——整个语言 + 虚拟机编译后只有几百 KB,却支持闭包、协程、动态类型、自动内存管理。

一句话定位Lua 不跟 Python/Java 抢"通用开发",它抢的是"把脚本能力嵌进别的程序"这个生态位。你玩的很多游戏、用的很多中间件,底层都在跑 Lua。

三种运行方式

  • 交互式(REPL):终端输入 lua 进入,逐行敲、回车即执行,适合试语法。
  • 运行脚本文件lua 文件名.lua,生产环境主流方式。
  • 嵌入到 C/C++:用 lua_State* API 在宿主程序里加载并执行 Lua 脚本,这是 Lua 的看家本领。
版本注意现在主流是 Lua 5.4(本站练习场即用 5.4.7)。5.1(很多老游戏)、5.3(整数子类型)仍在存量系统里大量存在,语法 95% 兼容,少数细节(如整数除法 //、环境变量)略有差异。

2. 第一个程序与注释从 print 开始

Lua 没有"主函数"概念,脚本从上往下顺序执行。print 是最常用的输出,自动换行。

-- 这是单行注释
print("Hello, Lua!")          -- 直接输出字符串

--[[ 这是多行注释
     可以写很多行
     常用于临时屏蔽大段代码 ]]

-- 打印多个值,用逗号分隔,print 会用制表符连接
print("和是", 1 + 2, "类型是", type(3))
细节print 各参数之间默认用 制表符 \t 分隔(不是空格)。要精确控制格式,用 string.format(见第 10 节)。
易错多行注释 --[[ ]] 如果要"可嵌套取消",写成 --[=[ ]=] 这种不同层级的等号配对,避免内部再出现 ]] 提前截断。

3. 变量与 8 种类型动态类型,但每个值都有明确类型

Lua 是动态类型:变量本身没有类型,值才有。用 type(v) 查看。一共 8 种基本类型:

类型含义例子
nil空 / 不存在nil(未赋值的变量都是它)
boolean布尔true / false
number数字(5.3+ 区分浮点/整数)3, 3.14, 0x1F
string不可变字符串"hi", 'hi', [[多行]]
table唯一的复合结构{1,2,3}, {x=1}
function函数(一等公民)function() end
userdataC 侧数据(宿主对象)文件句柄、C 结构体
thread协程coroutine.create(...)
x = 10
print(type(x))        -- number
x = "hello"
print(type(x))        -- string(同一个变量换了类型)
print(type(nil))      -- nil
print(type(print))    -- function

-- 只有 false 和 nil 是"假",其余包括 0 和空串 "" 都是真!
if 0 then print("0 是真") end   -- 会打印
致命坑Lua 里 0"" 都是真值!和 C/Python 不一样。写 if x then 时,若 x 可能是 0 或空串,逻辑会和你想的不一样——要显式判断 if x ~= nil and x ~= 0 then

4. 字符串不可变、自动转换、长括号

字符串用双引号或单引号包裹,不可变(改字符串会生成新串)。连接用 ..(不是 +)。取长度用 #

local s = "Lua"
print(s .. " 很轻量")     -- 连接:Lua 很轻量
print(#s)                -- 长度:3
print("a" .. 1)          -- 数字自动转字符串:"a1"

-- 长字符串 / 多行:用双方括号 [[ ]],内部引号不用转义
local sql = [[
  SELECT * FROM users
  WHERE name = 'bob'
]]
print(sql)

-- 转义:\n \t \\ \" 等;单双引号等价
print("一行\n两行")
易错连接数字必须显式用 .."值=" .. n。若写成 "值=" + n,Lua 会尝试把 "值=" 转数字失败 → 报错。另外 # 取字符串长度按字节算,UTF-8 中文占 3 字节,所以 #"中文" 是 6,不是 2;要数字符用 utf8.len
性能在循环里反复 .. 拼接大字符串会生成很多中间串。大量拼接请用 table.concat(list) 一次性合并,速度快得多。

5. table 表(核心中的核心)唯一的数据结构,撑起一切

Lua 只有一种复合类型 table,它是关联数组(哈希表):既可以用整数当键当"数组",也可以用字符串当键当"字典/对象"。数组默认从 1 开始。

-- 数组(序列):整数键从 1 开始
local a = {10, 20, 30}
print(a[1])        -- 10(不是 a[0]!)
print(#a)          -- 3(长度)

-- 字典:字符串键
local person = { name = "Bob", age = 18 }
print(person.name)        -- Bob(等价于 person["name"])
person.age = 19           -- 修改
person.city = "BJ"        -- 新增字段

-- 混用:键可以是任意值(除 nil)
local t = { [1]="a", ["k"]="v", [true]=99 }
print(t[true])    -- 99

-- ipairs:只遍历"从 1 开始的连续整数键",遇到断档就停
-- pairs:遍历所有键值对(含字符串键),顺序不保证
for i, v in ipairs(a) do print(i, v) end
for k, v in pairs(person) do print(k, v) end
最经典坑① 数组从 1 不是 0,a[0] 是 nil;② #t有空洞的表不可靠(如 {1,2,nil,4} 长度可能算 2 或 4,取决于实现),要长度请保证是"紧凑序列"或用 table.maxn;③ 访问不存在的键返回 nil,不会报错,但拿 nil 做运算会炸。
经验需要"数组"语义(有序、可 #)就保持 1..n 紧凑;需要"对象/字典"语义就用字符串键。别把两者混在一个表里指望 # 还能用。

6. 控制流if / while / for

没有花括号,用 end 收尾;数值 for 的结束值是包含的(闭区间);比较用 < > <= >= == ~=~= 是不等于)。

local n = 7
if n < 0 then
  print("负")
elseif n == 0 then
  print("零")
else
  print("正")
end

-- 数值 for:i 从 1 到 5,步长默认 1
local s = 0
for i = 1, 5 do
  s = s + i
end
print(s)          -- 15

-- 带步长:从 10 到 1,每次 -2
for i = 10, 1, -2 do print(i) end   -- 10 8 6 4 2

-- while
local i = 1
while i <= 3 do print(i); i = i + 1 end

-- 泛型 for(遍历表,见第 5 节)
for k, v in pairs({a=1,b=2}) do print(k,v) end
易错数值 for i=1,5 的 5 是包含的(i 会取到 5);步长为负时,结束值必须比起始值小,否则循环一次都不执行。另外 for 的循环变量是局部且只读的,循环里改 i 不影响下一次迭代。

7. 函数与多返回值一等公民、可变参数

函数是值,可以赋值给变量、当参数传、当返回值。函数可以返回多个值,这是 Lua 一大特色。

-- 定义与调用
local function add(a, b) return a + b end
print(add(2, 3))     -- 5

-- 多返回值
local function minmax(a, b)
  if a < b then return a, b else return b, a end
end
local lo, hi = minmax(9, 4)
print(lo, hi)       -- 4 9

-- 只取第一个
local first = minmax(9, 4)   -- first = 4,多余返回值被丢弃

-- 可变参数 ...
local function sum(...)
  local t = {...}          -- 把参数收进表
  local s = 0
  for _, v in ipairs(t) do s = s + v end
  return s
end
print(sum(1,2,3,4))        -- 10
print(select("#", 1,2,3))  -- 3(用 select 数出参数个数)
易错多返回值只在表达式尾部才完整保留。例如 print(minmax(9,4)) 会打印两个;但 print("x", minmax(9,4)) 只拿到第一个(因为不在尾部)。需要强制保留时用括号包成单值:(minmax(9,4)) 只取第一个。

8. 作用域与 local永远优先写 local

没写 local 的变量是全局,存进全局表 _G。全局变量随处可读写,极易互相污染、难排查。Lua 官方建议:默认全部用 local

-- 全局(危险)
count = 0          -- 等价于 _G.count = 0,任何文件都能改

-- 局部(安全)
local count = 0   -- 只在当前块/文件可见

do
  local x = 1     -- 块级作用域
  print(x)        -- 1
end
-- print(x)       -- 这里 x 已不可见(nil)

-- 局部函数
local function helper() end

print(_G.count)   -- 读全局
血泪坑两个脚本都写了全局 tmp,后加载的会覆盖前面的,且没有任何报错。大型 Lua 项目(如游戏 mod)80% 的诡异 bug 来自全局污染。铁律:能 local 就 local,模块接口才用 return 暴露
技巧把整个文件包在 do ... end 或用 local _ENV = ...(第 6 进阶)可以彻底屏蔽意外的全局写入。

9. 模块与 require用 return 暴露接口

Lua 没有 class/import 关键字,模块就是"一个返回 table 的 Lua 文件"。require 负责加载并缓存它。

-- mymath.lua 内容:
-- local mymath = {}
-- function mymath.add(a,b) return a+b end
-- return mymath

-- 主程序:
local mymath = require("mymath")   -- 不带 .lua 后缀
print(mymath.add(2, 3))            -- 5

-- require 只会加载一次(缓存),第二次直接返回同一份
local again = require("mymath")
print(again == mymath)             -- true
易错require 找文件的路径由 package.path 决定(类似其他语言的 PATH),找不到会报错;② 模块必须用 return 把接口交出去,不然 require 拿到的是 nil;③ 想强制重新加载,要从 package.loaded 里把缓存清掉。

10. 常用标准库table / string / math / os

Lua 内核很小,功能靠标准库补。常用的几个:

-- table 库
local t = {3,1,2}
table.insert(t, 99)     -- 末尾插入
table.sort(t)           -- 排序 {1,2,3,99}
table.remove(t, 1)      -- 删除第 1 个
print(table.concat({"a","b","c"}, "-"))  -- a-b-c

-- string 库
print(string.upper("lua"))        -- LUA
print(string.sub("hello", 1, 3))  -- hel
print(string.format("pi=%.2f", 3.14159))  -- pi=3.14

-- math 库
print(math.floor(3.9), math.max(1,9), math.pi)

-- 返回多个值时的解包
local nums = {10, 20, 30}
print(math.max(table.unpack(nums)))  -- 30(unpack 把表拆成多参数)
提效table.unpack(5.2+;老版本 unpack)能把表"炸开"成多个参数传给函数,配合 math.max/print 很好用。注意 5.1 叫全局 unpack,5.2+ 挪到了 table.unpack

进阶 1 · 闭包函数捕获外部局部变量

当一个函数"活"在另一个函数内部,并且引用了外层函数的局部变量时,就形成了闭包。外层变量不会被回收,函数"记住"了它——这是实现私有状态和迭代器的关键。

-- 闭包实现计数器(外部无法直接改 n)
local function makeCounter()
  local n = 0
  return function()
    n = n + 1
    return n
  end
end

local c = makeCounter()
print(c(), c(), c())   -- 1 2 3(n 被闭包持有,持续累加)

-- 闭包做迭代器:每次调用吐下一个
local function from(n)
  return function()
    n = n + 1
    return n
  end
end
local next = from(10)
print(next(), next())  -- 11 12
用处闭包 = "带记忆的函数"。游戏里做技能冷却、配置回调、延迟执行,全靠它。注意:闭包持有变量意味着不会被 GC 回收,循环里大量创建闭包要留意内存。

进阶 2 · 元表与元方法重载运算符、控制取值

每个 table 可以挂一个元表(metatable),里面放"元方法"来改写默认行为:找不到键时怎么办、相加时怎么办、打印时显示什么……

local t = {a = 1}
local mt = {
  __index = function(self, k) return "缺省值" end,  -- 取不到键时返回
  __tostring = function(self) return "表内容:" .. tostring(self.a) end,
}
setmetatable(t, mt)
print(t.b)          -- 缺省值(__index 兜底)
print(t)            -- 表内容:1(__tostring 控制打印)

-- 用元表重载运算符(+ 等)
local Vec = {}
Vec.__add = function(a, b) return {x=a.x+b.x, y=a.y+b.y} end
setmetatable(Vec, Vec)
local v = setmetatable({x=1,y=2}, Vec)
local u = setmetatable({x=3,y=4}, Vec)
local w = v + u                -- 触发 __add
print(w.x, w.y)                -- 4 6
易错__index 可以是函数也可以是表(从那个表里找);② __newindex 控制"写入不存在的键",常用来做只读表/代理;③ __eq/__lt 等比较元方法只在两个表共用同一个元表时才生效,否则默认按引用比较。

进阶 3 · 用 table 模拟面向对象没有 class,用元表造

Lua 没有 class 关键字,但用"原型 + __index"就能实现类和继承。这是写游戏逻辑、框架的标配手法。

-- 基类
local Animal = {}
Animal.__index = Animal
function Animal.new(name)
  local o = {name = name}
  setmetatable(o, Animal)   -- o 找不到的方法去 Animal 里找
  return o
end
function Animal:speak()
  print(self.name .. " 叫了一声")
end

-- 子类(继承)
local Dog = setmetatable({}, Animal)  -- Dog 的元表是 Animal
Dog.__index = Dog
function Dog.new(name)
  local o = Animal.new(name)
  setmetatable(o, Dog)
  return o
end
function Dog:speak()                    -- 重写
  print(self.name .. " 汪汪")
end

local d = Dog.new("阿黄")
d:speak()          -- 阿黄 汪汪(调用了子类方法)
print(getmetatable(d).__index == Dog)   -- true
要点冒号语法 function Animal:speak() 等价于 function Animal.speak(self),只是自动补了 self 参数。继承链就是一层层 __index 向上找方法。

进阶 4 · 协程 coroutine协作式并发,比回调优雅

协程(coroutine)让函数能中途让出(yield)、之后恢复(resume),像"可暂停的函数"。它单线程、不并行,但能把异步/流式逻辑写得像同步一样直白。

local co = coroutine.create(function()
  for i = 1, 3 do
    print("产出", i)
    coroutine.yield(i)   -- 让出,把 i 传回去
  end
  return "结束"
end)

print(coroutine.status(co))   -- suspended
print(coroutine.resume(co))   -- true  产出1  1
print(coroutine.resume(co))   -- true  产出2  2
print(coroutine.resume(co))   -- true  产出3  3
print(coroutine.resume(co))   -- true  结束(函数跑完)
print(coroutine.resume(co))   -- false  cannot resume dead coroutine
典型场景生产者-消费者、状态机、游戏 AI 行为树、异步 IO 的"同步写法"(如 OpenResty 用协程扛高并发)。coroutine.wrap 返回一个可直接调用的函数,更省事。
注意协程不等于多线程,它不并行,只是让出执行权。真正的并行要用 LuaJIT 的线程或宿主的多线程。

进阶 5 · 字符串模式匹配Lua 的"正则"(但不是正则)

Lua 的字符串"模式(pattern)"长得像正则,但更简单也更受限:没有 | 多选、没有贪婪/懒惰的复杂规则、捕获用 %1..%9。日常 90% 的文本处理它够用且更快。

local s = "订单 12345 金额 99.5"
-- %d 数字,+ 一个或多个
local code = s:match("订单 (%d+)")
print(code)                     -- 12345

-- gsub 替换,返回新串和替换次数
local new, n = s:gsub("%d+", "#")
print(new, n)                  -- 订单 # 金额 #.#  2

-- gmatch 迭代所有匹配
for num in string.gmatch("a1b2c3", "%d") do print(num) end  -- 1 2 3

-- 常用魔法字符:%a字母 %d数字 %s空白 %w字母数字 %l小写 %u大写 %.点号 %%%转义
易错模式里 . 匹配任意字符(包括点),要匹配真正的点必须写成 %.。捕获组是 () 配合 %1没有 \1 也没有命名捕获。复杂正则需求(如邮箱严格校验)Lua 模式力不从心,得上第三方 lpeg 库。

进阶 6 · 环境与沙箱 _ENV控制全局、安全执行

Lua 5.2+ 用 _ENV 表示"当前全局环境"(本质就是一张表,默认是 _G)。把 _ENV 换成你自己的表,就能沙箱化一段不受信任的脚本——它只能访问你允许的少数函数。

-- 只暴露 print 和 math 的"白名单"环境
local safe_env = { print = print, math = math, ipairs = ipairs, pairs = pairs }
local code = [[
  print("在沙箱里:", math.floor(3.9))   -- 允许
  -- os.execute("rm -rf /")            -- 若打开会报错:os 不在白名单
]]
local fn = load(code)
setfenv and setfenv(fn, safe_env)        -- 5.1 用 setfenv
-- 5.2+:把 local _ENV = safe_env 写在代码顶部即可
fn and fn()
实用做插件系统、配置脚本、在线判题(本站运行器同理)时,沙箱是必做的安全措施。配合 pcall 包住执行,既隔离又防崩。

🧪 动手实验室(点 ▶ 直接看结果)

下面每个程序代码已预填,点「运行」即可看输出;想改就在框里改再运行。Lua 走云端编译(首次稍慢,相同代码会缓存秒回)。

① Hello 与多返回值

② table 遍历与求和

③ 函数多返回值 + 变长参数

④ 闭包计数器

⑤ 元表运算符重载(向量相加)

💡 连老师都不会教你的干货(工业级技巧)

为什么单列这一节学校里教 Lua 语法,但真在游戏/网关/嵌入式里写 Lua,靠的是下面这些"踩过坑才懂"的经验。每条都是实战总结。
  1. table 是 Lua 唯一的复合结构——数组、字典、对象、类、集合、队列,全靠它。理解了 table,Lua 就通了一半。
  2. 数组默认从 1 开始,且 #t有空洞的表不可靠。要长度请保持 1..n 紧凑,或自己维护计数,别迷信 #
  3. 永远优先用 local。全局变量存进 _G,跨文件互相覆盖还不报错,是 Lua 项目最隐蔽的 bug 来源。模块接口才用 return 暴露。
  4. 多返回值只在表达式尾部完整保留。想强制只取一个,用 (f()) 包成单值;想忽略多余值,直接少写几个接收变量。
  5. 闭包能实现私有状态与迭代器,但闭包持有变量不会被 GC。循环里大量 new 闭包要留意内存,必要时复用。
  6. 元表 __index 实现继承与默认值__newindex 可做只读表/代理;__tostring 控制打印、__eq/__lt 控制比较(注意需共用元表才生效)。
  7. 协程比回调直观:用 resume/yield 把"异步流式"逻辑写成看似同步的代码,游戏 AI、状态机、OpenResty 高并发都靠它。但记住它不并行
  8. 字符串模式用 % 而非正则:没有 | 多选、没有回溯、捕获是 %1..%9。性能稳、够用,但复杂校验得上 lpeg。
  9. pcall 包住错误而非裸 assert:可恢复错误用 pcall 返回 true/false + 错误信息;xpcall 还能带 traceback,定位线上错误更方便。
  10. _ENV 重定义实现沙箱:把不受信任脚本的全局环境换成白名单表,再配合 pcall,就能安全地让用户输入逻辑(插件系统、在线判题同理)。
  11. 尾调用优化:写成 return f(...) 的尾调用不新增栈帧,可无限递归不爆栈。写递归/状态机时优先用尾调用形式。
  12. 用 table 做缓存(memoization):把已算结果存 cache[key],斐波那契/动态规划类递归从指数级降到线性,几乎零成本。
  13. 大量字符串拼接用 table.concat,别在循环里反复 ..,否则生成大量中间串拖垮性能。
  14. 与 C 交互是 Lua 的杀手锏:宿主用 lua_State*lua_push*/lua_to* 注册 C 函数、读写 table,让 Lua 当逻辑层、C 当性能层——Redis 脚本、Nginx-Lua、游戏引擎皆此架构。

自测题(点击展开答案)

  • Q1:Lua 数组第一个元素用哪个下标访问?
    A:a[1]。Lua 数组默认从 1 开始,a[0] 是 nil。
  • Q2:没写 local 的变量存在哪里,有什么风险?
    A:存在全局表 _G 里。风险是跨文件互相覆盖、且无报错,是最隐蔽的 bug 源;应默认全部 local。
  • Q3:if 0 then 在 Lua 里是真还是假?
    A:。Lua 中只有 falsenil 是假,0 和空串 "" 都是真,和 C/Python 不同。
  • Q4:函数 return a, b 在表达式 print("x", f()) 里会拿到几个返回值?
    A:只有第一个。多返回值只在表达式尾部保留;f() 不在尾部,多余返回值被丢弃。
  • Q5:想让一个 table 相加(a + b)时触发自定义逻辑,该设哪个元方法?
    A:__add。把它写进该 table 的元表,并重载加法行为;注意 __eq/__lt 等比较元方法需两个表共用同一元表才生效。
本 Lua 课程遵循 Lua 5.4 常用子集,手机与电脑端自适应,配套本站运行器(Lua 5.4.7)练习效果最佳。 公网参考:Lua 官方文档 lua.org · Lua 5.4 Reference Manual。
🛠 在线练习