Lua 八股复习笔记

速览卡片

概念 一句话说明
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 三个高频追问

Q:number 为什么不区分 int 和 float?

A:5.3 之前所有数字都是 double;5.3 起每个值带类型标签,number 下分 64 位整型和 double 两个子类型,位运算符(& | ~ << >>)和整除 // 也是 5.3 才引入的。Lua 是动态语言:类型长在值身上,变量只是一张可以贴到任何格子上的便利贴。
Q:string 是值类型还是引用类型?和 C# 的 string 什么关系?

A:值语义:== 比内容、不可变。底层短字符串做了驻留(同一份内存全虚拟机共享),正因不可变,共享才绝对安全。对照:C# 的 string 是引用类型但 immutable、== 被重载为比内容——两者殊途同归。注意 Lua 的 string 像 C# 的 string,不像 C++ 可原地修改的 std::string。
Q:查一个不存在的键会怎样?

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
2
3
local t = {}
setmetatable(t, { __index = function(table, key) return "default" end })
getmetatable(t) -- 查询元表,没有则返回 nil

raw 前缀四兄弟:rawget / rawset / rawequal / rawlen——绕过元表直接操作本表,相当于”跳过应急指令单,直接开柜门”。

2.2 核心双子:__index 与 __newindex

__index = 读兜底(查不到才去别处找)  __newindex = 写拦截(本表没有此键才触发)
  • __index 指向一张表:本表查不到键 → 去那张表接着查(继承的地基);
  • __index 指向一个函数:调 __index(t, key),返回值即结果(可做默认值、只读保护、日志);
  • __newindex 指向函数:赋值的最后机会,函数里若不 rawset(t, k, v),这次赋值悄悄落空;
  • 经典用法:__newindex 里先校验再 rawset,就是只读/校验层;配合”数据存进内部表”实现真正的私有字段。
Q:__index 查不到时会接着查"元表的元表"吗?

A:会。元表自己也是 table,也能贴元表——委托单可以无限套娃。继承链就是利用这一点:实例的元表是类表,类表的元表 __index 指向父类表。注意区分:查键走 __index 链,查元方法(如 __add)只看本表元表那一层,不会沿 __index 链找别的表的元表。
⚠️ 易混淆点:"赋值不触发 __newindex"的错觉
__newindex 只在本表没有此键时触发。已有键的赋值走正常路径,不打扰元表;想全量拦截写入,数据必须放在另一个内部表里,对外暴露的壳才有"每个键都是新键"的效果。

2.3 运算符元方法全家

__add __sub __mul __div __mod __pow __unm(算术)、__concat(.. 连接)、__len(# 长度)、__eq __lt __le(比较)、__call(把表当函数调)、__tostring(print 时)、__gc(userdata 回收时)。

Q:两个 table 能直接比大小吗?

A:不能,报错 attempt to compare two table values。table 在 Lua 里是引用身份,== 比的是"是不是同一个表";想要值语义的大小比较,必须由元表提供 __lt/__le。这也是为什么"table 没有内置排序规则",table.sort 必须传比较函数。

三、深拷贝

浅拷贝只拷第一层(b = {} 再 for 循环赋值)——嵌套的 table 仍是同一个柜子。深拷贝三要素:

  1. 递归:遇到 table 就继续往下拷;
  2. seen 账本法防循环引用:a.self = a 的结构,不记账会无限递归;
  3. 移植元表:拷完别忘了 setmetatable(copy, getmetatable(orig))。
1
2
3
4
5
6
7
8
9
10
11
function deepCopy(orig, seen)
if type(orig) ~= "table" then return orig end
seen = seen or {}
if seen[orig] then return seen[orig] end -- 账本里有:直接引用,打断循环
local copy = {}
seen[orig] = copy -- 先记账,再递归
for k, v in pairs(orig) do
copy[deepCopy(k, seen)] = deepCopy(v, seen)
end
return setmetatable(copy, getmetatable(orig))
end
⚠️ 易混淆点:深拷贝拷不了 function 和 userdata
函数拷的是引用(同一个函数对象,本来就该共享);userdata 本体在 C 侧,Lua 无权复制——硬拷等于伪造取件凭据。深拷贝只对纯 table 结构有效,配置表、数据表是它的主场。

四、继承

Lua 没有 class 关键字,继承 = 元表 __index 串成的委托链。实例是数据表,方法是类表上的函数,调用时沿链向上找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Base = {}
Base.__index = Base

function Base.new(name)
local o = setmetatable({}, Base) -- 实例的"应急指令单"指向类表
o.name = name
return o
end
function Base:speak() print("Base: " .. self.name) end

-- 继承:子类表的 __index 指向父类表(类表层面再套一层委托)
Derived = setmetatable({}, { __index = Base })
Derived.__index = Derived

function Derived.new(name, level)
local o = Base.new(name) -- 先走父类构造
o.level = level
return setmetatable(o, Derived) -- 元表换成子类表
end
function Derived:speak() -- 重写
print("Derived Lv." .. self.level .. ": " .. self.name)
end
Q:调用未重写的方法为什么会落到父类?重写后想调父类版本怎么办?

A:实例上查不到方法 → __index 指向子类表 → 子类表也没有 → 子类表的元表 __index 指向父类表,一路委托上去。想调父类版本用显式路径:Base.speak(self)(把 self 手动传进去),不能用点号调用——点号会把 o 当第一个参数但查不到时行为不可控,面试里写 Base:speak() 漏传 self 是经典错误。

五、闭包

闭包 = 函数 + 它捕获的上值(upvalue)打包成的整体——函数出门自带记忆,记忆存在函数体外、调用者的视野外。

例 1 · 计数器:局部变量 n 本该在 counter 返回后销毁,被内层函数捕获后一直活着,且外界无法直接摸到它——这就是”私有变量”。

1
2
3
4
5
6
7
8
9
10
11
12
13
function counter()
local n = 0
return function()
n = n + 1
return n
end
end

local c1 = counter()
local c2 = counter()
c1() --> 1
c1() --> 2
c2() --> 1 (两个闭包各记各的账,互不可见)
Q:循环里创建一批闭包,它们的上值是同一个变量吗?

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 个数。

Q:C 函数怎么让 userdata"有方法可点"?

A:给 userdata 贴元表,元表里 __index 指向方法表,方法表里每个函数都是 lua_CFunction。于是 obj:method() 的完整链路:查 obj 无此键 → __index 给方法 → 调用进 C → C 从栈上取回 userdata、翻译成真实对象 → 干活。这也是 ToLua 的底座。

七、ToLua:C# ↔ Lua 的”三跳”

C# 对象要进 Lua,由 ObjectTranslator 登记造册:对象 ↔ userdata 一一映射。Lua 调 C# 方法走“三跳”:

Lua 调用 → ① __index 转发 → ② C 侧包装函数查册 → ③ 翻译回 C# 对象执行真实方法
  • 第一跳:Lua 侧 obj:Method(),obj 是 userdata 查无此键 → 触发元表 __index;
  • 第二跳:__index 转发到 C 侧的 lua_CFunction 包装,按名字在注册表里找到对应的 C# 方法信息;
  • 第三跳:ObjectTranslator 把 userdata 翻译回 C# 对象,执行真实方法(Wrap 预生成代码直接调用,没生成走反射兜底)。
Q:为什么要用 userdata 包 C# 对象,直接传一个整数 ID 不行吗?

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 层,性能最好
Q:为什么 C# 代码不能直接热更,非要在 Lua 层写逻辑?

A:iOS 禁止 JIT(动态生成机器码),上架的 C# 全是 AOT 编译进安装包的死代码,跑错只能发版。Lua 是解释执行的脚本,在苹果眼里属于"资源文件"而非可执行代码,更新脚本钻的就是这个空子。huatuo 更进一步:把热更 DLL 用解释器执行,同样绕开 JIT 禁令,让 C# 也能热。
⚠️ 易混淆点:热更不是万能的
① ToLua/xLua 方案里,C# 框架层的 bug 热更不了(Lua 管不到 C# 的实现),能热的只有写在 Lua 侧的业务逻辑;② 首次加载要解析全部 Lua 文本,有 IO 和编译开销;③ Lua 与 C# 往返有"三跳"成本,高频调用(如每帧 Update 大面积互调)要谨慎。

面试速记

  1. 口诀”nil 布数串,表函用线”——8 类型,前四值后四引用。
  2. 值类型复印文件,引用类型递钥匙;Lua 的 string 像 C# 的 string,不像 C++ 的 std::string。
  3. t[1] ≠ t["1"];数组从 1 开始;带洞的表 #t 不可靠,遍历用 pairs 保全部、ipairs 保连续。
  4. __index 读兜底、__newindex 写拦截(不 rawset 就落空);raw 系列 = 跳过应急指令单。
  5. 继承 = 委托链:实例 → 类表(__index)→ 父类表(元表的 __index);调父类版本写 Base.speak(self)。
  6. 闭包 = 函数 + 上值;LuaJIT(5.1)循环闭包共享 upvalue,捕获前手动 local 复制。
  7. userdata = C 对象的取件凭据;full 有 __gc 与 GC 联动,light 只是裸指针名片;查无键直接报错。
  8. ToLua 三跳:__index 转发 → C 包装查册 → ObjectTranslator 翻译执行;ID 轻但没有 GC 联动。
  9. 热更本质 = 脚本是资源不是程序;iOS 禁 JIT 所以 C# 只能 AOT,huatuo 用解释执行破局。
  10. 热更的边界:C# 框架层 bug 热不了,Lua↔C# 高频互调有三跳成本。