ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

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

💪 今日博客励志语录:别因为现在还普通,就怀疑过去所有的努力。很多成长,在结果出现之前,本来就是沉默的。



思维导图

Lua 的变量 │ ├── 动态类型:变量不声明固定的数据类型 │ └── 值始终具有具体类型 │ └── 运行时根据类型操作值(TValue 心智模型) │ └── 变量需要能够被代码访问 └── 那么:在什么位置可以访问它? │ ├── local:声明局部变量 │ └── 具体可见范围由词法作用域决定 │ ├── Chunk 中的局部变量 │ ├── 函数中的局部变量 │ └── 控制块中的局部变量 │ └── 普通赋值:a = 100 ├── 存在可见的同名局部变量 → 修改该变量 └── 不存在可见的同名局部变量 → 访问全局环境 │ └── 不同 Lua 文件可共享该环境 ├── dofile 加载并执行 └── 赋值必须真的执行

引入:同样写在函数内部,为什么有的变量外部可以访问?

此前我们已经对 Lua 的基本运行方式以及动态类型有了初步认识。接下来继续学习变量时,很自然就会碰到另外一个问题:变量究竟在什么范围内可以被访问?

我们知道,在 C++ 中,函数内部当然可以访问和修改外部的全局变量,这本身就是一个正常的作用域现象。

这里真正值得我们思考的,不是“函数为什么能修改外部变量”,而是另一个问题:如果此前根本没有声明某个变量,直接在 Lua 函数内部写a = 100,那么 Lua 会把它理解成什么变量?

例如下面这段代码:

functiontest()a=100endtest()print(a)-- 100

这里a = 100写在test()函数内部,也没有使用local声明它,但函数外部依然能够访问a。

这是不是意味着:Lua 的函数作用域,并不会自动让其中的普通赋值创建局部变量?

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

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

前者涉及 Lua 的动态类型,后者才是这篇文章要重点讨论的local与作用域。


一、先回顾动态类型:变量本身不需要固定的数据类型

1. 变量与值并不是同一个概念

在 C++ 中,我们通常会在声明变量时明确指定其类型:

inta=100;

这里的a被声明为int类型。之后我们可以修改它保存的数值,但是不能直接让这个int变量变成一个std::string变量。

而对于 Lua 来说,情况不同:

locala=100print(type(a))-- numbera="hello"print(type(a))-- stringa=trueprint(type(a))-- boolean

在这段代码中,我们始终使用同一个变量名称a,但是它先后保存了数值、字符串以及布尔值。

因此,我们可以建立第一个认识:

Lua 是动态类型语言,变量无需声明固定的数据类型;但这并不意味着值没有类型。每个值仍然具有明确的运行时类型。

例如:

  • 100属于number;
  • "hello"属于string;
  • true属于boolean。

这里真正会变化的,是变量当前所保存的值及其类型,而不是说一个具体的值可以在完全不受规则限制的情况下任意操作。

2. 为什么解释器必须知道当前值的类型?

原因很简单:只有知道值的具体类型,才能决定怎样正确地访问和操作它。

例如,我们现在要执行:

locala=10localb=20print(a+b)-- 30

这里的+执行的是数值加法。

而如果换成字符串:

locala="hello"localb="lua"print(a..b)-- hellolua

这里使用的是 Lua 的字符串连接运算符..。

所以虽然 Lua 不要求我们事先为变量声明固定的数据类型,但是运行时必须能够识别具体值的类型,才知道应当采用什么操作规则。

3. 用 TValue 建立底层心智模型

此前我们已经认识过,在 Lua 的典型 C 语言实现中,可以借助TValue这种带类型标记的值表示方式来理解运行时数据。

我们可以先将其抽象为:

一个运行时的 Lua 值(简化模型) ┌─────────────────────────────┐ │ 类型标记(Type Tag) │ ├─────────────────────────────┤ │ 值的有效载荷(Value) │ └─────────────────────────────┘

例如一个数值可能需要记录它是数值类型以及对应的数值内容;一个字符串类型的值则需要记录字符串类型,以及用于找到字符串对象的信息。

这样一来,Lua 虚拟机执行相关指令时,便能够依据运行时类型信息处理数据。

不过这里要注意:将“变量理解为一个保存类型标记与值的 TValue 对象”是一种便于学习的模型,并不意味着 Lua 源码里的每个变量名称,都一定在内存中独立对应一个固定的TValue结构体。

因为变量名称、运行时值和具体的解释器存储实现,属于不同层面的概念。现阶段我们只需要记住:Lua 的运行时值携带类型信息即可。

至此,我们解决了“一个变量如何保存不同类型的值”的问题。

但接下来还存在一个与类型完全不同的问题:既然变量是访问值的入口,那么哪些代码有资格通过这个变量名称访问它?

这就引出了作用域。


二、从动态类型过渡到作用域:变量在什么位置可以被访问?

1. 先区分两个维度

对于一个变量来说,我们可以分别考虑:

第一个维度:类型 当前保存什么类型的值?number / string / boolean / ... 第二个维度:作用域 哪一段代码可以通过这个变量名称访问它?

例如:

locala=100

其中:

  • 100是一个number类型的值;
  • local则是在声明一个局部变量;
  • 这个局部变量能在哪些位置被访问,由它所在的词法作用域决定。

因此,动态类型与局部/全局是两件不同的事情:一个回答“值是什么类型”,另一个回答“变量可以从哪里访问”。

2. Lua 同样存在函数与控制块等作用域

Lua 并不是完全没有作用域的语言。它同样存在函数体、do ... end代码块、if分支、循环体等代码范围。

例如:

dolocala=100print(a)-- 100endprint(a)-- nil(假设没有同名全局变量)

这里do ... end明确创建了一个代码块,而a通过local声明为局部变量,因此它的可见范围受到这个代码块的限制。

但这里还有一个非常重要的区别:Lua 中一个赋值语句写在函数或者代码块内部,并不意味着它会自动声明一个新的局部变量。

我们接下来重点认识这一点。


三、local 的核心作用:声明局部变量,而不是由函数位置自动决定

1. 为什么函数内部没有 local,外部仍然能够访问?

先看最关键的例子:

functiontest()a=100endtest()print(a)-- 100

按照执行顺序来分析:

第一,Lua 执行到function test() ... end时,创建并赋值了函数test;这时test函数体内部的a = 100还没有执行。

第二,当我们调用test()后,函数体才开始执行a = 100。

第三,等到函数执行结束,我们在函数外部访问a,自然能够读到之前保存的100。

这里需要记住的核心结论是:

函数或者控制块能够形成作用域,但不会自动把里面的普通赋值语句变成局部变量声明。

也就是说,在上述条件下,即使a = 100出现在函数内部,它操作的依然是全局变量。

2. 添加 local 以后,结果又有什么不同?

现在我们只修改一处代码:

functiontest()locala=100print(a)-- 100endtest()print(a)-- nil(假设没有同名全局变量)

这次我们在函数内部使用了local。

因此,a是一个局部变量,其可见范围由所在函数作用域决定。函数内部自然可以访问它,但函数外部不能直接通过名称a访问这个局部变量。

所以,我们可以把local与作用域的职责区分为:

local 关键字 ↓ 声明局部变量 ↓ 根据声明位置所在的词法作用域 确定它在哪些代码位置可见

而没有通过local声明新的局部变量时,就不能简单地因为一条赋值语句写在函数内部,便断定它创建了局部变量。

3. 一个必要的补充:普通赋值不等于声明

还有一个小细节需要补充,但并不复杂。

locala=100a=200print(a)-- 200

这里第二条a = 200显然只是修改原来的局部变量,并不会因为没再写一次local,就突然变成另一个全局变量。

在函数内部也是同样的道理:

locala=100functiontest()a=200endtest()print(a)-- 200

test()定义在外部局部变量a的可见范围内,所以当然可以修改它。这与我们已有的正常作用域认知一致,不需要将它理解成一套额外的特殊规则。

因此,判断一条普通赋值语句a = 200实际修改的是哪个变量,只需要记住:

如果当前位置能够看到同名局部变量,就使用这个局部变量;否则访问全局环境中的同名变量。


四、局部变量的可见性:还要看 local 写在什么位置

现在我们已经认识了local的作用。接下来的问题也很自然:局部变量虽然通过local声明,但它具体能够在多大的范围内被访问?

答案就是:看它位于哪个词法作用域。

1. 在函数内部声明 local

functiontest()localage=20print(age)-- 20endtest()print(age)-- nil(假设没有同名全局变量)

这里的age只能在对应的函数作用域内,通过这个局部变量名称直接访问。

2. 在控制块内部声明 local

iftruethenlocalscore=90print(score)-- 90endprint(score)-- nil(假设没有同名全局变量)

score的可见范围被限制在if的对应代码块中。

再看一个对照例子:

doresult=100localtemp=200endprint(result)-- 100print(temp)-- nil(假设没有同名全局变量)

虽然两条语句都位于同一个do ... end内,但是result在没有可见同名局部变量的前提下访问全局变量,而temp是在代码块中声明的局部变量。

这也说明:定义位置在局部代码块中,不代表普通赋值会自动创建局部变量。

3. 在 Chunk 最外层声明 local

此前我们已经知道,Lua 的Chunk 是编译单元。我们平时编写的一个 Lua 脚本文件,其源码就可以作为一个 Chunk 整体进行编译。

假设有一个a.lua:

locala=100print(a)-- 100

这里的a虽然不在显式的函数体或者do ... end中,但它仍然是一个局部变量。

原因是Chunk 本身也可以看作一个代码块,所以在 Chunk 中同样能够声明局部变量。

我们可以认为:a的可见范围是当前 Chunk 中,从它的声明生效位置开始,直到所属代码块结束。

注意,不是说它一定可以从这个文件的第一行开始访问。

print(a)-- nil(假设没有同名全局变量)locala=100print(a)-- 100

因为在局部变量声明之前,它还不处于可见范围之中。

4. 需要知道的少量例外

上面的讲解针对我们平时显式使用local声明变量的情况。Lua 还存在一些本来就具有局部变量语义的名称,不需要再额外写local。

例如函数形参:

functiontest(x)print(x)endtest(100)

这里的x是函数形参,属于局部变量。

以及for循环的控制变量:

fori=1,3doprint(i)endprint(i)-- nil(假设没有同名全局变量)

i也是循环语句自动提供的局部变量。

这两个情况属于 Lua 语言本身规定的语义,我们知道即可,不必因此推翻前面建立的主要理解。


五、从单文件走向多文件:函数内部的全局变量,其他文件能访问吗?

现在我们继续按照之前的逻辑进行推导。

既然一个没有被声明为局部变量的名称,即使出现在函数内部,也可能访问全局变量,那么问题来了:

如果另一个 Lua 文件加载了这个脚本,那么它是否也可以访问这个全局变量?

答案是:在共享同一个默认全局环境,并且相关赋值已经执行的情况下,可以。

1. 用两个文件验证

首先编写a.lua:

-- a.luafunctioninit()score=100endinit()

这里的score = 100虽然写在函数内部,但是没有可见的同名局部变量,因此操作的是全局变量score。

随后编写b.lua:

-- b.luadofile("a.lua")print(score)-- 100

这里的dofile("a.lua")会加载并执行a.lua。在普通 Lua 的默认运行环境中,两个文件使用同一个 Lua 状态中的全局环境,因此可以访问到score。

整个过程可以理解为:

运行 b.lua │ ├── 执行 dofile("a.lua") │ │ │ └── 加载并执行 a.lua │ │ │ └── 调用 init() │ │ │ └── 执行 score = 100 │ │ │ └── 写入全局环境 │ └── print(score) │ └── 从同一个全局环境读取 100

2. 别忽略一个细节:函数定义与函数执行不同

这里我们还需要结合此前对 Lua 脚本执行顺序的认识。

假设a.lua只有:

-- a.luafunctioninit()score=100end

然后b.lua写成:

-- b.luadofile("a.lua")print(score)-- nil(假设之前没有为 score 赋值)init()print(score)-- 100

为什么第一次输出nil?

这是因为执行function init() ... end时,虽然会创建并赋值函数,但是init内部的语句不会立刻执行。

只有调用init(),函数体内部的score = 100才会真正执行。之后另一个文件才能读取到对应的全局变量值。

因此,我们不能因为某个 Lua 文件中写着给全局变量赋值的代码,就认为加载这个文件后一定已经产生了对应的值;还要看那条赋值语句是否实际执行。

3. 为什么跨文件不能直接访问顶层 local?

再来看另一种情况。

a.lua:

-- a.lualocalsecret=300public_value=500

b.lua:

-- b.luadofile("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与词法作用域:

声明 local a = 100 │ └── 声明一个局部变量 │ └── 可见范围由声明位置的词法作用域决定 普通赋值 a = 100 │ ├── 当前有可见的局部变量 a │ └── 修改这个局部变量 │ └── 当前没有可见的局部变量 a └── 访问全局环境中的 a

再继续延伸到两个 Lua 文件:

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


返回列表