Rust 1.96 讓 range 變可複製
Rust 1.96.0 把 range 拆成更像資料的型別,解掉 Iterator + Copy 的坑,讓你更安心地存、傳、複製範圍值。

Rust 1.96.0 把 range 改成更適合複製與保存的型別,順手拆掉一個常見的 Iterator 陷阱。
我用 Rust 一陣子了,越寫越不敢相信「看起來很簡單」的東西。range 就是那種我原本以為最沒戲的東西:0..n、a..=b,直覺上就是一段範圍嘛,結果一塞進 helper、一放進 struct、一想重複用,ownership 就開始跟我鬧脾氣。它明明像資料,卻又帶著 iterator 的影子,寫起來不算爆炸,但每次都會卡一下。這種卡法最煩,因為你不能說它錯,只能說它很欠修。
我最受不了的是,這東西在腦中應該是「可保存、可傳遞、可複製」的值,實際上卻常常逼你先拆成 start/end,再自己補回語意。那不是優雅,那是 workaround。Rust 平常很少讓我這麼不耐,但 range 這件事真的拖太久。直到我看到 Rust 1.96.0 這次把新 range 型別收進來,我才覺得:啊,終於有人承認這東西本來就不該長得像半個 iterator。
我這次是從 Paul Krill 在 InfoWorld 的文章 Rust introduces new Range types 看到線索的。原始資訊裡沒有給觀看數、書籤數或 star 數,所以我不亂掰。重點不是數字,是 Rust 1.96.0 release notes 背後那個很務實的整理:把「range 當資料」和「range 當 iterator 狀態」分開,別再混著玩。
Rust 終於承認:range 不該同時是資料和狀態
訂閱 AI 趨勢週報
每週精選模型發布、工具應用與深度分析,直送信箱。不定期,不騷擾。
不會寄垃圾信,隨時可取消。
It is a footgun to implement both Iterator and Copy on the same type.
翻譯一下就是:同一個型別如果又能隨便複製、又自己帶著迭代狀態,遲早有人踩雷。這句話其實很直白,Rust 標準庫這次等於是在說,舊的 range 設計把兩件本來不同的事硬塞在一起,方便是方便,但很容易把人搞到懷疑人生。

我自己最常碰到的就是把 range 放進 API 之後,心裡想它只是個「區間描述」,結果用了幾層之後,某個地方把它當成 iterator 消耗掉了。那種 bug 不會很吵,甚至不會立刻炸,只是讓你在後面某個地方發現東西怎麼少了。這比直接編譯失敗還煩,因為它會讓你先相信自己寫對了。
Rust 1.96.0 的方向很清楚:新的 range 型別走 IntoIterator,不是直接把 range 本體做成 iterator。白話講就是,range 先是一個值,需要迭代時再生出 iterator。這個切法很重要,因為它讓「我想複製這個範圍」和「我想跑這個範圍」不再互相打架。
實操上,我現在會這樣想:如果你的型別是拿來描述事情,讓它像資料;如果你的型別是拿來執行事情,讓它像狀態。兩種責任混在一起,後面一定會有人來收拾殘局。Rust 這次只是把殘局先收了。
- 可重用的描述,用 value 表示。
- 會被消耗的流程,用 iterator 表示。
- 兩者要轉換,就明講,不要偷偷混血。
core::range 其實是在把 API 拉回正常
Rust 1.96.0 把新的 range 型別放進 core::range,像 Range、RangeFrom、RangeInclusive 這些都變成更適合當資料的版本。這不是在加花樣,這是在把命名和用途對齊。以前你看到 0..10,腦中以為自己拿到的是一個乾淨的區間;實際上它帶著歷史包袱,像穿西裝但裡面藏著跑步鞋。
翻譯一下就是:標準庫現在終於給「range 作為值」一個正經住址。這件事看起來很小,但對寫 library 的人很有差。因為你要的是一個能存、能傳、能 copy 的 range,不是某個已經開始消耗狀態的怪東西。新 namespace 的好處是,你在 code review 時可以很直接地講:「這裡用 core::range,因為它就是拿來當值的。」
我以前在做一個內部工具時,也遇過類似問題:我們把「區間」和「游標」混在同一個 struct,結果大家都要先猜現在到底是描述狀態,還是已經進行到一半。那套設計後來只剩兩種用途:debug 時讓人煩,和 migration 時讓人更煩。Rust 這次其實是在幫大家少走一遍這種冤枉路。
實操寫法很簡單:只要你的 API 是要讓使用者把範圍拿來保存、比對、轉傳,就把它當資料型別設計。不要一開始就把 iterator 行為塞進去。能分開就分開,別貪圖少一個型別,最後多一堆註解。
- 命名先對齊使用者的心智模型。
- 舊行為保留,但新行為要有清楚落點。
- 文件直接寫「這是值,不是狀態」。
真正省事的是:你不用再硬拆 start/end
InfoWorld 提到,這次穩定下來之後,slice accessors 可以放進 Copy 型別裡,而不用先把 start/end 拆開。這句話很技術,但意思很白:以前你想把一段範圍當成一個值帶著走,常常得先把它拆成兩個欄位,等於自己把語意打碎再縫回去。

也就是說,Rust 現在比較像是在承認:很多時候你要的不是兩個數字,你要的是「這一段」。把它拆掉只是為了迎合舊設計,並不是因為你的程式真的比較清楚。這種 workaround 我做過,做完只會得到一個看起來很務實、其實很醜的 struct。欄位多了,語意少了,後人還得猜你當初到底在怕什麼。
我現在會把這件事當成一個設計檢查點:如果一個值在你的 API 裡會被反覆傳遞、複製、保存,那它最好能保持完整。不要逼使用者先拆成零件,除非 start 和 end 本來就各自有獨立意義。很多 API 之所以難懂,不是因為功能太複雜,是因為型別把本來單純的概念切碎了。
實操寫法:你在設計自己的型別時,先問一句,使用者需不需要只把它當成一個區間?如果答案是要,那就直接給他一個可複製的 range-like value。真的要拆欄位,也請把拆開的理由寫清楚,不然下一個接手的人只會以為你在逃避設計。
- 能保留整體語意,就別先拆零件。
- 欄位拆分只在各自有獨立意義時使用。
- 讓型別本身就是答案,不要讓人猜。
RangeInclusive 露出欄位,反而比較誠實
文章裡有一個我很喜歡的細節:新的 RangeInclusive 會把欄位公開,因為它不再需要藏 iterator 的 exhausted 狀態。這件事很像把一個原本硬撐著裝成熟的東西,終於拆掉多餘的面具。舊版之所以要把內部狀態藏起來,是因為它同時扮演了資料和 iterator,得防止大家亂摸;新版不用演了,欄位自然也不用藏那麼深。
翻譯一下就是:當型別真的只是資料,公開欄位不一定是壞事。很多人一看到 public field 就先皺眉,但問題從來不是 public 本身,而是這個型別有沒有需要保護的隱性狀態。沒有的話,你把它包得跟保險箱一樣,只是在增加閱讀成本。
我以前也很愛把東西全藏起來,覺得這樣比較「安全」。後來才發現,很多時候那只是讓 API 變得更難用,順便把簡單問題做成黑盒。Rust 這次的做法很對我的胃口:如果型別是乾淨的資料,那就老實一點,別再靠封裝假裝自己很複雜。
實操寫法很直接:設計資料型別時,先看你到底有沒有不想讓人碰的內部狀態。沒有,就別硬藏。你要保護的是不變式,不是你的心理安全感。
舊 range 還留著,這才是成熟的做法
Rust 1.96.0 沒有把舊的 range 直接整包炸掉。現在像 0..1 這種語法還是先走 legacy 型別,未來才會慢慢切到新的 range 家族。這種做法我很買單,因為它不是把大家丟下去重寫,而是先把新路鋪好,再慢慢把舊路標成 legacy。Rust 官方網站 一直都很擅長這種不粗暴的遷移。
白話講就是:舊東西先活著,但別再假裝它是長期答案。這對大型生態系很重要,因為你不可能要求每個 crate、每個團隊、每個內部工具都同一天一起改完。真正麻煩的不是新舊並存,而是新舊並存卻沒有明確標示,最後大家都以為自己踩的是同一塊地板。
我在公司內部做過一次型別遷移,最糟的版本就是「今天改完、明天全壞」。那種做法很爽,因為看起來乾淨,但實際上只有一小撮人覺得爽,其他人都在半夜補洞。Rust 這次選擇的路線比較像老司機:先告訴你哪裡是新路,哪裡是舊路,然後讓你自己慢慢切。
實操寫法:如果你也在做 API 重構,請先把新舊邊界講清楚。保留舊介面可以,但命名要誠實,文件要寫明白,遷移步驟要有。別讓 legacy 假裝自己還是主線。
- 保留相容性,但不要美化舊設計。
- 把 migration path 寫成使用者看得懂的步驟。
- 新 API 要先有清楚的落點,再慢慢推舊 API 退場。
這次順手加的 assertion macro,比 range 小一號但很實用
Rust 1.96.0 也加了 assert_matches! 和 debug_assert_matches!,用來檢查值有沒有符合 pattern。這東西有用,但我不會把它當主菜。它比較像是把測試裡那些「我知道你應該長這樣」的判斷寫得更順手,失敗訊息也更好看。
翻譯一下就是:你不用再手刻一堆 enum 比對,Rust 幫你把樣板碼收掉一點。這種改善我當然歡迎,因為它不改你的思考方式,只是少一點摩擦。range 的改動是在修模型,這個 macro 比較像是在修手感。
我自己會把它放在測試和除錯用的地方,尤其是你在處理 enum、Result、Option 這類結構時,pattern 比對比手寫 if/else 更乾淨。只是別亂用到 production 邏輯裡,否則你只是把可讀性換成另一種形式的炫技。
實操寫法:當你在測試裡關心的是「形狀」不是「精準值」時,用這兩個 macro。你會少寫很多樣板,失敗時也比較知道哪裡歪掉。
我真正想學的,是這套拆法
我看 Rust 1.96.0 這次 range 的整理,學到的不是「多了哪些型別」,而是它怎麼拆問題。它先承認舊設計把資料和狀態混在一起,再把新型別放到更誠實的 namespace,最後保留舊行為做過渡。這不是炫技,這是把一個長年不順眼的設計拆乾淨。
白話講就是:如果你的 API 讓人常常懷疑自己是不是把東西用錯了,那多半不是使用者太笨,是你的型別責任分配有問題。Rust 這次把問題講得很直接,也很符合我平常在 code review 會碎念的點:能當資料就別裝成狀態,能複製就別逼人拆零件,能遷移就別假裝沒成本。
我會把這次更新當成一個很好的設計範本。不是因為它多潮,而是因為它夠老實。老實地承認舊 API 有包袱,老實地把新概念放到對的位置,老實地讓使用者少踩一個坑。這種改法,才真的值得拿來抄。
可抄的模板
## Rust 1.96.0 range 改動我怎麼解讀Rust 1.96.0 把 range 從「像 iterator 的值」整理成「更像資料的值」。我現在會這樣設計自己的型別:- 如果是可複用的描述,用 Copy-friendly 的 value 表示- 如果是會被消耗的流程,用 iterator 或明確的 state 表示- 如果需要轉換,讓 IntoIterator 這種轉換層出現,不要把兩種責任塞進同一個型別### 範例:把 range 當成資料存起來use core::range::Range;#[derive(Copy, Clone)]struct Window { bounds: Range<usize>,}impl Window { fn new(bounds: Range<usize>) -> Self { Self { bounds } }}### 我自己的 migration checklist- 先確認這個型別是「資料」還是「狀態」- 能存就直接存,不要先拆 start/end- 舊 API 保留,但命名要標示 legacy- 測試裡想看 pattern,就用 assert_matches!- 文件裡直接寫清楚:這個型別會不會被消耗這段模板是我根據 Rust 1.96.0 的 range 整理自己重寫的,不是原文照搬。原始來源主要是 InfoWorld 這篇 Rust introduces new Range types,搭配 Rust 官方文件 core::range、assert_matches!、debug_assert_matches!。我拆解的是方法論,模板是我整理給台灣開發者直接拿去改的版本。