1.2 第一个 Lua 程序与语法骨架 本节摘要:本节动手跑通 Lua 的两种运行形态——交互式解释器与脚本文件,然后一次过完语法骨架级的约定:大小写敏感、分号可选、两种注释、end 界定的块、保留字清单、多变量赋值。末尾给出全册第一段"被宿主调用"的脚本,把语法骨架放回寄宿现场。 动手:三分钟跑起来 先把解释器弄到手。Lua 的发行极其朴素:官网提供 C 源码,一条标准的编译命令就能出可执行文件;多数平台也有现成渠道——Windows 下可用包管理器一键安装或下载他人编译好的解释器压缩包,macOS 与 Linux 用系统包管理器装 lua 即可。装好后在终端里敲: 能看到类似下面的回应就说明到位了(版本号以你装到的为准): 直接输入 回车,进入交互式解释器,提示符是 。
本节摘要:本节动手跑通 Lua 的两种运行形态——交互式解释器与脚本文件,然后一次过完语法骨架级的约定:大小写敏感、分号可选、两种注释、end 界定的块、保留字清单、多变量赋值。末尾给出全册第一段"被宿主调用"的脚本,把语法骨架放回寄宿现场。
先把解释器弄到手。Lua 的发行极其朴素:官网提供 C 源码,一条标准的编译命令就能出可执行文件;多数平台也有现成渠道——Windows 下可用包管理器一键安装或下载他人编译好的解释器压缩包,macOS 与 Linux 用系统包管理器装 lua 即可。装好后在终端里敲:
lua -v
能看到类似下面的回应就说明到位了(版本号以你装到的为准):
Lua 5.4.6 Copyright (C) 1994-2023 Lua.org, PUC-Rio
直接输入 lua 回车,进入交互式解释器,提示符是 >。从这里开始,每敲一行它算一行:
> print("hello, host") hello, host > 1 + 2 3 > "lua" .. 5.1 lua5.1
注意第二个例子:表达式敲进解释器,它会把值直接回显。两个点 .. 是字符串连接运算符,数字会被自动转成字符串再接上。这种"敲一行见一行"的体验是学习 Lua 的最佳途径,全册所有短示例都默认你在这种环境里验证。
交互模式有个便利设计:表达式没写完就回车,提示符会变成 >> 等你续行:
> print( >> "跨行也没关系" >> ) 跨行也没关系
退出交互模式用 os.exit(),或直接按平台对应的结束快捷键。
第二种形态是脚本文件。把代码存进一个文本文件,在命令行把文件名交给解释器:
-- 脚本内容:三行 local name = "gateway" print("booting " .. name) print(("=%s="):format(name))
命令行执行 lua 加脚本文件名,输出:
booting gateway =gateway=
脚本形态是宿主最常用的调用方式:游戏引擎启动时加载一堆脚本文件,Redis 的 SCRIPT LOAD 收一段脚本文本缓存起来,OpenResty 在配置里声明脚本路径——本质都是"把一段文本交给 Lua 虚拟机执行"。还有一种内嵌字符串形态:宿主把脚本放在 C 代码的字符串常量里直接 dostring 执行,适合极短的初始化逻辑。三种形态你在后续章节都会见到。
Lua 把每一段被加载执行的代码叫一个块(chunk):一个脚本文件是一个块,交互解释器里的每一行也是一个块,dostring 收到的字符串还是一个块。块可以很大——整个项目的启动脚本;也可以小到一行。块内的 local 变量作用到块尾,块与块之间通过全局表或宿主注入的接口通信。
理解"块"对嵌入式开发格外重要,因为宿主调用脚本的最小单位就是块。游戏引擎把每个 UI 文件当作一个块加载;Redis 把每段 EVAL 脚本作为一个块缓存;OpenResty 按 content_by_lua 等指令引用的文件为块加载。你在哪个块里声明 local,决定了这个名字能活多久、被谁看见。后续讲模块与热更新时,"块边界"会反复出场。
脚本还能接收命令行参数。用脚本形态运行时,解释器把参数收进全局表 arg,arg[0] 是脚本名,正负下标分别是从前数和从后数的位置参数:
-- 脚本开头打印自己收到的参数 for i = 0, #arg do print(i, arg[i]) end
命令行传入三个参数运行,输出形如:
0 script 1 -critical 2 workers=8 3 10.0.0.8
宿主世界里同样的机制换个名字出现:Redis 的 EVAL script numkeys key1 key2 arg1 arg2 把键与参数分别交给脚本的 KEYS 与 ARGV 表;游戏引擎把事件参数打包成表传给回调。"参数从外面进来"是脚本的日常,早点习惯它。
Lua 的语法骨架小得可以在一节里讲完。逐条来,每条都顺带看它的寄宿动机。
大小写敏感。print、Print、PRINT 是三个不同的名字。原因不浪漫:实现上名字进哈希表,区分大小写让词法分析器少一层规则;对宿主的好处是注册进来的 C 函数名不会因为大小写折叠而撞车。
分号可选。Lua 允许语句以分号结尾,但完全不写也行:
> local a = 1 > local b = 2; local c = a + b > print(a, b, c) 1 2 3
写不写分号纯属个人与团队习惯。有个易踩的点:以括号开头的语句会被当成对上一行的函数调用,所以换行写语句时别用 ( 打头。
两种注释。单行用双横线,多行用双横线加双方括号:
-- 单行注释:宿主常用它写参数说明 --[[ 多行注释 可以跨很多行 ]]
有个高级玩法:给多行注释加等量等号(如 --[==[ ]==])可以在注释内再嵌注释内容,等级配对即可, OpenResty 配置生成器就靠这个特性拼装大段脚本文本。
--[==[ 内含 ]] 也不会提前结束 ]==] print("still alive")
字面量速查。骨架级还有一类约定值得提前备案:怎么写常量。数字支持十进制、十六进制(0xff)、二进制(0b1010,3.2 之后)与科学计数(1e3、5e-2);字符串支持双引号、单引号(两者完全等价,随手选用)与长括号形式:
> local doc = [[ 一行说明可以随便换行 转义序列 \n 在这里原样保留 ]] > print(#doc) 46
长括号 [[...]] 里的反斜杠不转义、换行原样保留,写多行文本、SQL、HTML 模板、注释文档时是救星。它同样支持等号分级([==[...]==]),与注释的等号分级是同一套词法规则。数字字面量在 5.3 之后还会自动带子类型(0x10 是整数,1e2 是浮点),1.3 节展开。
块用 end 界定。Lua 没有大括号,函数体、if 体、for 体都以关键字开头、以 end 收尾:
local function greet(who) if who then return "hi " .. who end end print(greet("lua"))
输出 hi lua。do...end 还能单独开一个作用域块,后面讲 local 时会用到。对宿主而言,end 界定的块在词法上一目了然,做代码着色、片段替换(热更新时按函数粒度换代码)都比大括号配对更稳。
保留字一共二十来个:and、break、do、else、elseif、end、false、for、function、goto(5.2 起)、if、in、local、nil、not、or、repeat、return、then、true、until、while。注意 repeat...until 是一对,until 后面直接跟条件。这份清单短到能背下来,也再次印证"一页文法"的说法。
标识符:字母或下划线开头,后跟字母、数字、下划线。习惯上,下划线开头的名字(如 _VERSION、_G)留给语言与宿主注入的保留物,自己的变量别抢。
多变量赋值一步到位:
> local x, y = 1, 2 > x, y = y, x -- 交换,不需要中间变量 > print(x, y) 2 1 > local a, b, c = 10 -- 多余的名字得 nil > print(a, b, c) 10 nil nil
右边多了的值被丢弃,左边多了的名字得 nil。这个"多退少补"规则同样适用于函数返回值的接收,是下一章多返回值机制的语法基础。
print 是万能探针。它接受任意多个参数、用制表符分隔输出、自动调用 tostring 规则:
> print(nil, true, 3.14, "s", print) nil true 3.14 s function: 0x55e0b4a3e0b0
table 类型的值 print 出来是地址,要看内容用后面章节的 table.concat 或专门函数。排错时 print 加 ...(可变参数)能拼出简易日志器,嵌入式现场没调试器时这是最趁手的工具。
写脚本就会遇到报错,早一点学会读报错,学习曲线会平缓很多。Lua 的错误消息格式统一:位置信息 + 企图做的事。故意制造一个:
> local n = nil > print(n.weight) input:1: attempt to index a nil value (local 'n')
拆开读:input:1 是"哪块哪行"(交互环境里块名叫 input,脚本形态则是文件名加行号);attempt to index a nil value 是"对一个 nil 值做索引";括号里的 (local 'n') 直接点名肇事变量。这份点名信息在 5.4 里相当完整——索引 nil、调用 nil、比较类型不匹配,都会告诉你具体是哪个名字。三个最高频的错误家族先混个脸熟:
> local t = {} > t.f() -- attempt to call a nil value (field 'f') > local s = "abc" > s = s + 1 -- attempt to perform arithmetic on a string value > return #nosuch -- attempt to get length of a nil value (global 'nosuch')
报错不是谴责,是路标。嵌入式现场里脚本抛错的默认结局是被宿主的错误处理接住(打印日志、报警、跳过本次调用),不会带崩宿主进程——这个"错误边界"的契约第 6 章细讲,现在你只需要知道:大胆在解释器里试错,报错的代价是零。
把社区问答里出现率最高的五个入门错误列在这里,每一条都可以立刻在解释器里复现:
其一,把 = 当比较用。Lua 的等于判定是 ==,单个 = 只出现在赋值位置,写在条件里会直接语法报错。其二,elseif 拼成 else if,程序仍能跑但要求多一个 end,块结构对不上时报错行号会指向很远处,排查费劲。其三,交互解释器里 local 跨行失效(1.3 节详细解释),新手常误以为 local 声明"不生效"。其四,注释符号写成 // 或 #——Lua 用 --,井号在 Lua 里是长度运算符,写在行首会引发离奇的解析错误。其五,用 != 表示不等于,正确写法是 ~=。这五条都与 C 家族的肌肉记忆有关,前两周有意识提醒自己,之后自然就切换过来了。
语法骨架看完了,回到寄宿视角。下面这段脚本是"宿主式"写法的雏形——它假设宿主已经注入了一个叫 host 的表,脚本通过它回调宿主能力:
-- 假设宿主注册了全局表 host,提供 log 与 config 两个接口 local profile = host.config("profile") or "default" if profile == "debug" then host.log("verbose", "running in debug profile") end local weights = { sword = 12, shield = 7, bow = 9 } local best for name, w in pairs(weights) do if not best or w > weights[best] then best = name end end host.log("info", ("best weapon %s weight %d"):format(best, weights[best]))
脚本没有调用任何"危险"库(io、os 一概未沾),只用了宿主注入的 host 表——这就是沙箱式脚本的雏形:能力全部由房东发放,房客自带行李只有语言本身。第 6 章讲标准库裁剪、第 9 章讲宿主注册 API 时,都会回到这个模型。

这张分层图值得现在就记住,全册反复出现:宿主在顶层拥有进程;C API 是唯一通道;每个 Lua 状态(lua_State)是一个独立的虚拟机实例,宿主可以同时开多个互相隔离的实例;脚本住在虚拟机里,读写到的"全局能力"其实都是宿主注册进去的函数表。同进程意味着快——没有跨进程通信;有边界意味着稳——脚本错误触发的是 Lua 错误处理,而不是进程崩溃,前提是宿主正确使用 pcall(第 6 章)。
把本节骨架内容压回寄宿视角收个尾。分号可选、块用 end 界定、保留字一页可数——这些"省略"共同服务于一个目标:让脚本代码成为可以被机器可靠处理的文本。宿主要对脚本做的事比人多:按块加载、按函数替换、按行数计费、做静态扫描(全局变量检查、危险调用检查)。语法的确定性越强,这些自动化就越可靠。同理,注释是宿主与脚本作者的协作通道——配置脚本头部的参数说明、插件文件头的版本与依赖声明,都是写给宿主的解析器看的。你写下的每个语法元素,都同时有两个读者:现在的人,与将来要处理这段文本的宿主机器。
下一节清点 Lua 的全部家当:八种值类型,以及变量与作用域的规矩。