深入理解 Rust 的可见性:为什么 pub 结构体不能被直接构造?#
在阅读关于 Rust 类型状态编程(Typestate Programming)的文章时,很多人会看到一个经典模式:通过 Builder 来构造对象。文章常常会强调:“不能直接生成一个 Foo 对象,必须通过 FooBuilder。” 这时,一个常见的疑问就冒了出来:
Foo不是被标记为pub的吗?既然是公开的,为什么不能直接构造它?难道不应该把Foo设为private才对吗?
这个疑问非常精准,它触及了 Rust 模块系统中一个极为核心且容易被混淆的概念:类型的可见性 与 字段的可见性 是两件不同的事。
本文将围绕这个问题,抽丝剥茧,彻底搞懂 Rust 的可见性规则,并揭示这种设计模式背后的深层逻辑。
一、pub 到底公开了什么?#
很多初学者会认为,pub struct Foo 意味着 Foo 这个结构体“完全公开”了,外部可以随意使用。但实际上,Rust 的可见性规则是精细化的:
- 结构体是
pub:这意味着 类型本身的名字 对外可见。外部模块可以引用这个类型,比如在函数签名中使用它。 - 字段不是
pub(默认私有):这意味着 字段的名字和值 对外不可见。
这两者叠加,会产生一个看似矛盾但实际非常符合逻辑的结果:外部代码知道 Foo 这个类型存在,但不知道如何把它“组装”出来。
我们来看下面这段典型的代码示例:
// src/lib.rs 或 src/foo.rs
/// Foo 类型是公开的,外部代码可以在函数签名中引用它
pub struct Foo {
// 字段没有 pub,是私有的
// 外部代码无法访问 inner,也无法使用 Foo { inner: 5 } 字面量构造
inner: u32,
}
/// Builder 也是公开的,提供唯一的构造入口
pub struct FooBuilder {
value: u32,
}
impl FooBuilder {
/// 公开的 new 方法,启动构建过程
pub fn new() -> Self {
FooBuilder { value: 0 }
}
/// 设置字段值的方法(消费 self,返回 Self,支持链式调用)
pub fn set_value(mut self, v: u32) -> Self {
self.value = v;
self
}
/// 唯一的“出口”:消费 Builder,生成 Foo 实例
/// 因为是同一个模块,所以可以访问 Foo 的私有字段 inner
pub fn into_foo(self) -> Foo {
Foo { inner: self.value }
}
}rust二、直接构造失败的原因#
在 Rust 中,构造一个结构体实例有两种常见方式:
- 使用结构体字面量:
Foo { inner: 5 } - 调用一个返回
Self的关联函数:Foo::new()
在类型状态编程的例子中,Foo 并没有提供 new 方法,而其字段 inner 又是私有的。因此,外部代码试图通过 Foo { inner: 5 } 构造时,编译器会直接报错:
// 外部代码,比如在 src/main.rs 或另一个模块中
fn main() {
// ❌ 编译报错:error[E0456]: field `inner` is private
// 即使 Foo 是 pub 的,也无法直接使用字段字面量构造
let f = Foo { inner: 42 };
// 这是唯一正确的做法:通过 Builder
let f = FooBuilder::new()
.set_value(42)
.into_foo();
}rust即使 Foo 是 pub 的,也没法绕过这个规则。因为 pub struct 允许你“看见”类型,但私有字段阻止你“触碰”内部结构。
三、如果 Foo 是 private,FooBuilder 还能访问吗?#
这取决于它们是否在同一个模块内。这是理解可见性的另一个关键点:模块是可见性的边界。
情况一:在同一个模块内(可以)#
如果 Foo 和 FooBuilder 定义在同一个文件中(属于同一个 mod),那么即使 Foo 没有 pub 修饰,FooBuilder 也能自由访问它。
// src/foo.rs (同一个模块)
struct Foo { inner: u32 } // 没有 pub,是私有的
pub struct FooBuilder;
impl FooBuilder {
pub fn into_foo(self) -> Foo {
Foo { inner: 42 } // ✅ 编译通过
}
}rust原因:私有性规则规定,一个模块内的项对其子模块是可见的,但在同一模块内更是畅通无阻。对于 FooBuilder 来说,Foo 就是“自己家里”的东西,不需要公开。
情况二:在不同的模块内(不可以)#
如果将它们放在不同的文件或 mod 中,那么 Foo 必须是 pub 的,FooBuilder 才能导入并操作它。
// src/foo.rs
struct Foo { inner: u32 } // 私有
// src/builder.rs
use crate::foo::Foo; // ❌ 编译报错:struct `Foo` is privaterust四、为什么文章依然把 Foo 标记为 pub?#
既然 FooBuilder 在同一个模块内无所谓,那为什么大多数示例代码还是要写成 pub struct Foo 呢?
原因是为了对外暴露 API。
因为外部调用者(比如 main 函数,或者依赖该库的其他 crate)最终需要拿到 Foo 这个类型。如果 Foo 是私有的,外部代码根本无法在变量类型、函数返回值或参数上使用它:
// 如果 Foo 是私有的,外部代码根本无法引用它
fn main() {
// ❌ 编译报错:找不到类型 Foo
let f: Foo = get_foo();
}
// 即使要封装成这样,返回值类型 Foo 也必须是公开的
// 否则外部代码无法声明这个变量
fn get_foo() -> Foo {
// ...
}rust为了让外部世界知道“我们返回了一个 Foo 类型的数据”,Foo 必须是 pub 的。而为了让外部无法随意伪造一个 Foo,我们把它的字段设为私有。
这样既保证了类型的公开可见,又保证了构造过程的绝对受控。
五、类型状态编程:更进一步的实战#
上面讲的是 Builder 模式的基础,而“类型状态编程”更进一步:它利用类型系统来编码对象的状态,让不合法的操作在编译期就被阻止。
如果我们把刚才的 FooBuilder 改造成带状态约束的类型,就能更清晰地看到为什么 Foo 必须是公开的——因为外部代码需要感知到不同的“状态类型”。
// 定义两个“空类型”(零大小类型),用来表示 Builder 的不同状态
pub struct MissingValue; // 尚未设置值
pub struct ValueSet; // 已经设置好了值
// Foo 本身仍然是公开的,字段私有的
pub struct Foo {
inner: u32,
}
// Builder 现在带有状态参数 S
// 初始状态是 MissingValue
pub struct FooBuilder<S> {
value: Option<u32>,
_state: std::marker::PhantomData<S>,
}
impl FooBuilder<MissingValue> {
/// 初始构造:处于 MissingValue 状态
pub fn new() -> Self {
FooBuilder {
value: None,
_state: std::marker::PhantomData,
}
}
/// 设置值:消费 MissingValue 状态,返回 ValueSet 状态
/// 注意返回值类型变了
pub fn set_value(self, v: u32) -> FooBuilder<ValueSet> {
FooBuilder {
value: Some(v),
_state: std::marker::PhantomData,
}
}
}
// 只有处于 ValueSet 状态的 Builder 才能调用 into_foo
impl FooBuilder<ValueSet> {
/// 唯一合法的出口:状态必须是 ValueSet
/// 同样,因为同一个模块,可以访问 Foo 的私有字段
pub fn into_foo(self) -> Foo {
// 这里的 unwrap 是安全的,因为状态保证了 Some
Foo { inner: self.value.unwrap() }
}
}
// 外部使用示例
fn main() {
// ✅ 正确流程:new -> set_value -> into_foo
let foo = FooBuilder::new()
.set_value(42) // 状态从 MissingValue 切换到 ValueSet
.into_foo(); // ✅ 编译通过
// ❌ 错误流程:忘了设置值就调用 into_foo
// 下面的代码无法编译,因为 FooBuilder<MissingValue> 没有 into_foo 方法
// let foo = FooBuilder::new().into_foo();
}rust在这个类型状态的版本中,into_foo 方法只存在于 FooBuilder<ValueSet> 上。如果用户忘记调用 set_value,编译器会直接报错,因为 FooBuilder<MissingValue> 压根没有 into_foo 这个方法。
这时你会发现,如果 Foo 不是 pub 的,外部 main 函数连 let foo: Foo = ... 这种赋值都写不了。所以 pub struct Foo 是必需的。
六、可见性与设计模式的结合#
这种“公开类型,私有字段”的策略,正是 Builder 模式 和 类型状态编程 能够成立的基础。
- 强制流程:由于没有公开构造方法,外部代码只能通过
FooBuilder来生成Foo对象。 - 保障安全:
FooBuilder内部可以包含复杂的校验逻辑、状态转换,确保生成的Foo永远处于合法状态。 - 封装细节:
Foo内部的字段可能随时调整,只要into_foo方法的签名不变,外部代码就不会被破坏。
总结:可见性的三层逻辑链#
为了方便记忆,我们可以把这套逻辑总结为三条规则:
- 类型公开(
pub struct):为了允许外部代码在接口中使用这个类型。 - 字段私有(不写
pub):为了禁止外部代码直接实例化或修改内部状态。 - Builder 同模块:因为同一个模块内无视可见性,所以
FooBuilder可以自由操作Foo的私有字段。
七、结语#
当你下次再看到类似的示例代码时,请记住:pub 是用来控制“能不能提这个名字”的,而字段可见性才是控制“能不能碰这个东西”的关键。
Rust 的可见性系统不是一把简单的“公开/私有”开关,而是一套精准的访问控制工具。理解这一点,你不仅能看懂 Builder 模式,还能更深刻地理解 Rust 如何在不依赖运行时检查的情况下,通过类型系统构建出安全、健壮的 API。
希望这篇文章能帮你解开这个困惑。毕竟,在编程的世界里,能看见不代表能触摸,能公开类型不代表能随意构造 —— 这正是 Rust 强大表达能力的一个缩影。