1975年,麻省理工学院的 Gerald Sussman 和 Guy Steele 正在开发一门新的 Lisp 方言。他们遇到了一个看似简单的问题:当一个函数引用外部变量时,它应该找到哪个值?这个问题困扰了编程语言设计者十五年,而他们的解决方案——采用词法作用域——后来成为现代编程语言的标配。
但事情并非一直如此。在很长一段时间里,动态作用域才是主流。这两种机制的分歧,深刻影响了我们今天写下的每一行代码。
一个变量,两种命运
考虑这段伪代码:
let x = 1
function foo() {
print(x)
}
function bar() {
let x = 2
foo()
}
bar() // 输出什么?
如果这段代码采用词法作用域(也叫静态作用域),foo() 会输出 1。因为 foo 定义在全局作用域,它看到的 x 是全局的 x。
如果采用动态作用域,foo() 会输出 2。因为 foo 是从 bar 内部调用的,而 bar 中有一个 x = 2。
同一个变量名,同一段代码,完全不同的结果。这不是边界情况——这是两种截然不同的程序设计哲学。
flowchart TD
subgraph Lexical["词法作用域"]
L1["foo 定义时捕获 x=1"]
L2["无论从哪里调用"]
L3["foo 永远看到 x=1"]
L1 --> L2 --> L3
end
subgraph Dynamic["动态作用域"]
D1["foo 调用时查找调用栈"]
D2["bar 中有 x=2"]
D3["foo 看到 x=2"]
D1 --> D2 --> D3
end
词法作用域:写在代码里的约定
词法作用域的核心原则极其简单:变量的可见性由它在源代码中的位置决定,而不是由运行时调用栈决定。
1960年发布的 ALGOL 60 是第一个明确采用词法作用域的主流语言。这份由 Peter Naur、John Backus 等人撰写的报告定义了一个革命性的概念:嵌套函数可以访问外层函数的变量,而且这种访问关系在编译时就确定了。
为什么叫"词法"(lexical)?因为"词法"指的是源代码的文本结构。编译器在词法分析阶段就能确定每个变量引用指向哪个声明——不需要等到运行时。
graph TD
subgraph CodeStructure["源代码结构"]
Global["全局作用域<br/>x = 1"]
Outer["outer 函数<br/>a = 10"]
Inner["inner 函数<br/>访问 a"]
end
Global --> Outer --> Inner
Capture["编译时确定<br/>inner 捕获 a"] -.-> Inner
function outer() {
let a = 10
function inner() {
console.log(a) // 编译器知道这个 a 指向外层的 a
}
return inner
}
const fn = outer()
fn() // 输出 10,虽然 outer 已经返回
这段代码揭示了一个关键点:词法作用域天然支持闭包。inner 函数"捕获"了它定义时的环境,即使 outer 已经执行完毕,a 依然存在。
编译器如何实现词法作用域
编译器处理词法作用域的核心数据结构是符号表(Symbol Table)。每个作用域对应一张表,记录该作用域内声明的所有变量。当编译器遇到变量引用时,它从最内层作用域向外查找:
graph TD
subgraph SymbolTables["符号表查找链"]
Local["局部作用域表<br/>a: int"]
Enclosing["外层作用域表<br/>b: string, c: float"]
Global["全局作用域表<br/>d: function"]
Builtin["内置作用域<br/>print, len, ..."]
end
Local --> Enclosing --> Global --> Builtin
Query["变量引用 x"] --> Local
Local -->|"未找到"| Enclosing
Enclosing -->|"未找到"| Global
Global -->|"未找到"| Builtin
Builtin -->|"未找到"| Error["NameError"]
对于闭包,编译器需要执行闭包转换(Closure Conversion)。当一个函数捕获外部变量时,编译器不能简单地将这些变量放在栈上,因为函数可能在栈帧销毁后仍被调用。
解决方案是:将捕获的变量提升到堆上。编译器将函数转换为两个部分:代码指针和环境指针。环境指针指向堆上分配的结构,存储所有被捕获的变量。
graph LR
subgraph Closure["闭包结构"]
Code["代码指针<br/>函数指令"]
Env["环境指针<br/>堆上数据"]
end
subgraph Heap["堆内存"]
VarA["变量 a = 10"]
VarB["变量 b = 20"]
end
Code --> Env
Env --> VarA
Env --> VarB
// 概念上,闭包是这个结构:
struct Closure {
CodePointer function; // 函数代码
Environment* env; // 捕获的环境
}
// 环境:
struct Environment {
int a; // 捕获的变量 a
string b; // 捕获的变量 b
}
这解释了为什么闭包可能有性能开销——堆分配、间接访问、垃圾回收。但现代编译器的优化已经让这种开销变得极小。
动态作用域:运行时的惊喜
动态作用域的逻辑完全不同:变量的可见性由运行时调用栈决定。
当一个函数引用变量 x 时,解释器沿着调用栈向上查找,找到最近的 x 绑定。这意味着同一个函数,在不同的调用上下文中,可能看到完全不同的 x。
graph TD
subgraph CallStack["运行时调用栈"]
Direction TB
Frame1["foo() 执行帧<br/>print(x)"]
Frame2["bar() 执行帧<br/>x = 2"]
Frame3["全局帧<br/>x = 1"]
end
Frame1 -->|"查找 x"| Frame2
Frame2 -->|"找到 x = 2"| Found["返回 2"]
style Frame2 fill:#90EE90
Lisp 是动态作用域最著名的例子——但这是意外。
1958年,John McCarthy 设计 Lisp 时,他并不关心作用域问题。Lisp 最初的实现使用了一种简单的变量查找机制:在当前环境中查找,找不到就查上层环境。这种"上层"指的是运行时的动态环境,而不是源代码的静态结构。
McCarthy 后来承认,他没有意识到这个问题的重要性。动态作用域成为早期 Lisp 的标志性特征,但也是一个持续争议的源头。
动态作用域的真实问题
动态作用域最大的问题是不可预测性。看这个例子:
// 假设采用动态作用域
var greeting = "Hello"
function greet() {
print(greeting)
}
function formal() {
var greeting = "Good day, sir"
greet() // 输出 "Good day, sir"
}
function casual() {
var greeting = "Hey!"
greet() // 输出 "Hey!"
}
greet() // 输出 "Hello"
formal() // 输出 "Good day, sir"
casual() // 输出 "Hey!"
greet() 函数的行为取决于谁调用了它。在大型程序中,这可能引发难以追踪的 bug。你调用一个库函数,它意外地看到了你定义的局部变量,然后做出了完全错误的事情。
更糟糕的是模块隔离问题:
// 模块 A
var debug = true
function logA() {
if (debug) print("A's debug message")
}
// 模块 B
var debug = false
function logB() {
logA() // 糟糕!logA 会看到 B 的 debug=false
}
两个模块都有自己的 debug 变量,但在动态作用域下,它们会互相干扰。这在构建可组合软件时是灾难性的。
动态作用域的合理用途
然而,动态作用域并非一无是处。在某些场景下,它恰好是我们想要的:
异常处理是一个经典例子。当你 throw 一个异常时,你希望它被调用栈上最近的 catch 块捕获。这正是动态作用域的行为——沿着调用栈向上查找最近的处理器。
配置和环境变量也类似。你可能希望某个函数的行为由调用者的上下文决定,而不是由定义时的上下文决定。日志级别、数据库连接、当前用户信息——这些"隐式参数"用动态作用域传递有时比显式传参更方便。
Common Lisp 和 Perl 都允许程序员选择使用哪种作用域。Common Lisp 的 defvar 声明"特殊变量"(动态作用域),普通变量默认词法作用域。Perl 的 local 创建动态作用域,my 创建词法作用域。
;; Common Lisp
(defvar *debug-mode* nil) ; 动态作用域变量(earmuff 约定)
(defun log-message (msg)
(when *debug-mode*
(print msg)))
(defun with-debug (fn)
(let ((*debug-mode* t)) ; 动态绑定
(funcall fn)))
这种设计承认了一个事实:两种作用域各有用途,优秀的语言应该让程序员选择。
历史:从混乱到标准
词法作用域的胜利并非一蹴而就。从 ALGOL 60 引入词法作用域,到它成为绝对主流,花了超过二十年时间。
timeline
title 作用域演进历史
1958 : Lisp 发布<br/>意外采用动态作用域
1960 : ALGOL 60<br/>首次定义词法作用域
1975 : Scheme<br/>明确采用词法作用域
1984 : Common Lisp<br/>支持双模式
1991 : Python<br/>词法作用域
1995 : JavaScript<br/>词法作用域 + 动态 this
2010s : Rust, Go<br/>现代词法作用域
1970年代:Scheme 的宣言
1975年,Sussman 和 Steele 发布了 Scheme 的第一篇论文《Scheme: An Interpreter for Extended Lambda Calculus》。他们明确指出,Scheme 是一个基于 lambda 演算的 Lisp 方言,采用词法作用域。
这不是一个随意的选择。他们在论文中解释,词法作用域是 lambda 演算的语义基础。函数应该"记住"它定义时的环境——这正是数学上的"闭包"概念。
Scheme 的设计产生了深远影响。它证明了 Lisp 可以有词法作用域,而且这不会破坏语言的表达能力。相反,它让 Lisp 变得更加一致和可预测。
1980年代:Common Lisp 的妥协
到 1980 年代初,Lisp 社区分裂为两大阵营。MacLisp 和它的继承者(包括 Emacs Lisp)坚持动态作用域;而 Scheme 和其他新方言采用词法作用域。
Common Lisp(1984年)试图统一 Lisp 世界。它做出了一个关键决策:默认词法作用域,但保留动态作用域作为选项。
这是工程智慧的体现。Emacs 等大量代码已经依赖动态作用域,强制切换会破坏兼容性。而词法作用域的优势又不可忽视。解决方案是让程序员选择——声明为"特殊"的变量使用动态作用域。
1990年代至今:词法作用域的统治
JavaScript(1995年)最初只有函数作用域,没有块作用域,但它严格采用词法作用域。这解释了 JavaScript 的闭包为何如此强大——它们是词法作用域的自然产物。
Python(1991年)同样采用词法作用域,并在 2.2 版本引入嵌套作用域。它的 LEGB 规则(Local, Enclosing, Global, Built-in)是词法作用域的清晰实现。
Java、C#、Rust、Go——几乎所有现代语言都默认词法作用域。动态作用域被 relegated 到特殊用途:异常处理、配置传递、动态绑定。
唯一的主流例外是 Emacs Lisp,直到今天仍默认动态作用域(尽管 2012 年后提供了词法绑定选项)。这是一个独特的存在,它的延续主要是历史包袱和庞大的现有代码库。
JavaScript:作用域的演进实验室
JavaScript 的作用域设计经历了多次演进,是理解这两种作用域区别的绝佳案例。
var 的问题
ES6 之前,JavaScript 只有 var 声明。var 有两个著名的问题:变量提升和函数作用域。
function example() {
console.log(x) // undefined,而不是 ReferenceError
if (true) {
var x = 10
}
console.log(x) // 10,x 泄露到整个函数
}
var 声明被提升到函数顶部,但赋值不会。这导致变量在声明前就可以访问(只是值是 undefined)。而且 if 块不创建新的作用域——x 泄露到整个函数。
这本质上是词法作用域的粗糙实现。变量确实由其在源代码中的位置决定,但"位置"的计算方式不直观。
let/const 与 Temporal Dead Zone
ES6 引入 let 和 const,带来了真正的块作用域和一个有趣的概念:Temporal Dead Zone(TDZ,暂时性死区)。
function example() {
console.log(x) // ReferenceError: Cannot access 'x' before initialization
let x = 10
}
let 声明同样被"提升"到块的开始,但在声明语句执行前,变量处于 TDZ——访问它会抛出 ReferenceError。
这是一种精巧的设计。它保留了词法作用域的语义(变量在其作用域内),同时防止了 var 风格的意外行为。
graph LR
subgraph Block["块作用域生命周期"]
Start["块开始"]
TDZ["TDZ<br/>变量存在但不可访问"]
Decl["let x = 10<br/>变量初始化"]
Use["变量正常使用"]
End["块结束"]
end
Start --> TDZ --> Decl --> Use --> End
this:动态作用域的幽灵
JavaScript 有一个独特的存在:this。它的行为类似动态作用域——this 的值由调用方式决定,而不是定义位置。
const obj = {
name: "Alice",
greet: function() {
console.log(this.name)
}
}
obj.greet() // "Alice" — this 是 obj
const fn = obj.greet
fn() // undefined — this 是全局对象(严格模式下是 undefined)
fn.call({name: "Bob"}) // "Bob" — this 被 call 显式设置
this 是 JavaScript 对动态作用域的妥协。它解决了"方法需要访问所属对象"的需求,但代价是复杂性。
箭头函数(ES6)引入了一个转折:箭头函数没有自己的 this,它继承外层的 this。这本质上是词法作用域的 this。
const obj = {
name: "Alice",
greet: function() {
const arrowGreet = () => console.log(this.name)
arrowGreet() // "Alice" — 箭头函数捕获了外层的 this
}
}
这展示了两种作用域如何在同一语言中共存:变量是词法作用域,但 this 默认是动态作用域(箭头函数例外)。
Python 的 LEGB 规则
Python 采用纯粹的词法作用域,它的 LEGB 规则清晰地定义了变量查找顺序:
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # local
inner()
print(x) # enclosing
outer()
print(x) # global
查找顺序是:
- Local:当前函数的局部变量
- Enclosing:外层函数的变量(闭包)
- Global:模块级全局变量
- Built-in:Python 内置名称
graph TD
subgraph LEGB["Python LEGB 查找顺序"]
L["L - Local<br/>当前函数局部变量"]
E["E - Enclosing<br/>外层函数变量"]
G["G - Global<br/>模块全局变量"]
B["B - Built-in<br/>内置名称"]
end
L --> E --> G --> B
Query["变量查找"] --> L
nonlocal 和 global 关键字
Python 有一个微妙的问题:在函数内部给变量赋值会创建局部变量,即使外层有同名变量。
x = 10
def modify():
x = 20 # 创建新的局部变量,而不是修改全局的 x
modify()
print(x) # 还是 10
global 和 nonlocal 关键字解决了这个问题:
x = 10
def modify():
global x
x = 20 # 现在修改的是全局的 x
modify()
print(x) # 20
def outer():
x = 10
def inner():
nonlocal x
x = 20 # 修改外层的 x
inner()
print(x) # 20
这些关键字是词法作用域的补充——它们允许"突破"当前作用域,但依然基于代码的静态结构。
Rust:作用域与所有权的融合
Rust 将作用域的概念提升到了新高度。它不仅有传统的词法作用域,还有所有权作用域。
{
let s = String::from("hello");
// s 在作用域内,拥有字符串数据
}
// s 离开作用域,内存被释放
在 Rust 中,作用域结束不仅意味着变量不可访问,还意味着资源被清理。这是 RAII(Resource Acquisition Is Initialization)原则的强制执行。
Rust 的生命周期(lifetime)概念更是对作用域的扩展。生命周期标注 'a 描述引用的有效范围——本质上是作用域的泛型化。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
这个函数声明:返回的引用的有效期,与两个输入引用中较短的那个相同。编译器使用这个信息确保不会出现悬垂引用。
Rust 的设计证明,作用域不仅是名称可见性的问题,还关系到内存安全和资源管理。
编译器实现:从符号表到闭包转换
理解作用域的实现机制,能帮助我们写出更高效的代码。
符号表管理
编译器为每个作用域维护一个符号表。当遇到变量声明时,编译器将名称和类型信息存入当前作用域的表。当遇到变量引用时,从最内层向外查找。
// 源代码
function outer(a, b) {
let c = a + b
function inner(x) {
return x + c
}
return inner
}
// 符号表结构(概念上)
Scope 0 (outer):
a: parameter, int
b: parameter, int
c: local, int
inner: function, closure
Scope 1 (inner):
x: parameter, int
c: free variable, captured from Scope 0
闭包转换
当函数捕获外部变量时,编译器执行闭包转换。函数不再是纯粹的代码指针,而是代码指针加上环境的组合。
// 转换前
function makeCounter() {
let count = 0
return function() {
count = count + 1
return count
}
}
// 转换后(概念上)
struct CounterEnv {
count: int
}
function counterBody(env* CounterEnv) -> int {
env.count = env.count + 1
return env.count
}
function makeCounter() -> Closure {
env = allocate(CounterEnv)
env.count = 0
return Closure{code: counterBody, env: env}
}
捕获的变量必须分配在堆上(或者使用其他逃逸机制),因为栈帧会在函数返回后销毁。这解释了为什么闭包有性能成本——堆分配和间接访问。
现代编译器使用多种优化来减少这种成本:
- 逃逸分析:如果闭包不会逃逸出当前函数,环境可以分配在栈上
- 内联:小函数的闭包可以被内联展开
- 环境优化:只捕获实际使用的变量,而不是整个作用域
实践:理解作用域的代价
理解作用域机制后,我们可以做出更明智的设计决策。
避免过深的嵌套
每增加一层嵌套,符号表查找就多一步。虽然这在实践中影响很小,但深层嵌套本身会降低代码可读性。
// 不推荐
function level1() {
function level2() {
function level3() {
function level4() {
// 查找变量需要遍历 4 层
}
}
}
}
// 推荐:提取为独立的函数
function helper() {
// 独立的作用域,清晰的接口
}
注意闭包的捕获
闭包捕获变量,而不是值。这意味着如果你在循环中创建闭包,它们可能共享同一个变量。
// 常见错误
let functions = []
for (var i = 0; i < 3; i++) {
functions.push(() => console.log(i))
}
functions.forEach(f => f()) // 输出 3, 3, 3
// 正确做法:使用 let 创建块作用域
let functions = []
for (let i = 0; i < 3; i++) {
functions.push(() => console.log(i))
}
functions.forEach(f => f()) // 输出 0, 1, 2
let 为每次循环创建新的绑定,每个闭包捕获的是不同的 i。var 只有一个函数作用域的 i,所有闭包共享它。
理解动态绑定的用途
如果你正在设计一门语言或库,考虑何时需要动态绑定:
// 动态绑定适合的场景:隐式配置
function processData(data) {
// verbose 等配置从调用上下文获取
if (verbose) log("Processing...") // 假设 verbose 是动态变量
// ...
}
function debugMode(fn) {
let savedVerbose = verbose
verbose = true
let result = fn()
verbose = savedVerbose
return result
}
这种模式在现代语言中通常用其他方式实现(依赖注入、上下文对象),但理解其背后的作用域原理有助于设计更好的 API。
六十年的选择
从 ALGOL 60 到现代语言,词法作用域成为主流不是偶然。它提供了:
- 可预测性:变量引用由代码位置决定,不依赖运行时状态
- 模块化:函数可以安全地忽略调用者的局部变量
- 闭包支持:函数可以携带它的环境,实现强大的抽象
动态作用域没有消失,它找到了自己的位置:异常处理、配置传递、上下文管理。Common Lisp 和 Perl 的双模式设计提醒我们,这不是非此即彼的选择。
理解这两种作用域的区别,是理解编程语言设计的关键一步。它解释了为什么闭包如此强大,为什么 JavaScript 的 this 如此令人困惑,为什么 Rust 的所有权与作用域紧密相连。
当你下次调试一个变量访问问题时,想想:这个变量是在哪里定义的?它在哪个作用域中?这些问题的答案,决定了你的程序能否正确运行。
参考文献
- Steele, G. L., & Sussman, G. J. (1975). Scheme: An Interpreter for Extended Lambda Calculus. MIT AI Memo 349.
- McCarthy, J. (1960). Recursive Functions of Symbolic Expressions and Their Computation by Machine. Communications of the ACM.
- Naur, P., et al. (1960). Report on the Algorithmic Language ALGOL 60. Communications of the ACM.
- Steele, G. L. (1978). Rabbit: A Compiler for Scheme. MIT AI Lab Technical Report.
- Abelson, H., & Sussman, G. J. (1996). Structure and Interpretation of Computer Programs. MIT Press.
- ECMA International. (2024). ECMAScript 2024 Language Specification.
- van Rossum, G., & Drake, F. L. (2009). Python 3 Reference Manual. CreateSpace.
- The Rust Reference. (2024). Ownership and Lifetimes.
- Might, M. (2012). Closure Conversion: How to Compile Lambda. Blog article.
- Monnier, S., & Sperber, M. (2017). Evolution of Emacs Lisp. HOPL IV.
- Appel, A. W. (1992). Compiling with Continuations. Cambridge University Press.
- Pierce, B. C. (2002). Types and Programming Languages. MIT Press.