第 4 章 / 共 58 章 卷一 起步

变量常量与作用域

变量是程序的"储物格"。这一章讲清格子怎么申请、贴什么标签、放在哪一层架子上。

本章导读

第 3 章认识了"值",这一章解决"值放在哪"。变量看似是最基础的话题,但每个语言在三个问题上的回答都不同:声明时必须给值吗?不给的话里面是什么?同名变量在不同位置谁说了算?

xlang 的回答简洁而严格:可以不初始化(有确定默认值)、同一作用域不允许重复声明、内层块可以遮蔽外层同名变量。本章逐一验证这些规则,最后透过 xl cgen 看一眼全局变量和局部变量在生成 C 代码里的真实差别。

本章学习目标

掌握变量声明与初始化、const 常量、各类型默认初始值;理解顶层/函数/块三级作用域与遮蔽规则;会用 auto q, r = f() 解构多返回值;了解 export 变量;理解全局与局部变量在代码生成层面的差异。

4.1 变量声明与初始化

声明的基本形态是"C 风格类型前置",等号初始化可选:

decl.xl
int a              // 声明,不初始化
int b = 1         // 声明并初始化
var v = 123       // 动态类型
auto c = b + 1    // 编译期推断为 int
println(a, b, v, c)
> xl run decl.xl 0 1 123 2

四条规则:

  • 类型在前、名字在后,和 C 一致;
  • 初始化可选——不给初值时变量持有确定的默认值(下一节);
  • 初始化表达式的类型必须与声明类型匹配(第 3 章坑 3 已经见过这条报错);
  • 同一作用域内不允许重复声明同名变量(见常见错误坑 1)。

变量可以声明在两个位置:文件顶层(模块级,全文件可见)和函数/块内部(局部,仅当前块可见)。4.4 节专门讲这个差别。

4.2 默认初始值

xlang 没有"未定义值"。声明不初始化时,每种类型都有明确的零值:

default.xl
int a
int b
string s
bool f
float d
println(a, b, s, f, d)
> xl run default.xl 0 0 false 0.0

对照表:

类型默认初始值
int / 定宽整型0
float / float32 / float640.0
boolfalse
string空字符串 ""(注意上面输出中 s 位置是"看不见的空")
class / 容器null(第 21 章详解)
varnull

"总是有确定初值"是 xlang 相比 C 的重要改进——C 的未初始化局部变量是随机垃圾值,xlang 从语言层面消灭了这类 bug。

4.3 常量 const

用 const 声明的名字在初始化后不可再赋值:

const.xl
const int MAX = 10
const string APP = "demo"
println(MAX, APP)

试图给 const 重新赋值,编译期直接报错:[sem] const 不能被重新赋值(见常见错误坑 2)。

什么时候用 const?一个简单标准:值在写代码时就能确定、且整个程序运行期间不该变——配置上限、数学常量、应用名。const 让"这个不会变"成为编译器强制保证的事实,而不是口头约定。

const 约束的是"变量本身不能再绑定新值"。对于 class、容器这类引用类型,const 防止的是换掉整个对象,对象内部内容的修改规则见第 27 章拷贝语义。

4.4 作用域层级

作用域回答"这个名字在哪些代码里有效"。xlang 的作用域按块嵌套,分三层:

  • 顶层(模块级):文件顶层声明,全文件可见,包括所有函数内部;
  • 函数级:函数参数和函数体内声明的变量,仅该函数内可见;
  • 块级:if、for、while 等块内声明的变量,块结束即失效。
scope.xl
int counter = 100           // 顶层:全文件可见

func bump() as int
    int local = counter + 1  // 函数级:能读顶层 counter
    return local
end

if true then
    int inner = 42           // 块级:仅 if 块内有效
    println(inner)
end
println(counter, bump())
> xl run scope.xl 42 100 101

两个关键事实:

  • 内层能看外层,外层不能看内层。函数里能读顶层的 counter;但在 if 块外访问 inner 会得到 undeclared identifier(见常见错误坑 3);
  • 块级变量的生命周期就是块的执行期:进入块时创建,离开块时销毁。循环每跑一轮,块内变量都是重新创建的。这是"生命周期"概念的初步——容器和对象的完整生命周期管理(引用计数、写时复制)在第 27 章展开。

4.5 变量遮蔽

内层块允许声明与外层同名的变量,内层的会"遮住"外层的——这叫遮蔽(shadowing):

shadow.xl
func main(...) as int
    int x = 1
    if true then
        int x = 2            // 遮蔽外层的 x
        println("块内:", x)
    end
    println("块外:", x)
    return 0
end
> xl run shadow.xl 块内: 2 块外: 1

遮蔽合法且有时有用(短小的临时用途),但读代码时容易混淆。工程建议:能用新名字就不用遮蔽,团队规范通常禁止它(第 42 章)。

RC0 已知问题:顶层块内遮蔽

写作本章时实测:在文件顶层的 if/for 块内遮蔽顶层同名变量(例如顶层 int x = 1,顶层 if 块内再 int x = 2),xl check 语义检查通过,但 run/build 时生成的 C 代码报 'x' undeclared——这是代码生成器的已知缺陷。函数内的遮蔽(上面的示例)完全正常。修复版本发布前,请避免在顶层块内遮蔽顶层变量。

4.6 解构声明

函数可以返回多个值(第 11 章正式讲),接收时可以用解构声明一次声明多个变量:

pair.xl
func pair() as (int, string)
    return 1, "one"
end

auto q, r = pair()
println(q, r)               // 1 one
println(type(q), type(r))   // int string

auto q, r = pair() 把第一个返回值装进 q(推断为 int)、第二个装进 r(推断为 string)。每个名字独立推断类型,与逐个声明完全等价,只是更紧凑。

注意:auto q, r = 1, 2 这种"右侧直接写字面量列表"的写法不是合法语法(实测报 [parse] 语法错误)——解构声明的右侧必须是返回多值的函数调用。想一次声明多个普通变量,就分行写。

4.7 export 导出变量

顶层变量和常量可以用 export 标记导出,供其他模块或 C 代码使用:

export.xl
export int exportedCounter = 1
export const int exportedMax = 10
println(exportedCounter, exportedMax)
> xl run export.xl 1 10

对普通单文件程序,export 不改变任何行为——它的作用在"被外部看见":导出到 package 供 import(第 35 章)、或生成 C ABI 符号供 C 代码访问(第 33 章)。现在只需认识这个关键字,知道"顶层变量默认是模块私有的,export 让它公开"。

深入细节

拾遗 1:全局与局部变量在生成 C 代码里的真实差别

用 xl cgen 观察 4.4 节那样的程序(顶层 int counter = 100 + 函数内 int local),生成的 C 里两者的地位完全不同(节选真实输出):

static long long __xl6_user_global_counter; // 顶层变量:static 全局,带 __xl6_user_global_ 前缀 ... long long local = (__xl6_user_global_counter + 1LL); // 局部变量:普通 C 局部变量 ... __xl6_user_global_counter = 100LL; // 顶层变量的初始化在模块初始化段执行

三个要点:顶层变量被生成为带 __xl6_user_global_ 前缀的 static C 全局变量(static 保证不污染外部符号);它的初始化表达式在模块初始化段统一执行,而不是在声明点;局部变量就是普通的 C 局部变量。这也解释了为什么顶层变量的生命周期等于整个程序运行期,而函数级变量随函数进出而生灭。

拾遗 2:顶层变量可以"先使用后声明"

实测一个反直觉行为:顶层的 println(x) 写在 int x = 1 之前可以编译运行,输出 0;而函数体内的同样写法报 undeclared identifier。原因就藏在拾遗 1 里:顶层变量是 C 全局变量,程序启动时就是零值,初始化表达式在模块初始化段执行——所以"提前"读到的是默认值 0。甚至可以写 int x = x + 1(自引用初始化),结果是 1。这是语言允许的,但属于坏味道:读到默认值往往不是本意。请遵守"先声明、后使用"的顺序,把这个行为当作理解模块初始化模型的钥匙,而不是可利用的特性。

拾遗 3:顶层字符串变量的初始化与释放

继续看 cgen 输出可以发现,顶层 string 变量的初始化不是简单的指针赋值,而是 __xl6_user_global_APP = xrtCopyStr("demo", 0u)——运行时为它复制了一份堆内存;程序结束时模块清理段会调用 xrtFree 释放。整数等值类型无需这样的处理。这就是"资源类型生命周期检查"的一个缩影:xlang 在模块边界自动安排资源的获取与释放,第 27 章会完整展开这套机制。

⚠️ 常见错误

以下报错均为真实输出。

坑 1:同一作用域重复声明
# dup.xl 内容: # int a = 1 # int a = 2 > xl run dup.xl dup.xl:2:5: [sem] 同一作用域内重复声明

原因:同一层作用域里一个名字只能声明一次。这是刻意的设计——重复声明几乎总是笔误或复制粘贴事故。正确做法:想改值就直接赋值 a = 2(不写类型);确实需要新变量就换个名字。

坑 2:给 const 重新赋值
# c.xl 内容: # const int MAX = 10 # MAX = 20 > xl run c.xl c.xl:2:1: [sem] const 不能被重新赋值

原因:const 的承诺由编译器强制兑现。正确做法:值会变化就不要声明成 const;如果一处"常量"在程序运行中需要调整,它本来就是变量。

坑 3:在块外使用块内声明的变量
# o.xl 内容: # if true then # int inner = 42 # end # println(inner) > xl run o.xl o.xl:4:9: [sem] undeclared identifier 'inner'

原因:inner 的生命周期在 end 处就结束了,块外这个名字根本不存在。正确做法:把需要带出块的变量声明在块外,块内只赋值:

# 正确写法 int result if true then result = 42 end println(result)
坑 4:函数内先使用后声明
# f.xl 内容: # func main(...) as int # println(x) # int x = 1 # return 0 # end > xl run f.xl f.xl:2:13: [sem] undeclared identifier 'x'

原因:局部变量严格按文本顺序生效,声明之前的代码里这个名字不存在(顶层变量的"提前可见"是另一套模块机制,见深入细节拾遗 2,不要混淆)。正确做法:把声明挪到使用之前——这也是可读性最好的顺序。

本章小结

知识点速查
声明int a / int b = 1;类型前置;同一作用域不可重复声明。
默认初始值数值 0 / 0.0、bool false、string 空串、class/容器/var 为 null;无"未定义值"。
const初始化后不可再赋值,编译期强制;运行期不变的值才用 const。
作用域三层顶层(全文件)→ 函数级 → 块级;内层可见外层,反之不行。
遮蔽内层块可声明同名变量遮蔽外层(函数内正常;顶层块遮蔽有 RC0 缺陷,避免使用)。
解构声明auto q, r = pair() 接收多返回值,逐个推断类型;右侧必须是多返回函数调用。
export顶层变量/常量导出给 package 或 C;单文件程序中无行为差异。
cgen 差异顶层变量 → static + __xl6_user_global_ 前缀,初始化在模块初始化段;局部变量 → 普通 C 局部变量。
声明顺序局部变量先声明后使用(硬性);顶层变量提前可见读到默认值(允许但应避免)。

复习题

✍️ 先遮住答案想一想
  1. int a 不给初值时,a 的值是什么?xlang 的这一设计与 C 有什么不同?
  2. const 和"值恰好没变的普通变量"有什么本质区别?
  3. 函数内能读顶层变量,顶层能用函数内的变量吗?为什么?
  4. 什么是变量遮蔽?为什么团队规范通常建议避免它?
  5. 顶层变量 int x = 1 在生成的 C 代码里变成什么?它的初始化在何时执行?

编程练习

练习 1:默认值调查报告

编写 defaults.xl:声明 int、float、bool、string、var 五种未初始化变量,用 f-string 逐行打印"int 默认值是 0"这样的调查报告(string 的空值用 [、] 括起来让它现形)。

参考思路:println(f"string 默认值是 [{s}]");var 的默认值是 null,直接插值即可。

练习 2:作用域实验

编写 scope_lab.xl:顶层声明 int level = 0,函数内声明 int level = 1,函数内 if 块再声明 int level = 2,三个位置各打印一次,验证遮蔽链;然后在函数外打印顶层的 level。

参考思路:三次输出应为 2、1、0。把任意一层 int 去掉(变成赋值),观察输出变化,体会"声明"与"赋值"的区别。

练习 3:解构与 cgen 观察

编写一个返回 (int, string) 的函数 stats(),用 auto n, label = stats() 接收并打印;然后执行 xl cgen stats.xl --emit stats.c,在生成的 C 中找到顶层变量(如顶层有声明)的 __xl6_user_global_ 前缀。

参考思路:多返回写法是 return 42, "answer";cgen 文件里搜索 user_global 即可定位。这个练习把 4.6 和拾遗 1 串了起来。