【Lua入门系列】从动态类型到变量作用域:彻底理解 local、全局变量与跨文件访问

🔥 本文专栏: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 的函数作用域,并不会自动让其中的普通赋值创建局部变量?

其实要回答这个问题,我们首先需要将两个容易混在一起的概念区分开来:

  1. 变量保存的值属于什么数据类型。
  2. 变量在什么代码位置能够被访问。

前者涉及 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、模块加载以及闭包 时,就可以在这套模型的基础上自然过渡,不需要从头重新理解变量的可见性。


相关推荐
程序猿乐锅1 小时前
面向对象练习:图书借阅系统里的封装、抽象类与多态
开发语言·前端·数据库·后端·python·学习·1024程序员节
沐晓时光1 小时前
C语言入门,深入理解指针(6)
c语言·开发语言
略略略咯咯1 小时前
33333
开发语言·windows·python
垆边人似月.1 小时前
第三篇:函数、文件与异常处理 / Functions, Files & Exception Handling
开发语言·python
云和数据.ChenGuang1 小时前
项目打开后没有主类
开发语言·人工智能·langchain·1024程序员节
鬼手点金1 小时前
缓存作为架构约束
开发语言·前端·人工智能·python·microsoft·缓存·架构
SEO_juper1 小时前
Node.js 页面主题聚焦度检测:title 和正文各说各的,AI 也读不懂
开发语言·人工智能·node.js·seo·外贸独立站
软件技术NINI2 小时前
基于PHP和MySQL的快递代收点管理系统的设计与实现
开发语言·javascript·数据库·mysql·毕业设计·php·毕设
Dovis(誓平步青云)2 小时前
一个分区超温就全棚浇水,控制系统怎样按区下策略
开发语言·前端·javascript·人工智能·机器学习