Rust 的独特之处不只内存安全:来自自主机器人的启示

点击查看原文>

在过去的几年里,我一直从事自主移动机器人领域的研究,本文中的许多示例就源自该领域。我写这篇文章是为了探讨一下,除了众所周知的内存安全保障之外,Rust 还有哪些独特之处。具体来说,就是该语言如何帮助开发者从一开始就编写出更正确的软件,不犯常见的错误,使生成的代码有更强的防错能力。

不只是内存安全

在我与开发者们讨论 Rust 时,一个常见的情况是:那些没有投入大量时间研究这门语言的人往往会对其嗤之以鼻,而这通常发生在短暂且不成功的首次尝试之后。然而,根据我的经验,那些能够克服初期学习曲线并将其应用于实际项目的开发者,往往会对 Rust 非常欣赏。我至今仍然记得,两年半以前,当我们要启动一个大型的新项目时,将有不同编程语言背景的人聚集到一起在一个纯 Rust 代码库上进行开发的情景。

其中有一位 C++ 背景的同事,最初两周每天都在向我抱怨。但大约三四周后,情况发生了转变。他开始真正喜欢上了这门语言,现在甚至表示再也不想回头了。

这个例子或许能说明,为什么 Rust 在 Stack Overflow 的调查中始终名列最受欢迎的编程语言之首。虽然毫无疑问,内存安全性非常重要,而且我在自己的项目中也受益匪浅,但这还不是全部原因。Rust 让编写从一开始就正确的软件变得容易许多,它能有效降低开发者引入错误的可能性,而且生成的代码在出现故障时会表现出更强的韧性。

内核简单

从本质上讲,Rust 是一种相对比较简单的语言。人们普遍认为, Rust 极难,学习曲线非常陡峭。这种看法虽然有一定的道理,但仔细考察其基本概念就会发现,该语言的核心在于数据类型以及操作这些数据类型的函数。其他语言中许多会增加复杂性的概念在 Rust 中根本不存在:没有垃圾回收、没有类、没有继承、没有传统的面向对象编程、没有空指针、没有函数重载,也没有类型强制转换。这种极简主义的设计理念,使得一些开发者将 Rust 的使用体验比作 C 语言,或者更新的 Zig 语言。

枚举:不只是整数

Rust 中的枚举与其他语言中的枚举有着根本性的区别,因为枚举变体可以存储数据。枚举的每个变体可以存储与其他变体不同的特定数据,也可以完全不存储任何数据。这一概念类似于 TypeScript 中的带标签联合体,但在 Rust 中,它作为一项统一的一等语言特性而存在。

模式匹配与穷尽性

要访问枚举中的数据,开发者必须使用和其他语言中 switch 语句类似的结构来匹配该枚举。通过匹配表达式,可以检查枚举值实际属于哪个变体,并执行相应的逻辑。关键在于,在特定的匹配分支内,编译器仅允许访问该变体关联的数据。匹配必须穷尽,也就是说必须显式处理每个变体,或者通过通配模式进行处理,以防止开发者无意中遗漏了某个情况的处理。

可选数据建模

在软件系统中,可选数据无处不在。许多编程语言通过空指针、nil 值或专门的标准库类型来实现这一概念。在 Rust 中,可选数据通过 Option 类型来实现,这是一个定义在标准库中的简单枚举类型,任何开发者都能在一分钟内实现它。Option 枚举有两种变体:None(不包含数据)和 Some(T)(封装一个泛型值)。

// 可选数据受到保护,可防止意外访问。// 只有当 Some 中确实存在数据时,才能访问该数据。// 标准库中实现的 Option 类型。enum Option<T> {  None,  Some(T)  }// 访问数据时必须匹配一个选项。match &robot.active_job {  None => return,  Some(job) => {    publish_job_update(job);  },}
复制代码

得益于枚举的数据访问规则,可选数据被保护了起来,避免了意外访问。例如,在我的机器人研究工作中,机器人可能处于执行任务状态,也可能处于空闲状态。若使用 Option 来建模这种行为,则在访问任务数据之前必须对值进行匹配,以确保代码仅对实际存在的任务进行操作。

使用枚举实现状态机

状态管理在软件开发中无处不在。Rust 提供了一种直观的方法,即使用枚举来进行状态建模,其中每个变体代表一种状态,并封装了与该状态相关的数据。对于机器人而言,其状态可能包括 Uninitialized(意识到机器人的存在)、Initialized(已知位置)以及 ExecutingJob(同时拥有位置和任务信息)。

// 每个变体都通过其数据来表示一个独特的状态。// 构建一个新状态需要该数据已存在。enum RobotState {   Uninitialized,   Initialized {       position: Position2d,   },   ExecutingJob {       position: Position2d,       job: Job,   }}
复制代码

这种方法提供了双向保护:数据处于错误状态时受到保护,可防止意外访问;而要转换到新状态时,必须提供必要的数据,这样就不会构建出无效的状态。编译器会自动强制执行这些约束。

所有权:只能有一个

所有权是 Rust 中的一个核心概念,在其他主流语言中没有与之直接对应的概念。其规则非常简单:在任何时候,Rust 中的每个值都只有一个所有者,不多也不少。编译器会在编译时通过静态分析来强制执行这条规则。

生命周期管理

Rust 能够精确追踪值的生命周期,因为所有权明确标识了每个值的所有者,以及所有权何时超出作用域。当所有者超出作用域时,它所拥有的值会被丢弃,内存会被释放,其他资源也会被释放。

所有权转移

所有权可以通过赋值或函数调用进行转移或“移动”。当数据被移动时,所有权会从原始所有者转移到新所有者。原始所有者将无法再访问该值。这种机制可以防止我所说的“双重使用”情况,即本应唯一存在的实体被意外地多次使用。

// 所有可以移动。let x = y // 所有权从 y 移动到 x
复制代码

在机器人系统中,一个任务每次只能由一个机器人执行一次。假设有一个函数签名,其中有一个 job 参数按值传递一个 Job 对象;换言之,该函数获得了该任务的所有权。当把任务分配给机器人时,所有权便从调用代码转移到了函数中。

尝试将同一个任务分配给两个不同的机器人会引发编译时错误,因为第一次赋值操作已将所有权从原始变量转移了出去。所有权转移就是这样在编译时优雅地防止了重复使用。

impl Robot {    // 按值传递一个 job 并将其赋给机器人。    fn assign_job(&self, job: Job) {...}}// 将下一个 job 移出队列。let new_job = jobs_queue.pop_front()?;// 将 job 移动到机器人里。robot_1.assign_job(new_job);// 这会导致编译错误,因为 new_job 已经移动了。robot_2.assign_job(new_job);error[E0382]: use of moved value: `new_job`
复制代码

资源管理不限于内存

值的生命周期不仅能管理内存。通过 Drop trait 挂接 drop 事件,可以为超出作用域的值编写自定义行为。在机器人领域,像狭窄的走廊这样的物理空间可能一次只允许存在一台机器人。ZoneAccess 令牌类型可以表示进入此类区域的权限。由于 ZoneAccess 既不能被复制也不能被克隆,因此同一时间只能存在一个令牌。

impl ZoneRegistry {    // 通过一个阻塞方法发起访问请求。一旦空闲,    // 则返回一个 ZoneAccess 令牌。    pub fn request_access(      &self,       zone: &ZoneId    ) -> ZoneAccess {...}}// 当令牌被放弃,区域再次被标记为空闲。impl Drop for ZoneAccess {    fn drop(&mut self) {         self.zone_registry.free(&self.id);    }}
复制代码

通过为 ZoneAccess 实现 Drop 来自动释放该区域,便无需手动处理所有可能需要释放该区域的代码路径,例如机器人断开连接、状态变更或其他任何场景。当拥有 ZoneAccess 的机器人超出作用域时,令牌也会随之超出作用域,资源随之释放。这种模式能够自动防止所有代码路径中的资源泄漏,从而极大地简化除内存之外的实际资源的管理。

借用:安全地引用数据

如果数据只能被拥有,那么语言的功能将受到极大的限制。借用提供了一种在不拥有数据所有权的情况下安全地引用数据的方式。借用规则规定,在任何给定的时刻,一个值可以存在一个可变引用,或者存在任意数量的不可变引用,但不能同时存在这两种引用。

// 可变引用允许修改引用值。let x = &mut y;// 不可变引用只允许查看它。let x = &y;
复制代码

这条规则对于内存安全来说至关重要,因为它消除了多个代码位置访问同一变量时可能引发的数据竞争。可变借用允许修改底层数据;不可变借用仅允许读取它。重要的是,借用不会转移所有权:只要引用存在,原始所有者仍保留所有权。

生命周期:这个数据是否还可以安全地使用?

生命周期与借用机制协同工作,用于判断在程序执行的某个特定时刻,借用是否仍然有效。引用的生命周期不能超过其所引用的值的生命周期;这条规则对于内存安全来说至关重要,因为它能防止值被释放后仍对其进行引用。

可变生命周期从创建时开始,到销毁时结束。编译器利用这些信息进行生命周期检查。大多数情况下,无需显式标注生命周期,因为编译器会自动推断出来。但在某些情况下,则需要显式标注,例如使用 'a 这样的语法来表示命名生命周期。

将协议嵌入类型

将所有权、借用和生命周期结合使用,可以实现一种特别有趣的功能:在编译时将运行时协议嵌入到类型中。Rust 的主要序列化库 Serde 便是一个极具说服力的例子。

struct Serializer {...}impl Serializer {    // 消耗一个通用序列化器,返回一个专用序列化器。    pub fn serialize_struct(        self,        name: &'static str,         len: usize    ) -> SerializeStruct {...}    ...}
复制代码

序列化器模式

假设有一个用于序列化单个值的序列化器类型。该 Serializer 实现了针对各种值类型(整数、浮点数、枚举和结构体)的方法。serialize_struct 函数接受按值传递的 self,这意味着它会消耗该序列化器实例。调用此函数后,原始序列化器将无法再次访问。该函数返回一个 SerializeStruct,它是从通用序列化器转换而来的一个序列化器,专门用于结构体序列化。

struct SerializeStruct {...}impl SerializeStruct {    // 将字段序列化时,会通过可变引用传递 self。该方法可以被多次调用。    pub fn serialize_field(        &mut self,         key: &'static str,         value: &str    ) {...}    // 调用 end 获取 self 所有权。一旦调用,序列化器    // 便无法再次被使用,否则会引发编译错误。    pub fn end(self) -> Result<(), Error> {...}}
复制代码

SerializeStruct 类型实现了两个方法:erialize_field 方法接受 self 的不可变引用,可针对多个字段反复调用;end 方法则用于结束序列化过程。end 函数承担着重要的职责,它可能会在 JSON 中写入一个闭合大括号,或计算包含结构体字节大小的文件头。许多序列化库的文档都会警告用户,不要在调用 end 之后再调用其他序列化函数,因为此类违规操作可能会导致程序崩溃或运行时错误。

在 Rust 中,这个警告是多余的,因为 end 函数会消耗 self。结构体序列化器的所有权会转移到 end 函数中,在那里它会被释放且无法再次被使用。在 end 之后,尝试调用序列化器的方法会引发编译时错误:“借用了已移动的值”。这一特性在编译时就消除了一整类的开发错误,从而节省了原本必须进行的大量的错误处理工作。

通过 Mutex 实现真正的受保护访问

另一个强有力的例子涉及通过互斥锁(mutex)对共享数据访问进行建模。传统方法将受保护的数据和互斥锁视为互相独立的实体,依赖开发者的自律,需要他们在访问数据前先锁定互斥锁。Rust 则采取了一种更稳健的方法。

基于所有权的保护

Mutex::new 方法按值传递数据,这意味着数据会被移入互斥锁中。数据的所有权发生转移,在互斥锁外部将无法再访问该数据。加锁成功时,它会返回一个 MutexGuard,其生命周期与互斥锁本身绑定;该锁守卫的生命周期不会超过互斥锁。

impl<T> Mutex<T> {    // 数据移入互斥锁。互斥锁获得所有权。    pub const fn new(t: T) -> Mutex<T> {}}// 锁定互斥锁时获得的锁守卫。它在内部保存着// 对互斥锁的引用,从而将其生命周期与互斥锁绑定在一起。pub struct MutexGuard<'a, T: ?Sized + 'a> {}
复制代码

访问受保护的数据需要解引用锁守卫,该操作会返回一个引用。其生命周期与锁守卫的生命周期绑定,而锁守卫的生命周期又与互斥锁的生命周期绑定。当 MutexGuard 被释放时,它会解锁互斥锁。

// 数据只能通过锁守卫访问,且只能获取引用!let data: &State = *guard;// 一旦锁守卫被丢弃,互斥锁即解除锁定。// 此外,当变量超出作用域时,互斥锁也会自动解锁。drop(guard);
复制代码

这些特性相结合可以提供一个强有力的保证:在移除锁守卫之后,对受保护数据的引用将不复存在;而且,由于移除锁守卫会解锁互斥锁,所以在未持有锁的情况下,将无法持有或使用对受保护数据的引用。

根据我处理重度多线程代码的经验,我见过许多这样的情况:开发者无意中持有引用,在代码中传递这些引用,随后却将其遗忘,然后解锁互斥锁,从而引发难以察觉的并发错误。Rust 的设计方法彻底消除了这类错误。

泛型:强大的占位符

Rust 中的泛型是“占位符”,不同的具体类型可以重复使用其定义。Option 类型和类似 Vec 这样的集合都体现了这种模式,即只需实现一次逻辑,就可以用于任何类型。泛型会在编译时通过单态化进行替换,针对每种类型特化生成特定的代码。这种代码消除了运行时开销,使得泛型代码与为具体类型编写的代码一样快。

泛型可以通过 trait(定义必需的行为)和生命周期进行约束,从而支持复杂的类型级编程。

结合泛型与类型安全的状态机

虽然枚举提供了一种直观的状态建模方式,但在某些情况下,另一种模式会更实用,例如“类型状态”模式,它能在编译时将状态信息编码到类型系统中。

将状态编码为类型

考虑三种表示机器人状态类型:Uninit(空的、大小为零的标记类型)、Init(包含位置数据)和 ExecutingJob(包含位置和任务数据)。这些类型与之前的枚举变体相对应,不过现在是作为独立的类型存在。

一个针对状态 S 的 Robot 类型的泛型,既能嵌入特定于状态的数据,又能保留机器人名称等通用数据。实现块可以针对泛型机器人(用于访问名称等与状态无关的功能),也可以针对特定的类型,例如 Robot

struct Uninit;struct Init {...};struct ExecutingJob {...};// 机器人状态泛化。struct Robot<S> {    state: S,    ...}// 为所有状态实现方法。impl<S> Robot<S> {    pub fn name(&self) -> &str {...}}// 还可以为状态的特定特化形式实现方法。impl Robot<Uninit> {    pub fn init(self, position: Position2d)         -> Robot<Init> {...}}impl Robot<Init> {    pub fn assign_job(self, job: Job)         -> Robot<ExecutingJob> {...}    pub fn position(&self) -> Position2d {...}}
复制代码

类型安全的状态转换

状态转换在具体类型上表现为方法实现。Robot 上的 init 函数获取 self 的所有权,接收位置数据,并返回 Robot。同样,Robot 可以转换为 Robot

位置访问仅可针对保证位置可用的状态 Robot 和 Robot 实现。若在 Robot 上调用 position() 方法,将导致编译时错误,并显示一条提示信息,说明哪些类型支持该方法。通过这种方式进行状态建模,在访问状态相关的数据时就无需运行时检查和错误处理了。

类型安全的构建器

这种模式可以优雅地扩展到构建器上。以需要初始位置和地图数据的 RobotSimulationBuilder 为例。使用 NoPosition、PositionSet、NoMap 和 MapSet 等标记类型作为泛型参数,可以实现构建协议的编译时强制检查。

struct NoPosition;struct PositionSet;struct NoMap;struct MapSet;// 构建器位置和地图状态泛化。struct RobotSimulationBuilder<P, M> {...}// 最初未设置位置和地图,两者均可以通过方法设定。impl RobotSimulationBuilder<NoPosition, NoMap> {    pub fn set_position(self, position: Position2d)         -> RobotSimulationBuilder<PositionSet, NoMap> {...}    pub fn set_map(self, map: Map)         -> RobotSimulationBuilder<NoPosition, MapSet> {...}}// PositionSet 和 NoMapSet 的特化版本仅允许设置地图,而不再允许设置位置。impl RobotSimulationBuilder<PositionSet, NoMap> {    pub fn set_map(self, map: Map)         -> RobotSimulationBuilder<PositionSet, MapSet> {...}}...// 位置和地图设置好以后,终结器就可以使用了。impl RobotSimulationBuilder<PositionSet, MapSet> {    pub fn build(self) -> RobotSimulation {...}}
复制代码

set_position 方法将 RobotSimulationBuilder 转换为 RobotSimulationBuilder。在这个类型上,set_position 方法没有实现,因此无法再次调用。build 方法仅存在于完全配置好的类型上。

这种设计避免了耗时的重复操作,强制执行了必需的配置,并消除了因构建器配置不完整而导致的运行时错误,所有这些验证都是在编译时完成的。

健壮性并不一定难以实现

要实现让人充满信心的软件仍然是一项富有挑战性的问题,需要严格的流程和昂贵的工具。然而,从实践角度来看,大多数开发人员并非在构建安全关键型应用程序,而只是在开发那些因用户依赖而必须可靠的应用程序。

Rust 通过其类型系统实现了其他语言难以企及的健壮性。所有权系统、借用规则、生命周期和泛型相互配合,能够在编译时而非运行时捕获整类的错误。

小结

我认为,Rust 所提供的价值主张远不止于其广为人知的内存安全特性。通过所有权、借用、生命周期以及强大的泛型系统,开发者能够将不变量、协议和约束直接编码到类型系统中。在其他语言中可能导致运行时错误或隐蔽漏洞的问题,在 Rust 中会直接转化为编译时错误,从而使问题代码无法部署到生产环境。

机器人和自主系统示例展示了这些技术的实际应用:防止对独有资源的双重使用,在机器人断开连接时自动释放物理区域,确保正确遵循序列化协议,以及保证共享数据的线程安全访问。

尽管学习曲线确实比较陡峭,但坚持下来的开发者会发现,这门语言从根本上改变了他们与正确性之间的关系。健壮性并不一定难以实现。根据我的经验,Rust 证明了一个设计良好的类型系统能够捕获仅靠测试难以发现的错误,从而使那些无力承担形式验证、却因用户依赖而必须确保正确运行的项目,也能更轻松地开发出可靠的软件。

声明:本文为 InfoQ 翻译,未经许可禁止转载。

原文链接:https://www.infoq.com/articles/practical-robustness-going-beyond-memory-safety-rust/


本文来源:InfoQ