速览卡片
| 概念 | 一句话说明 |
|---|---|
| 8 种类型 | nil / boolean / number / string 是值类型,table / function / userdata / thread 是引用类型 |
| 值 vs 引用 | 值类型拷数据(复印件),引用类型拷句柄(递钥匙),改副本先看是不是同一个柜子 |
| 元表 | 贴在 table/userdata 上的”应急指令单”,正常操作不插手,走不通才翻出来看 |
__index |
读兜底:本表查不到键,去它指向的表/函数里查(继承的地基) |
__newindex |
写拦截:本表没有此键时触发;函数里不 rawset 则赋值落空 |
| 深拷贝 | 递归复制 table 结构 + seen 账本法防循环引用 + 移植元表 |
| 继承 | 委托单连成链;实例是数据表,方法是类表上的函数 |
| 闭包 | 函数 + 捕获的上值(upvalue)打包成的整体,函数出门自带记忆 |
| userdata | C/C++ 对象在 Lua 侧的”取件凭据”,本体是一块 Lua 代管的内存 |
| 热更本质 | Lua 脚本是”可下载的资源”而不是”焊死的程序”,换文本即换逻辑 |
| ToLua | C# 对象进 Lua 变 userdata,由 ObjectTranslator 管映射,调用走”元表三跳” |
| 反射 | 运行时拿字符串名字查类型、调方法;框架的通用性靠它,性能靠缓存救 |
一、基本数据类型:值与引用的分野
1.1 八类型与口诀
Lua 只有 8 种基本类型,口诀:“nil 布数串,表函用线”——前四个值类型(nil、boolean、number、string),后四个引用类型(table、function、userdata、thread)。
| 阵营 | 成员 | 记忆点 |
|---|---|---|
| 值类型(拷数据) | nil、boolean、number、string | 全是”死”的:nil 是空、boolean 是常量、number 和 string 不可变 |
| 引用类型(递名片) | table、function、userdata、thread | 全是”活”的:会被增删改、有状态、有生命周期 |
值类型是复印文件,引用类型是递钥匙——你拿到钥匙的复制品,开的还是同一个柜门。
例 1 · 赋值后的互相影响:local b = a 之后改 b.x,a.x 跟着变(同一柜子);若改成 b = 新表,只是 b 撕掉旧便利贴改贴新柜子,a 纹丝不动。
1.2 三个高频追问
A:5.3 之前所有数字都是 double;5.3 起每个值带类型标签,number 下分 64 位整型和 double 两个子类型,位运算符(& | ~ << >>)和整除 // 也是 5.3 才引入的。Lua 是动态语言:类型长在值身上,变量只是一张可以贴到任何格子上的便利贴。
A:值语义:== 比内容、不可变。底层短字符串做了驻留(同一份内存全虚拟机共享),正因不可变,共享才绝对安全。对照:C# 的 string 是引用类型但 immutable、== 被重载为比内容——两者殊途同归。注意 Lua 的 string 像 C# 的 string,不像 C++ 可原地修改的 std::string。
A:table 返回 nil("这个槽空着");userdata 没有这个待遇——查无键又无
__index 时直接报错 attempt to index a userdata value。新手经典灵异 bug:键名手误不报错,症状是莫名其妙的 nil。
1.3 table 访问规则(地基中的地基)
- 点号是糖:
t.x完全等价于t["x"],只对”合法标识符”字符串键有效; - 数字键用中括号:
t[1];数组下标从 1 开始; t[1](数字键)和t["1"](字符串键)是两个不同的键;- 多层嵌套像写快递地址:
playerData.equips.weapon.enhance,访问和赋值寻路规则相同; - 键可以是任何类型(table、函数都能当 key),nil 除外。
"洞" = 整数键序列中间缺档(如只有 1、2、4)。
#t 只统计从 1 连续无洞的键,带洞的表 #t 结果不可靠(规范不承诺)。pairs 遍历全部键值对但顺序不保证;ipairs 只走 1..n 连续段且有序。稀疏数字键字典不要用 #t/ipairs,用 pairs。
二、元表:应急指令单
2.1 定义与基本操作
元表是一张普通 table,贴在 table/userdata 上,扮演”应急指令单”:正常操作走通时完全不插手,走不通才翻出来看。指令单上每行是一个 __ 开头的元方法(metamethod)。
1 | local t = {} |
raw 前缀四兄弟:rawget / rawset / rawequal / rawlen——绕过元表直接操作本表,相当于”跳过应急指令单,直接开柜门”。
2.2 核心双子:__index 与 __newindex
- __index 指向一张表:本表查不到键 → 去那张表接着查(继承的地基);
- __index 指向一个函数:调
__index(t, key),返回值即结果(可做默认值、只读保护、日志); - __newindex 指向函数:赋值的最后机会,函数里若不
rawset(t, k, v),这次赋值悄悄落空; - 经典用法:
__newindex里先校验再 rawset,就是只读/校验层;配合”数据存进内部表”实现真正的私有字段。
A:会。元表自己也是 table,也能贴元表——委托单可以无限套娃。继承链就是利用这一点:实例的元表是类表,类表的元表 __index 指向父类表。注意区分:查键走 __index 链,查元方法(如 __add)只看本表元表那一层,不会沿 __index 链找别的表的元表。
__newindex 只在本表没有此键时触发。已有键的赋值走正常路径,不打扰元表;想全量拦截写入,数据必须放在另一个内部表里,对外暴露的壳才有"每个键都是新键"的效果。
2.3 运算符元方法全家
__add __sub __mul __div __mod __pow __unm(算术)、__concat(.. 连接)、__len(# 长度)、__eq __lt __le(比较)、__call(把表当函数调)、__tostring(print 时)、__gc(userdata 回收时)。
A:不能,报错
attempt to compare two table values。table 在 Lua 里是引用身份,== 比的是"是不是同一个表";想要值语义的大小比较,必须由元表提供 __lt/__le。这也是为什么"table 没有内置排序规则",table.sort 必须传比较函数。
三、深拷贝
浅拷贝只拷第一层(b = {} 再 for 循环赋值)——嵌套的 table 仍是同一个柜子。深拷贝三要素:
- 递归:遇到 table 就继续往下拷;
- seen 账本法防循环引用:
a.self = a的结构,不记账会无限递归; - 移植元表:拷完别忘了
setmetatable(copy, getmetatable(orig))。
1 | function deepCopy(orig, seen) |
函数拷的是引用(同一个函数对象,本来就该共享);userdata 本体在 C 侧,Lua 无权复制——硬拷等于伪造取件凭据。深拷贝只对纯 table 结构有效,配置表、数据表是它的主场。
四、继承
Lua 没有 class 关键字,继承 = 元表 __index 串成的委托链。实例是数据表,方法是类表上的函数,调用时沿链向上找。
1 | Base = {} |
A:实例上查不到方法 → __index 指向子类表 → 子类表也没有 → 子类表的元表 __index 指向父类表,一路委托上去。想调父类版本用显式路径:
Base.speak(self)(把 self 手动传进去),不能用点号调用——点号会把 o 当第一个参数但查不到时行为不可控,面试里写 Base:speak() 漏传 self 是经典错误。
五、闭包
闭包 = 函数 + 它捕获的上值(upvalue)打包成的整体——函数出门自带记忆,记忆存在函数体外、调用者的视野外。
例 1 · 计数器:局部变量 n 本该在 counter 返回后销毁,被内层函数捕获后一直活着,且外界无法直接摸到它——这就是”私有变量”。
1 | function counter() |
A:看版本。Lua 5.1(含 LuaJIT/ToLua/xLua 项目里跑的全是它):循环变量只有一份,所有闭包共享同一个 upvalue,循环结束后读到的全是终值——经典灵异 bug。Lua 5.4 起每轮迭代循环变量都是新局部,各闭包各捕各的。项目里用 LuaJIT 时,循环体内要捕获就手动
local x = i 复制一份。
六、userdata 与 C 交互
userdata 是 C/C++ 对象在 Lua 侧的取件凭据:Lua 侧只能拿着它、传给它,本体是一块 Lua 代管的内存。分两代:
| 种类 | 本体 | 特点 |
|---|---|---|
| full userdata | Lua 代管的一块内存(lua_newuserdata) | 有 __gc 元方法,回收时回调 C 析构,能与 GC 联动 |
| light userdata | 只存一个 void* 指针 | 轻量,不参与 GC 通知,类比”裸指针名片” |
C 侧通过 lua_CFunction 与 Lua 通信:参数和返回值都走 lua_State 栈——读参数用 lua_toXXX(L, 索引),返回值是压栈后 return 个数。
A:给 userdata 贴元表,元表里 __index 指向方法表,方法表里每个函数都是 lua_CFunction。于是
obj:method() 的完整链路:查 obj 无此键 → __index 给方法 → 调用进 C → C 从栈上取回 userdata、翻译成真实对象 → 干活。这也是 ToLua 的底座。
七、ToLua:C# ↔ Lua 的”三跳”
C# 对象要进 Lua,由 ObjectTranslator 登记造册:对象 ↔ userdata 一一映射。Lua 调 C# 方法走“三跳”:
- 第一跳:Lua 侧
obj:Method(),obj 是 userdata 查无此键 → 触发元表 __index; - 第二跳:__index 转发到 C 侧的 lua_CFunction 包装,按名字在注册表里找到对应的 C# 方法信息;
- 第三跳:ObjectTranslator 把 userdata 翻译回 C# 对象,执行真实方法(Wrap 预生成代码直接调用,没生成走反射兜底)。
A:ID 确实轻,但有两个致命伤:① GC 不联动——Lua 侧 ID 还在,C# 对象可能已被回收,再用就是悬垂引用;userdata 的 __gc 能在 Lua 侧回收时通知 C# 解绑。② 没有类型行为——userdata 可以贴元表获得"方法可点"的完整体验,裸 ID 只是一串数字。
八、热更新:方案与本质
热更的本质:Lua 脚本是”可下载的资源”而不是”焊死的程序”——换文本即换逻辑。游戏出 bug 不用发版,下发新脚本就完事。
| 方案 | 出品 | 机制 | 特点 |
|---|---|---|---|
| ToLua + LuaFramework | 社区 | 导出 Wrap 包装类,C# ↔ Lua 互调 | 老牌稳定,导出配置繁琐 |
| xLua | 腾讯 | 打 [LuaCallCSharp] 等标签自动生成绑定,未生成的走反射兜底 |
接入快,反射首调慢靠缓存救 |
| huatuo(HybridCLR) | 社区/原 Tuanjie | 直接解释执行热更 DLL | “真·C# 热更”,无 Lua 层,性能最好 |
A:iOS 禁止 JIT(动态生成机器码),上架的 C# 全是 AOT 编译进安装包的死代码,跑错只能发版。Lua 是解释执行的脚本,在苹果眼里属于"资源文件"而非可执行代码,更新脚本钻的就是这个空子。huatuo 更进一步:把热更 DLL 用解释器执行,同样绕开 JIT 禁令,让 C# 也能热。
① ToLua/xLua 方案里,C# 框架层的 bug 热更不了(Lua 管不到 C# 的实现),能热的只有写在 Lua 侧的业务逻辑;② 首次加载要解析全部 Lua 文本,有 IO 和编译开销;③ Lua 与 C# 往返有"三跳"成本,高频调用(如每帧 Update 大面积互调)要谨慎。
面试速记
- 口诀”nil 布数串,表函用线”——8 类型,前四值后四引用。
- 值类型复印文件,引用类型递钥匙;Lua 的 string 像 C# 的 string,不像 C++ 的 std::string。
t[1]≠t["1"];数组从 1 开始;带洞的表 #t 不可靠,遍历用 pairs 保全部、ipairs 保连续。- __index 读兜底、__newindex 写拦截(不 rawset 就落空);raw 系列 = 跳过应急指令单。
- 继承 = 委托链:实例 → 类表(__index)→ 父类表(元表的 __index);调父类版本写
Base.speak(self)。 - 闭包 = 函数 + 上值;LuaJIT(5.1)循环闭包共享 upvalue,捕获前手动
local复制。 - userdata = C 对象的取件凭据;full 有 __gc 与 GC 联动,light 只是裸指针名片;查无键直接报错。
- ToLua 三跳:__index 转发 → C 包装查册 → ObjectTranslator 翻译执行;ID 轻但没有 GC 联动。
- 热更本质 = 脚本是资源不是程序;iOS 禁 JIT 所以 C# 只能 AOT,huatuo 用解释执行破局。
- 热更的边界:C# 框架层 bug 热不了,Lua↔C# 高频互调有三跳成本。