跳到主要內容
程式語言

編譯期就擋,還是執行期才爆?Java、Python、JavaScript 的 forward reference

同樣是「先用後宣告」,Java 直接編譯失敗,Python 有時候可以有時候噴 NameError,JavaScript 則給你一個 undefined。差別不在語法,而在「名字是什麼時候被解析的」。本文以 Java 的 JLS 規則為主軸,往下對照 Python 與 JavaScript。

先看三段程式碼。它們做的是同一件事——用一個「還沒宣告」的名字。

// Java
class Config {
    int timeout = retries;   // ?
    int retries = 3;
}
# Python
print(greeting)
greeting = "hi"
// JavaScript
console.log(v);
var v = 1;

三個結果完全不一樣:Java 連編譯都過不了,Python 丟出 NameError,JavaScript 印出 undefined

但把同一件事換個位置寫,答案又會反過來。

// Java:方法擺哪裡都行,倒著呼叫也照編
class Calculator {
    int start(int n)  { return helper(n); }   // helper 在下面才定義
    int helper(int n) { return n * 2; }
}
# Python:函式體裡引用後面才定義的名字,完全沒事
def outer():
    return inner()

def inner():
    return "ok"

print(outer())   # ok
// JavaScript:var 換成 let,突然變得比 Java 還兇
console.log(l);   // ReferenceError: Cannot access 'l' before initialization
let l = 1;

同樣三個語言、同樣是「先用後宣告」,這次的結果跟上面那三段完全對不起來。剛剛編譯失敗的 Java 現在最寬鬆,剛剛只給你 undefined 的 JavaScript 現在直接丟例外。

這種混亂不是語法規則背不熟。它們的差異只有一條軸線:名字是在編譯期解析,還是執行到那一行才解析。 三個語言在這條軸上站的位置不同,所有行為差異都是這件事的推論。


forward reference 到底是什麼

forward reference(前向參照) 指的是:某個名字的使用點,在原始碼上出現得比它的宣告點還早。

int a = b;   // 使用點
int b = 1;   // 宣告點

注意兩件事。第一,這裡談的是文字順序,不是執行順序——兩者常常不一樣,而不一樣的地方就是所有陷阱的來源。第二,「名字在不在作用域裡」跟「這裡能不能用它」是兩個獨立的問題。上面那個 b 從頭到尾都在作用域裡,Java 還是不讓你用。

語言設計者要回答的問題只有一個:看到一個還沒定義的名字,現在就報錯,還是等到跑到這行再說?

Java 選前者,Python 選後者,JavaScript 選了一個很奇怪的中間點。


Java:編譯期就要把話講完

Java 的立場最強硬——編譯完成時,每個名字指向誰都必須是確定的。但這個立場執行起來有層次,不是一句「必須先宣告後使用」講得完。

方法可以互相呼叫,因為 javac 先掃過一遍

回到開頭那段 Calculator。寫 C 的人看到會覺得奇怪——C 得在檔案開頭放 forward declaration,不然編譯器往下讀到一半不認得後面才定義的函式。Java 沒有這個東西。

原因是 javac 不是單趟由上往下讀的。它先建立符號表(symbol table)——把整個編譯單元裡的類別、欄位、方法簽名全部登記一遍,這時候還不看方法體長什麼樣。等符號表齊了,才進第二趟去檢查每個方法體。

輪到檢查 start() 的時候,helper 早就在表上了。方法定義的先後順序因此完全不影響編譯,跨類別、跨檔案的互相參照也一樣:

class Node   { Edge edge; }   // Edge 還沒編譯到
class Edge   { Node node; }   // 互相指也可以

這是 Java 開發者幾乎不會意識到 forward reference 存在的原因。在方法與型別的層次,Java 早就幫你解決了。

但欄位初始化會被擋下來

問題出在欄位初始化器(field initializer)。這裡 Java 突然變得很嚴格:

class Config {
    int timeout = retries;   // 編譯錯誤:illegal forward reference
    int retries = 3;
}

retries 明明在符號表上,作用域涵蓋整個類別,為什麼不行?

因為欄位初始化器是有執行順序的。它們會依照原始碼的文字順序被塞進建構子(static 欄位則塞進類別初始化流程)。如果放行,timeout 拿到的會是 retries 尚未初始化時的預設值 0——編譯得過、跑起來錯,而且錯得毫無提示。Java 選擇在編譯期就把這種寫法擋掉。

JLS §8.3.3 把觸發條件寫得很死。同時滿足下面四條才是編譯錯誤:

  1. 欄位的宣告在文字順序上晚於使用
  2. 使用點必須是 simple name(也就是直接寫 retries,前面不掛任何限定)
  3. 這個使用不在指派的左邊
  4. 使用點所在的最內層類別,就是宣告該欄位的類別

四條都要滿足。反過來說,只要破壞其中任何一條,編譯器就閉嘴。 接下來三個小節就是在示範怎麼破壞它——順便看看破壞之後會拿到什麼。

破壞第三條:寫進去可以,讀出來不行

class UseBeforeDeclaration {
    static {
        x = 100;             // OK——在指派的左邊
        // int y = x + 1;    // 錯誤:illegal forward reference
    }
    static int x;
}

指派給它沒問題,讀它就不行。這條規則的邏輯很直接:寫入不需要它已經有值,讀取需要。

破壞第二條:加個 this. 就過了

class Config {
    int timeout = this.retries;   // 編譯通過
    int retries = 3;
}

this.retries 不是 simple name,第二條不成立,檢查整條失效。

timeout 是多少?0。 執行順序沒有因為你加了 this. 而改變——timeout 的初始化器先跑,那時 retries 還停在預設值。

編譯器的守衛只是一道語法層次的柵欄,不是語意分析。繞過去要付的代價,是一個安靜的 0。

破壞第二條的進階版:用方法包一層

這個例子直接出自 JLS §8.3.3:

class Z {
    static int peek() { return j; }
    static int i = peek();   // 呼叫方法,不是 simple name
    static int j = 1;
}

System.out.println(Z.i);   // 0

方法呼叫當然不是 simple name,編譯器完全放行。但 peek()j 被指派為 1 之前就被叫了,讀到的是 0

這裡可以看清楚 Java 的態度:它擋的是「一眼看得出來」的 forward reference,不是所有 forward reference。 只要中間隔了一層間接(限定名稱、方法呼叫),編譯器就沒辦法在編譯期靜態判定,只好放行。

區域變數:連討價還價的餘地都沒有

上面所有討論都限於欄位。區域變數是另一套規則,而且更絕:

void demo() {
    System.out.println(x);   // 錯誤:cannot find symbol
    int x = 1;
}

錯誤訊息不是 illegal forward reference,是 cannot find symbol。根本找不到這個名字。

因為區域變數的作用域是從它的宣告子(declarator)開始,到所在區塊結束為止。宣告之前的那幾行,x 這個名字根本不存在,談不上什麼前向參照。

同樣一行程式碼,在欄位跟區域變數身上的差別很值得比對:

class T {
    int a = a + 1;       // 編譯通過,a 最後是 1(讀到預設值 0)

    void m() {
        int b = b + 1;   // 錯誤:variable b might not have been initialized
    }
}

欄位有預設值可讀,區域變數沒有。這也是為什麼 Java 對區域變數可以做更嚴格的確定指派分析(definite assignment)——沒有預設值當退路,就沒有安靜出錯的空間。

執行期的 forward reference:建構子叫到被覆寫的方法

前面談的都是編譯期。但 Java 還有一種 forward reference 是編譯器完全看不到的,而且咬人最痛:

class Base {
    Base() {
        init();          // 呼叫的其實是子類別的版本
    }
    void init() { }
}

class Derived extends Base {
    int value = 42;

    @Override
    void init() {
        System.out.println(value);   // 印出 0,不是 42
    }
}

new Derived();

物件建構的順序是:Derived 建構子 → 先呼叫 super()Base 建構子跑 init() → 動態分派到 Derived.init() → 印出 value。而 Derived 的欄位初始化器要等 super() 回來之後才執行。

所以 value 這時候還是 0

這是跨越繼承階層的 forward referenceinit() 提前用到了一個尚未初始化的狀態。編譯器擋不了它。init() 是方法呼叫,不是 simple name,第二條就不成立了。

避開它只有一個原則:建構子裡不要呼叫可被覆寫的方法。 要嘛宣告成 finalprivatestatic,要嘛把邏輯搬出建構子。


Python:名字要等到執行到那一行才查

Java 的所有嚴格,來自它必須在編譯期把話講完。Python 沒有這個包袱——import 一個模組就是由上往下執行它,名字在執行到那一行時才去命名空間查。

這一個決定,同時解釋了 Python 的寬鬆,也解釋了它的陷阱。

函式體內可以自由 forward reference

開頭那段 outer / inner 之所以跑得動,就是這個原因:def inner 這行執行完,inner 才被綁進模組的命名空間;而 outer() 是在那之後才被呼叫的,它去查 inner 的時候早就有了。

函式體只是被編譯成 bytecode 存著,裡面的名字要到執行時才解析。所以 Java 那套「先掃符號表」的機制,Python 用一個更簡單的方式達成了同樣效果。

module 和 class body:由上往下跑,沒得商量

但只要不在函式體裡,Python 就退回最原始的逐行執行:

print(greeting)     # NameError: name 'greeting' is not defined
greeting = "hi"

class body 也一樣,它其實就是一段在自己命名空間裡執行的程式碼:

class Node:
    parent = Node   # NameError: name 'Node' is not defined

Node 這個名字要等到整個 class body 執行完、類別物件建好,才會被綁到模組命名空間。class body 執行期間,它還不存在。

這條分界線跟 Java 幾乎是鏡像:Java 是「方法體寬鬆、初始化器嚴格」,Python 是「函式體寬鬆、模組與 class body 嚴格」。兩者卡住的都是同一類東西:那些會立刻執行的初始化程式碼。

type hint 曾經是這條規則最痛的地方

型別註記是 Python 這條規則最尷尬的受害者。遞迴資料結構天生就需要引用自己:

class Node:
    next: Node          # Python 3.13 以前:NameError

因為註記在 3.14 之前是立刻求值的一般運算式。標準解法是把型別名寫成字串(forward reference 這個詞在 PEP 484 裡就是指這個用法):

class Node:
    next: "Node"        # 延後到需要時才用 typing.get_type_hints() 解析

或是用 from __future__ import annotations(PEP 563),讓整個檔案的註記都自動變成字串:

from __future__ import annotations

class Node:
    next: Node

print(Node.__annotations__)   # {'next': 'Node'} ← 字串

Python 3.14 把註記整個延後了

PEP 649 在 3.14 落地之後,這個問題從根上消失了。註記不再是立刻求值的運算式,而是被編譯成一個延遲求值的函式,等到有人真的去讀 __annotations__ 才跑:

# Python 3.14,不需要引號、不需要 __future__
class Node:
    next: Node

print(Node.__annotations__)   # {'next': <class '__main__.Node'>} ← 真的類別物件

跟 PEP 563 的字串方案比,這個做法保留了真正的物件。typing.get_type_hints() 不用再自己去 eval 字串,執行期做型別檢查的工具也不用再想辦法還原名字。

不過延後的只有註記。預設參數值還是照舊立刻求值:

def f(x=Later()):   # NameError: name 'Later' is not defined
    return x

class Later: pass

道理跟前面完全一樣。def 那一行執行的時候,Later() 就得被算出來。


JavaScript:提前登記,但不一定給你用

JavaScript 站在一個很怪的位置。它不像 Java 有編譯期檢查,也不像 Python 純粹逐行執行,而是在每次進入一段程式碼時,先跑一趟 Creation Phase(建立階段),把這個作用域裡所有宣告先登記起來,然後才開始執行。

所謂 hoisting,就是這趟預先登記的副作用。(這一段的完整機制在從 Hoisting 到 Execution Context Stack 講過,這裡只看跟 forward reference 相關的部分。)

var 與 function declaration:登記時就給值

console.log(outer());     // "ok"

function outer() { return inner(); }
function inner() { return "ok"; }

函式宣告在 Creation Phase 就被完整建好並綁定,所以擺哪裡都能呼叫。這點跟 Java 的方法、Python 的函式體殊途同歸:機制完全不同,結果卻一樣。

var 就沒那麼好運了:

console.log(v);   // undefined
var v = 1;

Creation Phase 只登記名字並給它 undefined,指派留在原地。所以你拿到的不是錯誤,是一個看起來很正常的 undefined

這正是 Java 欄位那個安靜的 0 的翻版——編譯器(或引擎)放你過去,代價是一個沒有意義的預設值。

let / const / class:登記了,但碰不得

ES6 之後的宣告換了策略。它們一樣在 Creation Phase 被登記,但不給初始值,而是被標記為「不可存取」,直到執行流程真的跑到宣告那一行為止。這段區間叫 TDZ(Temporal Dead Zone,暫時性死區),也就是開頭那段 let 炸掉的原因:

console.log(l);   // ReferenceError: Cannot access 'l' before initialization
let l = 1;
const d = new Dog();   // ReferenceError: Cannot access 'Dog' before initialization
class Dog {}

錯誤訊息值得看仔細——是 Cannot access ... before initialization,不是 is not defined。名字存在,只是還不能碰。這跟 Java 那句 illegal forward reference 表達的是同一件事:我知道你在說誰,但現在不准用。

class 不會像 function 那樣可以倒著用,理由也在這裡。它走的是 let 的規則。

function expression 不是 function declaration

這個差別每年都還在坑人:

f();                        // TypeError: f is not a function
var f = function () { return 1; };

被 hoist 的是 var f 這個宣告,不是右邊那個函式。呼叫的當下 fundefined,所以錯誤是 TypeError 而不是 ReferenceError——名字有,值不對。

同一個建構子陷阱,換一種語言演一次

前面那個 Java 的建構子陷阱,JavaScript 原封不動地照抄了:

class Base {
  constructor() { this.init(); }
  init() {}
}

class Derived extends Base {
  value = 42;
  init() { console.log(this.value); }   // undefined,不是 42
}

new Derived();

super() 跑完才輪到 Derived 的欄位初始化,跟 Java 的順序一模一樣。差別只在 Java 給你 0,JavaScript 給你 undefined

只要語言有「先跑父類別建構子、再初始化子類別欄位」的規則,加上方法可以被覆寫,這個坑就必然存在——它是物件模型的結果,不是哪個語言設計壞了。


三個語言擺在一起

JavaPythonJavaScript
名字何時解析編譯期執行到那一行Creation Phase 登記,執行期取值
函式/方法互相呼叫可以(符號表)可以(函式體延後查名)可以(function declaration 被 hoist)
初始化程式碼裡前向參照編譯錯誤(simple name)NameErrorundefined(var)/ReferenceError(let)
繞過的代價拿到 0null無法繞過拿到 undefined
型別/類別的前向參照完全沒問題3.14 起沒問題(PEP 649)class 有 TDZ,不能提前用
建構子呼叫被覆寫的方法讀到 0讀到 AttributeError讀到 undefined

看完這張表,三個語言的性格就很清楚了。

Java 想在編譯期擋掉問題,但它的檢查是語法層次的——只認 simple name。你只要加個 this.、包一層方法呼叫,柵欄就形同虛設,換來一個安靜的 0。它擋掉的是笨拙的寫法,不是危險的寫法。

Python 誠實得多。它不假裝有靜態檢查,該炸的地方直接炸 NameError,位置精準、訊息清楚。代價是你得等到跑起來才知道。PEP 649 在 3.14 落地,是這條路線的自然延伸:既然名字本來就是執行期解析的,那註記也沒理由例外。

JavaScript 是三個裡面歷史包袱最重的。varundefined 是早期設計選錯了邊留下的疤,let 的 TDZ 則是後來的補救:明明可以像 var 一樣給個預設值,卻刻意選擇報錯。ES6 的設計者顯然認為,一個明確的 ReferenceError 比一個安靜的 undefined 值錢。


結語

這三個語言的 forward reference 規則看起來零碎,但它們全都是同一個決定的下游:名字什麼時候被解析。

決定在編譯期解析,就得接受 Java 那種「我現在還無法確定,所以拒絕你」的僵硬;決定在執行期解析,就得接受 Python 那種「跑到才知道」的延遲;想兩者兼得,就會變成 JavaScript——一部分名字提前登記、一部分登記了不給用,最後靠 varlet 兩套規則並存來收拾。

真正該記住的不是三張規則表,而是那個反覆出現的模式:每次語言選擇「放行」而不是「報錯」,代價都會變成一個安靜的預設值。 Java 的 0、JavaScript 的 undefined,都不是 bug,是編譯器或引擎在說「我不確定,但我不想擋你」。

下次你在 code review 看到有人用 this. 繞過 illegal forward reference,或是在建構子裡呼叫一個 protected 方法,你要看到的不是一行可疑的程式碼——而是一個已經埋好、只等某天被繼承一次就會引爆的 0