先看三段程式碼。它們做的是同一件事——用一個「還沒宣告」的名字。
// 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 把觸發條件寫得很死。同時滿足下面四條才是編譯錯誤:
- 欄位的宣告在文字順序上晚於使用
- 使用點必須是 simple name(也就是直接寫
retries,前面不掛任何限定) - 這個使用不在指派的左邊
- 使用點所在的最內層類別,就是宣告該欄位的類別
四條都要滿足。反過來說,只要破壞其中任何一條,編譯器就閉嘴。 接下來三個小節就是在示範怎麼破壞它——順便看看破壞之後會拿到什麼。
破壞第三條:寫進去可以,讀出來不行
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 reference:init() 提前用到了一個尚未初始化的狀態。編譯器擋不了它。init() 是方法呼叫,不是 simple name,第二條就不成立了。
避開它只有一個原則:建構子裡不要呼叫可被覆寫的方法。 要嘛宣告成 final、private 或 static,要嘛把邏輯搬出建構子。
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 這個宣告,不是右邊那個函式。呼叫的當下 f 是 undefined,所以錯誤是 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。
只要語言有「先跑父類別建構子、再初始化子類別欄位」的規則,加上方法可以被覆寫,這個坑就必然存在——它是物件模型的結果,不是哪個語言設計壞了。
三個語言擺在一起
| Java | Python | JavaScript | |
|---|---|---|---|
| 名字何時解析 | 編譯期 | 執行到那一行 | Creation Phase 登記,執行期取值 |
| 函式/方法互相呼叫 | 可以(符號表) | 可以(函式體延後查名) | 可以(function declaration 被 hoist) |
| 初始化程式碼裡前向參照 | 編譯錯誤(simple name) | NameError | undefined(var)/ReferenceError(let) |
| 繞過的代價 | 拿到 0/null | 無法繞過 | 拿到 undefined |
| 型別/類別的前向參照 | 完全沒問題 | 3.14 起沒問題(PEP 649) | class 有 TDZ,不能提前用 |
| 建構子呼叫被覆寫的方法 | 讀到 0 | 讀到 AttributeError | 讀到 undefined |
看完這張表,三個語言的性格就很清楚了。
Java 想在編譯期擋掉問題,但它的檢查是語法層次的——只認 simple name。你只要加個 this.、包一層方法呼叫,柵欄就形同虛設,換來一個安靜的 0。它擋掉的是笨拙的寫法,不是危險的寫法。
Python 誠實得多。它不假裝有靜態檢查,該炸的地方直接炸 NameError,位置精準、訊息清楚。代價是你得等到跑起來才知道。PEP 649 在 3.14 落地,是這條路線的自然延伸:既然名字本來就是執行期解析的,那註記也沒理由例外。
JavaScript 是三個裡面歷史包袱最重的。var 的 undefined 是早期設計選錯了邊留下的疤,let 的 TDZ 則是後來的補救:明明可以像 var 一樣給個預設值,卻刻意選擇報錯。ES6 的設計者顯然認為,一個明確的 ReferenceError 比一個安靜的 undefined 值錢。
結語
這三個語言的 forward reference 規則看起來零碎,但它們全都是同一個決定的下游:名字什麼時候被解析。
決定在編譯期解析,就得接受 Java 那種「我現在還無法確定,所以拒絕你」的僵硬;決定在執行期解析,就得接受 Python 那種「跑到才知道」的延遲;想兩者兼得,就會變成 JavaScript——一部分名字提前登記、一部分登記了不給用,最後靠 var 和 let 兩套規則並存來收拾。
真正該記住的不是三張規則表,而是那個反覆出現的模式:每次語言選擇「放行」而不是「報錯」,代價都會變成一個安靜的預設值。 Java 的 0、JavaScript 的 undefined,都不是 bug,是編譯器或引擎在說「我不確定,但我不想擋你」。
下次你在 code review 看到有人用 this. 繞過 illegal forward reference,或是在建構子裡呼叫一個 protected 方法,你要看到的不是一行可疑的程式碼——而是一個已經埋好、只等某天被繼承一次就會引爆的 0。