Rust 1.100.0 在 2026 年 11 月 12 日发布。对大多数团队来说,这次升级不会带来编译错误,但它同时改动了类型系统的一个边角、内存分配的基础抽象,以及 lint 抑制的可追责性,值得单独排一次回归。
三位数版本号意味着什么
按语义化版本的规则,1.100.0 与 1.99.0 之间不存在特殊含义:MAJOR 仍是 1,MINOR 从 99 走到 100 只是进位,不构成任何破坏性承诺。真正的麻烦来自工程侧那些把版本号当字符串处理的脚本。字典序里 1.100.0 排在 1.9.0 之前,因为它逐字符比较到第三位时 1 小于 9。任何用 sort、字符串比较或正则 1\.\d+ 写的版本门槛判断,都会在 1.100 上给出错误结论。这类问题不会在编译期暴露,只会在发布流水线里悄悄放行或拦截错误的构建。
用版本排序而不是字符串排序
下面这段脚本演示了两种排序方式的差别,可以直接在 shell 里跑一遍确认行为。凡是需要判断「至少某个版本」的地方,都应该换成版本排序,或者干脆交给 cargo 的 MSRV 解析器。
printf '%s\n' 1.9.0 1.99.0 1.100.0 | sort | tail -1 # 字典序,输出 1.99.0 printf '%s\n' 1.9.0 1.99.0 1.100.0 | sort -V | tail -1 # 版本序,输出 1.100.0
MSRV 的声明方式没有变化,仍然是 Cargo.toml 里的 rust-version 字段。真正需要注意的是 Cargo.lock 与 CI 镜像里锁定的工具链版本:如果镜像标签写成 1.9 这类前缀匹配,1.100 会直接被跳过。检查方式是 rustc --version 与实际构建日志里的版本行是否一致。
never type 转正:十年、五次失败之后
never type 在代码里写作 !,表示不存在任何值的类型,用来描述不会正常返回的计算,return、break、std::process::exit 的表达式类型都是它。因为它无法被构造,Rust 允许 ! 到任意类型的强制转换,这正是 match 某个分支写 return 也能通过类型检查的原因。
过去 ! 只能在有限的位置出现:函数返回类型可以写 -> !,但一旦把它放进泛型参数,比如 Result,编译器就会拒绝。1.100 放开了这个限制,! 可以在任意位置命名。
在类型定义里显式写 never type
把 ! 写进 Result 的错误位,语义上等于声明「这个函数不会产生错误」。下面的写法在 1.99 上无法编译,在 1.100 上可以通过,而且后续可以直接用 ? 向上传播。
// 1.99 及以前:Result不是合法的类型写法
// 1.100 起:never type 可以在任意位置命名
fn parse_or_die(s: &str) -> Result{
// 解析失败直接 panic,Err 分支永远不产生
Ok(s.parse().expect("input must be a number"))
}
fn sum_all(items: &[&str]) -> Result{
let mut total = 0u32;
for item in items {
total += parse_or_die(item)?; // ? 在这里不会引入新的错误类型
}
Ok(total)
}同样的写法可以消掉一批手写的 Infallible 包装。core::convert::Infallible 在 1.100 里变成了 ! 的别名,因此历史上所有返回 Result 的 API 都能与 ! 互操作,不需要改签名。
edition 回退变化:少数代码会受影响
为了让 Infallible 能安全地改成别名,官方不得不动一处回退规则:在 2015、2018、2021 三个 edition 中,never type 的默认回退类型从 () 改成了 !,与 2024 edition 此前已经采用的行为对齐。官方措辞是「大部分情况下不会被察觉」,但有两类代码会碰到:
第一类是泛型参数由发散表达式填充、类型推断依赖回退的场景;第二类是回退为 ! 后,编译器会把更多后续代码判定为不可达,从而新增 unreachable_code 警告。
fn default_value() -> T {
T::default()
}
fn main() {
// 发散分支参与推断:回退类型改变后,这里的推断结果也变了
let v = if std::env::var_os("FAST").is_some() {
default_value()
} else {
return;
};
println!("{:?}", v);
}如果 CI 上开了 -D warnings,这段代码很可能从「能过」变成「构建失败」。处理顺序建议是:先升级依赖,官方已经为受影响的公开 crate 发了修复版本;再在分支上跑一次 cargo +1.100 check --all-targets,把新增警告逐条确认,而不是直接加 allow 压下去。
Allocator API:先只覆盖 Vec 和 Box
Allocator trait 的稳定是 Allocators 1.0 项目目标的地基。它把「内存从哪来」抽成一个可以传入的类型参数:Vec 和 Box 现在接受自定义分配器,配套稳定了 Vec::new_in、Vec::with_capacity_in、Box::new_in、Box::from_raw_in、Vec::allocator 等一批构造与访问方法,Global 与 System 也实现了这个 trait。
限制同样明确。第一,目前只有 Vec 和 Box 支持自定义分配器,HashMap、VecDeque、BTreeMap、Rc、Arc 的支持仍在路上,官方公告只说「后续会稳定更多集合与指针类型」。第二,分配器类型会渗进公开 API:一个返回 Vec 的函数,会把 A 变成调用方必须写出或推导的类型参数,封装成本不低。第三,实现 Allocator 是 unsafe 契约,allocate 返回的指针必须满足 layout 对齐,deallocate 必须与分配时的 layout 严格配对。
一个统计分配次数的分配器
下面的例子实现了一个只做计数、实际转发给全局分配器的包装。它可以直接放进 src/main.rs 编译运行,输出能看到 Vec 在扩容时真实的分配序列。
use std::alloc::{AllocError, Allocator, Global, Layout};
use std::ptr::NonNull;
#[derive(Clone, Copy)]
struct Counting;
unsafe impl Allocator for Counting {
fn allocate(&self, layout: Layout) -> Result<NonNull, AllocError> {
println!("alloc size={} align={}", layout.size(), layout.align());
Global.allocate(layout)
}
unsafe fn deallocate(&self, ptr: NonNull, layout: Layout) {
Global.deallocate(ptr, layout)
}
}
fn main() {
let mut v: Vec= Vec::with_capacity_in(4, Counting);
for i in 0..9u32 {
v.push(i);
}
println!("len={} cap={}", v.len(), v.capacity());
}这类包装在排查「谁在偷偷扩容」时非常直接。生产上更常见的用法是 arena:一次性大块申请,请求结束统一释放。
DropGuard:三条退出路径共用一个回滚
Drop 已经能保证变量离开作用域时执行清理,问题在于「我只想在这个函数退出时干一件事」时,仍然要定义一个结构体、写一个 impl,成本太高。core::mem::DropGuard 把这个模式压成一行:它接收一个变量和一个闭包,实现 Deref 与 DerefMut,所以中途可以照常读写那个变量,等 guard 被 drop 时执行闭包并消费变量。
真正让它好用的是 DropGuard::dismiss:成功路径上调用它,把变量原样取回,闭包不会执行。于是正常返回、? 传播错误、panic 三条退出路径共享同一段回滚逻辑,只有一条路径需要显式「确认提交」。
实战:批量写入失败即回滚
下面的函数把一个字符串切片全部解析成整数并追加到 Vec,任何一个解析失败都要求 Vec 保持原样。写法是进入时记住长度,guard 里截断,成功时 dismiss。
use std::mem::DropGuard;
use std::num::ParseIntError;
/// 全部解析成功才写入,任意一个失败则 nums 保持原样
fn parse_all(nums: &mut Vec, inputs: &[&str]) -> Result {
let mark = nums.len();
let mut nums = DropGuard::new(nums, move |nums| {
nums.truncate(mark); // 失败回滚:截回进入时的长度
});
for s in inputs {
nums.push(s.parse()?); // ? 提前返回时,guard 触发回滚
}
DropGuard::dismiss(nums); // 成功路径:取回变量,不执行闭包
Ok(())
}
fn main() {
let mut nums = vec![1, 2, 3];
assert!(parse_all(&mut nums, &["4", "5"]).is_ok());
assert_eq!(nums, vec![1, 2, 3, 4, 5]);
assert!(parse_all(&mut nums, &["6", "x", "7"]).is_err());
assert_eq!(nums, vec![1, 2, 3, 4, 5]); // 已回滚
}把它换成数据库写入、消息队列批量发送、文件追加,结构完全一样。panic 场景同样覆盖,因为回滚走的是 Drop,而 Drop 在栈展开时必然执行。
conjure_zst 的安全前提
零大小类型(ZST)在 Rust 里既常见又危险。常见是因为标记类型、能力凭证、状态机标签都用它;危险是因为「构造一个 ZST」在物理上没有内存写入,看起来无害,实际上常常绕过了类型系统用来表达的前置条件。
std::mem::conjure_zst 是这次给出的唯一官方答案,替代过去那些 transmute(())、MaybeUninit::uninit().assume_init() 的花招。它本身是 unsafe fn,因为是否安全完全取决于调用点是否满足该 ZST 的不变式。
官方举的例子很典型:PyO3 的 Python 是一个零大小凭证,代表当前持有 Python 的 GIL。凭空造出一个这样的值,就能在不持锁的情况下调用需要 GIL 的 API,安全性直接崩塌。
用零大小类型做能力凭证
下面的例子用一个 ZST 表示「初始化窗口已开启」。构造函数是 unsafe 的,注释里写清前置条件,这是使用 conjure_zst 时唯一正确的姿势。
use std::mem::conjure_zst;
/// 只有初始化窗口内才允许存在的能力凭证
struct InitWindow(());
/// # Safety
/// 调用方必须保证当前处于初始化窗口内,且没有并发写配置。
unsafe fn enter_init_window() -> InitWindow {
unsafe { conjure_zst() }
}
fn load_config(_: &InitWindow) -> &'static str {
"config loaded"
}
fn main() {
// SAFETY: main 的第一条语句,此刻没有任何并发与已初始化状态
let window = unsafe { enter_init_window() };
println!("{}", load_config(&window));
}判断标准很简单:如果这个 ZST 的存在本身就代表某种许可,那么构造它必须是 unsafe 且带文档化的前置条件;如果它只是标记泛型参数,不承载任何安全含义,那用普通构造器即可,不必动用 conjure_zst。
expect 属性:让过期的抑制自己报警
#[allow(lint)] 最大的问题是不会过期。代码重构之后,原本被抑制的警告不再触发,那行 allow 仍然静静地留着,没人知道它可以删。RFC 2383 定义的 #[expect(lint)] 正是为解决这一点:它抑制警告,同时要求这条 lint 必须真的触发,否则编译器给出 unfulfilled_lint_expectations 提示。
需要说明的是,1.100 的稳定 API 清单里并没有单独列出属性项,expect 属于已经稳定可用的能力,本文按既成事实使用,不把它算作本版新增。
一个抑制已过期的例子
下面这段代码先带着 expect 编译,再人为去掉触发条件,观察编译器如何提示这条抑制已经无用。这个信号比任何代码评审都可靠。
// src/lib.rs
#[expect(clippy::needless_return, reason = "与历史分支保持一致的写法")]
pub fn legacy_add(a: u32, b: u32) -> u32 {
return a + b; // 这行会触发 needless_return,expect 被满足
}
// 重构后把写法改掉,但忘了删属性:
#[expect(clippy::needless_return, reason = "与历史分支保持一致的写法")]
pub fn modern_add(a: u32, b: u32) -> u32 {
a + b // 不再触发 lint,expect 未被满足
}用 cargo clippy --all-targets 编译,第二条函数会得到一条「this lint expectation is unfulfilled」的警告。配合 -D warnings 或者单独的 -D unfulfilled_lint_expectations,就能把「清理过期抑制」变成构建流程的一部分。迁移策略可以分两步:新代码一律写 expect,老代码在动到哪个模块时顺手把 allow 换掉,不要一次性全局替换,因为替换后暴露的过期项可能一次涌出上百条。
Cargo 侧的三项变化
Cargo 在 1.100 里做了三件与供应链和构建效率相关的事,官方公告给出的名称分别是:报告未使用的依赖、减少构建缓存碰撞、以及最小发布年龄(min-publish-age,也叫依赖冷却期)。
第一项解决的是「依赖列表腐烂」问题。Cargo.toml 里长期挂着已经不再被引用的 crate,会让审计面虚高,也会拖慢构建。第二项针对的是缓存键碰撞导致的错误复用,多工具链、多 profile 混用时尤其明显。第三项给依赖加了一个时间闸门,让刚发布的版本在一段时间内不被自动采用,直接压缩了「投毒后几分钟内被拉取」的窗口。
需要提醒的是,官方公告只给出了特性名称与背景,具体的配置键名与默认时长并未在公告中披露。因此升级后不要凭记忆写配置,务必查对应版本的 cargo 文档;不确定就直接用命令行验证行为,而不是猜字段。
检查依赖与缓存状态
升级前后各跑一次下面的命令,把依赖树和重复版本记录下来做对比,能快速发现冷却期或缓存改动带来的意外解析结果。
cargo tree -d # 列出被多个版本同时引入的 crate cargo build --offline # 确认离线构建可用,缓存健康的直接证据 cargo update --dry-run # 只看解析结果,不真正下载
升级步骤与回归顺序
推荐顺序是:先在分支上换工具链,再处理编译错误,最后处理新增警告。不要把三步揉在一次提交里,否则回滚时无法判断问题来自哪一层。
rustup update stable rustup component add clippy rustfmt cargo +1.100.0 check --all-targets # 先只做类型检查 RUSTFLAGS="-D warnings" cargo +1.100.0 check --all-targets # 暴露新增的不可达代码警告 cargo +1.100.0 test --all cargo +1.100.0 clippy --all-targets -- -D warnings
关于缓存,有两点经验值得写进升级说明。一是切换工具链后第一次构建会重新编译大量依赖,时间明显变长属于正常现象,不要误判为缓存失效。二是如果团队共享 target 目录或用了远程缓存,务必在升级完成后再让旧工具链的流水线继续写入,否则新旧产物混在一起,容易出现「本地能过、CI 不过」的假故障。稳妥的做法是给 1.100 单独开一个缓存命名空间,观察一到两周再合并。
最后补一句边界:有第三方博客称 async trait 与 rustfmt 的 edition 2024 支持也在本版稳定,但官方公告的稳定清单里没有对应条目,本文不采信。