Rust Restrictions 提案解读:用 `impl(crate)` 密封 trait、用 `mut(crate)` 限定字段可变作用域

原创2026-10-15 14:46:021,952 阅读
文章标签:文档

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 的作用域

两个关键点贯穿全文:

  1. impl 和 mut 的语法与 pub 完全同构:crate、self、super、in SimplePath 四种形式一应俱全,但不允许只写关键字而不带路径(例如不能裸写 impl trait Foo 或 mut foo: u8)。
  2. 不声明限制时,行为完全向后兼容:两种限制省略时,其作用域默认继承自 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: () 字段。

这套做法有两个致命问题:

  1. 占位条目是真实存在的:用户仍然可以对 enum 做穷尽匹配,占位变体绕不开;
  2. 占位条目连定义它的 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"的模拟有三点收益:

  1. 意图表达更清晰:文档可以自动展示"该 trait 的实现被限制",编译器能给出精确的错误定位(如上例);
  2. 杜绝"只实现了 Sealed 却忘了实现 Foo"的隐蔽错误:在旧模式里,很容易给某个类型实现 Sealed 却漏掉 Foo(或反之),这种错误极难察觉;新语法下只有一个 trait 需要关心,错误根本无从产生;
  3. 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 逐一列举:

  1. 类型无法实现 Deref:readonly 宏本身已经占用了 Deref,这排除了大量合法用法;
  2. 无法只让部分字段只读:Deref 是全有或全无的,要么整个类型被包装,要么不包装;
  3. 无法做到"仅在某模块内可变":Deref 是 trait,trait 实现不能只存在于某些位置;
  4. 对借用检查毫无帮助:通过 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 坦诚列出了三条缺点:

  1. 宏需要处理的新语法:所有宏解析器(尤其是过程宏)需要理解并转发 impl(...)/mut(...);
  2. 需要学习的新语法:语言又多了一类修饰符;
  3. 视觉混淆风险:trait impl(crate) Foo 虽然解析上无歧义,但与既有的 impl Foo 在视觉上过于相似,可能引起困惑。

备选方案部分则考察了三种替代设计,并说明了为何不采用:

  • 属性方案:把限制做成类似 #[non_exhaustive] 的属性(如 #[restriction(...)]),提案语法可作为这些属性的语法糖。但属性方案会导致错误推迟到编译流程更后期才报告,且宏作者无法只接受特定上下文中合法的语法。
  • 并入可见性参数:如 pub(crate, mut = self)。这不理想,因为限制并非在所有可见性出现的位置都合法,错误生成会更晚;且 enum 变体等在语义上不接受可见性,却接受限制——两种概念的位置集合并不重合。
  • 更简化的独立语法:既然当前语法较冗长,是否可以用 sealed 或 readonly 这样的专用关键字换取更少的灵活性与更多的简洁性?RFC 将其留作开放权衡,没有武断定论。

既有实践与生态参考(Prior art)

  • readonly crate:模拟"定义模块外不可变"的字段;但类型不能实现 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(...) 形成对称而完整的"可见性 + 能力"双维度控制体系。

若读者想深入了解本提案的上下文,本仓库提供了完整的演进链条:

登录后查看全文
rfcs