8 月 24 日,Rust 貢獻者 waffle 敲定了 never type ! 的穩定化——這件事本身就花了兩年多;這批要等 Rust 1.99 才生效的改動裡,還藏著一個連作者自己都沒預料到的 breaking change,跟另一個規則變動疊在一起,方向剛好相反。
Rust 用兩年補齊 never type
八月 24 日這天,Rust 的型別系統補上了最後一塊拼圖——never type ! 完成穩定化,會隨 Rust 1.99 一起發佈。讓這件事拖了兩年多才完成的,不是技術本身多難,而是牽動了一條每個 Rust 使用者天天在用、卻幾乎沒人會去讀規格的隱性規則:型別推斷的 fallback。
從一個沒人敢動的 fallback 開始
never type 在 Rust 裡代表「這裡不可能有值」——panic!()、return、無窮迴圈,這些運算式的型別都是 !。多數時候這件事不會造成困擾,因為 ! 可以隱式轉換成任何其他型別:一個 match 裡有一支手臂回傳整數、另一支手臂 panic!(),整條運算式的型別照樣是整數,! 那一邊直接讓路。真正麻煩的情況是相反的:如果一段程式碼裡,所有分支都是 !,而這個結果本身又完全沒有被用到,編譯器手上沒有任何線索能決定型別,只能套用一條叫做 fallback 的預設規則去猜一個型別出來。
這種「猜出來的型別」會不會外洩,取決於這段程式碼寫得多鬆散。多數時候寫程式碼的人會明確標出回傳值該是什麼型別,fallback 根本沒有出場機會;只有在型別完全交給編譯器自己判斷、而且判斷結果又沒有被拿去做任何要求具體型別的操作時,fallback 才會被觸發。這也是為什麼多數 Rust 開發者對這條規則完全無感——平常寫的程式碼,通常根本不會踩到這條路徑。
這條 fallback 規則的尷尬之處,2024 edition 之前一直存在。遇到這種完全推不出型別的情況,編譯器預設猜的是 (),也就是 unit type。這個選擇當年是安全的——! 能轉換成任何型別,轉成 () 不會讓已經能編譯的程式碼壞掉。但語意上並不準確:這段運算式明明不可能真的產生一個值,把它當成「產生了一個空值」,等於在型別系統裡留了一個心照不宣的洞。
LWN 那篇報導只用一句話帶過 edition 制度的用意——在不破壞舊程式碼、也不要求整個生態系一起更新的前提下,允許語言前端做出破壞性改動——機制本身沒有細講,這裡補一段背景:edition 是 Rust 用來在不打破生態系的前提下逐步調整語言行為的機制——同一份 Rust 編譯器,可以用 2015、2018、2021 或 2024 edition 的規則去編譯同一份 crate,切換 edition 不需要換編譯器版本,也不代表舊 edition 會被丟棄。這個設計原本是為了讓「改變語言預設行為」這件事有台階可下:新寫法先進新 edition,舊程式碼留在舊 edition 也照樣能編,好處是舊程式碼永遠有退路——只要不升 edition,就不會被新規則影響;代價是同一份工具鏈要同時維護好幾種 edition 的語意,編譯器內部的複雜度只增不減。但 fallback 規則恰好是這套制度也不容易溫柔處理的例外——它不是「新寫法 vs 舊寫法」的選擇,而是「同一段沒有任何顯式標註的寫法,編譯器猜出來的答案變了」。
補這個洞為什麼要花兩年多?文章沒有回答這一題,但 fallback 的性質本身給了線索:它是編譯器每次遇到型別完全推不出來時都會悄悄呼叫的規則,動它等於把手伸進所有 Rust 程式碼共同依賴的隱藏機制裡——任何調整都要先確認不會讓現有的、已經編譯通過的程式碼在悄無聲息中變成另一種行為。waffle 8 月 24 日敲下最後一行的時候,距離這件事真正開始已經超過兩年;報導沒有交代中間這兩年具體做了哪些事,合理的推測是,反覆確認的正是改了之後舊程式碼還能不能編過、編過之後行為是否還一樣。
拖曳圓點沿時間軸移動 · 3 個時間點
這兩年多裡發生的事,時間軸上能標出來的重點其實只有三個:問題開始被認真處理、8 月 24 日 waffle 把穩定化敲定、以及 never type 隨 Rust 1.99 進入穩定版。中間漫長的拉鋸,圍繞著同一時間點還有第二個改動——兩者疊在一起會發生什麼。
兩個 breaking change 疊在一起會怎樣
讓事情更複雜的是,fallback 規則的改動不是唯一動到的東西。標準函式庫裡的 Infallible——原本是一個沒有任何 variant 的獨立 enum(這是背景,文章沒有描述舊定義),常被用在「這個 Result 不會出錯」的情境——同時要變成 ! 的 type alias。這聽起來像是把兩個本來就等價的東西畫上等號,應該無傷大雅,但後來有人指出,這個定義調整本身是一個意外的 breaking change:凡是有程式碼把 Infallible 當成一個具體、獨立的型別在用,把它換成別名之後,這些程式碼看到的型別關係就變了。
文章沒有細講 Infallible 平常怎麼用,這裡補一段 Rust 的背景知識:它常見的用法是 TryFrom 之類會回傳 Result 的 trait,某些實作「這個轉換不可能失敗」,用 Result<T, Infallible> 讓型別系統把「不可能出錯」直接寫進簽名,呼叫端甚至可以用一段「理論上用不到」的錯誤處理把 impossible case 消掉。這一整套用法能成立,靠的正是 Infallible 是一個有名字、可以被 pattern match 窮舉掉的具體型別;換成 ! 的別名之後,這些寫法在語意上更精確了,但任何依賴 Infallible 這個名字本身、而不是它的語意在做特化的程式碼,就會踩到前面講的意外 breaking change。
同樣屬於背景補充的還有一點:edition 是 crate 層級的設定,不是專案層級的——一個工作區裡,可以有一部分 crate 用 2024 edition、另一部分維持在舊 edition,彼此照樣能互相依賴、互相呼叫。這個彈性讓遷移可以分批進行,不需要整個 workspace 一次到位,但也意味著同一個專案裡,fallback 的行為可能因為不同 crate 用了不同 edition 而不一致,這件事值得在遷移計畫裡明確寫下來。
要看清楚 fallback 什麼時候真的會被觸發,一段最小的例子比長篇解釋更直接。下面把同一個機制拆成三個可以分別點開的標註點:
點選任一行看它的責任 · 3 個標註點
if 分支 · 責任
panic!(…) 的型別是 !——這條路徑代表「不可能正常返回到這裡」,不是回傳了某個特殊的值。
不知道的事:另一個分支長什麼樣、x 最後會不會被用到。
else 分支 · 責任
另一條路徑一樣是 !——兩個分支都發散,代表整個 if 運算式永遠不會正常求值完畢。
不知道的事:if 分支已經處理過什麼情況,兩邊的 panic 訊息是否重複。
let x = ⋯ · 責任
兩個分支都是 !,編譯器手上完全沒有其他線索能決定 x 的型別,只能套用 fallback 規則猜一個預設值。2024 edition 之前,這個猜測固定是 ();2024 edition 起,猜測換成 ! 本身。
不知道的事:x 之後會不會被讀取——如果會,且讀取方式要求某個具體型別,型別推斷根本不會走到 fallback 這一步,這條路徑只在 x 完全沒被使用時才會被觸發。
三個標註點合起來說的是同一件事:if 和 else 兩個分支都不可能正常返回,x 的型別完全沒有其他線索可以確定。2024 edition 之前,編譯器對這種情況的答案永遠是 ();進了 2024 edition,答案變成 ! 本身。差別看起來只是一個型別記號,但對任何靠型別大小做記憶體佈局假設、或是靠 () 這個具體型別做 trait 分派的程式碼,答案換了,行為就可能跟著換。
這種「答案換了」的風險,Rust 並沒有讓它默默發生。報導裡有一句很容易被略過的話:自 2024 年起,只要程式碼以會被新規則弄壞的方式觸發 never-type fallback,Rust 就會發出警告;文章把「多數函式庫有充足時間自行更新」這個結果,直接歸因於這道早就開始響的警告。換句話說,這不是一個無預警落地的改動。照報導給的數字看,真正留到最後的殘餘只有兩類:少數真的完全編不過的 crate,以及相依鏈裡卡著舊版函式庫、問題其實不在自己身上的那一大批。至於 waffle 為什麼花掉兩年多而不是幾個月,合理的推測是相容性的窮舉佔掉了相當比例的時間;但報導只給出「兩年多」這個總長,並沒有交代這段時間實際怎麼分配。下面這個滑桿把同一段程式碼放進 Rust 目前並存的四個 edition 裡各跑一次,可以直接看到 fallback 猜的型別在哪一個 edition 切換。
拖曳滑桿切換 4 個 edition
()互動圖表
2024 edition 之前,推不出型別時 fallback 猜的是 ();2024 edition 起改猜 ! 本身,同一段程式碼的答案就換了。
這正是 fallback 改動與 Infallible 別名化「疊在一起」的地方——很多原本會因為 fallback 從 () 換成 ! 而受影響的程式碼,剛好也是那些把 Infallible 當「等同於 ()」在用的地方;Infallible 本來就沒有值,把它改成 ! 的別名,反而讓兩邊的型別對上了。兩個 breaking change 各自看起來都會傷人,湊在一起,傷害面反而比想像中小——但「小」不等於「零」,這一點下一節的 crater 數字會說清楚。
這裡再補一段文章沒有觸及的背景:() 的大小是零,! 照理也不佔任何 bit,兩者在多數情境下可以互換而不出事。順著這個方向往下推,最可能出事的會是那些用 TypeId、Any::downcast 之類機制在執行期比對「這是不是 ()」的寫法——fallback 從 () 換成 ! 之後,這類比對的結果就反過來了;不過這條危害路徑是順著型別語意推出來的,文章既沒有提到它,也沒有列舉任何具體的受害寫法。可以確定的只有前面那一點:這類改動不會毫無預警地落在使用者頭上,2024 年起編譯器就會對「會被新規則弄壞」的 fallback 用法示警。下面拖曳分隔線,看 Infallible 的定義實際換了什麼。
拖曳分隔線比較 Infallible 定義前後
互動圖表
Infallible 從一個沒有 variant 的獨立 enum,改成 ! 的 type alias,1.99 之後兩種寫法指向同一個型別。
crater 掃過 3,300 個 crate 之後
「小」的具體數字,靠的是 crater——把 crates.io 上所有公開函式庫全部拉下來,用新規則重新編譯一次的機制。這次跑完,crater 把 3,300 個 crate 列為受這個改動影響。這個數字乍看嚇人,拆開來看卻完全是另一回事:真正因為自己程式碼寫法而完全編不過的,只有 7 個;剩下的絕大多數,只是因為釘住了某個舊版本的函式庫,而那個函式庫後來已經被上游修好了。
文章對 crater 的描述其實只有一句:它能把 crates.io 上所有公開的 Rust 函式庫下載下來編譯,從中找出被編譯器改動弄壞的程式碼。至於用哪些工具鏈跑、拿什麼當基準比對、怎麼判定一個 crate 算「壞掉」,文章都沒有交代。但光是「所有公開函式庫」這個範圍,就足以說明這種驗證方式的代價與價值:每跑一次都要花上可觀的機器時間,換來的是一個有實證基礎的數字,而不是憑印象估出來的風險感覺。3,300 這個數字站得住腳,前提正是它涵蓋的範圍就是整個 crates.io。
換句話說,3,300 這個數字量的是「理論上會被牽動的範圍」,7 這個數字量的才是「真正需要開發者自己動手改程式碼的範圍」。中間那段落差,來自 Rust 生態系一個常見但不太被注意的現象:大量的 crate 並不是自己寫壞了,而是相依鏈裡卡著一個沒人記得去升級的舊版本,新規則一上路,舊版本裡早就修好的問題又重新冒出來。
對付這批「相依舊版而失敗」的 crate,好幾位函式庫作者願意把修復回頭 backport 到舊版分支上,一次救回 1,553 個原本會 failing 的 crate——這些 crate 本身一行程式碼都不用改,只要它們相依的函式庫舊分支收到修補就自動過關。但 backport 不是萬靈丹:有些維護者直接拒絕接受回頭修復,理由是那個版本早就已經 EOL,沒有義務再為它發新的修補程式。
為什麼會拒絕?合理的推測是,對已經 EOL 的版本來說,接受 backport 意味著要重新啟動一套早就關閉的發布流程——重新切分支、重新跑測試、重新發版,對一個已經沒有主動維護資源的專案,這筆成本往往比「請下游自己升級」更高。文章本身沒有寫出維護者的確切理由,EOL 是他們給出的說法,另外補的一句是:使用者可以留在舊版 Rust,或是改用還有維護的那個版本。這等於把責任線劃清楚了——舊分支不再接受修復,出路留在使用者這一端。
這個模式其實不是第一次出現在 Rust 的版本歷程裡——語言核心規則的調整,最大的不確定性往往在於舊生態系裡到底藏了多少依賴舊行為的角落。crater 存在的意義,正是把這種不確定性從「猜測」變成「可以事先量到的數字」,讓團隊在真正推出改動之前,就知道要準備多少 backport 資源、要提前警告哪些維護者。
| 項目 | 數字 | 備註 |
|---|---|---|
| crater 掃到受影響的 crate | 3,300 | 全 crates.io 範圍,僅代表「理論上會被牽動」 |
| 其中真正完全壞掉 | 7 | 需要開發者自己修程式碼,不是相依問題 |
| 靠 backport 修好的 crate | 1,553 | 相依的舊版函式庫收到回頭修補,crate 本身不用改 |
| 拒絕接受 backport 的維護者 | 有 | 理由是版本已經 EOL,不再接受修復 |
這張表格把三層數字疊在一起看:受影響的範圍、真正壞掉的範圍、修復覆蓋到的範圍——三個數字一個比一個小,但沒有一個小到可以忽略。對一個進入穩定軌道十多年的語言來說,「幾乎所有生態系都不受影響」本來就是任何 breaking change 唯一能拿到及格分的答案;3,300 對 7 中間的落差,靠 backport 修復處理掉了其中 1,553 個 crate——文章沒有給出這樣算下來的覆蓋比例。
留在 1.98,還是往前走
如果你的專案剛好落在那 1,553 個之外——相依鏈裡有一段已經 EOL、沒人要再修的舊版函式庫——升級到 Rust 1.99 就不是跑一次相依更新指令那麼簡單的事。真正會踩到 fallback 差異的程式碼,往往長得很無害:一個沒有標注回傳型別的函式、一段兩個分支都會 panic! 或 return 的邏輯、一個依賴推斷結果剛好是 () 才能通過編譯的巨集展開。這些寫法在舊 edition 下能正常編譯,換了 edition 就不一定。
文章列出三條因應方式(原文的引導句寫的是「有幾個選項」,並沒有宣稱這就是全部):留在 Rust 1.98 觀望,等社群把剩下的坑填得差不多;把所有相依都升級到已經拿到 backport 修復的版本;或者在會被 fallback 影響的位置,自己明確寫出回傳型別,不要把決定權交給編譯器的預設猜測。
三條路各自的成本不一樣,也不是互斥的選項。留在 1.98 最省事,但代價是遲早要補這筆作業,而且拖得越久,往前跳的距離就越遠;升級相依最乾淨,把問題留給上游解決,但前提是每一段相依鏈上都有人願意接手維護,遇到已經 EOL 的分支就卡住;明確標注型別最麻煩,需要一行一行找出可能踩雷的地方,但好處是徹底繞開 fallback 的猜測,往後編譯器再怎麼調整這條規則都影響不到你。下面這個手把把三條路放在同一條線上,拖著看每條路實際要付出什麼。
實際判斷「我會不會中」的做法並不神秘:在升級前針對 2024 edition 先跑一次編譯檢查,把所有「這裡型別推斷可能改變」的警告收集起來,再一個個看是不是真的用在會被使用到的路徑上。這比等正式升級後線上行為跑掉才回頭查,成本低了一個量級。已經在 CI 裡長期鎖定 edition 2021 的專案,這筆檢查可以留到真正決定升級的那一刻再做,不用現在就急。
拖曳手把在 3 條路徑間切換
三條路湊在一起看,其實是同一個提醒的三種寫法:fallback 這種「編譯器幫你猜」的機制,一旦你的程式碼真的走到需要猜的那條路徑,最安全的做法是明確標注型別,不讓編譯器有機會猜。
這三條路也不是永遠鎖死——如果之後有更多維護者更新已經 EOL 的分支,或者社群整理出更完整的「哪些寫法會踩雷」清單,留在 1.98 的成本會隨時間下降。真正的問題是,這些事會不會在你的專案撐不住之前發生。
沒被 backport 救到的那批:留在 EOL 版本上的相依鏈不會自己變好,Rust 1.99 上線之後,剩下的選擇就是升級、標注型別,或是接受自己暫時卡在 1.98。