🔥 本文专栏:Lua
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录:
别因为现在还普通,就怀疑过去所有的努力。很多成长,在结果出现之前,本来就是沉默的。
思维导图
text
Lua 的变量
│
├── 动态类型:变量不声明固定的数据类型
│ └── 值始终具有具体类型
│ └── 运行时根据类型操作值(TValue 心智模型)
│
└── 变量需要能够被代码访问
└── 那么:在什么位置可以访问它?
│
├── local:声明局部变量
│ └── 具体可见范围由词法作用域决定
│ ├── Chunk 中的局部变量
│ ├── 函数中的局部变量
│ └── 控制块中的局部变量
│
└── 普通赋值:a = 100
├── 存在可见的同名局部变量 → 修改该变量
└── 不存在可见的同名局部变量 → 访问全局环境
│
└── 不同 Lua 文件可共享该环境
├── dofile 加载并执行
└── 赋值必须真的执行
引入:同样写在函数内部,为什么有的变量外部可以访问?
此前我们已经对 Lua 的基本运行方式以及动态类型有了初步认识。接下来继续学习变量时,很自然就会碰到另外一个问题:变量究竟在什么范围内可以被访问?
我们知道,在 C++ 中,函数内部当然可以访问和修改外部的全局变量,这本身就是一个正常的作用域现象。
这里真正值得我们思考的,不是"函数为什么能修改外部变量",而是另一个问题:如果此前根本没有声明某个变量,直接在 Lua 函数内部写 a = 100,那么 Lua 会把它理解成什么变量?
例如下面这段代码:
lua
function test()
a = 100
end
test()
print(a) -- 100
这里 a = 100 写在 test() 函数内部,也没有使用 local 声明它,但函数外部依然能够访问 a。
这是不是意味着:Lua 的函数作用域,并不会自动让其中的普通赋值创建局部变量?
其实要回答这个问题,我们首先需要将两个容易混在一起的概念区分开来:
- 变量保存的值属于什么数据类型。
- 变量在什么代码位置能够被访问。
前者涉及 Lua 的动态类型,后者才是这篇文章要重点讨论的 local 与作用域。
一、先回顾动态类型:变量本身不需要固定的数据类型
1. 变量与值并不是同一个概念
在 C++ 中,我们通常会在声明变量时明确指定其类型:
cpp
int a = 100;
这里的 a 被声明为 int 类型。之后我们可以修改它保存的数值,但是不能直接让这个 int 变量变成一个 std::string 变量。
而对于 Lua 来说,情况不同:
lua
local a = 100
print(type(a)) -- number
a = "hello"
print(type(a)) -- string
a = true
print(type(a)) -- boolean
在这段代码中,我们始终使用同一个变量名称 a,但是它先后保存了数值、字符串以及布尔值。
因此,我们可以建立第一个认识:
Lua 是动态类型语言,变量无需声明固定的数据类型;但这并不意味着值没有类型。每个值仍然具有明确的运行时类型。
例如:
100属于number;"hello"属于string;true属于boolean。
这里真正会变化的,是变量当前所保存的值及其类型,而不是说一个具体的值可以在完全不受规则限制的情况下任意操作。
2. 为什么解释器必须知道当前值的类型?
原因很简单:只有知道值的具体类型,才能决定怎样正确地访问和操作它。
例如,我们现在要执行:
lua
local a = 10
local b = 20
print(a + b) -- 30
这里的 + 执行的是数值加法。
而如果换成字符串:
lua
local a = "hello"
local b = "lua"
print(a .. b) -- hellolua
这里使用的是 Lua 的字符串连接运算符 ..。
所以虽然 Lua 不要求我们事先为变量声明固定的数据类型,但是运行时必须能够识别具体值的类型,才知道应当采用什么操作规则。
3. 用 TValue 建立底层心智模型
此前我们已经认识过,在 Lua 的典型 C 语言实现中,可以借助 TValue 这种带类型标记的值表示方式来理解运行时数据。
我们可以先将其抽象为:
text
一个运行时的 Lua 值(简化模型)
┌─────────────────────────────┐
│ 类型标记(Type Tag) │
├─────────────────────────────┤
│ 值的有效载荷(Value) │
└─────────────────────────────┘
例如一个数值可能需要记录它是数值类型以及对应的数值内容;一个字符串类型的值则需要记录字符串类型,以及用于找到字符串对象的信息。
这样一来,Lua 虚拟机执行相关指令时,便能够依据运行时类型信息处理数据。
不过这里要注意:将"变量理解为一个保存类型标记与值的 TValue 对象"是一种便于学习的模型,并不意味着 Lua 源码里的每个变量名称,都一定在内存中独立对应一个固定的 TValue 结构体。
因为变量名称、运行时值和具体的解释器存储实现,属于不同层面的概念。现阶段我们只需要记住:Lua 的运行时值携带类型信息即可。

至此,我们解决了"一个变量如何保存不同类型的值"的问题。
但接下来还存在一个与类型完全不同的问题:既然变量是访问值的入口,那么哪些代码有资格通过这个变量名称访问它?
这就引出了作用域。
二、从动态类型过渡到作用域:变量在什么位置可以被访问?
1. 先区分两个维度
对于一个变量来说,我们可以分别考虑:
text
第一个维度:类型
当前保存什么类型的值?number / string / boolean / ...
第二个维度:作用域
哪一段代码可以通过这个变量名称访问它?
例如:
lua
local a = 100
其中:
100是一个number类型的值;local则是在声明一个局部变量;- 这个局部变量能在哪些位置被访问,由它所在的词法作用域决定。
因此,动态类型与局部/全局是两件不同的事情:一个回答"值是什么类型",另一个回答"变量可以从哪里访问"。
2. Lua 同样存在函数与控制块等作用域
Lua 并不是完全没有作用域的语言。它同样存在函数体、do ... end 代码块、if 分支、循环体等代码范围。
例如:
lua
do
local a = 100
print(a) -- 100
end
print(a) -- nil(假设没有同名全局变量)
这里 do ... end 明确创建了一个代码块,而 a 通过 local 声明为局部变量,因此它的可见范围受到这个代码块的限制。
但这里还有一个非常重要的区别:Lua 中一个赋值语句写在函数或者代码块内部,并不意味着它会自动声明一个新的局部变量。
我们接下来重点认识这一点。
三、local 的核心作用:声明局部变量,而不是由函数位置自动决定
1. 为什么函数内部没有 local,外部仍然能够访问?
先看最关键的例子:
lua
function test()
a = 100
end
test()
print(a) -- 100
按照执行顺序来分析:
第一,Lua 执行到 function test() ... end 时,创建并赋值了函数 test;这时 test 函数体内部的 a = 100 还没有执行。
第二,当我们调用 test() 后,函数体才开始执行 a = 100。
第三,等到函数执行结束,我们在函数外部访问 a,自然能够读到之前保存的 100。
这里需要记住的核心结论是:
函数或者控制块能够形成作用域,但不会自动把里面的普通赋值语句变成局部变量声明。
也就是说,在上述条件下,即使 a = 100 出现在函数内部,它操作的依然是全局变量。
2. 添加 local 以后,结果又有什么不同?
现在我们只修改一处代码:
lua
function test()
local a = 100
print(a) -- 100
end
test()
print(a) -- nil(假设没有同名全局变量)
这次我们在函数内部使用了 local。
因此,a 是一个局部变量,其可见范围由所在函数作用域决定。函数内部自然可以访问它,但函数外部不能直接通过名称 a 访问这个局部变量。
所以,我们可以把 local 与作用域的职责区分为:
text
local 关键字
↓
声明局部变量
↓
根据声明位置所在的词法作用域
确定它在哪些代码位置可见
而没有通过 local 声明新的局部变量时,就不能简单地因为一条赋值语句写在函数内部,便断定它创建了局部变量。
3. 一个必要的补充:普通赋值不等于声明
还有一个小细节需要补充,但并不复杂。
lua
local a = 100
a = 200
print(a) -- 200
这里第二条 a = 200 显然只是修改原来的局部变量,并不会因为没再写一次 local,就突然变成另一个全局变量。
在函数内部也是同样的道理:
lua
local a = 100
function test()
a = 200
end
test()
print(a) -- 200
test() 定义在外部局部变量 a 的可见范围内,所以当然可以修改它。这与我们已有的正常作用域认知一致,不需要将它理解成一套额外的特殊规则。
因此,判断一条普通赋值语句 a = 200 实际修改的是哪个变量,只需要记住:
如果当前位置能够看到同名局部变量,就使用这个局部变量;否则访问全局环境中的同名变量。
四、局部变量的可见性:还要看 local 写在什么位置
现在我们已经认识了 local 的作用。接下来的问题也很自然:局部变量虽然通过 local 声明,但它具体能够在多大的范围内被访问?
答案就是:看它位于哪个词法作用域。
1. 在函数内部声明 local
lua
function test()
local age = 20
print(age) -- 20
end
test()
print(age) -- nil(假设没有同名全局变量)
这里的 age 只能在对应的函数作用域内,通过这个局部变量名称直接访问。
2. 在控制块内部声明 local
lua
if true then
local score = 90
print(score) -- 90
end
print(score) -- nil(假设没有同名全局变量)
score 的可见范围被限制在 if 的对应代码块中。
再看一个对照例子:
lua
do
result = 100
local temp = 200
end
print(result) -- 100
print(temp) -- nil(假设没有同名全局变量)
虽然两条语句都位于同一个 do ... end 内,但是 result 在没有可见同名局部变量的前提下访问全局变量,而 temp 是在代码块中声明的局部变量。
这也说明:定义位置在局部代码块中,不代表普通赋值会自动创建局部变量。
3. 在 Chunk 最外层声明 local
此前我们已经知道,Lua 的 Chunk 是编译单元。我们平时编写的一个 Lua 脚本文件,其源码就可以作为一个 Chunk 整体进行编译。
假设有一个 a.lua:
lua
local a = 100
print(a) -- 100
这里的 a 虽然不在显式的函数体或者 do ... end 中,但它仍然是一个局部变量。
原因是 Chunk 本身也可以看作一个代码块,所以在 Chunk 中同样能够声明局部变量。
我们可以认为:a 的可见范围是当前 Chunk 中,从它的声明生效位置开始,直到所属代码块结束。
注意,不是说它一定可以从这个文件的第一行开始访问。
lua
print(a) -- nil(假设没有同名全局变量)
local a = 100
print(a) -- 100
因为在局部变量声明之前,它还不处于可见范围之中。
4. 需要知道的少量例外
上面的讲解针对我们平时显式使用 local 声明变量的情况。Lua 还存在一些本来就具有局部变量语义的名称,不需要再额外写 local。
例如函数形参:
lua
function test(x)
print(x)
end
test(100)
这里的 x 是函数形参,属于局部变量。
以及 for 循环的控制变量:
lua
for i = 1, 3 do
print(i)
end
print(i) -- nil(假设没有同名全局变量)
i 也是循环语句自动提供的局部变量。
这两个情况属于 Lua 语言本身规定的语义,我们知道即可,不必因此推翻前面建立的主要理解。
五、从单文件走向多文件:函数内部的全局变量,其他文件能访问吗?
现在我们继续按照之前的逻辑进行推导。
既然一个没有被声明为局部变量的名称,即使出现在函数内部,也可能访问全局变量,那么问题来了:
如果另一个 Lua 文件加载了这个脚本,那么它是否也可以访问这个全局变量?
答案是:在共享同一个默认全局环境,并且相关赋值已经执行的情况下,可以。
1. 用两个文件验证
首先编写 a.lua:
lua
-- a.lua
function init()
score = 100
end
init()
这里的 score = 100 虽然写在函数内部,但是没有可见的同名局部变量,因此操作的是全局变量 score。
随后编写 b.lua:
lua
-- b.lua
dofile("a.lua")
print(score) -- 100
这里的 dofile("a.lua") 会加载并执行 a.lua。在普通 Lua 的默认运行环境中,两个文件使用同一个 Lua 状态中的全局环境,因此可以访问到 score。
整个过程可以理解为:
text
运行 b.lua
│
├── 执行 dofile("a.lua")
│ │
│ └── 加载并执行 a.lua
│ │
│ └── 调用 init()
│ │
│ └── 执行 score = 100
│ │
│ └── 写入全局环境
│
└── print(score)
│
└── 从同一个全局环境读取 100
2. 别忽略一个细节:函数定义与函数执行不同
这里我们还需要结合此前对 Lua 脚本执行顺序的认识。
假设 a.lua 只有:
lua
-- a.lua
function init()
score = 100
end
然后 b.lua 写成:
lua
-- b.lua
dofile("a.lua")
print(score) -- nil(假设之前没有为 score 赋值)
init()
print(score) -- 100
为什么第一次输出 nil?
这是因为执行 function init() ... end 时,虽然会创建并赋值函数,但是 init 内部的语句不会立刻执行。
只有调用 init(),函数体内部的 score = 100 才会真正执行。之后另一个文件才能读取到对应的全局变量值。
因此,我们不能因为某个 Lua 文件中写着给全局变量赋值的代码,就认为加载这个文件后一定已经产生了对应的值;还要看那条赋值语句是否实际执行。
3. 为什么跨文件不能直接访问顶层 local?
再来看另一种情况。
a.lua:
lua
-- a.lua
local secret = 300
public_value = 500
b.lua:
lua
-- b.lua
dofile("a.lua")
print(secret) -- nil(假设没有同名全局变量)
print(public_value) -- 500
这里 secret 是在 a.lua 的 Chunk 中声明的局部变量,b.lua 无法直接通过名称 secret 访问它。
而 public_value 是全局变量,因此在共享同一个全局环境的前提下,b.lua 可以访问它。
不过,"不能直接访问局部变量"不代表这个值永远不能交给其他文件。后续学习模块和函数返回值时,我们会认识如何有意识地向外提供数据。现在先理解它不会自动变成全局变量即可。
七、最后形成一套完整的 Lua 变量心智模型
回过头看,我们这篇文章实际上解决了两个不同层次的问题。
第一个问题是:Lua 的变量为什么可以保存不同类型的值?
因为 Lua 是动态类型语言,变量不需要声明固定的数据类型,而实际的运行时值始终拥有其具体类型。解释器在运行时根据值的类型信息,采用对应的操作规则。
第二个问题是:Lua 的变量究竟在哪里可以访问?
这就需要理解 local 与词法作用域:
text
声明 local a = 100
│
└── 声明一个局部变量
│
└── 可见范围由声明位置的词法作用域决定
普通赋值 a = 100
│
├── 当前有可见的局部变量 a
│ └── 修改这个局部变量
│
└── 当前没有可见的局部变量 a
└── 访问全局环境中的 a
再继续延伸到两个 Lua 文件:
text
文件 A 中的全局变量
│
├── 对应赋值语句已经执行
│
└── 文件 B 与文件 A 使用同一个全局环境
│
└── 文件 B 可以通过全局名称访问该值
我们可以将这几个概念放到一张表中进行对照:
| 场景 | 实际情况 |
|---|---|
local a = 100 |
声明局部变量,具体可见范围由作用域决定 |
a = 100,不存在可见的同名局部变量 |
访问全局环境中的 a |
函数内部 a = 100,不存在可见的同名局部变量 |
仍然访问全局环境中的 a |
函数内部 local a = 100 |
声明函数作用域内的局部变量 |
Chunk 最外层 local a = 100 |
声明当前 Chunk 作用域内的局部变量 |
在局部变量的可见范围内执行 a = 200 |
修改原有局部变量 |
| 另一个 Lua 文件加载并执行脚本 | 共享同一环境时可访问已赋值的全局变量 |
| 不同 Lua 解释器进程 | 全局变量不会因为同名而自动共享 |
最终,我们可以将整篇文章归纳成一句话:
Lua 的动态类型决定了变量可以保存不同类型的值;
local与词法作用域则决定了局部变量的可见范围。函数或者控制块不会自动将普通赋值变成局部变量声明,而在共享全局环境的条件下,全局变量即使是在函数内部赋值,也可以在其他 Lua 文件中访问。
至此,Lua 变量、动态类型、局部变量、全局变量、作用域以及跨文件访问之间的关系,就形成了一条完整的逻辑链。
接下来继续学习 table、模块加载以及闭包 时,就可以在这套模型的基础上自然过渡,不需要从头重新理解变量的可见性。
