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 引入 letconst,带来了真正的块作用域和一个有趣的概念: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

查找顺序是:

  1. Local:当前函数的局部变量
  2. Enclosing:外层函数的变量(闭包)
  3. Global:模块级全局变量
  4. 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

globalnonlocal 关键字解决了这个问题:

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 为每次循环创建新的绑定,每个闭包捕获的是不同的 ivar 只有一个函数作用域的 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 到现代语言,词法作用域成为主流不是偶然。它提供了:

  1. 可预测性:变量引用由代码位置决定,不依赖运行时状态
  2. 模块化:函数可以安全地忽略调用者的局部变量
  3. 闭包支持:函数可以携带它的环境,实现强大的抽象

动态作用域没有消失,它找到了自己的位置:异常处理、配置传递、上下文管理。Common Lisp 和 Perl 的双模式设计提醒我们,这不是非此即彼的选择。

理解这两种作用域的区别,是理解编程语言设计的关键一步。它解释了为什么闭包如此强大,为什么 JavaScript 的 this 如此令人困惑,为什么 Rust 的所有权与作用域紧密相连。

当你下次调试一个变量访问问题时,想想:这个变量是在哪里定义的?它在哪个作用域中?这些问题的答案,决定了你的程序能否正确运行。


参考文献

  1. Steele, G. L., & Sussman, G. J. (1975). Scheme: An Interpreter for Extended Lambda Calculus. MIT AI Memo 349.
  2. McCarthy, J. (1960). Recursive Functions of Symbolic Expressions and Their Computation by Machine. Communications of the ACM.
  3. Naur, P., et al. (1960). Report on the Algorithmic Language ALGOL 60. Communications of the ACM.
  4. Steele, G. L. (1978). Rabbit: A Compiler for Scheme. MIT AI Lab Technical Report.
  5. Abelson, H., & Sussman, G. J. (1996). Structure and Interpretation of Computer Programs. MIT Press.
  6. ECMA International. (2024). ECMAScript 2024 Language Specification.
  7. van Rossum, G., & Drake, F. L. (2009). Python 3 Reference Manual. CreateSpace.
  8. The Rust Reference. (2024). Ownership and Lifetimes.
  9. Might, M. (2012). Closure Conversion: How to Compile Lambda. Blog article.
  10. Monnier, S., & Sperber, M. (2017). Evolution of Emacs Lisp. HOPL IV.
  11. Appel, A. W. (1992). Compiling with Continuations. Cambridge University Press.
  12. Pierce, B. C. (2002). Types and Programming Languages. MIT Press.