先看兩段程式碼。它們表面上在解同一個問題——把一個欄位藏起來。
class Account:
def __init__(self):
self.__balance = 100
a = Account()
print(a._Account__balance) # 100
print(a.__balance) # AttributeError: 'Account' object has no attribute '__balance'
class Account {
#balance = 100;
}
console.log(new Account().#balance);
// SyntaxError: Private field '#balance' must be declared in an enclosing class
結果差得很遠。Python 那個「藏」只是換了個名字,你把名字打對就拿得到,連 vars() 都列得出來。JavaScript 那個連檔案都載不進去。錯誤發生在解析階段,整份模組一行都還沒跑。
麻煩的是,這兩件事常常被歸進同一個詞:name mangling(名稱改寫)。而這個詞原本講的還不是它們任何一個。
實際上有四種東西共用這個名字,或者說被誤用同一個名字。它們發生在不同階段、由不同的東西決定新名字、擋的也不是同一批人。攤開來看會發現:三種在做名字管理,只有一種在做封裝——而那一種根本沒有改任何名字。
這個詞原本在講什麼
name mangling 來自 C++。C 的目標檔只有一張扁平的符號表,一個名字對一個符號。但 C++ 有 overload、有 namespace、有成員函式。app::foo(int) 和 app::foo(double) 在原始碼裡是兩個不同的函式,到了符號表卻會撞成同一個 foo。
編譯器的解法是把型別與作用域編進符號名字裡。
namespace app {
int foo(int x) { return x; }
int foo(double x) { return (int)x; }
struct S { int foo(int x); };
int S::foo(int x) { return x; }
}
extern "C" int plain_foo(int x) { return x; }
$ c++ -c mangle.cpp -o mangle.o && nm mangle.o | awk '{print $NF}'
__ZN3app1S3fooEi
__ZN3app3fooEd
__ZN3app3fooEi
_plain_foo
$ nm mangle.o | awk '{print $NF}' | c++filt
app::S::foo(int)
app::foo(double)
app::foo(int)
_plain_foo
_ZN3app3fooEi 拆開來是:_Z 起手、N...E 包住巢狀名稱、3app 是三個字的 namespace、3foo 是三個字的函式名、最後的 i 是一個 int 參數。c++filt 就是照這套規則反著讀回來。(macOS 的 Mach-O 符號比 Linux 的 ELF 多一個前導底線,所以這裡是 __ZN...,多數教學文章寫的是 _ZN...。)
最後那個 plain_foo 被 extern "C" 標記過,於是保留原名。這正是 C 的 header 要包 extern "C" 的原因。忘了包,連結器就會拿著 mangled 名字去找一個從來沒被 mangle 過的符號:
Undefined symbols for architecture arm64:
"add(int, int)", referenced from:
_main in main-06b7e5.o
NOTE: found '_add' in lib.o, declaration possibly missing 'extern "C"'
這裡有兩件事決定了後面的對照。第一,C++ 的改名是把名字變長,加上足夠的資訊讓連結器分得出來。第二,改完的名字必須跨編譯單元一致,對不上就是 undefined symbol。
Java 常被順口歸進同一類,但它不需要這種改名。class file 的 method_info 把名字和簽名拆成 name_index 與 descriptor_index 兩個欄位,overload 靠 descriptor 區分,方法名原封不動。Java 真正的編譯期改名在別的地方:巢狀類別被攤平成 Outer$Inner、建構子在 class file 裡叫 <init>、靜態初始化區塊叫 <clinit>。後兩個還刻意選了不合法的 Java 識別字,讓你在原始碼裡根本打不出來。
Python 的 __x 改的不是屬性,是名字
規則寫在 Language Reference 的 6.2.2.1「Private name mangling」:在 class 定義內,以兩個以上底線開頭、且不以兩個以上底線結尾的識別字,會被改寫成 _類別名__識別字。
class C:
__a = 1 # 兩前導、無尾隨 -> 改
__b_ = 2 # 兩前導、一尾隨 -> 改
__c__ = 3 # 兩前導、兩尾隨 -> 不改
_d = 4 # 一前導 -> 不改
___e = 5 # 三前導、無尾隨 -> 改
__ = 8 # 純底線 -> 不改
___ = 9 # 純底線 -> 不改
class Empty: pass
print(sorted(set(vars(C)) - set(vars(Empty))))
# ['_C___e', '_C__a', '_C__b_', '__', '___', '__c__', '_d']
讀這條規則最快的方式是記住它同時看頭和尾。__c__ 兩頭都是兩個底線,尾巴那條一票否決。__ 和 ___ 也是同樣的道理:它們既「以兩個以上底線開頭」,也「以兩個以上底線結尾」,所以完全不動。
類別名那邊另有一條規則:文件寫的是把類別名「去掉開頭的底線、再補一個底線」後插到識別字前面。
class _Foo:
__x = 1
class ___Baz_:
__x = 1
class __:
__x = 1
for cls in (_Foo, ___Baz_, __):
print(repr(cls.__name__), [k for k in vars(cls) if 'x' in k])
'_Foo' ['_Foo__x']
'___Baz_' ['_Baz___x']
'__' ['__x']
class ___Baz_ 的三個前導底線全被吃掉、尾隨那個留著,結果是 _Baz___x,中間三個連續底線,乍看很像規則寫錯了。而類別名全由底線構成時,轉換變成 identity,什麼都不改。這是文件明文規定的,不是實作巧合。
接下來是關鍵的一點:改寫發生在編譯期,改完就烙進 code object。
import dis
class C:
def __init__(self):
self.__x = 1
def get(self, other):
return self.__x + other.__x
dis.dis(C.get)
6 RESUME 0
7 LOAD_FAST_BORROW 0 (self)
LOAD_ATTR 0 (_C__x)
LOAD_FAST_BORROW 1 (other)
LOAD_ATTR 0 (_C__x)
BINARY_OP 0 (+)
RETURN_VALUE
LOAD_ATTR 載入的名字已經是 _C__x。self.__x 和 other.__x 甚至共用同一個 co_names 項目。對編譯器來說,它們就是同一個字串。
「改的是名字、不是屬性」這句話的份量在這裡:
class C:
import os as __os # import 綁定名
def m(self, __param): # 參數名
__local = 1 # 區域變數
print(__glob) # 全域參考
return self.__x, __param.__y # 自己的屬性 + 別人物件的屬性
print([k for k in vars(C) if 'os' in k]) # ['_C__os']
print(C.m.__code__.co_varnames) # ('self', '_C__param', '_C__local')
print(C.m.__code__.co_names) # ('print', '_C__glob', '_C__x', '_C__y')
參數名、區域變數、你去讀的全域變數、import 綁定的名字,全部一起改。self 和「屬性」跟這件事完全無關。
最刺的是 other.__x。你在 class C 裡寫 other.__x,編譯出來是 other._C__x——用的是寫這行程式碼的類別,不是 other 的類別。
class C:
def poke(self, other): return other.__x
class D:
def __init__(self): self.__x = 9
C().poke(D())
# AttributeError: 'D' object has no attribute '_C__x'. Did you mean: '_D__x'?
錯誤訊息把你自己的類別名塞了進去,看起來像 D 少了東西。Python 3.14 的拼字建議還會熱心地指向 _D__x,那正是你以為自己在讀的東西,而它一輩子也不會被讀到。
不過改寫範圍是一份封閉清單,不是「所有 __ 開頭的東西」。Reference 列的只有三類:被讀寫的變數名與屬性名、import 的模組名、from ... import 的成員名。清單外的不改:
def probe(**kw): return kw
class Foo:
kw = probe(__k=1) # 呼叫端的關鍵字引數名
s = "__str" # 字串字面值
def outer(self):
def __inner(): pass
class __K: pass
return __inner.__name__, __K.__name__, sorted(locals())
print(Foo.kw, Foo.s) # {'__k': 1} __str
print(Foo().outer()) # ('__inner', '__K', ['_Foo__K', '_Foo__inner', 'self'])
巢狀函式與類別的綁定名被改成 _Foo__inner、_Foo__K,但它們的 __name__ 屬性值原封不動。這是文件明列的豁免,也是「純文字識別字轉寫」最漂亮的示範。
而「呼叫端的關鍵字引數名不改」會造出一個不對稱的陷阱:
class Q:
def f(self, __a=1): return __a
def call(self): return self.f(__a=9)
Q().call()
# TypeError: Q.f() got an unexpected keyword argument '__a'
def 那邊的參數名被改了,呼叫端這邊沒有。兩邊對不上。
只要是字串就不算數
Tutorial 有一句話把所有例外一次收乾淨:exec()、eval()、getattr()、setattr()、delattr(),以及直接碰 __dict__,全都不套用 mangling。
這段輸出值得看兩遍:
class C:
def __init__(self): self.__v = 1
def probe(self):
print("attr syntax :", self.__v)
print("hasattr '__v' :", hasattr(self, "__v"))
setattr(self, "__v", 99) # 建出一個字面 '__v' 的 key
print("dict :", self.__dict__)
print("self.__v still:", self.__v)
C().probe()
attr syntax : 1
hasattr '__v' : False
dict : {'_C__v': 1, '__v': 99}
self.__v still: 1
hasattr(self, "__v") 在自己的類別裡回 False。setattr 沒有報錯,它另外建了一個字面 '__v' 的 key,跟 _C__v 並存——同一個物件上兩個看起來一模一樣的屬性,而 self.__v 讀到的還是舊的那個。除錯時很難發現。
規則一句話:凡是用字串指涉名字的路徑,一律不改寫。 序列化、ORM、動態產生程式碼、mock.patch,走的全是字串。
但別把它推廣成「字串一律不改」,因為有一個反例:
class S:
__slots__ = ['__x']
def __init__(self): self.__x = 1
print(S.__slots__) # ['__x']
print(sorted(n for n in vars(S) if 'x' in n)) # ['_S__x']
print(S._S__x) # <member '_S__x' of 'S' objects>
__slots__ 裡的字串保持 '__x',實際建立的 descriptor 卻叫 _S__x。這次動手的不是編譯器,是 type.__new__ 在建類別的時候做的。任何靠讀 __slots__ 做內省或序列化的程式碼,拿到的名字都是錯的。
所以雙底線換到了什麼
先看官方立場。Tutorial 9.6 的第一句話就把話講死了:
“Private” instance variables that cannot be accessed except from inside an object don’t exist in Python.
mangling 的正當用途,文件自己給了:避免與子類別定義的名稱撞名,以及讓子類別能覆寫方法而不弄壞類別內部的呼叫。官方的示範就是這個:
class Mapping:
def __init__(self, it):
self.items = []
self.__update(it) # 編譯期綁死成 _Mapping__update
def update(self, it):
self.items.extend(it)
__update = update # private copy
class Sub(Mapping):
def update(self, keys, values): # 換了簽名
self.items.extend(zip(keys, values))
s = Sub([1, 2])
print(s.items) # [1, 2]
print(s._Mapping__update.__func__ is Mapping.update) # True
Sub 換掉了 update() 的簽名,Mapping.__init__ 照樣跑得完,因為它呼叫的 self.__update 在編譯期就綁死成 _Mapping__update,攔不到。最後一行同時證明了另一件事:那個「私有」的方法從外面拿得到,而且拿到的就是原本那一個。
第二個真正划算的場景是 mixin:
class LoggerMixin:
def log(self, m): self.__cache = m # -> _LoggerMixin__cache
def last(self): return self.__cache
class CacheMixin:
def put(self, v): self.__cache = v # -> _CacheMixin__cache
def get(self): return self.__cache
class App(LoggerMixin, CacheMixin): pass
a = App(); a.log('hi'); a.put(42)
print(a.last(), a.get()) # hi 42
print(sorted(vars(a))) # ['_CacheMixin__cache', '_LoggerMixin__cache']
兩個互不相識的 mixin 都用 self.__cache,因為改名而各自為政。換成 _cache,就是後寫的蓋掉先寫的。
所以 Python 的雙底線保護的是繼承階層裡的名字空間(誰擁有這個名字),而不是物件的內部狀態。改完之後,_Class__name 是再普通不過的屬性:
class Counter:
def __init__(self): self.__count = 42
c = Counter()
print(vars(c)) # {'_Counter__count': 42}
c._Counter__count = 99 # 沒有任何阻力
print(vars(c)) # {'_Counter__count': 99}
順帶澄清兩件常被引錯的事。
第一,「We are all consenting adults here」不在 Zen of Python 裡。python3 -c "import this" 只有 19 條格言(第 20 條 Tim Peters 從來沒寫出來),裡面沒有這句。它是社群流傳、廣泛歸給 Guido 但找不到一手出處的一句話。要引官方立場,該引 Tutorial 那句。
第二,PEP 8 對雙底線的態度比多數人以為的保留。它列出 mangling 會讓 debug 與 __getattr__ 變麻煩,直說「Not everyone likes name mangling」,接著補一句 mangling 演算法有完整文件、手算也不難,最後要你在防撞名和進階呼叫者的需求之間取平衡。這不是一句推薦。
用雙底線之前先算這筆帳
最先跳出來的是 Template Method 失效。父類別內部呼叫的私有方法,子類別覆寫不到:
class Report:
def render(self): return f"<{self.__fmt()}> + <{self._fmt2()}>"
def __fmt(self): return "base-private"
def _fmt2(self): return "base-protected"
class HtmlReport(Report):
def __fmt(self): return "child-private" # -> _HtmlReport__fmt
def _fmt2(self): return "child-protected"
print(HtmlReport().render())
# <base-private> + <child-protected>
self.__fmt() 在編譯期就是 self._Report__fmt(),子類別那個 __fmt 變成 _HtmlReport__fmt,兩個名字毫無關係。單底線的 _fmt2 才被正常覆寫。
想覆寫也不是做不到,子類別顯式寫 def _Report__fmt(self) 就會被 dispatch 到。而那正好說明問題:雙底線把父類別的實作細節逼進子類別的原始碼裡。
@dataclass 會把改過的名字洩漏到公開 API。
import dataclasses, inspect
@dataclasses.dataclass
class P:
__x: int
y: int = 0
print([f.name for f in dataclasses.fields(P)]) # ['_P__x', 'y']
print(inspect.signature(P.__init__)) # (self, _P__x: int, y: int = 0) -> None
print(P(1, 2)) # P(_P__x=1, y=2)
想藏的名字被印在 repr 裡、寫進 __init__ 的簽名,呼叫端還得打對 P(_P__x=1) 才傳得進去。藏得更醜,也更公開。
enum.Enum 則是靜默吞掉它。
import enum
class E(enum.Enum):
__A = 1
B = 2
print(list(E.__members__), E._E__A) # ['B'] 1
__A 被改名之後不再被當成 member,__members__ 裡根本沒有它,也沒有任何警告。相對地 typing.NamedTuple 至少會大聲報 ValueError,只是訊息裡秀的是已經改過的 _N__x,第一次看會愣一下。
測試也會被綁死。
from unittest.mock import patch
class Svc:
def run(self): return self.__fetch()
def __fetch(self): return "real network call"
with patch.object(Svc, "__fetch", return_value="fake"):
pass
# AttributeError: <class '__main__.Svc'> does not have the attribute '__fetch'
with patch.object(Svc, "_Svc__fetch", return_value="fake"):
print(Svc().run()) # fake
patch.object 吃字串,測試碼只能把 _Svc__fetch 硬編進去。
我自己的判準是:這個類別會不會被你控制不到的人繼承。framework 的 base class、要被混進未知類別的 mixin,雙底線划得來;其餘情況預設單底線。這後半是偏好,不是規範。文件並沒有說你只能在 mixin 用它。
JavaScript 的 #x 不是改名
Python 的 __x 改完還是一個屬性。JS 的 #x 從一開始就不是。
class Counter {
#count = 42;
name = 'c1';
get value() { return this.#count; }
}
const c = new Counter();
console.log(Object.keys(c)); // [ 'name' ]
console.log(Reflect.ownKeys(c)); // [ 'name' ]
console.log(Object.getOwnPropertySymbols(c)); // []
console.log(JSON.stringify(c)); // {"name":"c1"}
console.log(c['#count']); // undefined
console.log(c.value); // 42
所有反射管道都看不到它。MDN 刻意避免用「private property」這個說法——它不是 property,沒有 key,也沒有 writable / enumerable / configurable 這些描述元。
這裡有兩種錯誤,分屬兩個階段,不能混為一談。語法合法但物件身上沒有那個私有欄位,是執行期 TypeError:
class A { #x = 1; static read(o) { return o.#x; } }
A.read(new A()); // 1
A.read({}); // TypeError: Cannot read private member #x from an object
// whose class did not declare it
在 class 外面寫 obj.#x,是解析期 SyntaxError:
console.log('this line never prints');
class A { #x = 1; }
try {
console.log(new A().#x);
} catch (e) {
console.log('caught?', e); // 接不到
}
SyntaxError: Private field '#x' must be declared in an enclosing class
第一行的 console.log 沒有印出來——整份模組在編譯階段就死了,try/catch 沒有任何意義。要接住它,只能讓那段程式碼的編譯落在另一段程式碼的執行期:eval、new Function、動態 import()、require()、node:vm 都可以。這跟 eval 有什麼魔力無關,純粹是編譯時機的問題。
(下面所有錯誤訊息都是 Node v25.8.1 也就是 V8 的措辭。規格只規定丟 TypeError,SpiderMonkey 和 JavaScriptCore 的字串不一樣。)
ES2022 還加了 #x in o,用來判斷某個物件是不是本類別產出的實體。但型別守衛不能省:
class Stack {
#items = [];
static isStack(o) {
return (typeof o === 'object' || typeof o === 'function')
&& o !== null && #items in o;
}
}
少了前面那半,#items in null 會丟 TypeError: Cannot use 'in' operator to search for '#items' in null,所有 primitive 也一樣。只有物件和函式才安全回傳布林。
繼承要分兩層講。詞法層:子類別的程式碼連寫出 this.#secret 都無法通過解析。
class Base { #secret = 'base'; reveal() { return this.#secret; } }
class Sub extends Base {
peek() { return this.#secret; } // SyntaxError,整份檔案載不進來
}
實體層:欄位確實裝在子類別實體上。
class Base {
#secret = 'base';
reveal() { return this.#secret; }
static has(o) { return #secret in o; }
}
class Sub extends Base {}
const s = new Sub();
console.log(s.reveal()); // base
console.log(Base.has(s)); // true
console.log(Object.getOwnPropertyNames(s)); // []
MDN 說 private element「不在原型繼承模型裡、不會被子類別繼承」,講的是可及性,不是實體上有沒有這個 slot。這兩件事在中文裡很容易被翻成同一句話,然後互相打架。
Proxy 也包不過去。
class Counter {
#n = 0;
inc() { return ++this.#n; }
static has(o) { return #n in o; }
}
const real = new Counter();
const p = new Proxy(real, {});
p.inc();
// TypeError: Cannot read private member #n from an object whose class did not declare it
console.log(Counter.has(p), Counter.has(real)); // false true
get trap 攔得到方法名,但方法被以 proxy 當 this 呼叫,而 proxy 是另一個物件、身上沒有那個私有欄位。連帶地 #n in proxy 也是 false。想拿 brand check 當安全檢查的人會踩到:合法物件包一層 Proxy 就被判成冒牌貨。(要繞開得用 membrane pattern,不是把 Proxy 換掉就好。)
Object.freeze 對它同樣無效,因為凍結作用在 property attributes 上,而私有欄位不是 property:
class C { pub = 0; #n = 0; bump() { return ++this.#n; } }
const c = new C();
Object.freeze(c);
try { c.pub = 99; } catch (e) { console.log(e.message); }
// Cannot assign to read only property 'pub' of object '#<C>'
console.log(c.bump(), c.bump()); // 1 2 ← 私有欄位照樣寫得進去
反過來,structuredClone、spread、Object.assign 會安靜地把私有欄位連同 prototype 一起丟掉,不報錯也不警告。深複製之後拿去做 brand check,一律 false。
為什麼是 #
FAQ 自己承認這不是美學決定:
No one came out and said,
#is the most beautiful, intuitive thing to indicate private state.
它是刪去法的結果。@ 被 decorator 提案先佔了,_ 跟既有的 JS 慣例衝突,其他中綴符號有 ASI 風險,非 ASCII 字元一般鍵盤上打不出來。
比符號更有意思的是幾個被否決的方案,每一個都指向同一個約束:私有存取必須在解析期就能靜態判定。
private x 宣告加 this.x 存取不行,因為 this.x 在私有欄位不存在時,會靜靜地建立或讀取一個公開欄位,不會報錯。JS 沒有靜態型別可以幫你擋這種手滑。而且同名的 #x 和 x 必須能並存,普通的 property lookup 分不出兩者。
「宣告 #x 之後,class 內所有 obj.x 一律當私有解讀」也被否決:class 的方法常常在操作不是本類別的物件,讓 obj.x 的意義隨程式碼位置改變太意外。
「執行期檢查 receiver 型別再決定走私有還是公開」則同時輸在效能和安全上:不想拖慢每一次 property access,也不想讓方法被騙去把別人的公開欄位當私有欄位操作。
至於為什麼不叫大家自己用 WeakMap:FAQ 說人體工學差、記憶體語意令人意外,還有一個不明顯的外洩風險:惡意 callback 可以從 this 取回那個裝私有資料的物件,然後直接改它。
順帶一提,obj["#x"] 這個寫法本身完全合法,它存取的是名為 "#x" 的普通字串鍵屬性,跟私有欄位並存互不干擾。被拒絕的是「讓 bracket notation 指到私有欄位」這個設計,不是這段語法。
FAQ 對「所以它保護誰」的正式回答是這句:
private fields are purely internal: no JS code outside of a class can detect or affect the existence, name, or value of any private field of instances of said class without directly inspecting the class’s source, unless the class chooses to reveal them. (This includes subclasses and superclasses.)
兩個限定子句都要讀。class 自己選擇揭露(寫個 getter)當然可以;而括號裡那句直接把子類別和父類別一起關在門外。
JS 圈真正在 mangle 的是建置工具
回到「name mangling」這個詞。在 JavaScript 的日常語境裡,它多半指的既不是 __x 也不是 #x,而是 minifier 的改名。
// basic.js
export function makeCounter(startValue) {
const state = { currentCount: startValue };
return {
increment(stepSize) {
state.currentCount += stepSize;
return state.currentCount;
}
};
}
$ esbuild basic.js --minify --format=esm
function e(n){const t={currentCount:n};return{increment(r){return t.currentCount+=r,t.currentCount}}}export{e as makeCounter};
區域變數和參數被改成 e、n、t、r,但 currentCount 和 increment 原封不動,export 的名字也保留成 export{e as makeCounter}。(本節輸出來自 esbuild 0.27.7 與 terser 5.50.0,短名的分配結果會隨版本變動,別拿去逐字比對。)
判準不是「這個名字私不私有」,而是這個名字能不能被靜態證明沒有別的存取路徑。函式內的區域變數可以;屬性名不行,因為隨時可能有人寫 obj["currentCount"]。所以屬性改名在 esbuild 和 terser 都是 opt-in。
打開來看看為什麼。拿這支當實驗品:
// props.js
const payload = '{"user_id": 7, "name": "Ada"}';
class Store {
constructor() { this.cache_ = new Map(); }
load() {
const data = JSON.parse(payload);
this.cache_.set(data.user_id, data.name);
return this.cache_.get(7);
}
}
console.log(new Store().load()); // Ada
$ esbuild props.js --minify --mangle-props='.'
... class Store{constructor(){this.s=new Map}t(){const s=JSON.a(payload); ...
$ node props.broken.js
TypeError: JSON.a is not a function
JSON.parse 被改成 JSON.a,當場炸掉。實務上因此靠命名約定圈範圍,只改結尾帶底線的欄位:
$ esbuild props.js --minify --mangle-props='_$'
... class Store{constructor(){this.s=new Map}load(){const s=JSON.parse(payload); ...
Ada
但「約定」不等於安全。terser 的 README 在屬性改名這一節開頭就寫了 THIS WILL BREAK YOUR CODE,而 regex 這個「比較收斂」的用法,小節標題是「Mangle all properties matching a regex (not as unsafe but still unsafe)」。只要那個底線欄位曾經被字串碰過,照樣壞:
const obj = { runIt_() { return "ok"; } };
const k = "run" + "It_";
console.log(obj[k]());
$ esbuild dyn.js --minify --mangle-props='_$'
const obj={o(){return"ok"}},k="runIt_";console.log(obj[k]());
$ node dyn.min.js
TypeError: obj[k] is not a function
而且失敗未必這麼大聲。terser 預設把九千多個 JS/DOM 內建名列入保護清單,所以「內建 API 被改掉」這種當場爆炸的失敗不會發生,留下來的是安靜的那種:
$ terser props.js --config-file tp.json
... this.o.set(o.t,o.name); ...
$ node props.terserall.js; echo "exit=$?"
undefined
exit=0
user_id 被改成 t,name 因為在保護清單裡而存活。程式印出 undefined,exit code 是 0。同一個物件的欄位一半被改一半沒改,這比全壞更難查。
引號的處理兩家還相反。esbuild 預設不動被引號括起來的屬性,於是 dot 存取和字串存取脫鉤:
const o = { alpha_: 1 };
console.log(o.alpha_ + o["alpha_"]); // 2
$ esbuild quoted.js --minify --mangle-props='_$'
const o={o:1};console.log(o.o+o.alpha_);
$ node quoted.min.js
NaN
terser 預設連 quoted 一起改(keep_quoted 預設是 false),同一個例子反而不壞。這是工具預設值的差異,不是語言規則。照抄別人的 minify 設定之前得先知道自己用的是哪一家。
#x 則走完全相反的路:
export class Account {
#balance = 100;
#log(msg) { return msg + this.#balance; }
peek() { return this.#log("bal="); }
}
$ esbuild hash.mjs --minify --format=esm
class r{#e=100;#t(e){return e+this.#e}peek(){return this.#t("bal=")}}export{r as Account};
不需要任何開關,--minify 就把 #balance 改成 #e。因為規格保證了私有欄位取不到字串形式,工具能靜態證明改名安全。這是整件事最漂亮的地方——語言層的私有反而讓工具敢動它,而看起來比較公開的那些名字,工具反而不敢碰。
還有一件事要講清楚:mangle 不是混淆。
$ esbuild basic.js --minify --sourcemap --outfile=basic.min.js
$ node -e 'console.log(JSON.parse(require("fs").readFileSync("basic.min.js.map")).names)'
[ 'makeCounter', 'startValue', 'state', 'stepSize' ]
source map 的 names 陣列原封不動存著所有被改掉的識別字,esbuild 預設連原始碼全文都內嵌。公開部署 .map 等於把改名整個還原回去。
最後是 TypeScript 的 private。它只活在型別層:
export class Account {
private balance: number = 100;
#realSecret: number = 999;
peek(): number { return this.balance; }
}
$ tsc acct.ts --target es2022
export class Account {
balance = 100;
#realSecret = 999;
peek() { return this.balance; }
}
private 在 emit 階段就被抹掉了。tsc --noEmit 抓得到越界存取(TS2341),但編出來的 JS 裡它完全不存在,執行期照樣列舉、照樣讀、照樣改。
最能說明問題的是同一行 minify 輸出:
$ esbuild acct.ts --format=esm --minify
class r{balance=100;#e=999;peek(){return this.balance}}export{r as Account};
#realSecret 自動縮成 #e,標了 private 的 balance 原名保留——minifier 根本不知道 private 存在過。所以 TS 的 private 連幫你省 bundle 體積都做不到。
反過來,把 target 降到 es2018,esbuild 會把 #private 整組換成 WeakMap/WeakSet。加上 --minify 之後 #balance、#log 這些名字一個都不剩,Object.getOwnPropertyNames(instance) 仍然是 [],把方法拆下來對別的物件呼叫仍然丟 TypeError: Cannot access private method。TS 的 private 是名字還在、保護沒了;降級後的 #x 是名字沒了、保護還在。
把四種擺在一起
| C++ 符號改名 | Python __x | minifier | JS #x | |
|---|---|---|---|---|
| 何時發生 | 編譯期 | 編譯期(產生 code object 時) | 建置期 | 不發生 |
| 新名字誰決定 | ABI 規範 | 所屬類別的名字 | 工具的計數器 | 沒有名字 |
| 變長還變短 | 變長 | 變長 | 變短 | — |
| 還原方式 | c++filt | 照規則手算 | source map | 無從還原 |
| 目的 | 讓連結器分得出來 | 避免繼承階層撞名 | 縮小體積 | 封裝 |
| 擋得住誰 | 沒有要擋人 | 手滑 | 沒有要擋人 | class 外部的所有程式碼 |
看完這張表,「Python 的 __x 算不算私有」就不必再吵了。它跟 C++ 的符號改名、跟 minifier 的縮名站在同一邊:都是名字管理,不是存取控制。四個裡面只有一個在做封裝,而那一個恰好沒有改任何名字。
結語
同一個動作被拿去解四個不相干的問題。C++ 把型別編進名字,是為了讓連結器分得出來;minifier 把名字縮短,是為了讓檔案變小;Python 把 __x 換成 _Class__x,是為了讓繼承階層不撞名。三個都在改名,三個都不在乎有沒有人繞過去——因為它們本來就沒有要擋人。
真正在擋人的那個,反而沒有改名。#x 從頭到尾就不是名字:它不進符號表、不進 property、不進 Reflect.ownKeys,也不存在任何字串形式讓你去拼。TC39 要的不是「不好猜的名字」,是「根本沒有名字可猜」。這兩件事在強度上差了一個量級,卻常常被同一個詞蓋住。
下次看到有人把 _x 改成 __x、說這樣比較安全,該問的不是他改得對不對,而是他想擋的到底是誰。如果答案是「怕別人亂改」,那 _Class__x 五秒就打得出來;如果答案是「怕子類別撞名」,那他改對了——只是要有心理準備,@dataclass、mock.patch 和 Template Method 會排隊來收帳。