Rust Restrictions 提案解读:用 `impl(crate)` 密封 trait、用 `mut(crate)` 限定字段可变作用域
Rust Restrictions 提案解读:用 impl(crate) 密封 trait、用 mut(crate) 限定字段可变作用域
本篇文章围绕 Rust RFC 3323《Restrictions》展开,该 RFC 提议在 Rust 语言中引入两类"限制"语法——impl(...) 限制(限定 trait 只能在指定模块/作用域内被实现)与 mut(...) 限制(限定结构体字段只能在指定作用域内被修改)。通过完整继承 RFC 的动机、语法设计、行为语义与示例,并结合本仓库中可见性、#[non_exhaustive]、功能更新语法等相邻 RFC 的演进脉络,读者将理解"密封 trait"与"只读字段"问题的历史解法、本提案的语法设计与边界情况,以及它尚未解决的开放问题。
RFC 概览:给"可见性"之外的"能力"加上限制
Rust 的 pub 体系控制的是谁能看到(命名)一个条目。但"能看到"与"能对它做什么"是两回事。RFC 3323 的核心思想非常直白:既然 pub(crate) 能限制可见范围,为什么不能有对应的语法来限制能力范围?
提案给出了两条平行的新语法:
impl限制(ImplRestriction):写在 trait 定义上,例如pub impl(crate) trait Foo {},将"实现该 trait 的能力"限制在声明的作用域内。作用域之外任何impl Foo for ...都是编译错误。mut限制(MutRestriction):写在字段声明上,例如pub struct Foo(pub mut(crate) u8);或pub struct Foo { pub mut(crate) foo: u8 },将"修改该字段的能力"限制在声明的作用域内。作用域之外只可读、不可写。
该 RFC 于 2022-10-09 启动(RFC PR 编号 3323,Rust 仓库跟踪 Issue 编号 105077),现已合入 RFC 书籍的 text/ 目录存档(见 text/3323-restrictions.md)。需要说明的是,归档于 RFC 书籍意味着提案已通过 RFC 评审流程,但特性是否被实现、何时稳定,以跟踪 Issue 的进展为准——本文描述的是提案所设计的目标语法与语义,并非 Rust 当前稳定版本已有的能力。
两条限制的对应关系速览
| 限制类型 | 关键字 | 放置位置 | 限制的能力 | 默认行为 |
|---|---|---|---|---|
| ImplRestriction | impl |
trait 定义处(unsafe? 之后、trait 之前) |
在作用域外实现该 trait | 省略时继承 pub 的作用域 |
| MutRestriction | mut |
结构体/元组结构体/枚举变体字段处(Visibility? 之后) |
在作用域外对字段做可变使用 | 省略时继承 pub 的作用域 |
两个关键点贯穿全文:
impl和mut的语法与pub完全同构:crate、self、super、in SimplePath四种形式一应俱全,但不允许只写关键字而不带路径(例如不能裸写impl trait Foo或mut foo: u8)。- 不声明限制时,行为完全向后兼容:两种限制省略时,其作用域默认继承自
pub的可见性作用域,因此对现有代码没有任何影响。
动机:可见 ≠ 可以实现,可见 ≠ 可以修改
trait 可见即可实现的问题
当前 Rust 的规则是:一个 trait 在某个位置可见(可以被命名),就意味着你有能力实现它。但对库作者来说,"能被别人命名"和"希望别人实现"常常是两回事。
一个库常常只希望 trait 被自己定义的某些类型实现,这是出于多种合理原因:实现可能依赖私有细节、可能依赖 unsafe 不变量、可能需要保证泛型约束下的行为封闭。这种 trait 通常被称为 sealed trait(密封 trait),社区普遍的做法是把一个公共 trait 放到私有或受限模块里来"模拟"密封效果。动机一节指出:这种模拟本身说明需求是真实的,但模拟手段绕了一圈。
字段可见即可修改的问题
字段同理:当前规则下,一个字段可见就意味着你可以修改它。但这常常不是作者想要的——字段的语义正确性可能依赖其他字段的值。例如一个字段必须始终处于某个数值范围内,一旦外部可以随意写入,类型的不变量就被破坏了。
因此,将"修改字段的能力"限制在特定作用域内(同时保留作用域外的只读访问)是一种理想形态:既保留直接字段访问的便利,又守住类型不变量。
先建立概念:Restriction 是什么
RFC 在"面向用户的解释"(Guide-level explanation)部分给出了一个重要的概念框架:限制(Restriction)就是"编译器不允许你对一个类型做某件事"。按这个定义,Rust 程序员其实每天都在用限制:
- 可见性是一种限制:私有类型不可被外部使用,这是编译器在阻止你。
#[non_exhaustive]也是一种限制:它强制你在match中写通配臂(wildcard arm),防止外部穷尽匹配。
限制之所以强大,是因为编译器是执行的保证者:如果你用 transmute 之类的 unsafe 手段绕过限制,结果代码是**不健全(unsound)**的。换句话说,限制把"约定"升级成了"编译期强制"。
为什么需要语言内建的限制:回顾 #[non_exhaustive] 之前的日子
为了说明内建限制的价值,RFC 回顾了 #<a href="https://link.gitcode.com/i/844e722e82cdca341cf212681959f797" target="_blank">non_exhaustive] 诞生前的标准实践(相关演进可见 [text/2008-non-exhaustive.md 的历史背景):
- 在
enum上放一个#[doc(hidden)] __NonExhaustive变体; - 在
struct上放一个私有的non_exhaustive: ()字段。
这套做法有两个致命问题:
- 占位条目是真实存在的:用户仍然可以对 enum 做穷尽匹配,占位变体绕不开;
- 占位条目连定义它的 crate 自己也要处理:定义方每次构造 struct 都要多写一个
non_exhaustive: ()字段,匹配时也躲不开这个变体。
而 #[non_exhaustive] 内建之后,这些问题都消失了:编译器知道这个限制的存在,定义方完全不受影响,只有外部使用者需要遵守。这为 impl / mut 限制提供了直接的先例:把常见的、靠模式变通才能实现的需求,变成编译器原生支持的语法。
impl 限制:让 trait 只能在指定作用域内被实现
现状:官方 API 指南中的 sealed trait 模式
密封 trait 是如此常见,以至于 Rust API 指南专门给出了标准做法。RFC 引用了指南中的例子(此处为改造后的版本):
/// This trait is sealed and cannot be implemented for types outside this crate.
pub trait Foo: private::Sealed {
// Methods that the user is allowed to call.
fn bar();
}
// Implement for some types.
impl Foo for usize {
fn bar() {}
}
mod private {
pub trait Sealed {}
// Implement for those same types, but no others.
impl Sealed for usize {}
}
这个模式利用的是 Rust 允许"公共条目(Sealed)位于私有模块(private)中"这一规则:Sealed 虽然是 pub,但外部 crate 的用户无法命名它,因此也就无法通过 impl Foo for MyType 来满足 Foo: private::Sealed 这个超 trait 约束——密封由此达成。
RFC 直言:这堆代码不过是想说一句"你不能实现 Foo"。而真正的痛点在于,这种变通手段有隐蔽的失败模式——后文会详述。
新语法:pub impl(crate) trait Foo
如果限制是语言内建的,作者只需写:
pub impl(crate) trait Foo {
fn bar();
}
impl Foo for usize {
fn bar() {}
}
注意这里既没有 Sealed trait,也没有 private 模块。编译器从 impl(crate) 这个新语法直接得知:Foo 只能被定义它的 crate 内的类型实现。
作用域变体:与 pub 完全同构
正如 pub 接受模块路径,impl 同样接受,因此以下形式都合法:
| 形式 | 含义 |
|---|---|
impl(crate) |
限制在当前 crate 内 |
impl(self) |
限制在当前模块内 |
impl(super) |
限制在父模块内 |
impl(in path::to::module) |
限制在指定模块路径内 |
RFC 中给出的示例展示了 impl(super) 与 impl(in ...) 的用法:
pub mod foo {
pub mod bar {
pub(crate) impl(super) trait Foo {}
}
// Okay to implement `Foo` here.
impl bar::Foo for i8 {}
}
impl foo::bar::Foo for u8 {} // Uh oh! We cannot implement `Foo` here.
这里 Foo 定义于 foo::bar,限制作用域是 super(即 foo 模块)。因此 foo 模块内的 impl bar::Foo for i8 是合法的;而 crate 根作用域(foo 之外)的 impl foo::bar::Foo for u8 越界,产生编译错误:
error: trait cannot be implemented outside `foo`
--> $DIR/impl-restriction.rs:13:1
|
LL | pub(crate) impl(super) trait Foo {}
| ----------- trait restricted here
...
LL | impl foo::bar::Foo for u8 {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^
error: aborting due to previous error
注意错误信息的措辞:"trait cannot be implemented outside foo",并直接标注了"trait restricted here"——这正是内建限制在诊断质量上的优势。
内建限制的三重好处
RFC 明确指出,把限制内建进语言相比"私有模块 + 公共 trait"的模拟有三点收益:
- 意图表达更清晰:文档可以自动展示"该 trait 的实现被限制",编译器能给出精确的错误定位(如上例);
- 杜绝"只实现了
Sealed却忘了实现Foo"的隐蔽错误:在旧模式里,很容易给某个类型实现Sealed却漏掉Foo(或反之),这种错误极难察觉;新语法下只有一个 trait 需要关心,错误根本无从产生; - trait 保持可命名:旧模式的外部用户无法命名
Sealed背后的 trait,使其无法作为泛型约束(generic bound)使用,这一点在 RFC 的 Prior art 部分被再次点名。
mut 限制:让字段只在指定作用域内可变
其他语言早就有的只读字段
C++、C#、Java、TypeScript、Kotlin、Swift 都以某种形式提供了只读字段。RFC 认为 Rust 可以更进一步:不仅支持"只读字段",还能支持"只在某个模块内可变的字段"——换句话说,你可以修改它,但别人不能。
经典案例:time 库的 Time 结构体
RFC 以 time 库为例:
pub struct Time {
hour: u8,
minute: u8,
second: u8,
nanosecond: u32,
}
作者希望这些字段公开(用户可以直接读),但绝不希望用户改动它们——否则 hour 会被写成 32 这样的非法值,类型不变量随之瓦解。现状是:作者只能把字段保持私有、写一堆 getter 方法。
如果 mut(crate) 存在,则可以直接写:
pub struct Time {
pub mut(crate) hour: u8,
pub mut(crate) minute: u8,
pub mut(crate) second: u8,
pub mut(crate) nanosecond: u32,
}
含义是:这些字段在 time crate 内部可变,在外部只读。这省去了为已存在字段编写 getter 的必要。
RFC 还指出一个常被忽视的收益:直接字段访问有助于借用检查。编译器知道同一结构体的两个字段访问不会重叠(overlap),因此可以同时持有多字段的可变借用;但编译器无法仅凭 getter 的函数签名得知这一点。换句话说,mut(crate) 不只是一种"语法糖",它让借用检查器能利用字段级的别名信息。
与 readonly crate 的对比
生态中已有 readonly crate 试图模拟只读字段,但它有四个硬伤,RFC 逐一列举:
- 类型无法实现
Deref:readonly宏本身已经占用了Deref,这排除了大量合法用法; - 无法只让部分字段只读:
Deref是全有或全无的,要么整个类型被包装,要么不包装; - 无法做到"仅在某模块内可变":
Deref是 trait,trait 实现不能只存在于某些位置; - 对借用检查毫无帮助:通过
Deref间接访问字段,编译器照样无法获得字段不重叠的别名信息。
结论是:readonly 在某些场景有用,但绝非完整方案——这也正是语言内建 mut 限制的价值所在。
边界问题一:突变发生在哪里?
限制的关键问题随之而来:什么才算一次"突变"? 这远比看起来复杂。先看最简单的例子:
let mut x = 5;
let y = &mut x;
*y = 6;
这里毫无疑问发生了突变,但"发生在哪一行"?是第二行(创建 &mut x)还是第三行(写入 *y)?
在简单例子里这无关紧要,但想象把可变引用传给函数、由函数内部修改:
fn foo<T>(x: &mut T, value: T) {
if random() {
*x = value;
}
}
let mut x = 5;
foo(&mut x, 6);
这里 x 在最后一行被可变借用,但值的实际修改发生在 if 块内部。如果按"逻辑修改点"来报错,那么:
- 错误必须等到单态化(monomorphization)之后才能生成——编译流程中这属于非常晚期的阶段,社区普遍厌恶后期错误;
- 而且,
foo内部是否真的修改x取决于运行时分支(random()),在编译期根本不可判定——函数签名里也没有声明这一点; - 若
foo实际没有修改x,则连错误都不该产生——这种"看函数实现决定错误"的方式破坏了函数签名的契约意义。
因此结论是唯一的:错误必须在创建可变引用的那一行生成。对应到上例,就是 foo(&mut x, 6); 这一行。这是从用户视角最合理的定义,也是 &mut 借用模型天然对应的位置。
边界问题二:内部可变性(interior mutability)不算突变
再看 Cell:
let x = Cell::new(5);
x.set(6);
x 没有声明 mut,也不需要——这正是内部可变性的定义。Cell::set 接收的是 &self 而非 &mut self,全程没有创建任何可变引用。
那么这里的"突变"算不算?RFC 的答案是:不算,内部可变性不被视为本限制下的突变。理由承接上文"错误必须在创建可变引用的位置生成"的结论:这里从未创建可变引用,因此无错可报。
但更深一层,这其实是一个有意的取舍:若要拦截内部可变性,唯一的办法是"对包含 mut 限制字段且带内部可变性的类型,连不可变引用也不允许取"——那意味着你永远无法持有这种类型的引用,这显然不可接受。因此:
接口若想连字段的内部可变性也一并限制,就应避免将其暴露为带私有可变性的公共字段。
即:mut 限制管的是"以 &mut 为媒介的显式突变",管不了 Cell/RefCell 这类内部通道。
边界问题三:struct 表达式被禁止(含 FRU)
既然 mut 限制最常见的用途是守护不变量,不变量就必须真正成立。考虑回 Time:如果允许外部写
Time {
hour: 32,
minute: 0,
second: 0,
nanosecond: 0,
}
不变量立刻被击穿——一天只有 24 小时(编号 0–23)。既然类型系统没有承载这个不变量,那么在这种场景下就无法靠类型系统兜底,唯一的选择是:在字段存在 mut 限制的作用域内,禁止对包含 mut 限制字段的类型使用 struct 表达式构造。
这条禁令同样覆盖功能更新语法(functional update syntax,FRU,即 ..base 形式),因为不变量可能依赖其他字段的值——即使某个受限字段没有在字面量里直接写出(它来自 ..base),构造仍然可能破坏不变量。这一点与本仓库中 RFC 736(text/0736-privacy-respecting-fru.md)的既有结论一脉相承:FRU 本质上应被展开为完整字面量并遵守字段级隐私/限制规则。
边界问题四:enum 变体与 union 的特殊处理
注意"struct 表达式"这个名字具有误导性——它同样用于初始化 enum 变体和 union。对于 enum 和 union,禁令只作用于被构造的那个变体。例如:
pub enum Foo {
Alpha { mut(crate) x: u8 },
Beta { y: u8 },
}
// In another crate:
Foo::Beta { y: 5 };
在另一个 crate 中,Foo::Beta { y: 5 } 是合法的,因为 Beta 变体的字段没有 mut 限制。而 Foo::Alpha { x: 5 } 只有在与 Foo 同 crate 时才允许构造(此时 x 不受限,可以自由写入);跨 crate 构造 Alpha 则被禁止——因为此时 x 是受限的,构造即意味着写入。
参考级设计:语法与语义
语法扩展
RFC 基于 Rust Reference 中 struct 的既有语法,给出了极小的增量改动:
StructField :
OuterAttribute*
Visibility?
+ MutRestriction?
IDENTIFIER : Type
TupleField :
OuterAttribute*
Visibility?
+ MutRestriction?
Type
+MutRestriction :
+ mut ( crate )
+ | mut ( self )
+ | mut ( super )
+ | mut ( in SimplePath )
trait 定义同样需要一处平行改动:
Trait :
unsafe?
+ ImplRestriction?
trait IDENTIFIER
GenericParams? ( : TypeParamBounds? )? WhereClause? {
InnerAttribute*
AssociatedItem*
}
+ImplRestriction :
+ impl ( crate )
+ | impl ( self )
+ | impl ( super )
+ | impl ( in SimplePath )
概括而言:mut 与 impl 拥有和 pub 相同的语法形态,只是换了个关键字,且必须提供路径(不带路径的关键字单独使用不合法)。
行为定义与默认作用域
RFC 明确了行为语义:
pub的现状是"在声明作用域内可见;未声明作用域则处处可见"。这一行为被impl和mut完整继承:使用限制时,能力只在声明作用域内被允许。- 大多数情况下条目的默认可见性是私有,但
enum变体、enum字段和trait项默认是pub。impl和mut采用一致的默认策略:完全省略时,作用域继承自pub。这一选择既是使用上最方便的,也是向后兼容的硬性要求——否则现有代码会在新语法加入后改变含义。
两条限制各自的行为:
ImplRestriction存在时:该 trait 的实现只允许出现在指定路径内,作用域外的任何impl都是编译错误;MutRestriction存在时:该字段的可变使用只允许出现在指定路径内,作用域外的任何可变使用都是编译错误;- 构造限制:包含
MutRestriction字段的struct、union或enum变体,在"任一字段在本作用域内受限"时不得用struct表达式构造——即使该字段未在字面量中直接写出(即使用 FRU 时)也同样禁止。
编译器中的 "mutable use" 概念
"可变使用(mutable use)"并非本 RFC 凭空发明的新概念——它在编译器内部早已存在。RFC 指出,编译器中的 mutating-use 判定已经覆盖了所有相关情形:包括 ptr::addr_of_mut!、&mut 取引用、以及对字段的直接赋值;同时它天然把内部可变性排除在外(与上文 Cell 的讨论一致)。因此 RFC 不必在此重新形式化"什么是可变使用"的完整语义,直接复用既有实现即可。
与 trait 别名的交互
trait 别名(trait alias,见本仓库的 text/1733-trait-alias.md,即 trait Alias = Bound; 语法)不能被实现——它只是约束的别名。因此 impl 限制与 trait 别名之间不存在任何兼容性问题,无需额外设计。
缺点与备选方案
RFC 坦诚列出了三条缺点:
- 宏需要处理的新语法:所有宏解析器(尤其是过程宏)需要理解并转发
impl(...)/mut(...); - 需要学习的新语法:语言又多了一类修饰符;
- 视觉混淆风险:
trait impl(crate) Foo虽然解析上无歧义,但与既有的impl Foo在视觉上过于相似,可能引起困惑。
备选方案部分则考察了三种替代设计,并说明了为何不采用:
- 属性方案:把限制做成类似
#[non_exhaustive]的属性(如#[restriction(...)]),提案语法可作为这些属性的语法糖。但属性方案会导致错误推迟到编译流程更后期才报告,且宏作者无法只接受特定上下文中合法的语法。 - 并入可见性参数:如
pub(crate, mut = self)。这不理想,因为限制并非在所有可见性出现的位置都合法,错误生成会更晚;且enum变体等在语义上不接受可见性,却接受限制——两种概念的位置集合并不重合。 - 更简化的独立语法:既然当前语法较冗长,是否可以用
sealed或readonly这样的专用关键字换取更少的灵活性与更多的简洁性?RFC 将其留作开放权衡,没有武断定论。
既有实践与生态参考(Prior art)
readonlycrate:模拟"定义模块外不可变"的字段;但类型不能实现Deref、限制适用于全部字段、且只在定义 crate 内生效。原生只读字段在借用检查上的优势也无法从中获得。derive-getters与getset:两个 derive 宏,前者生成 getter,后者还能生成 setter。它们证明了"为常见行为减少样板代码"的巨大需求;getset允许显式设置派生方法的可见性,这与mut限制"可指定路径"的能力高度相似。- "公共 trait 置于私有模块":可模拟实现限制,但致命缺点是外部用户无法命名该 trait,导致其不能用作泛型约束;且当前诊断虽然技术上正确,对下游用户却不友好(正是本提案要改进的体验)。
- 其他语言的只读字段:C++、C#、Java、TypeScript、Kotlin、Swift 均有类似机制。
- "getter 无 setter"惯例:包括 Rust 在内的许多语言用户,长期通过"提供 getter 不提供 setter"来模拟只读字段,这本身就证明了需求的普遍性。
尚未解决的设计问题
RFC 明确列出了若干开放问题,它们不阻塞提案评审,但需要在实现/稳定化阶段解决:
1. "多余限制"lint 是否引入?
当一个限制的作用域与可见性同样严格或更宽松时(如 pub(self) 这种冗余写法),是否应该发警告?这个 lint 是否必须随本 RFC 一并决定?
2. 与 macro_rules! 匹配器的交互?
当前 macro_rules! 有 vis 匹配器(匹配可见性)。为每种限制各加一个匹配器恐怕不妥:
-
提案语法不能并入
vis匹配器——vis并不限制其后能跟什么 token,直接并入会破坏既有代码,例如:macro_rules! foo { ($v:vis impl(crate) trait Foo) => {} } foo!(pub impl(crate) trait Foo); -
一个通用的
restriction匹配器理论上可行,但限制并不是在所有位置都相同; -
mut_restriction、impl_restriction作为匹配器名又相对冗长。
3. 限制与稳定性的交互? 建议的方向是:可见性是条目的固有属性,限制也应当如此;未来若需指示"某个条目的限制何时被解除",可另行添加元数据。该设计交由语言团队按需决定,且不需要在稳定化之前作出决定——因为稳定性属性本身也尚未稳定。
4. in 语法是否允许?
mut(in path) / impl(in path) 与可见性的 pub(in path) 保持一致。若不引入,impl 场景会继续依赖旧变通方案;而对 mut 来说根本没有变通方案。in 形式在可见性中虽不常用,但一旦需要就非常有用。
5. struct 表达式是否应该被禁止?
需要权衡:在哪些场景下,人们希望"构造后禁止修改,但允许用未校验的值构造"?也许有更细粒度的设计空间。
6. 是否提供常见场景的简化语法?
例如 sealed 或 readonly 关键字,或完全不同的语法形态。
未来可能性
RFC 展望了若干自然的演进方向,均明确标注为"未来可另行设计",不构成本提案的承诺:
- 显式 sealed / exhaustive trait:未来可能出现真正的"密封/穷尽 trait",让其他 crate 可以依赖"实现列表是完整的"这一事实来利用一致性(coherence)。由于 trait 默认不密封,现在无需就此做决定。
- trait 项自身的可见性与限制:让 trait 内部的关联项获得自己的可见性/限制,例如无法被覆写的私有或默认 trait 项。
- set-once 字段:功能上等价于"真正只读"的字段——可以构造,但永不修改。本提案未包含它,因为用例尚不清晰,也没有立刻显而易见的语法。
- 未来 edition 改变默认值:例如让
pub field: Type默认"仅模块内可变"。RFC 认为这几乎不可能发生——破坏性太大,收益必须极其显著才值得。 impl(mod)语法:作为impl(self)的别名,可提高可读性。- 无路径的
impl/mut:若确有必要,可允许不带路径使用,行为与完全省略关键字相同。 struct/变体级别的mut:把mut放到整个struct或变体上,等价于每个字段都带相同限制,可避免重复书写。- 限制 trait 实现的使用:更进一步,限制"使用某个 trait 实现"的范围(不仅限制实现,还限制使用场景)。
结语:一个把"约定"变成"编译器强制"的提案
RFC 3323 的精髓可以概括为一句话:Rust 允许"公共条目放在私有模块里"这类技巧来模拟密封,但技巧终究是技巧——它让 trait 失去可命名性、让错误诊断变得隔靴搔痒、还埋下"实现了辅助 trait 却漏实现主 trait"的雷。impl(...) 与 mut(...) 把"限制能力的作用域"提升为一等语法,与 pub(...) 形成对称而完整的"可见性 + 能力"双维度控制体系。
若读者想深入了解本提案的上下文,本仓库提供了完整的演进链条:
- 可见性限制的源头:text/1422-pub-restricted.md(
pub(crate)/pub(in path)的原始设计); #<a href="https://link.gitcode.com/i/d51cda24b06074f2f46c88bc282705c1" target="_blank">non_exhaustive]作为"限制"类特性的先例:[text/2008-non-exhaustive.md;- 功能更新语法与字段隐私的既有裁决:text/0736-privacy-respecting-fru.md;
- trait 别名为何不受影响:text/1733-trait-alias.md;
- 早期对"密封 trait"作为语言能力的设想:text/1105-api-evolution.md;
- 语言变更必须经过 RFC 流程的说明:lang_changes.md,以及 RFC 的标准撰写模板:0000-template.md。