译者 | 平川
在过去的几年里,我一直从事自主移动机器人领域的研究,本文中的许多示例就源自该领域。我写这篇文章是为了探讨一下,除了众所周知的内存安全保障之外, 还有哪些**之处。具体来说,就是该语言如何帮助开发者从一开始就编写出更正确的软件,不犯常见的错误,使生成的代码有更强的防错能力。
不只是内存安全
在我与开发者们讨论 Rust 时,一个常见的情况是:那些没有投入大量时间研究这门语言的人往往会对其嗤之以鼻,而这通常发生在短暂且不成功的首次尝试之后。然而,那些能够克服初期并将其应用于实际项目的开发者,往往会对 Rust 非常欣赏。我至今仍然记得,两年半以前,当我们要启动一个大型的新项目时,将有不同背景的人聚集到一起在一个纯 Rust 代码库上进行开发的情景。
其中有一位 背景的同事,最初两周每天都在向我抱怨。但大约三四周后,情况发生了转变。他开始真正喜欢上了这门语言,现在甚至表示再也不想回头了。
这个例子或许能说明,为什么 Rust 在 Stack Overflow 的调查中始终名列****的编程语言之首。虽然毫无疑问,内存安全*非常重要,而且我在自己的项目中也受益匪浅,但这还不是全部原因。Rust 让编写从一开始就正确的软件变得容易许多,它能有效降低开发者引入错误的可能*,而且生成的代码在出现故障时会表现出更强的韧*。
从本质上讲,Rust 是一种相对比较简单的语言。人们普遍认为, Rust *难,学习曲线非常陡峭。这种看法虽然有一定的道理,但仔细考察其基本概念就会发现,其他语言中许多会增加复杂*的概念在 Rust 中根本不存在:没有垃圾回收、没有类、没有继承、没有传统的面向对象编程、没有空指针、没有函数重载,也没有类型强制转换。这种*简主义的设计理念,使得一些开发者将 Rust 的使用体验比作 语言。
中的带标签联合体,但在 Rust 中,它作为一项统一的一等语言特*而存在。
模式匹配与穷尽*
开发者必须使用和其他语言中 switch 语句类似的结构来匹配该枚举。通过匹配表达式,可以检查枚举值实际属于哪个变体,并执行相应的逻辑。关键在于,在特定的匹配分支内,匹配必须穷尽,也就是说必须显式处理每个变体,或者通过通配模式进行处理,以防止开发者无意中遗漏了某个情况的处理。
值或专门的标准库类型来实现这一概念。在 Rust 中,这是一个定义在标准库中的简单枚举类型,任何开发者都能在一分钟内实现它。
可防止意外访问。// 标准库中实现的 Option 类型。
避免了意外访问。例如,在我的机器人研究工作中,机器人可能处于执行任务状态,也可能处于空闲状态。若使用 Option 来建模这种行为,以确保代码仅对实际存在的任务进行操作。
使用枚举实现状态机
软件开发中无处不在。Rust 提供了一种直观的方法,即使用枚举来进行状态建模,其中每个变体代表一种状态,对于机器人而言,其状态可能包括 Uninitialized(意识到机器人的存在)、Initialized(已知位置)以及 ExecutingJob(同时拥有位置和任务信息)。
可防止意外访问;而要转换到新状态时,这样就不会构建出无效的状态。编译器会自动强制执行这些约束。
所有权:只能有一个
所有权是 Rust 中的一个核心概念,在其他主流语言中没有与之直接对应的概念。其规则非常简单:在任何时候,Rust 中的每个值都只有一个所有者,不多也不少。编译器会在编译时通过静态分析来强制执行这条规则。
生命周期管理
,因为所有权明确标识了每个值的所有者,以及所有权何时超出作用域。当所有者超出作用域时,它所拥有的值会被丢弃,内存会被释放,其他资源也会被释放。
所有权可以通过赋值或函数调用进行转移或“移动”。所有权会从原始所有者转移到新所有者。原始所有者将无法再访问该值。这种机制可以防止我所说的“双重使用”情况,即本应**存在的实体被意外地多次使用。
// 所有可以移动。机器人系统中,一个任务每次只能由一个机器人执行一次。假设有一个函数签名,其中有一个 job 参数按值传递一个 Job 对象;换言之,该函数获得了该任务的所有权。当把任务分配给机器人时,所有权便从调用代码转移到了函数中。
尝试将同一个任务分配给两个不同的机器人会引发编译时错误,因为**次赋值操作已将所有权从原始变量转移了出去。所有权转移就是这样在编译时优雅地防止了重复使用。
// 按值传递一个 job 并将其赋给机器人。队列// 将 job 移动到机器人里。// 这会导致编译错误,因为 new_job 已经移动了。
资源管理不限于内存
值的生命周期不仅能管理内存。通过 Drop trait 挂接 drop **,可以为超出作用域的值编写自定义行为。在机器人领域,像狭窄的走廊这样的物理空间可能一次只允许存在一台机器人。ZoneAccess 令牌类型可以表示进入此类区域的权限。,因此同一时间只能存在一个令牌。
// 则返回一个 ZoneAccess 令牌。// 当令牌被放弃,区域再次被标记为空闲。
通过为 ZoneAccess 实现 Drop 来自动释放该区域,便无需手动处理所有可能需要释放该区域的代码路径,例如机器人断开连接、状态变更或其他任何场景。当拥有 ZoneAccess 的机器人超出作用域时,令牌也会随之超出作用域,资源随之释放。这种模式能够自动防止所有代码路径中的资源泄漏,从而**地简化除内存之外的实际资源的管理。
那么语言的功能将受到**的限制。借用规则规定,在任何给定的时刻,一个值可以存在一个可变引用,或者存在任意数量的不可变引用,但不能同时存在这两种引用。
// 可变引用允许修改引用值。// 不可变引用只允许查看它。
这条规则对于内存安全来说至关重要,重要的是,借用不会转移所有权:只要引用存在,原始所有者仍保留所有权。
生命周期与借用机制协同工作,用于判断在程序执行的某个特定时刻,借用是否仍然有效。引用的生命周期不能超过其所引用的值的生命周期;这条规则对于内存安全来说至关重要,因为它能防止值被释放后仍对其进行引用。
可变生命周期从创建时开始,到销毁时结束。编译器利用这些信息进行生命周期检查。大多数情况下,无需显式标注生命周期,因为编译器会自动推断出来。但在某些情况下,则需要显式标注,例如使用 'a 这样的语法来表示命名生命周期。
将协议嵌入类型
将所有权、借用和生命周期结合使用,可以实现一种特别有趣的功能:在编译时将运行时协议嵌入到类型中。Rust 的主要序列化库 Serde 便是一个*具说服力的例子。
// 消耗一个通用序列化器,返回一个专用序列化器。序列化器模式
假设有一个用于序列化单个值的序列化器类型。该 Serializer 实现了针对各种值类型(整数、浮点数、枚举和结构体)的方法。serialize_struct 函数接受按值传递的 self,这意味着它会消耗该序列化器实例。调用此函数后,原始序列化器将无法再次访问。该函数返回一个 Serialize,它是从通用序列化器转换而来的一个序列化器,专门用于结构体序列化。
// 将字段序列化时,会通过可变引用传递 self。该方法可以被多次调用。一旦调用,序列化器// 便无法再次被使用,否则会引发编译错误。
SerializeStruct 类型实现了两个方法:erialize_field 方法接受 self 的不可变引用,它可能会在 字节大小的文件头。许多序列化库的文档都会警告用户,因为此类违规操作可能会导致程序崩溃或运行时错误。
在 Rust 中,这个警告是多余的,在那里它会被释放且无法再次被使用。尝试调用序列化器的方**引发编译时错误:“借用了已移动的值”。这一特*在编译时就消除了一整类的开发错误,从而节省了原本必须进行的大量的错误处理工作。
依赖开发者的自律,Rust 则采取了一种更稳健的方法。
加锁成功时,它会返回一个 MutexGuard,其生命周期与互斥锁本身绑定;该锁守卫的生命周期不会超过互斥锁。
互斥锁获得所有权。// 对互斥锁的引用,从而将其生命周期与互斥锁绑定在一起。
该操作会返回一个引用。其生命周期与锁守卫的生命周期绑定,而锁守卫的生命周期又与互斥锁的生命周期绑定。当 MutexGuard 被释放时,它会解锁互斥锁。
且只能获取引用!// 一旦锁守卫被丢弃,互斥锁即解除锁定。// 此外,当变量超出作用域时,互斥锁也会自动解锁。
这些特*相结合可以提供一个强有力的保证:在移除锁守卫之后,由于移除锁守卫会解锁互斥锁,所以在未持有锁的情况下,
代码的经验,我见过许多这样的情况:开发者无意中持有引用,在代码中传递这些引用,随后却将其遗忘,然后解锁互斥锁,从而引发难以察觉的并发错误。Rust 的设计方法彻底消除了这类错误。
Rust 中的泛型是“占位符”,不同的具体类型可以重复使用其定义。Option 类型和类似 Vec 这样的都体现了这种模式,即只需实现一次逻辑,就可以用于任何类型。泛型会在编译时通过单态化进行替换,针对每种类型特化生成特定的代码。这种代码消除了运行时开销,使得泛型代码与为具体类型编写的代码一样快。
泛型可以通过 trait(定义必需的行为)和生命周期进行约束,从而支持复杂的类型级编程。
虽然枚举提供了一种直观的状态建模方式,但在某些情况下,另一种模式会更实用,例如“类型状态”模式,它能在编译时将状态信息编码到类型系统中。
将状态编码为类型
这些类型与之前的枚举变体相对应,不过现在是作为**的类型存在。
一个针对状态 S 的 Robot 类型的泛型,实现块可以针对泛型机器人(用于访问名称等与状态无关的功能),也可以针对特定的类型,例如 Robot
// 机器人状态泛化。// 为所有状态实现方法。// 还可以为状态的特定特化形式实现方法。
类型安全的构建器
这种模式可以优雅地扩展到构建器上。使用 NoPosition、PositionSet、NoMap 和 MapSet 等标记类型作为泛型参数,可以实现构建协议的编译时强制检查。
// 构建器位置和地图状态泛化。// 最初未设置位置和地图,两者均可以通过方法设定。// PositionSet 和 NoMapSet 的特化版本仅允许设置地图,而不再允许设置位置。就可以使用了。
强制执行
要实现让人充满信心的软件仍然是一项富有挑战*的问题,需要严格的流程和昂贵的工具。然而,从实践角度来看,大多数开发人员并非在构建安全关键型应用程序,而只是在开发那些因用户依赖而必须可靠的应用程序。
Rust 通过其类型系统实现了其他语言难以企及的健壮*。所有权系统、借用规则、生命周期和泛型相互配合,能够在编译时而非运行时捕获整类的错误。
我认为,Rust 所提供的价值主张远不止于其广为人知的内存安全特*。通过所有权、借用、生命周期以及强大的泛型系统,开发者能够将不变量、协议和约束直接编码到类型系统中。在其他语言中可能导致运行时错误或**漏洞的问题,在 Rust 中会直接转化为编译时错误,从而使问题代码无法部署到生产环境。
机器人和自主系统示例展示了这些技术的实际应用:防止对*有资源的双重使用,在机器人断开连接时自动释放物理区域,确保正确遵循序列化协议,安全访问。
尽管学习曲线确实比较陡峭,但坚持下来的开发者会发现,这门语言从根本上改变了他们与正确*之间的关系。健壮*并不一定难以实现。Rust 证明了一个设计良好的类型系统能够捕获仅靠测试难以发现的错误,从而使那些无力承担形式验证、却因用户依赖而必须确保正确运行的项目,也能更轻松地开发出可靠的软件。
**:本文为 翻译,
今日好文推荐






发表评论