- 文档
【免费下载链接】rfcs
RFCs for changes to Rust
本篇文章深入解读 Rust RFC 仓库中的 RFC 2523:cfg 路径版本条件编译(cfg_version与cfg_accessible),系统梳理该 RFC 提出的两个条件编译原语——按最小编译器版本(#[cfg(version(1.27.0))])与按外部路径可达性(#[cfg(accessible(::std::mem::ManuallyDrop))])进行条件编译——的动机、语法规范、语义细节与设计权衡。读完本文,你将掌握这两种机制面向库作者的完整使用范式(含全部原始代码示例),理解其与version_check等 build.rs 方案的优劣对比,并能通过仓库内相关 RFC 的交叉印证看清该特性的后续演进脉络。
特性总览:让库作者告别"支持新特性还是兼容旧编译器"的两难
RFC 2523 的核心提案可以浓缩为两句话:
- 允许用户针对编译器的最低 Rust 版本做
#[cfg(..)]条件编译:#[cfg(version(1.27.0))]; - 允许用户针对某个外部路径是否可访问做
#[cfg(..)]条件编译:#[cfg(accessible(::std::mem::ManuallyDrop))]。
这两个机制分别对应两个 feature 名称:cfg_version与cfg_accessible。它们不是互相替代的关系,而是互补关系:version(..)面向"语言特性"(例如某个新语法、新属性是否可用),accessible(..)面向"库特性"(例如标准库中的某个类型、方法、字段是否存在)。
动机:稳定而不停滞,但库作者的处境并不理想
Rust 的核心叙事之一是"stability without stagnation"(稳定而不停滞)。语言与社区在持续演进方面取得了长足进展,Rust 2018 edition 的到来更是如此。然而,当语言本身的演进状况良好时,库作者的处境却没有那么好。
今天,crate 作者经常面临一个两难抉择:"我是为更新版本的 Rust 提供更多特性与实现,还是保持与更多编译器版本的兼容?"
围绕如何通过增强 Cargo 来缓解"依赖地狱"(dependency hell),业界已有大量思考,方向集中在三点:
- 用户对依赖的控制力(control);
- crate 之间彼此的兼容性(compatibility);
- 维护"各版本互相兼容"所需的可维护性负担(maintainability)的降低。
但 RFC 指出,很少有工作聚焦在如何改进条件编译以扩展一个 crate 能向后支持的编译器版本数量。这一点在 Rust 获得 RFC 2483 所提出的LTS(长期支持)通道之后将变得尤为关键。
当时现有的条件编译支持是不足的。虽然借助version_check这样的 crate 可以检查编译器是否高于某个版本,但这类设施并不符合人体工学:
- 需要设置一个
build.rs文件,并且事先声明你关心哪些版本; - 这些工具无法在不执行"金丝雀构建"(canary builds,即编译一个带有
use ::std::some::path;的小程序)的情况下判断某个路径是否存在,反而强迫你去了解该路径是在哪个版本引入的。
RFC 的作者认为"我们可以做得更好":把工具直接内建到语言里。借助提案中的两个特性,让库作者在保留兼容性与支持更多编译器版本这两件事上做到"无痛",并赋予作者对"支持什么、不支持什么"的控制力。
该机制还有一个次要但实用的用例:绕开编译器 bug——通过检查当前是否处于某个特定版本来规避问题。RFC 中以rust-lang-nursery/error-chain的 issue #101 作为真实案例。
用户指南(Guide-level explanation)
#[cfg(accessible($path))]:按路径可达性条件编译
设想这样一个场景:我们希望优先使用标准库的Iterator::flatten方法(如果它已经存在,例如在某个新版本 Rust 中引入),否则回退到Itertools::flatten。RFC 给出了完整可运行的代码:
#[cfg(accessible(::std::iter::Flatten))] fn make_iter(limit: u8) -> impl Iterator<Item = u8> { (0..limit).map(move |x| (x..limit)).flatten() } #[cfg(not(accessible(::std::iter::Flatten)))] fn make_iter(limit: u8) -> impl Iterator<Item = u8> { use itertools::Itertools; (0..limit).map(move |x| (x..limit)).flatten() } // Even better fn make_iter(limit: u8) -> impl Iterator<Item = u8> { #[cfg(not(accessible(::std::iter::Flatten)))] use itertools::Itertools; (0..limit).map(move |x| (x..limit)).flatten() } fn main() { println!("{:?}", make_iter(10).collect::<Vec<_>>()); }这段代码的行为是:
- 如果路径
::std::iter::Flatten存在,编译器将编译第一个版本的make_iter; - 如果路径不存在,编译器将编译第二个版本的
make_iter。
由此带来的结果是:你的 crate 在较新版本的 Rust 上会使用Iterator::flatten,在旧编译器上则使用Itertools::flatten。作为 crate 作者,当用户使用更新版本的 Rust 时,你无需发布任何新版本,编译器就会自动切换到 libstd 版本。一旦标准库稳定了iter::Flatten,未来的 stable 编译器就会自动开始使用第一个版本的函数——这正是"未来兼容"的力量。
上面的第三个变体("Even better")展示了更优雅的写法:函数体内部用局部use配合cfg(not(..))引入回退依赖,函数签名保持完全一致。
accessible不仅可以用于处理"新增特性破坏旧写法"这类问题(如Iterator::flatten与Itertools::flatten的冲突),也可以用于纯增量(strictly additive)的场景。RFC 以proptestcrate 为RangeInclusive增加支持为例:
// #[cfg_attr(feature = "unstable", feature(inclusive_range))] // ^-- If you include this line; then `cargo build --features unstable` // would cause nightly compilers to activate the feature gate. // Note that this has some inherent risks similar to those for // `#[cfg(nightly)]` (as discussed later in this RFC). macro_rules! numeric_api { ($typ:ident) => { ... #[cfg(accessible(::core::ops::RangeInclusive))] impl Strategy for ::core::ops::RangeInclusive<$typ> { type Tree = BinarySearch; type Value = $typ; fn new_tree(&self, runner: &mut TestRunner) -> NewTree<Self> { Ok(BinarySearch::new_clamped( *self.start(), $crate::num::sample_uniform_incl(runner, *self.start(), *self.end()), *self.end())) } } ... } } macro_rules! unsigned_integer_bin_search { ($typ:ident) => { pub mod $typ { use rand::Rng; use strategy::*; use test_runner::TestRunner; int_any!($typ); } } } unsigned_integer_bin_search!(u8); unsigned_integer_bin_search!(u16); ...这意味着proptest可以在不发布破坏性新版本的前提下,持续演进并添加对标准库RangeInclusive以及语言中x..=y语法的支持。proptest的使用者只需要切换到定义了::core::ops::RangeInclusive的编译器版本即可自动享受这一支持。
上述例子中accessible(..)引用的都是标准库中的路径。虽然这可能是一个较少见的用例,但你也可以用这个 flag 检测生态中某个第三方库的路径是否存在——例如当你需要同时支持某个库的较低 minor 版本,又希望为较高 minor 版本中的新特性添加支持时,这会非常有用。
#[cfg(version(1.27.0))]:按最小编译器版本条件编译
到目前为止,我们只改善了库特性的支持,而从未触及语言特性。通过检查当前 Rust 是否处于某个最低版本(或更高),我们可以有条件地支持这类新特性。例如:
#[cfg_attr(version(1.27), must_use)] fn double(x: i32) -> i32 { 2 * x } fn main() { double(4); // warning: unused return value of `double` which must be used // ^--- This warning only happens if we are on Rust >= 1.27. }注意这里使用的是cfg_attr:must_use属性只在 Rust 1.27 及以上版本才会被附加到double函数上,从而在旧编译器上不会产生"未使用的返回值"警告(因为旧编译器可能并不认识must_use)。
另一个例子是在 Rust 1.28 及以后选择系统分配器:
#[cfg(version(1.28))] // or: #[cfg(accessible(::std::alloc::System))] use std::alloc::System; #[cfg_attr(version(1.28), global_allocator)] static GLOBAL: System = System; fn main() { let mut v = Vec::new(); // This will allocate memory using the system allocator. // ^--- But only on Rust 1.28 and beyond! v.push(1); }这里展示了version(..)与accessible(..)的等价用法:#[cfg(version(1.28))]与#[cfg(accessible(::std::alloc::System))]均可用于门控use std::alloc::System;。
RFC 特别提醒:无法用#[cfg(version(..))]去门控这些特定特性,因为它们在该 RFC 的特性被稳定之前就已引入。也就是说,你不能在代码里写上version(1.28)并期待 Rust 1.28 编译器启用相应代码——因为 1.28 编译器本身根本不认识cfg(version(..))语法。不过,未来会有新特性可以借助这一机制来门控。
参考级规范(Reference-level explanation)
#[cfg(version(<semver>))]的语法与语义
在cfg属性中加入一个versionflag。其文法如下(\d表示0到9的任意数字):
flag : "version" "(" semver ")" ; semver : digits ("." digits ("." digits)?)? ; digits : \d+ ;语义:当且仅当一个 Rust 编译器认为自己的版本大于或等于semver字符串中的版本时,#[cfg(version(<semver>))]才被认为是激活的。"大于或等于"的定义遵循 Cargo 的caret requirements(脱字符号版本要求)规则(即^1.27语义,允许不高于下一个不兼容版本的所有升级)。文法允许省略 minor/patch:version(1.27)与version(1.27.0)都合法。
#[cfg(accessible($path))]的语法与语义
在cfg属性中加入一个accessibleflag。该 flag 的规则比version复杂得多,RFC 从四个维度展开规范。
语法形式
该 flag 要求在其括号内指定一个path 片段,且该路径不能放在字符串字面量里。$path必须以前导::开头,并且不得引用自身 crate 的任何部分(例如::crate::foo、::self::foo、::super::foo这类路径,若它们合法的话)。
这一限制的目的是防止用户针对自己 crate 的内部内容进行条件编译——因为在检查一个 item 上的accessibleflag 时,该 crate 本身还没有被编译完成。
基本语义
当且仅当$path指向的路径存在且是公开(public)的时,#[cfg(accessible($path))]才被认为是激活的。
#![feature(..)]门控的考量
在检查路径是否存在时,编译器会把feature gate 已启用的项视为存在。也就是说,如果某个路径当前仍处于#![feature(..)]门控之下,只要本地 crate 启用了对应 gate,accessible(..)就会返回"存在"。
注意:在关于#[cfg(nightly)]的章节以及用户指南中,RFC 都指出了将cfg(feature = "unstable")与accessible(..)组合、为"预期即将稳定"的不稳定特性添加条件支持时存在的风险。针对这类用法,RFC 明确了两点配套政策:
- 面向用户的
accessible(..)文档应当重点突出(包括给出示例)可能发生破坏的风险场景; - 稳定性政策更新为:因误用
accessible(..)造成的破坏属于"被允许的破坏"(allowed breakage)。因此,rust 各团队不会因为某个 crate 用accessible(..)门控了某特性、而该特性后来被破坏,就推迟发布或撤销该特性的稳定化。
这一政策修订在仓库中得到了延续印证:text/1105-api-evolution.md 与 text/1122-language-semver.md 两篇 RFC 都以 Amendment 形式正式记录了"accessible(..)的误用破坏不在稳定性承诺覆盖范围内"这一条款,并提示读者"请参阅 RFC 2523 以了解更多细节"。
固有实现(inherent implementations)
如果一个路径指向某个固有实现(inherent impl)内部的 item,那么只要存在任何一种泛型参数的配置能导出该 item,该路径即被视为存在。若要检查某个类型构造器应用了特定具体类型序列后 item 是否存在,可以使用::foo::bar::<T>::item这种带显式泛型实参的语法。
字段(Fields)
accessible(..)还可以引用struct、enum与union的字段。假设foobarcrate 中有如下定义:
pub struct Person { pub ssn: SSN, age: u16 } pub enum Shape<Unit> { Triangle { pub sides: [Unit; 3] }, ... } pub union MaybeUninit<T> { uninit: (), pub value: T }那么可以这样引用它们:
#[cfg(all( accessible(::foobar::Person::ssn), accessible(::foobar::Shape::Triangle::sides), accessible(::foobar::Shape::MaybeUninit::value) ))] fn do_stuff() { ... }注意:Person的age字段是私有的,因此不能出现在accessible(..)中;三个被检查的字段都是pub的。
宏(Macros)
在判定路径是否可访问时,**bang 宏、derive 宏、各类属性(包括内置属性、用户提供属性以及潜在的 derive helper 属性)**都会被纳入考量。
cfg_attr与cfg!同样受益
以上所有规范同样适用于#[cfg_attr(..)]属性以及特殊的宏cfg!(..)——version(..)和accessible(..)都会被加入这两者。这意味着三种使用位置的行为是一致的:
#[cfg(version(1.27))]:编译期剔除 item;#[cfg_attr(version(1.27), must_use)]:编译期决定是否附加属性;if cfg!(accessible(::std::iter::Flatten)) { ... }:运行时/编译期布尔表达式。
潜在缺点(Drawbacks)
一个反对论点是:假设标准库移除了某个不稳定项,如果所有人都通过#[cfg(accessible(..))]来使用它,那么这种移除可能"不会被注意到"。
增量垃圾代码及其收集
有时 feature gate 永远无法稳定,而是被废弃。这种情况虽然不常发生,但一旦发生,在#[cfg(accessible(::std::the::obsoleted::path))]下条件编译的代码就会变成无处安放的垃圾代码。随着时间的推移,这些垃圾会累积到不容忽视的规模。
不过,如果 Rust 按照 RFC 2483 的风格提供 LTS 通道,那么当 LTS 版本切换时,就有机会对"不会被再使用的定义"执行某种"垃圾收集"。
设计权衡与备选方案(Rationale and alternatives)
为什么accessible(..)优于用version(..)做代理
accessible机制的首要理由是:当你想要支持某个库特性时,你脑海中想到的是某个路径,而不是它被加入的版本号。例如你想用ManuallyDrop,直接问"它是否存在"即可。version只是特性的一种代理(proxy)。与其通过间接方式去探测我们想要的路径是否可用,不如直接检查该路径本身是否存在。这样用户就不必去查该特性的最低版本号。
你可能认为version(..)已经包含了accessible(..)的能力。但 RFC 论证并非如此:在启用 libstd 中某个路径所需的feature = "unstable"特性时,我们还不知道该路径将在哪个最低版本下获得支持。如果用version(..)去支持它,可能需要多次更新最低版本号——哪怕只更新一次,你就得发布新版本的 crate。而如果改用accessible(..),除非路径名中途改变,否则一次都不用发布。
accessible(..)还支持一个version(..)无法覆盖的用例:检查原子类型的支持情况,例如accessible(::std::sync::atomic::AtomicU8)。这个机制可以取代当时拟议的#[cfg(target_has_atomic = "..")]构造。
为什么强制路径以::开头(禁止相对路径)
强制accessible(..)内所有路径以::开头的原因在于:如果允许相对路径,用户写出accessible(self::foo)就可能构造出这样的循环依赖情形:
#[cfg(accessible(self::bar)] fn foo() {} #[cfg(accessible(self::foo)] fn bar() {}绕过该问题的一种办法是在 cfg 剥离之前先收集所有 item,但这在**阶段分离(stage separation)**上可能引发问题。因此 RFC 选择用一个简单的语法检查来杜绝此问题。
另一种让相对路径可行的方案是:为accessible(..)使用与use不同的解析算法——先语法性地拒绝self::$path、super::$path和crate::$path,然后让解析算法通过"不考虑本地 item、假定bar与foo是 crate"来处理这类情形:
#[cfg(accessible(bar)] fn foo() {} #[cfg(accessible(foo)] fn bar() {}虽然这样能让accessible($path)更符合人体工学(省掉两个字符),但在uniform_paths体系下,很可能给某些"以为bar和foo指向本地 crate"的用户带来意外——因为必须借助另一个 crate 才能观察出差异,用户无法立即分辨哪个是哪个。
另外值得注意:要求前导::的绝对路径与"不要求前导::"是完全向前兼容的。如果未来发现该限制确实构成问题,可以随时移除它。
语法选择:accessible(..)还是accessible = ..
RFC 需要在前缀式accessible(..)与赋值式accessible = ..之间做选择。最终选择前者的理由是:前者的语法看起来更像一个提问/查询(question/query),而后者更像一个对事实的陈述(statement of fact)。
此外,若要支持accessible = $path,需要扩展meta 文法(meta grammar)。RFC 论证了这一改动本身的合理性:观察到serde_derive这类 crate 允许用户写#[serde(default = "some::function")],通过修改文法可以让用户改写成#[serde(default = some::function)]。但就本提案而言,accessible($path)才是最优记号。
如果真要扩展 meta 文法,可以把:
named_value : "=" lit ; meta_or_lit : meta | lit ; meta_or_lit_list : meta_or_lit "," meta_or_lit_list ","? ; meta_list : "(" meta_or_lit_list ")" ; meta : path ( named_value | meta_list )? ;改成:
lit_or_path : path | lit ; named_value : "=" lit_or_path ; meta_or_lit : meta | lit ; meta_or_lit_list : meta_or_lit "," meta_or_lit_list ","? ; meta_list : "(" meta_or_lit_list ")" ; meta : path ( named_value | meta_list )? ;命名之争(the bikeshed)
为 flag 命名时,考虑过的备选包括:reachable、path_accessible、usable、can_use、path_exists、have_path(或has_path)、have、have_item、path_reachable、item_reachable、item_exists。
accessible(最终选择):它清晰地传达了意图,同时又足够简短以保持人体工学。虽然path_accessible可能更无歧义,但从accessible(::std::foo::bar)的上下文看,因为参数是一个路径而非其他东西,所以"我们谈论的是路径"这一点是明确的。
reachable:reachable是accessible的同义词,而且短一个字符。但它在代码中往往有不同含义,例如std::hint::unreachable_unchecked、std::unreachable。综合来看,选择accessible是更直观的选项。
usable/can_use:它们可能暗示用户"只有能use $path;的东西才能放进去"。但你可以写#[cfg(accessible(::foo::MyTrait::my_method))——这不可能写成use ::foo::MyTrait::my_method;。该论点同样适用于其他关联项、固有方法以及 struct 字段。
has_path/have_path:这一候选存在"拥有(having)"含义模糊的问题——它没有说明究竟指"可访问且公开"还是"可用"。如前所述,名字里带path也有点冗余,因为::std::bar显然是一个路径。另一个小瑕疵是have还是has不好定夺,取决于主语被视为什么(如果把模块视为"它",就该用has)。不过has_path有一个优势:在clang编译器中有先例——用户可写#if __has_feature(cxx_rvalue_references)或__has_feature(c_generic_selections)。另一个好处是has_前缀为引入has_path、has_feature、has_$thing的一族机制留下了空间。
version(..)的语法与命名
version(..)之所以必要,是因为它用于支持语言特性(而非库特性)的条件编译,且如前所述,version(..)并不包含accessible(..),二者是互补机制。
version(..)特有的一大问题是它可能过于 rustc 专属。对于 rustc 以外的 Rust 实现来说,配合这种版本编号可能很困难,因为库会针对 rustc 的发布编号进行编译。不过其他实现完全可以追随 rustc 的编号方式及其提供的特性。这大概不算太不合理——可以预期 rustc 是参考实现,其他实现会落后于它。GHC 与各种替代 Haskell 编译器的经验正是如此。
参数语法之争:versionflag 大致有两种写法:
version = "<semver>"version(<semver>)
语法 2 在当时的#[cfg(..)]中是一个错误,可以用如下代码验证:
// error[E0565]: unsupported literal #[cfg(abracadabra(1.27))] fn bar() {} ^^^^但属性文法在技术上其实是:
attribute : "#" "!"? "[" path attr_inner? "]" ; attr_inner : "[" token_stream "]" | "(" token_stream ")" | "{" token_stream "}" | "=" token_tree ;特别注意#[my_attribute(<token_stream>)]是文法中的合法产生式,因此我们完全可以支持#[cfg(version(1.27.0))]。
既然语法 2 可行,RFC 最终选择它,理由(引用自 @eddyb)是:使用flag = ".."语法的 cfg flag都是静态的,而非动态的。换言之,cfg(x = "y")的语义是在一个预先确定的固定集合内做成员测试,这个集合也可以通过rustc --print=cfg查看。用户从其他cfg(flag = "..")flag 的工作方式可能推断出version = ".."是精确匹配版本——但这并不是本 RFC 的解释。选择语法 1 的一个理由是version(..)看起来像列表。
属性命名之争:其他可能的名称包括rustc_version、compiler_version、min_version。RFC 选择当前命名是因为它足够清晰且简短。不过min_version也是值得考虑的备选,因为它直接传达了该 flag 的>=本质。
至于<semver>语法,未来也可以调整,例如写成version(>= 1.27),甚至支持精确匹配(==)、低于某版本(<=),以及 Cargo 提供的 tilde requirements 和 wildcard requirements。但作为第一版迭代,version(1.27.0)简单且覆盖了绝大多数用例。
与version_checkcrate 的对比
使用version_checkcrate 可以借助build.rs实现条件编译。例如dbgcrate 就是这样做的:
// src/lib.rs: // ----------------------------------------------------------------------------- #![cfg_attr(use_nightly, feature(core_intrinsics, specialization))] // Deal with specialization: // On nightly: typeof(expr) doesn't need to be Debug. #[allow(dead_code)] #[doc(hidden)] pub struct WrapDebug<T>(pub T); use std::fmt::{Debug, Formatter, Result}; #[cfg(use_nightly)] impl<T> Debug for WrapDebug<T> { default fn fmt(&self, f: &mut Formatter) -> Result { use ::std::intrinsics::type_name; write!(f, "[<unknown> of type {} is !Debug]", unsafe { type_name::<T>() }) } } ... // build.rs: // ----------------------------------------------------------------------------- //! //! This build script detects if we are nightly or not //! extern crate version_check; fn main() { println!("cargo:rerun-if-changed=build.rs"); if let Some(true) = version_check::is_nightly() { println!("cargo:rustc-cfg=use_nightly"); } }version_check还支持用如下方式测试最低版本:
extern crate version_check; if let Some((true, _)) = version_check::is_min_version("1.13.0") { println!("cargo:rustc-cfg=MIN_COMPILER_1_13"); }RFC 的结论是:这相对而言过于啰嗦,而且要求你为#[cfg(..)]flag 发明临时的、crate 专属的名字(如MIN_COMPILER_1_13),这些名字在不同 crate 之间并不统一。你还得为想支持的每个版本重复一遍。这导致该机制的可扩展性远不如version(1.27)——后者简单且直观。
为什么不直接对 feature gate 做条件编译
version(..)和accessible(..)之外还有一种备选:允许用户查询某个 feature gate 是否已稳定。但 RFC 指出,允许这样做实质上会稳定化 gate 的名字,而这是历史上 Rust 一直避免的。此外,accessible(..)更直观——自然的想法是"通过它的路径(如何使用它)来思考一个特性",而非通过有时略显任意的 feature gate 名称。
既有先例(Prior art)
Rust 生态:version_check与rustc_version
如前所述,version_checkcrate 为该 RFC 所期望的条件编译提供了先例,此外还有rustc_versioncrate。二者合计拥有 18 + 67 个直接反向依赖,这说明该能力既被需要也被实际使用。
Haskell GHC
使用 Glasgow Haskell Compiler(GHC),可以通过其自带的预处理器进行条件编译:
{-# LANGUAGE CPP #-} module Main where version :: String #if __GLASGOW_HASKELL__ >= 706 version = "Version 7.0.6" #else version = "Below." #endif main :: IO () main = putStrLn versionClang
clang编译器提供了一整套特性检查宏,可以用它检查某个特性、扩展或属性是否受支持。例如:
#if __has_feature(cxx_rvalue_references) // This code will only be compiled with the -std=c++11 and -std=gnu++11 // options, because rvalue references are only standardized in C++11. #endif这相当于 Rust 中检查某个 feature gate 是否存在。Clang 还支持检查某个include 是否会成功:
#if __has_include("myinclude.h") && __has_include(<stdint.h>) #include "myinclude.h" #endif这与accessible($path)的精神相似。
悬而未决的问题(Unresolved questions)
RFC 明确指出:可选 Cargo 依赖(optional cargo dependencies)的能力不在本 RFC 范围内。剩余未决问题如下:
accessible(..)在技术上是否可行?例如,如果 cfg 剥离(cfg-stripping)先于路径解析运行,实现起来可能很困难。@eddyb 对此表示:好消息是,如今我们应当能在宏展开(macro expansion)期间解析它。坏消息是,我不知道早期的稳定性检查会有多难——不过,不,我们应该能轻松地在某处添加一个
DefId -> Option<Stability>方法,只要有足够的信息来对照 feature gate 进行检查(假设本地 crate 的#![feature(...)]集合在 cfg 剥离时已知)。是否允许在
accessible(..)中引用类型定义的字段?(虽然规范章节已给出字段示例,但 RFC 仍将其列为未决问题。)"编译器认为自身版本"的确切含义。参考级规范说"当且仅当一个 Rust 编译器认为自己的版本大于或等于……",但"认为自身(considers itself)"具体指什么并未被良好定义。例如,对一个周期中期的 nightly 编译器执行
rustc --version得到rustc 1.29.0-nightly (31f1bc7b4 2018-07-15),但 1.29.0 尚未在 stable 通道发布,那么version(1.29.0)对这个 nightly 是否激活?这个问题之所以重要,是因为在某个 1.29.0-nightly 编译器上某个特性可能尚未稳定,而几天后(在 1.29.0 进入 beta 或 stable 之前)该特性却稳定了。为了解决这个问题,大致有 3 种方案:
- 肯定回答(激活)。这有时会在使用 nightly 编译器时造成破坏。
- 否定回答,通过修改编译器中版本常量被提升的时机——即只有发布新的 stable 或 beta 编译器时才提升版本,nightly 编译器始终按最近 stable/beta 版本编号。这也意味着,对于
#[stable(feature = "foobar", since = "1.42.0")]的特性foobar,该特性只有在真正到达 stable/beta 后才可用。 - 与方案 2 相同,但将
rustc --version报告的版本与version(..)使用的版本分离。例如,若最近 stable 编译器是1.42.0,version(..)使用1.42.0,而rustc --version报告1.43.0-nightly。技术上可通过维护一个追踪最近 stable/beta 编译器为x.y.z的版本常量来实现,--version则报告x.(y + 1).0-nightly。
支持方案 2 或 3 的一个论据是它们更讲原则:毕竟在到达 stable 或 beta 之前,我们并没有真正"稳定"任何东西。RFC 认为这一未决问题属于实现细节,可在实现过程中解决。
未来可能的扩展(Possible future work)
#[cfg(rust_feature(..))]
一个可能的未来扩展是允许用户检查某个rustcfeature gate 是否被启用。例如写#[cfg(rustc_feature(generic_associated_types))]来检查编译器是否支持 GAT(generic associated types)特性。
主要优点:比检查特定版本更直接。而且如前所述,clang正是采用这种思路。
缺点同样明显:
- feature gate 的名称并非总是命名得当,通常缺乏连贯的命名体系。编译器使用的特性名通常直接取自 RFC,因此要么接受这种随意的命名,要么引入一套体系。
- 允许在 stable 上依赖 feature gate 名称,要求我们对 feature gate 更加讲原则。例如
rustc(或任何其他 Rust 编译器)将无法在不改名的前提下移除 gate 或大幅改变其实现。这会给库团队和编译器团队带来额外负担。
#[cfg(has_attr($attribute))]
另一个可能的扩展是引入has_attr(..)特性:检查指定属性是否可用于其所在 item 上的cfg(或cfg_attr)指令。例如:
#[cfg_attr(have_attr(must_use), must_use)] fn double(x: i32) -> i32 { 2 * x }这允许代码在使用某属性之前检测其可用性,同时在该属性不存在时不编译失败。在cfg块中使用has_attr可用于条件编译那些"仅在某属性(如global_allocator)存在时才有意义"的代码;在cfg_attr块中使用则可在支持的编译器上附加属性,同时仍兼容不支持该属性的编译器。
如前所述,当前 feature gate 的名字通常不会出现在面向 stable 版本的代码中。允许代码按名称检测指定 feature gate 的可用性,将要求为这些特性承诺稳定的名称,并确保这些名称指向固定的功能集合,这需要额外的整理工作。然而,由于属性名本来就必须标准化,has_attr(..)不会遭遇同样的问题,因此它可能是更好的解决方案。
#[cfg(nightly)]
在本 RFC 的早期迭代中,曾包含一个#[cfg(nightly)]flag,但它后来被移除了。未来若需要,仍可再添加。RFC 为此保留了完整的设计备忘录。
技术规范:向cfg属性添加一个nightlyflag。当且仅当 Rust 编译器允许用户指定#![feature(..)]时,该 flag 才被认为是激活的。
缺点:组合nightly与accessible(..)的风险。设想某个流行库写了:
#![cfg_attr(nightly, feature(some_feature))] #[cfg(accessible(::std::foo:SomeFeature))] use std::foo::SomeFeature; #[cfg(not(accessible(::std::foo:SomeFeature)))] struct SomeFeature { ... }这种迁移构造的一个潜在危险是:一旦SomeFeature最终稳定,它可能以修改后的形态发布——包括方法名、类型签名或 trait 实现的变化。该问题只在组合nightly与accessible(..)(或nightly与version(..))时出现,但有破坏"在某个 stable 版本上工作的代码"的风险。缓解因素包括:
- 可以用路径检查
SomeFeature的方法是否可访问,从而降低风险; - 若 crate 作者在 CI 中包含对 nightly 工具链的测试,破坏可以在任何 crate 被破坏之前被发现,从而发布一个移除 nightly 特性或调整用法的补丁版本。剩余的问题是依赖 crate 的
Cargo.lock可能固定了该 crate 的补丁版本; - 用户应谨慎,除非相当确信该库不会发生重大变化,否则不要使用此机制。风险仍然存在,但它是**可选加入(opt-in)**的。
最终结论是:与feature = "unstable"(反向依赖可以退出)相比,nightly无法被退出(除非给 Cargo 增加执行此类覆盖的机制,但那会反模块化)。这是本提案暂不包含nightly的根本原因。
优点:包含#[cfg(nightly)]的一个理由是它在自身场景下很有用——基于 nightly/非 nightly 做条件编译,而不是在Cargo.toml中提供unstable特性。dbgcrate 当前正是用version_check提供这种自动化。
替代方案#![if_possible_feature(<feature>)]:作为#[cfg_attr(nightly, feature(<feature>))]的替代,可允许用户写#![if_possible_feature(<feature>)]。优点是意图非常直接。但以nightly方式实现已有version_check的先例,而且nightly还能通过any、not、all与其他 flag 组合。该替代方案同样存在前述问题。
命名:如果再次提出该 flag,它大概不会叫nightly,而是一个更贴合意图的名字:unstable_features。
仓库内相关 RFC 的交叉印证
RFC 2523 在后续 RFC 生态中留下了清晰的足迹,在 text 目录下可以找到多处直接引用:
- text/1105-api-evolution.md(API 演进)与 text/1122-language-semver.md(语言 semver)都在"Amendment"章节正式记录了
accessible(..)的误用破坏属于被允许的破坏,明确了其与稳定性承诺的边界。 - text/3143-cargo-weak-namespaced-features.md 在讨论
dep:弱命名空间特性时指出,开发者可能需要在cfg(any(feature="foo", feature="bar"))与cfg(feature="b")之间纠结,并明确预期未来cfg(accessible(::b))这类语法会帮助简化这一场景。 - text/3491-remove-implicit-features.md 在备选方案中提及"通过
#[cfg(accessible)]允许访问可选依赖特性"这一思路。 - text/3537-msrv-resolver.md 在讨论 MSRV 感知的依赖解析器时,将
cfg(version)视为"稳定后可以充当 polyfill 垫片"的短期收益,并如实记录cfg_accessible作为第一步"目前处于停滞状态"。 - text/2957-cargo-features2.md 也提到在相应语法稳定后使用
cfg(accessible(dep_name))的可能性。
这些交叉引用共同勾勒出该 RFC 从设计提案走向生态实践的全貌:它不仅是条件编译语法的扩充,更是 Rust 在"库作者兼容性"与"依赖管理"两条战线上持续演进的基石之一。
总结:RFC 2523 用两个看似简单的 flag 解决了一个长期困扰库作者的痛点。version(..)回答"编译器是否足够新",accessible(..)回答"这个路径是否存在且公开",两者与cfg_attr、cfg!组合后,让"旧编译器用回退实现、新编译器自动切换为标准实现"成为语言内建能力,无需build.rs、无需金丝雀构建、无需提前声明版本清单。理解这份 RFC 的语法细节与设计权衡,是驾驭 Rust 多版本兼容编程(MSRV 策略)的关键一步。
- 文档
【免费下载链接】rfcs
RFCs for changes to Rust
相关推荐
Rust By Example:深入解析 `cfg` 条件编译属性与 `cfg!` 宏
Rust By Example:深入解析 cfg 条件编译属性与 cfg! 宏 cfg 是 Rust 中实现 条件编译 的核心机制,它允许同一份源码根据目标平台
文档教程Rust 条件编译语法演进:从扁平 cfg 列表到递归 cfg pattern(RFC 194 深度解析)
Rust 条件编译语法演进:从扁平 cfg 列表到递归 cfg pattern(RFC 194 深度解析) cfg ... 是 Rust 条件编译的基石,允许开
文档在 Rust By Example 中掌握自定义条件编译:`--cfg` 标志与 `[cfg]` 属性的实战指南
在 Rust By Example 中掌握自定义条件编译: cfg 标志与 cfg 属性的实战指南 本文以 Rust By Example(RBE)仓库中 自定
文档教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考