编程 Rust 1.96 深度拆解:当 Range 终于能「Copy」——RFC 3550 如何用 IntoIterator 解耦数据与迭代,顺带修好 Wasm 链接与 Cargo 双源依赖

2026-07-17 12:43:59 +0800 CST views 5

Rust 1.96 深度拆解:当 Range 终于能「Copy」——RFC 3550 如何用 IntoIterator 解耦数据与迭代,顺带修好 Wasm 链接与 Cargo 双源依赖

2026 年 5 月 28 日,Rust 1.96.0 稳定版悄然发布。它没有一个像「async/await」那样惊天动地的语法糖,也没有像「const generics」那样改变整个生态的能力跃迁。它做的是另一种更难、也更能体现 Rust 工程哲学的事:在几个最基础、最被日常使用的 API 的深处,修掉那些积攒了数年、每一个老 Rustacean 都曾绊过一跤的设计瑕疵。

一、背景:六周一次,但每一次都有它的「长尾」

Rust 以每六周一个稳定版的节奏前进。这种高频发布的好处是:新能力可以快速落地,而不必等三年一次的大版本。但代价是——任何一个破坏性的改动都会被无限放大。所以 Rust 团队对「稳定 API 的修改」极其保守:一旦某个 API 进入 stable,它就几乎被永久锁定。

这带来一个经典困境:早期的设计决策,哪怕后来被证明是错的,也很难再改。Range 类型正是这样一个例子。

1.1 一个你每天都写、却从没真正拥有过的类型

let r = 0..10;      // 这行代码你写过无数次
let s = &text[r];   // 用 Range 去切片字符串
for i in 0..10 { }  // 或者作为 for 循环的范围

0..10 这个语法糖,生成的是 std::ops::Range<usize>。它是 Rust 里最高频使用的类型之一,几乎出现在每一段代码里。然而,绝大多数 Rust 开发者并不知道——这个类型,你不能 Copy 它,也不能轻易把它塞进一个需要 Clone/Copy 的结构体里

为什么?答案藏在「迭代器」这个词里。这是本文要解开的第一个核心谜题。

二、核心痛点:为什么旧的 Range 不能 Copy?

2.1 Iterator 与 Copy 的天然互斥

Rust 的 std::ops::Range 直接实现了 Iterator trait:

impl Iterator for Range<usize> {
    type Item = usize;
    fn next(&mut self) -> Option<usize> {
        // 每次调用都会推进内部的 start 指针
    }
}

Iterator::next 的签名是 fn next(&mut self) -> Option<Self::Item>——它会修改自身状态来推进迭代。一个会被修改内部状态、且被设计为「一次性消费」的类型,天然不可能实现 Copy

Copy 的语义是「按位复制后,原件和副本完全等价、可独立使用」。但 Range 一旦开始迭代,它的 start 字段就被改写了——你复制的到底是「迭代到一半的状态」还是「原始区间」?这正是 Copy 所不允许的歧义。所以编译器干脆禁止 Range: Copy

2.2 真实世界里的三处痛点

这听起来是语言洁癖,但落到工程里就是实实在在的麻烦:

痛点一:无法成为 Copy 结构体的字段

#[derive(Clone, Copy)]   // ❌ 编译错误
struct Span {
    range: std::ops::Range<usize>,  // Range 不是 Copy,Span 也不可能是 Copy
}

如果你写一个做源码解析、文本切片的库,想用一个 Span 来标记「第 3 到第 7 个字符」并让它随值自由传递,你被迫退化成手动存两个字段:

#[derive(Clone, Copy)]   // ✅ 但很丑
struct Span {
    start: usize,
    end: usize,
}
impl Span {
    fn slice<'a>(&self, s: &'a str) -> &'a str { &s[self.start..self.end] }
}

你丢失了 Range 自带的一切语义和方法,还要自己小心别把 start/end 写反。

痛点二:迭代即消费

let r = 1..10;
let first = r.next();   // r 的内部状态被推进
let r2 = r;             // ❌ 错误:r 在 next() 调用后已部分移动/借用

你想先看看区间的第一个元素,再把它传给别的函数?对不起,做不到。因为 next() 借走了 &mut self,之后 r 处于一个「半迭代」的模糊状态,编译器拒绝让你再把它当完整值使用。

痛点三:RangeInclusive 的私有化字段

std::ops::RangeInclusive(也就是 a..=b)更尴尬:为了保证「已迭代完成」这个内部状态正确,它的 start/end 字段是私有的。你既不能 RangeInclusive { start: 0, end: 10 } 直接构造,也不能解构它,只能依赖 ..= 语法糖。一旦你想在泛型代码里统一处理各种 Range,私有字段就成了绊脚石。

这三个痛点,每一个单独看都不致命,但叠加起来,就是无数 Rust 开发者心里的「那点别扭」。

三、架构分析:RFC 3550 的解法 —— 把「数据」和「迭代」拆开

Rust 团队的解法干净利落,体现了这门语言最核心的设计直觉:如果一个类型承担了两种职责,就把它们拆开

3.1 两套世界:std::ops::Range(旧) vs core::range::Range(新)

RFC 3550 引入了一套全新的范围类型,位于 core::range 模块:

旧类型(已实现 Iterator)新类型(仅实现 IntoIterator)
std::ops::Range<T>core::range::Range<T>
std::ops::RangeFrom<T>core::range::RangeFrom<T>
std::ops::RangeInclusive<T>core::range::RangeInclusive<T>

关键区别只有一句话:core::range::* 类型不再实现 Iterator,而是实现 IntoIterator

  • Iterator:类型本身就是迭代器,调用 next() 会消费/改写自身 → 不能是 Copy。
  • IntoIterator:类型只是数据,当你显式调用 .into_iter() 时,才「生成」一个独立的迭代器 → 数据本身可以安然地 Copy

用一句更形象的话说:旧 Range 是「正在播放的 CD」,你没法复印它,因为你不知道复印的是歌单还是现在的播放进度;新 Range 是「一张纯歌单」,想听就 .into_iter() 生成一个播放器,歌单本身想复印几份都行。

3.2 为什么 IntoIterator 足够了?

你可能会担心:如果新 Range 不是 Iterator,那我还能写 for i in range { } 吗?

能。因为 Rust 的 for 循环底层就是 IntoIterator

for i in range { }   // 编译器自动展开为:let mut it = range.into_iter(); ...

IntoIteratorfor 循环的真实契约,而 Iterator 只是迭代器的能力。旧设计让类型既是数据又是迭代器,新设计让类型只做数据,把迭代交给 into_iter() 的产物。职责清晰,鱼与熊掌兼得。

3.3 迁移路径:绝不破坏现有代码

这是 Rust 风格的精髓。注意:

  • 你今天写的 0..10 语法糖,暂时仍然生成旧版的 std::ops::Range。这是为了 100% 向后兼容——你的几十万行存量代码一行都不用改。
  • 新类型需要你显式 opt-inuse core::range::Range;
  • 未来某个 Rust Edition(版本)里,语法糖才可能默认指向新类型;在那之前,两套世界长期共存。

这种「加新不改旧、靠 Edition 缓慢迁移」的策略,正是 Rust 能在九年里保持 API 稳定的根本原因。

四、代码实战:core::range 的四种打开方式

4.1 实战一:让 Span 真正 Copy 起来

这是最经典的受益场景——文本/字节区间标记:

use core::range::Range;

#[derive(Clone, Copy)]
pub struct Span(Range<usize>);

impl Span {
    /// 从一个字符串里切出本 Span 覆盖的子串
    pub fn of(self, s: &str) -> &str {
        &s[self.0]
    }

    pub fn start(&self) -> usize { self.0.start }
    pub fn end(&self) -> usize { self.0.end }
}

fn main() {
    let src = "hello, rust";
    let span = Span(Range { start: 7, end: 11 });  // "rust"
    println!("{}", span.of(src));  // 输出: rust

    // 因为是 Copy,span 可以随意传递、拷贝、存入数组
    let spans = [span, span, span];
    for sp in spans {
        println!("{}", sp.of(src));
    }
}

注意 &s[self.0] 这个索引操作:新的 core::range::Range<T> 实现了 SliceIndex,所以它能直接用于切片,和旧 Range 的用法完全一致。但区别在于——现在 Span 可以是 Copy,你可以把一堆 Span 塞进数组、当作函数返回值自由传递,而无需任何 clone()Rc 包装。

4.2 实战二:在 no_std / 嵌入式里用 core::range

core::range 属于 core 而非 std,意味着在 #![no_std] 环境、裸机嵌入式、WASM 核心里同样可用:

#![no_std]
use core::range::Range;

/// 在固定大小的显存 buffer 上标记一帧的绘制区间
#[derive(Clone, Copy)]
struct DrawRegion(Range<usize>);

impl DrawRegion {
    fn len(self) -> usize { self.0.end - self.0.start }
}

对嵌入式开发者来说,能在一个零成本、无堆分配、可 Copy 的区间类型上构建抽象,是这类场景里非常实在的红利。

4.3 实战三:泛型 + trait 的统一处理

因为新 Range 是普通结构体、字段公开,你可以轻松写出跨区间类型的泛型逻辑:

use core::range::{Range, RangeInclusive};
use core::ops::RangeBounds;

/// 任意可 Copy 的 usize 区间都能算长度
fn length_of(r: impl RangeBounds<usize> + Copy) -> usize {
    // RangeBounds 让 std::ops 与 core::range 的各类 Range 被统一对待
    match (r.start_bound(), r.end_bound()) {
        (core::ops::Bound::Included(s), core::ops::Bound::Excluded(e)) => e - s,
        (core::ops::Bound::Included(s), core::ops::Bound::Included(e)) => e - s + 1,
        _ => 0,
    }
}

fn main() {
    let a = core::range::Range { start: 0, end: 10 };
    let b = core::range::RangeInclusive { start: 0, end: 9 };
    println!("{} {}", length_of(a), length_of(b));  // 10 10
}

RangeBounds 是把 std::opscore::range 两大系列 Range 统一起来的公共 trait,因此你可以在同一个泛型函数里混用新旧类型。

4.4 实战四:RangeInclusive 终于能直接构造了

use core::range::RangeInclusive;

// 旧世界:字段私有,只能写 0..=10 语法糖
// 新世界:普通结构体,字段公开
let inc = RangeInclusive { start: 0, end: 10 };
println!("{}..={}", inc.start, inc.end);  // 0..=10

对写解析器、状态机、需要在泛型里动态构造闭区间的库作者来说,公开字段意味着不再依赖语法糖,代码可以纯粹地「构造——传递——解构」。

五、断言宏:assert_matches! 与 debug_assert_matches!

1.96 还稳定了两个被期待已久的宏,专门解决「模式匹配校验」的样板问题。

5.1 旧世界的啰嗦写法

let val: Result<i32, &str> = Ok(42);

// 你不得不手写这段啰嗦的样板
if let Ok(n) = &val {
    if *n <= 0 {
        panic!("expected positive, got {}", n);
    }
} else {
    panic!("expected Ok, got {:?}", val);
}

5.2 新世界的清爽写法

use core::assert_matches::assert_matches;

let val: Result<i32, &str> = Ok(42);

// 一行搞定,且失败时自动以 Debug 格式打印出实际值
assert_matches!(val, Ok(n) if n > 0);

assert_matches! 在断言失败时,会像 assert_eq! 一样打印出实际值的结构化 Debug 输出,而不是一句干巴巴的 called 'Result::unwrap()' on an 'Err' value。这在调试复杂的枚举、嵌套 Result、或者测试解析器输出时,体验提升是质变的。

配套的 debug_assert_matches! 则只在开发模式(--cfg debug_assertions)下编译进代码,在 release 构建里被完全剔除——零运行时成本,却能在开发期帮你挡住一大类「形状不对」的 bug。

use core::assert_matches::debug_assert_matches;

fn process(input: Option<u32>) -> u32 {
    debug_assert_matches!(input, Some(_));  // release 下这行直接消失
    input.unwrap()
}

六、Cargo 双源依赖:git 与 registry 终于能共存

这可能是 1.96 里对企业/库作者最实用的特性。

6.1 旧世界的死结

此前,一个依赖项要么来自 git,要么来自 registry(如 crates.io),二者不可兼得。这导致一个常见场景无解:

你本地开发时,想用一个打了补丁的 fork(通过 git = "...");但当你把自己的库发布到 crates.io 时,下游用户不应该去拉你的私有 git,而应该用 crates.io 上的正式版。

旧 Cargo 做不到「本地用 git、发布用 registry」这种分流。

6.2 新世界的写法

1.96 允许一个依赖同时声明 git 和 registry

[dependencies]
serde = { version = "1.0", git = "https://github.com/serde-rs/serde", registry = "crates-io" }

语义是:

  • 本地构建时:使用 git 指定的仓库(你本地的 fork / 补丁版)。
  • 当你的 crate 被发布、下游用户拉取时:Cargo 会自动把依赖改写为 registry 版本,用户走 crates.io 正常解析。

这意味着库作者可以「用 fork 做本地开发和 CI 验证」,同时「对全世界暴露干净的标准依赖」。对维护大型 monorepo、需要对上游做临时 patch 又不想 fork 整个生态的团队,这是个实打实的效率提升。

七、Wasm 链接收紧:--allow-undefined 的退场

wasm32 系列目标(wasm32-unknown-unknown 等)在 1.96 里做了一项看似微小、实则重要的链接行为变更

链接器不再默认传递 --allow-undefined

7.1 这意味着什么?

在旧的 Wasm 工具链里,未定义的符号会被链接器静默放过。因为 wasm 的很多符号确实是在运行时通过 JS 导入(env 命名空间)解析的,所以 --allow-undefined 曾是默认行为。但副作用是:如果你的 Rust 代码里少写了一个 #[export_name],或者 import 声明和实际 JS 端对不上,链接器不会报错,问题会一直潜伏到运行时才以「undefined symbol」的形式炸出来,而且往往很难定位。

1.96 收紧后,链接阶段就会直接报出未定义符号,把 bug 提前到编译/链接期——这正是 Rust「fail fast、把错误尽量推到编译期」哲学在 Wasm 上的延伸。

7.2 你需要做什么?

绝大多数纯 Rust 的 Wasm 项目无需改动,因为正常的 #[wasm_bindgen] / #[export_name] 符号都是明确定义的。如果你之前依赖「链接器兜底放行未定义符号」来绕过某些声明,现在需要补全对应的导入/导出声明。建议升级后跑一遍 wasm-pack buildcargo build --target wasm32-unknown-unknown,确认没有新的链接错误。

八、性能与工程影响:零成本抽象的真实含义

Rust 有一句口头禅:「零成本抽象」(zero-cost abstractions)——你用上的高级抽象,不该比手写底层代码慢或胖。

core::range 就是这句话的范本:

  1. 纯数据结构,零堆分配Range<T> 就是 { start: T, end: T } 两个字段,没有任何 vtable、没有任何间接层。它和手写 struct { start, end } 在内存布局上完全一致。
  2. 完全内联:因为只是普通字段 + IntoIterator 的简单实现,into_iter() 生成的迭代器在编译期被完全内联,和旧的 for i in 0..n 生成的汇编几乎逐字节相同。你用上了更灵活的类型,却没有付出任何运行时代价。
  3. 更好的缓存局部性:当一个 Span 能直接 Copy 嵌入到你的结构体里,你就少了一层指针间接、少了一个可能的堆分配,数据更紧凑,缓存命中率更高。在解析器、编译器、序列化库这类「海量小对象」场景里,这种差异会累积成可观的吞吐提升。
  4. 更清晰的 API 边界:把「数据」和「迭代」拆开后,编译器能更精准地做借用检查——迭代是迭代、数据是数据,二者不再互相污染借用状态。这对静态分析的友好度,是隐形的长期红利。

换句话说,「让 Range 能 Copy」表面是个 API 小改进,底层却是 Rust 类型系统哲学的一次微缩展示:用类型的边界,去约束运行时的行为

九、升级指南与总结展望

9.1 升级只需一行

rustup update stable

1.96 是增量更新,没有破坏性改动,cargo build 大概率直接通过。建议顺手跑一遍测试套件和 Wasm 构建。

9.2 什么时候该用新 Range?

  • 如果你在写需要 Copy/Clone 的区间相关类型(解析器、Span、文本处理库)→ 立刻 use core::range::Range;,收益最大。
  • 如果你只是写 for i in 0..n 循环 → 暂时不用改,旧语法糖依旧工作,等未来 Edition 自然迁移。
  • 如果在 no_std / 嵌入式里需要轻量区间类型 → core::range 是比 std::ops 更合适的选择。

9.3 这版更新到底说明了什么?

Rust 1.96 没有 headline feature,但它恰好展示了 Rust 这门语言最被低估的一种能力:在保持绝对兼容的前提下,悄悄把地基垫高一块

Range 的问题存在了接近十年。换作很多语言,要么「将错就错」永远不改,要么「下个大版本破坏性重写」。Rust 选了第三条路:在 core 里放一个更好的原语,默认不破坏任何人,靠 Edition 做跨越数年的平滑迁移。

这种克制,才是 Rust 能在系统编程、嵌入式、Wasm、服务端如此多领域被信任的根本原因。当你下次写 0..10 时,不妨想一想:你踩在的,是一块被精心打磨了九年、如今终于能 Copy 的地基上。


本文基于 Rust 1.96.0 稳定版(2026-05-28 发布)官方 Release Notes 与 RFC 3550 整理。代码示例以「可读、可理解」为优先,实际迭代行为请以你本地 rustc --version 的文档为准。

推荐文章

Go配置镜像源代理
2024-11-19 09:10:35 +0800 CST
JavaScript设计模式:组合模式
2024-11-18 11:14:46 +0800 CST
Node.js中接入微信支付
2024-11-19 06:28:31 +0800 CST
Chrome DevTools MCP 深度实战
2026-06-22 20:27:14 +0800 CST
向满屏的 Import 语句说再见!
2024-11-18 12:20:51 +0800 CST
程序员茄子在线接单