两年的 Rust 使用感悟
我最近结束了一份工作,在过去两年里我用 Rust 编写了一个 B2B SaaS 产品的后台,所以现在是反思这段经历并写下来的理想时机。
目录
学习
我学习 Rust 的方式并不常见:阅读教程、书籍或编写小项目。恰恰相反,我把学习 Rust 作为构建 Austral 的研究工作的一部分。我会阅读有关 Rust 的论文和规范,有时我还会去 Rust playground 写一个小程序,以了解借用检查器在特定边缘情况下是如何工作的。
因此,当我开始使用 Rust 时,我的知识非常片面: 我对借用检查器的细枝末节有着百科全书式的了解,却无法告诉你如何编写 “Hello, world!”。我写过的最大的 Rust 程序大概只有 60 行代码,是用来实证测试特质解析是如何工作的。
结果还不错。一两天之内,我就提交了修改。问题是,当人们向我打听学习 Rust 的资源时,我却一无所知。
优点
我对 Rust 的总结是:它是更好的 Go,或者更快的 Python。它速度快,静态类型多,拥有 SOTA 工具和一个伟大的生态系统。它不难学。它是一门工业语言,而非学术语言,你可以用它提高工作效率。它是一种通用语言,因此可以构建后端、CLI、TUI、GUI 和嵌入式固件。它还不太适合的两个领域是网络前端(尽管你可以尝试)和本地 macOS 应用程序。
性能
Rust 速度很快。
你可以用任何语言编写慢代码:四元循环、n+1 查询和糟糕的缓存使用。但这些都是离散瓶颈。在 Rust 中,当你解决了瓶颈问题,程序就会很快。
而在其他语言中,性能问题往往是普遍存在的,因此在 Python 中,经常会出现这样的情况:你已经解决了所有的瓶颈问题,但一切仍然慢得令人无法接受。为什么?因为 Python 中的基元比 Rust 中的要慢 10 倍到 100 倍,而慢速基元的组合就是慢速程序。无论你如何优化程序,性能上限都是由语言本身设定的。
当你发现自己处于这种情况时,该怎么办呢?你可以纵向扩展硬件,结果就像那些每月在 AWS 上花费五位数来获得每秒四次请求的人一样。你可以不断更新你的依赖关系,并希望社区正在努力提高性能。你可以尽可能多地使用 async,因为你相信你的代码是 I/O 绑定的,但当发现你的代码实际上是 CPU 绑定的时,你就会失望了。
Rust 有很高的性能上限,这让你在编写默认速度很快的程序时无需过多考虑优化问题,而当你需要提高性能时,在触及性能上限之前,你有很大的优化空间。
工具
在我使用过的所有构建系统+软件包管理器中,Cargo 的 DX 是最好的。通常情况下,你会称赞程序的功能,而使用 Cargo,你会称赞它的缺失:没有麻烦,没有脚枪,没有你必须在愤怒中学习的传说,没有怪异,没有需要配置的环境变量,没有忘记激活的 virtualenvs。当你从文档中复制一条命令并运行它时,它能正常工作,而不会吐出一条无用的错误信息,这条信息只能作为一个唯一的标识符,用来查找相关的 StackOverflow/Discourse 线程。
DX 的许多优点都源于这样一个事实,即货物完全是声明性的,而非状态性的。举个例子:我在使用 npm 时总是会遇到这样的问题:当我更新 package.json
中的依赖关系时,运行类型检查器/构建工具/其他工具并不能发现变化。我得到了一个意想不到的错误,然后我就会想,哦,对了,我得先运行 npm install
。有了 cargo,如果你更新了 Cargo.toml
文件中的依赖关系,任何后续命令(cargo check
、build
或 run
)都会首先解析依赖关系、更新 Cargo.lock、下载任何缺失的依赖关系,然后运行命令。Cargo.toml
、Cargo.lock
、本地依赖库)的状态始终是同步的。
类型安全
Rust 拥有良好的类型系统:带穷尽性检查的总和类型、选项类型而非 null
、没有令人惊讶的类型转换。同样,就像工具一样,类型系统的好坏取决于少量的特性和无数的缺失,以及没有犯过的错误。
这样做的实际结果是,你对代码的健壮性有很高的信心。而在 Python 等语言中,你对代码的健壮性毫无信心,因此你需要花时间编写测试(以弥补类型系统的不足)并等待测试完成 CI(因为 Python 慢得要命)。在 Rust 中,你只需编写代码,只要能编译,几乎总能运行。因为很少会出现缺陷,所以写测试会让人觉得是件苦差事。
举个例子: 我其实不知道如何调试 Rust 程序,因为我从来没有调试过。我唯一需要调试的代码部分是 SQL 查询,因为 SQL 有很多不足之处。但 Rust 代码本身绝大多数都很扎实。出现错误时,通常都是概念性错误,即对规范的误解。这种错误在任何语言中都可能出现,但测试却会漏掉。
错误处理
有两种方法可以处理错误:传统的异常处理(如 Java 或 Python 中的异常处理)可以让错误处理代码保持畅通无阻,但却很难知道在特定程序点可能引发的错误集。在 Go 中,“错误即值”(Errors-as-values)使得错误处理更加明确,但代价是非常冗长。
Rust 有一个非常不错的解决方案,它将错误表示为普通值,但有语法糖,这意味着你不必慢吞吞地写上一千遍 if err != nil
。
在 Rust 中,错误是任何实现 Error
trait 的类型。然后是Result
类型:
enum Result<T, E: Error> {
Ok(T),
Err(E)
}
易错函数只返回一个Result
,例如
enum DbError {
InvalidPath,
Timeout,
// ...
}
fn open_database(path: String) -> Result<Database, DbError>
通过问号运算符 ?
,可以编写简洁的代码来处理错误。像这样的代码:
fn foo() -> Result<(), DbError> {
let db = open_database(path)?;
let tx = begin(db)?;
let data = query(tx, "...")?;
rollback(tx)?;
Ok(())
}
被转换为更为冗长的版本:
fn foo() -> Result<(), DbError> {
let db = match open_database(path) {
Ok(db) => db,
Err(e) => {
// Rethrow.
return Err(e);
}
};
let tx = match begin(db) {
Ok(tx) => tx,
Err(e) => {
return Err(e);
}
};
let data = match query(tx, "...") {
Ok(data) => data,
Err(e) => {
return Err(e);
}
};
match rollback(tx) {
Ok(_) => (),
Err(e) => {
return Err(e);
}
};
Ok(())
}
当需要明确处理错误时,可以省略问号操作符,直接使用Result
值。
借用检查器
借用检查器是 Rust 的头号功能:它让你在没有垃圾回收的情况下也能保证内存安全,它让 “无畏并发 ”成为可能。对大多数人来说,它也是学习和使用 Rust 过程中最令人沮丧的部分。
就我个人而言,我没有遇到过借用检查器的问题,但那是因为在我开始使用 Rust 工作之前,我已经设计并构建了自己的借用检查器。我不知道这是否是一种可扩展的教学方法。很多人说,他们必须经历一段与借用检查器斗争的漫长时间,慢慢地,他们的大脑会发现隐含的规则集,最终,他们可以在编写代码时不会触发难以理解的借用检查器错误。但这意味着很多人因为不喜欢与借用检查器对抗而放弃了学习 Rust。
那么,如何才能更有效地学习 Rust,而不需要构建自己的编译器,也不需要与借用检查器搏斗呢?
首先,了解借用检查器背后的概念、“别名 XOR 可变 ”规则、线性类型背后的动机等是非常有用的。遗憾的是,我并没有从头开始解释的经典资源。
其次,改变思维模式也很有用:很多人对借用检查器的思维模式是将其作为 Rust 的 “顶部”,就像在 C/C++ 代码库中运行的静态分析器,而编译器恰好内置了该分析器。这种思维方式会导致与系统对抗,因为你会想:我的代码是合法的,它进行了类型检查,所有类型都在那里,只有最后一层,即借用检查器,才会有问题。最好把借用检查器看作是语言语义的内在组成部分。借用检查必然发生在类型检查之后(因为它需要知道术语的类型),但借用检查失败的程序与未进行类型检查的程序一样无效。与其在头脑中用 C/C++ 实现一些东西,然后思考 “如何以满足借用检查器的方式将其转换到 Rust 中?”,不如思考 “如何在 Rust 的语义中,以线性和生命周期的方式实现目标?”。但这很难,因为这需要高度的流畅性。
当你对借用检查器驾轻就熟时,生活就会变得非常美好。与借款检查器 “作斗争 ”是不会发生的。当借用检查器抱怨时,要么是因为你正在做的事情有多个正交特性相互影响(如 async + 闭包 + 借用),要么是因为你正在做的事情太复杂,而错误是你必须简化的信号。通常情况下,借用检查器会将你引向具有机械共鸣、符合硬件工作原理的设计。当你设计出一种利用生命周期来实现完全无clone()
数据流的设计时,你会感到非常满意。当你设计出一个线性类型的应用程序接口时,线性使其很难被误用,你会对借用检查器心存感激。
异步
每个人都在抱怨异步。他们会抱怨它太复杂,或者引用 “有色函数 ”的陈词滥调。当把某件事情与某种模糊、抽象、理想的状态相比较时,抱怨它是很容易的;但是,async 的具体和现存的替代方案究竟是什么呢?
操作系统线程速度慢是一个约束条件。这不是偶然的,而是内在的,因为有内核,每次上下文切换时都要交换 CPU 状态和堆栈。操作系统线程永远不会快。如果你想构建高性能的网络服务,那么并发连接的数量和每个 CPU 的吞吐量就非常重要。因此,你需要一种能最大限度利用硬件资源的并发方式。
基本上有两种选择。
- 绿色线程,它为程序员提供了与操作系统线程相同的语义(好!),但往往会降低性能(坏!),因为你需要为每个线程的堆栈分配内存,还需要运行时调度程序来进行抢占式多任务处理。
- 如 Rust 中的无栈线程(stackless coroutines),它增加了语言语义和实现的复杂性(坏!),但性能上限却很高(好!)。
从语言实现者或关心编程语言语义的人的角度来看,async 并不是一个微不足道的特性。async 和生命周期的交叉点很难理解。从库实现者的角度来看,即从编写服务构件的角度来看,以及从在战壕里与 Pin
/Poll
/Future
打交道的角度来看,async
都很难理解。
但从用户的角度来看,异步 Rust 还算不错。它大多 “就是能用”。从用户的角度来看,你可以在执行 IO 的函数定义前面加上 async
,并在调用位置加上 await
,仅此而已。唯一不符合人体工程学的地方是在迭代器内部调用 async 函数。
重构
重构是用数字作画。类型错误让重构变得异常简单和安全。
招聘
Rust 程序员难招吗?不难。
首先,像 Python 和 TypeScript 这样的主流语言太容易招聘了,以至于招聘起来非常困难。要找到一个真正有才华的 Python 程序员,你必须从成千上万份简历中筛选。
其次,质量也有选择效应。“使用过 Rust“、”用 Rust 写过开源代码 “或 ”想在专业领域使用 Rust “都是对应聘者巨大的积极信号,因为这说明他们很有好奇心,也很在意提高自己的技能。
就我个人而言,我从不认为自己是 “Python 程序员 ”或 “Rust 程序员”。我只是一个程序员!当你学习了足够多的语言后,你就能形成一套正交的编程概念基础,并在不同语言间进行转换。我认为真正有天赋的程序员也是如此:他们能够快速学习语言。
影响
技术谈够了。我们来谈谈感受。
当我使用 Python+Django 时,最大的感受就是焦虑。写 Python 就像用树枝堆城堡,越往上,风越大。我预料到事情会出错,我预料到代码会很慢,我预料到事情会因为最荒谬的原因而爆炸。我不得不防御性地编写代码,在代码中处处加入类型断言。
Rust 感觉很好。你可以充满信心地构建代码。你可以构建出不仅能按预期运行,而且还很美观的东西。你可以为自己所做的工作感到骄傲,因为它不是泔水。
缺点
本节将介绍我不喜欢的地方。
模块系统
在 Rust 中,有两个层次的代码组织:
- Modules是具有可见性规则的命名空间。
- Crates是模块的集合,它们可以依赖于其他crates。crates可以是可执行文件,也可以是库。
一个项目或工作区可以由多个crates组成。例如,一个网络应用程序的每个正交功能都可以有库板块,而可执行板块则将它们连接在一起并启动服务器。
让我感到惊讶的是,模块并不是编译单元,当我注意到同一crate 1 中的模块之间可能存在循环依赖关系时,我无意中了解到了这一点。相反,crate 才是编译单元。当你更改板条箱中的任何模块时,整个crate 都必须重新编译。这意味着编译大型板块的速度会很慢,因此大型项目应分解成许多小型板块,并安排它们的依赖 DAG,以最大限度地实现并行编译。
这是一个问题,因为创建一个模块很便宜,但创建一个板条箱却很慢。创建一个新模块只需创建一个新文件,并在同级的 mod.rs
文件中为其添加一个条目。创建新crate 需要运行 cargo new
,别忘了在 Cargo.toml
中设置 publish = false
,并在工作区范围内的 Cargo.toml
中添加crate 名称,以便从其他crate 中导入。在crate 中导入符号很简单:输入名称后,LSP 就会自动插入使用声明,但这并不能跨crate 使用,你必须手动打开你正在处理的crate的 Cargo.toml
文件,并手动为你想导入代码的crate 添加依赖关系。这非常耗时。
板块拆分的另一个问题是,rustc
有一个非常不错的功能,可以在代码未使用时发出警告。这个功能非常全面,我很喜欢,因为它有助于保持代码库的整洁。但它只在一个crate 中起作用。在多板块工作区中,在板块中公开导出但未被其他兄弟板块导入的声明不会被报告为未使用2。
因此,如果你想让构建速度更快,就必须重新整理架构,手动调整依赖 DAG,并完成创建和更新crate 元数据的所有工作。这样做的结果是……模块内部循环导入,这是一种可怕的反模式,会让理解代码库变得更加困难。我更希望模块是不相连的编译单元。
我还认为模块系统有点过于复杂,需要重新导出,导入符号的方式也太多了。它可以精简很多。
构建性能
Rust 体验中最糟糕的是编译时间。这通常被归咎于 LLVM,这很公平,但我认为部分原因在于该语言的固有特性,例如模块并非独立的编译单元,当然还有单态化。
有各种技巧可以加快构建速度:缓存、cargo chef、调整配置。但这些都是技巧,而技巧是脆弱的。当你发现编译性能下降时,原因可能有很多:
- 代码确实变大了,需要更长的时间来构建。
- 你使用的语言特性拖慢了前端(例如复杂的类型级代码)。
- 使用了会拖慢后端速度的语言特性(例如过度的单态化)。
- 一个 proc 宏耗时过长(尤其是 tracing::instrument 非常慢)。
- crate DAG 变了形,以前并行构建的crate 现在要串行构建。
- 以上任何一种情况,但都是在依赖关系的传递闭包中。
- 您添加/更新了一个直接依赖关系,而这个直接依赖关系会带来大量的传递依赖关系。
- 缓存太少,导致依赖项被下载。
- 缓存过多,导致缓存膨胀,下载时间延长。
- 缓存最近失效了(例如通过更新 Cargo.lock),尚未稳定下来。
- 今天的 CI 运行速度很慢,原因不明。
- 以上所有情况的集合。
- (插入罗素悖论笑话)
不值得琢磨。花钱买更大的 CI 运行程序就好了。四核或八核应该足够了。并行太多是浪费:使用 --timings
标志运行 cargo build
,在浏览器中打开报告,查看 “最大并发 ”的值。这将告诉你可以并行构建多少个箱子,从而告诉你在收益递减之前可以购买多少个内核。
提高构建性能的主要方法是将工作区分割成多个板条箱,并安排板条箱的依赖关系,以便并行构建尽可能多的工作区。这在项目开始时很容易做到,但之后就非常耗时了。
模拟
也许这是一个技能问题,但我还没有找到一种好的方法来编写代码,让组件具有可交换的依赖关系,并能独立于它们的依赖关系进行测试。核心问题是生命周期会影响后期绑定。
考虑一个在网络应用程序中创建新用户的工作流程。三个外部效应是:在数据库中为用户创建记录、向用户发送验证电子邮件以及在审计日志中记录事件:
fn create_user(
tx: &Transaction,
email: Email,
password: Password
) -> Result<(), CustomError> {
insert_user_record(tx, &email, &password)?;
send_verification_email(&email)?;
log_user_created_event(tx, &email)?;
Ok(())
}
测试该功能需要启动数据库和电子邮件服务器。这可不行!我们希望将工作流与其依赖关系分离,这样就可以在不测试其依赖关系的情况下对其进行测试。有三种方法可以做到这一点:
- 使用 traits 定义接口,并在编译时进行传递。
- 使用 traits 定义接口,并在运行时使用动态分派来传递信息。
- 使用函数类型定义接口,并以闭包的形式传递依赖关系。
所有这些方法都行之有效。但它们需要大量的工作。而在 TypeScript、Java 或 Python 中,这将是轻而易举的事,因为这些语言没有生命周期,所以动态分派或闭包 “就是能用”。
例如,我们使用 traits,并在编译时完成所有工作。为了减少工作量,让我们只关注将用户的电子邮件和密码写入数据库的依赖关系。我们可以为它定义一个特质:
trait InsertUser<T> {
fn execute(
&mut self,
tx: &T,
email: &Email,
password: &Password
) -> Result<(), CustomError>;
}
(我们将数据库事务的类型参数化,是因为 mock 不会使用真实的数据库,因此我们无法在测试中构建Transaction
类型)。
真正的实现需要定义一个占位符类型,并为其实现 InsertUser
特性:
struct InsertUserAdapter {}
impl InsertUser<Transaction> for InsertUserAdapter {
fn execute(
&mut self,
tx: &Transaction,
email: &Email,
password: &Password
) -> Result<(), CustomError> {
insert_user_record(tx, email, password)?;
Ok(())
}
}
模拟实现使用单位类型()
作为事务类型:
struct InsertUserMock {
email: Email,
password: Password,
}
impl InsertUser<()> for InsertUserMock {
fn execute(
&mut self,
tx: &(),
email: &Email,
password: &Password
) -> Result<(), CustomError> {
// Store the email and password in the mock object, so
// we can afterwards assert the right values were passed
// in.
self.email = email.clone();
self.password = password.clone();
Ok(())
}
}
最后,我们可以这样定义 create_user
工作流程:
fn create_user<T, I: InsertUser<T>>(
tx: &T,
insert_user: &mut I,
email: Email,
password: Password,
) -> Result<(), CustomError> {
insert_user.execute(tx, &email, &password)?;
// Todo: the rest of the dependencies.
Ok(())
}
实际生产执行情况如下:
fn create_user_for_real(
tx: &Transaction,
email: Email,
password: Password,
) -> Result<(), CustomError> {
let mut insert_user = InsertUserAdapter {};
create_user(tx, &mut insert_user, email, password)?;
Ok(())
}
而在单元测试中,我们将创建 InsertUserMock
并将其传入:
#[test]
fn test_create_user() -> Result<(), CustomError> {
let mut insert_user = InsertUserMock {
email: "".to_string(),
password: "".to_string()
};
let email = "foo@example.com".to_string();;
let password = "hunter2".to_string();
create_user(&(), &mut insert_user, email, password)?;
// Assert `insert_user` was called with the right values.
assert_eq!(insert_user.email, "foo@example.com");
assert_eq!(insert_user.password, "hunter2");
Ok(())
}
显然,这需要大量的键入。使用特质和动态分派可能会让代码稍微短一些。使用闭包可能是最简单的方法(带有类型参数的函数类型在某种意义上就是带有单个方法的特质),但这样就会遇到闭包和生命周期的人体工程学问题。
同样,这也可能是一个技能问题,也许有一种优雅而习以为常的方法可以做到这一点。
或者,你可以完全否认模拟的必要性,编写没有可交换实现的代码,但这样做也有自己的问题:测试会变得更慢,因为你必须启动服务器来模拟 API 调用等;测试需要大量代码来设置和拆卸这些依赖关系;测试必须是端到端的,而你的测试越是端到端,由于输入的组合爆炸,你需要检查每条路径的测试用例就越多。
表现力
使用 proc 宏和 trait 魔术很容易让人发疯,从而构建出一个难以理解的代码库,让人无法跟踪控制流或调试任何东西。你必须加以控制。
脚注
如果模块是独立的编译单元,这就行不通了。如果模块 A 依赖于 B,要编译 A,首先需要编译 B,才能知道它导出了哪些声明以及它们的类型。但如果 B 也依赖于 A,就会出现无限回归。
解决这个问题的方法之一是制作粒度极细的板条箱,并依靠 cargo-machete
在依赖级别识别未使用的代码。但这会耗费太多时间。
本文文字及图片出自 Two Years of Rust
两年后,我对《Rust》最大的问题就像你强调的那样:MOD/Crate 的划分很糟糕!
我希望能更容易拥有更多的 crates。将模块树转换为新 crate 的开销很大。模块有了层次结构,但 crate 最终还是扁平的。这其中有些是板条箱命名空间扁平化的直接结果。
很多工作最终都是因为需要处理 toml 文件,而 Rust-analyzer 无法帮我做到这一点。我希望能有重构工具,轻松地将模块树转化为 crate。
我觉得当我想这么做的时候,我必须先复制文件,然后玩 “打地鼠 ”游戏,直到把所有依赖关系都弄对为止。我希望依赖关系能像 go 那样在代码文件中表达出来。我认为 go 在打包和依赖结构方面做得非常好。这是我最怀念的地方。
Rust 选择将编译单位和发布单位重合是一个令人惊讶的选择。我之所以说令人吃惊,是因为我在 Rust 中看到并真正欣赏的一个隐性设计原则就是分解正交特性。
例如,经典的面向对象编程使用类作为封装边界(在此维护不变式并隐藏信息)和数据边界,而在 Rust 中,这些边界被分别分离为模块系统和结构体。这使得复杂的不变式可以跨越类型,而类中的私有成员只能在类中访问,包括模块中的同级成员。
另一个例子是 Trait 对象(dyn Trait),它允许 Trait 的客户端决定是否有必要进行动态派发,而不是通过虚函数将其嵌入到类型的规范中。
还要注意的是它的可组合性:如果你确实想强制执行动态调度,你可以使用模块系统,要么只发布 Trait 对象,要么将一个对象不透明地隐藏在一个结构体中。因此,这并不会损失表现力。
这里的历史非常有趣,Rust 在早期经历了大量的设计迭代,然后就这样停滞了很长一段时间,然后做出了其他选择,使得修改之前的选择变得更加困难。然后我们确实在 Rust 2018 中设法有了一些重大改变(好的方面)。
Rust 的用户发现模块系统甚至比借用检查器还要困难。多年来,我一直试图找出原因,并想办法解释清楚。但从未真正破解过这一难题。TRPL 的模块章节历来最不受欢迎,尽管我重写了很多次。不知道他们最近有没有再试一次,我应该去看看。
> 另一个例子是 Trait 对象(dyn Trait),它允许 Trait 的客户端决定是否需要动态派发,而不是用虚函数将其烘焙到类型的规范中。
在此我不敢苟同:这是将两个特性完全分开。将其嵌入类型意味着你只有一种选择。这也是在外来类型上实现 Trait 如此简单的原因,这一点非常重要。
如果我的评论没有说清楚,请原谅:我的意思是,我认为在模块和 Trait 对象的情况下,Rust 都很好地做到了干净利落地分离特性,这与经典(Java 或 C++)风格的 OOP 不同。
我很惊讶模块系统会引起争议。一开始,尤其是涉及 Trait 时,它有点让人摸不着头脑,但它的可见性规则非常合理。它非常简洁地解决了子模块如何与可见性交互的问题。我已经开始在我的 Python 项目中使用 Rust 的约定。
我只有两个批评意见:
首先,当你确实需要一种面向对象的方法(一种 “模块结构”)时,它的人机工程学还不够完善,而这可能是更常见的用例。不过,我不知道这是否是一个可以解决的设计问题,所以我更喜欢 Rust 所做的权衡。
其次,也许是一个较弱的批评,当存在像 pub use 这样的重输出时,像 pub(crate) 这样的 pub 可见性限定符似乎就显得不相干了。我知道这可能是人机工程学所必需的,但它确实使设计复杂化了。
我还对 Rust 的另一个历史性设计感到好奇,那就是选择在线程恐慌中包含堆栈解卷。这似乎与 Rust 的系统编程原则用例不符。但我对这个设计问题的理解还不够深入,所以无法发表意见。
啊,是的,我稍微理解错了,不用担心:)
> 我也有这种感觉,但有些代码的可见性限定符(如 pub(crate))似乎是不相干的。
我也这么觉得,但有些人似乎在使用它们。
> 我也这么觉得,但有些人似乎在使用它们。这似乎与 Rust 的系统编程原则用例不符。
怎么说?
> Rust 的用户发现模块系统甚至比借用检查器还要困难。多年来,我一直试图找出原因,并想出更好的解释方法。
Rust 的模块系统在概念上非常庞大,我觉得它需要一个’Rust 模块:好的部分’资源来引导人们。
(1) `pub`有五种不同的使用方法。这太让人难以接受了,而且在实践中我几乎从未见过`pub(in foo)`的用法。
(2) 可以在一个文件中或多个文件中嵌套模块。除了 `mod tests` 之外,我几乎从未见过使用大括号的模块。
(3) 可以使用 foo.rs 或 foo/mod.rs。也有可能同时有 foo.rs 和 foo/bar.rs,这让人感觉不一致。
(4)“使用 ”顺序并不重要,这使得导入难以推理。下面是一个愚蠢的例子
use foo::bar; use bar::foo;
(顺便说一句,我是您的超级粉丝!)
我完全同意第 1 点,我也会根据情况使用第 2 点(如果我在制作模块树以进行组织,而一个模块只包含其他模块的导入,我会使用大括号形式,以省去制作文件的麻烦),我不明白为什么第 4 点会增加难度?也许我需要看一个完整的例子:)
谢谢!
很难同意。现在回过头来看,我认为应该采用 Delphi 的模式,即必须 “手动 ”组装一个 “pkg”,然后才能向全世界输出。
这也解决了一个问题,那就是你最终使用大量的 “public ”并不是因为逻辑的需要,而是作为跨 crate 共享的唯一方式。
它本应是所有模块(甚至是带有强制 “lib.rs ”之类的 main.rs),而 “crate ”本应是重新导出的接口。
> 很难同意。现在回过头来看,我觉得应该采用 Delphi 的模式,即必须手动组装一个 `pkg` 才能向全世界输出。
非常古老的 Rust 有 “crate 文件”,它是这样的:https://github.com/rust-lang/rust/blob/a8eeec1dbd7e06bc811e5…
.rc代表 “rust crate”。
这里有利有弊。我有两种想法。
你如何将其与围棋进行比较?我认为 go 的发布单位是模块,而编译单位是包。尽管如此,通过使用 “内部 ”包和接口,你同样可以创建类似的不透明封装。
> 我对 Rust 的概括是:它是更好的 Go,或者更快的 Python。
这个观点很有意思。我觉得这三种语言都有各自的特点,而其他语言则没有。Python 适用于快速黑客或科学领域,Go 适用于网络服务和独立程序,Rust 适用于可移植性(特别是共享 C ABI 或 WASM 代码)和安全性。
> 不难学
我同意 Rust 很容易学。我已经学过四五遍了。
> 我同意 Rust 很容易学。我已经做过四五次了
不是玩笑,是真的。
当我第一次看到 Rust 时,我同意所有的东西,同意它是正确的,是好的(这让我很受伤,因为我之前在专业领域使用过大约 10 多种语言,而我刚从 F# 来,所以需要转换的东西太少了!)。
显然,如果其他语言有适当的功能,我就应该这样做!
然后,我需要真正正确地编程,咣当!太难了!
我需要重新学习很多次。是的,最难的部分是停止做我在所有其他语言中隐含做的事情。
顺便说一下,Rust 的难点在于:a)它的语法太熟悉了;b)它是一种完全不同的编程模型。除非它理解了第二部分,并真正关注 “移动、借用、共享、锁定、克隆、复制”,而不是 “循环、迭代、条件、读写等”,否则很难取得进展。
我同意,它们只有表面上的相似之处。比如它们都是基于 3 个 C 语言。而 Go 和 Rust 都能编译成机器码。我相信 Go 的一位创造者曾经提到过,有些用户觉得它 “像更快的 Python”。但我不知道 Python 和 Rust 有什么联系,我看不出有任何相似之处。事实上,我几乎倾向于说 Python 和 Rust 的区别多于相似之处。
> 我几乎倾向于说 Python 和 Rust 的区别多于相似之处。
这有点牵强:Rust 中的 dyn Traits 有点像编译时的 duck typing。相反,Go 中的接口和 C++ 中的虚函数是一回事。
这取决于你说的是哪种轴。在引擎盖下,Go 的接口和 Rust 的 “dyn 类型”(Trait 对象的新术语)是一样的,而 C++ 的虚函数则不同。
(虽然你可以在 Rust 中用不安全的方式来模拟它们,比如无论如何)。
我真希望能有更多关于 Nim 的讨论。
我从未仔细研究过 Nim,因为我认为除了 Go 之外,我不需要另一种快速 GC 语言。你对我这样的人有什么建议?
Go 对我来说太啰嗦了。Nim 在保持可读性的同时还能做到相当简洁。我喜欢 Nim 的一个功能,但使用其他语言时最怀念的就是模板–它减少了很多模板。
此外,Nim 的类型系统也非常不错,可能是非函数式编程语言中最好的之一。重载、泛型、类型推断、独特的类型别名、一级函数、子范围等等。
我看到 Go 因不支持 OOP 而受到好评。Nim 也有一些 =D. 没有类,只有结构和函数。事实上,每个运算符都是一个函数,而且你可以为自定义类型重载它们。OOP 仍然可行,但要通过工厂工厂制造继承怪物就比较困难了。
Nim 赋予你力量,但请记住,有力量就有责任。
这就是我对 Nim 语言的建议。
与其说是推销,不如说是很好的演示。https://nim-lang.org/blog/2021/07/28/Nim-Efficient-Expressiv…
> 我同意 Rust 很容易学。我已经学过四五遍了。
https://www.lurklurk.org/effective-rust/ 可能适合你;虽然它从最基础的东西开始学起,但对于一个懂得编程的人来说,它似乎涵盖了很多东西,而且是有条理的。
我希望一个人至少能在第 70 页之前学到一些新东西:)。
Python 需要的代码行数更少(少得多?) 将 Ruby 与 Python 进行比较不会让我感到震惊。
这真的取决于你在做什么,以及如何编写 Rust。我从来不会说这一定是对的,但 Rust 可以非常简洁,这取决于你在做什么。请参阅我之前的这篇评论,其中链接到了我在这个论坛上逐渐形成的其他一些例子:https://news.ycombinator.com/item?id=42312721。
模拟示例看起来毫无意义。
IO 无法进行单元测试,因此需要进行模拟。但他的代码除了确认他的 mock 有效外,什么也没做。他在编写模拟并测试模拟。
他所引用的功能本质上是不可单元测试的。再说一遍,如果你试图模拟它并对其进行测试,你最终只能测试你的模拟代码。就是这样。
我曾多次看到这种奇怪的测试理念出现,测试代码会漏掉大量错误,因为它只是在确认模拟代码是否工作。
在这一领域,如果你想确认它是否有效,就需要进行集成测试。如果实现方式发生变化,重写测试就会带来痛苦,但仅仅测试模拟并不能解决这个问题。
只有当你在做大量的大型算法而不是大量的 IO 时,你的单元测试才真正重要。如果你在单元计算中加入了一些 IO,模拟就会有所帮助。在他给出的例子中,每一个操作都是 IO,每一个操作都必须被模拟,所以他到底是怎么想的,要把它放在单元测试中?
> IO 无法进行单元测试,因此需要对其进行模拟。
比方说,我有一个使用私有 MongoDB 作为缓存的模块。它的单元测试会启动一个标准 MongoDB 容器并使用它(然后将其关闭)。它们还是单元测试吗,还是我应该开始称它们为 “集成测试”?
集成。
单元测试应该只测试代码单元。所有外部的东西都应被模拟或不进行测试。
帖子中的例子就是单元测试。
将单元测试和集成测试分开很好,因为单元测试非常容易运行,而且不复杂,速度也快得多。集成测试则要复杂得多,而且经常会冻结代码,因为它锁定了实现。
不过,如何定义 “外部事物 ”很重要。只要你的函数调用了另一个函数,你的测试就可以说是 “集成测试”,因为你现在隐含地也在测试另一个函数的逻辑。
或者,你模拟了_everything_,那么你的 “单元测试 ”最终就只是一个同义反复的测试,断言 “我写的代码按照我写的方式执行”。(更不用说,每当你模拟某样东西时,你也在隐含地断言该东西的预期行为是什么)。
唯一真正可靠的测试是 E2E 测试,但这种测试成本太高,无法涵盖所有可能的排列组合,因为排列组合实在太多了。
这就是测试的第 22 个陷阱,我们总是不得不做出务实的选择,确定在哪里划定边界,以最大限度地提高它们的价值(即实际捕捉到的 bug)。
语言极不鼓励使用全局变量意义上的函数。定义一个类型通常要容易得多,这样就有了明确的界限。
也就是说,在 Mongo 中,你可以使用 Serde,最后只得到操作过的有效记录,而这些记录是由这样的值组成的表格。
我所说的外部是指编译后的可执行文件之外的任何东西。
或任何必须使用 IPC(如套接字或共享内存)的东西。
我认为,如果 “标准 MongoDB 容器 ”实际上是密封的,那么这仍然是单元测试。
让 “我们将变出一个环回网络服务器 ”这样的测试变得密封是很容易的,同样,实体框架的技巧也是如此,它可以针对 SQLite 数据库运行测试,尽管这并不是你的真实数据库的工作方式,但这通常已经足够好了。容器也有可能是密封的,但我持怀疑态度。
> 模拟示例看起来毫无意义。
这只是一篇博文的示例。我不可能为此写几千行代码,所以我只是勾勒了一个模糊的轮廓。
> 如果作者不使用 “货物”,你会称赞它的缺失:没有麻烦,没有脚枪,没有你必须在愤怒中学习的传说,没有怪异,没有环境变量。
假设作者没有使用 build.rs,它似乎几乎完全是由所列内容组成的。
如果需要做更复杂的事情,build.rs 是一个有用的逃生舱口,但 cargo 的好处是,大多数情况下默认值都是有效的。一般来说,只有在需要处理 C、C++ 或其他外部生态系统时,才会用到 build.rs。纯粹的 Rust crate 基本上不需要接触它,跨越多个平台和构建配置。
一般来说是的,但有些东西如 lalrpop 是纯 Rust 的,我个人属于纯 Rust build.rs 用户范围…
我不会把 lalrpop 称为 “纯 Rust”。它是一种编译为 Rust 的独立语言。
但它属于 Rust 生态系统。也许货物可以提供一种更简单的方法来使用代码生成器。
当你仍然拒绝学习时,很高兴拥有它
对于借用检查器的不满,我认为可以通过以下令人惊讶的事实和有趣的 “解决问题 ”的语言设计方法来解决:
在 Rust 中,按强度排序至少有 5 种类型:
– 值/非限定/”拥有”
– 可变参照
– 引用/共享引用
– 原始常量指针
[*const T
– 你可以有任意多的常量指针,而且它们不经过有效性检查
– 原始可变指针
我之所以说 “至少”,是因为当你发现自己在处理适用于 “引用 ”的生命周期时,事情会变得复杂得多,这些引用本身确实是类型,但最终代表了编译器执行的关于相对于某个值的有效性的微积分。
它们也可以像多引用指针一样 “扇出”,但最棘手的问题是,例如,类型的应用程序接口(API)如何与之保持一致;
既然集合中有 3 种不同类型的事物,那么就有 3 种不同的迭代方式:`iter()`、`iter_mut()`、`into_iter()`,它们的强度依次递增。大部分的广度或早期复杂性都源于将这些方法视为干扰因素的冲动,而不是系统代码的基本方面。
Crates / 模块有点像一个备忘录:https://www.reddit.com/r/rust/comments/ujry0b/media_how_to_c…
Bevy 对构建性能做了一些调查:https://bevyengine.org/learn/quick-start/getting-started/set…
我觉得把这些东西看作第二个类型系统比考虑编译器如何工作或运行时如何运作的细节更直观。给定的值以一种叠加的形式存在,我根据当时需要的权衡结果,选择我希望它折叠成的形式(值、引用、可变引用等)。我不知道为什么这样做会有帮助,也不知道除了我之外,这对其他人是否有帮助(甚至是连贯的)。这也可能会让我陷入某种概念上的外在黑暗,在那里我将永远听到可恶的鼓声和狰狞的笛声。
当然,随着我学习语言的时间越来越长,我的脑子里已经把“&”运算符当成了 “借用 ”的意思,然后你就继承了 1x 间接操作。
只有在连接 &/&mut 和 *const/*mut 世界时才会出现问题。如果你需要后者,即使不能完全做到,也要尽量保持在它们的范围内。
>与其先用 C/C++ 来实现,然后思考 “如何将其转换到 Rust 中才能满足借用检查器的要求?”,不如思考 “如何在 Rust 的语义中,从线性和生命周期的角度来实现目标?”。
我仍然是用 C++ 的术语来思考这一切的。借用和所有权只是特定的术语,是创建一个具有任何有用复杂性和性能的正确 C++ 程序所必须了解的东西。两个常见的反对 Rust 的论点是:
– 很难学习
– 你应该 Git Gud at C++
但是,在借用检查器上苦苦挣扎的人和精通 C++ 的人的欧拉图并没有重叠。同样,String 和 &str.
我还认为,这意味着性能用户故事在某种程度上被低估了,尤其是对天真的用户而言。不可变的借用和移动只是开发体验的一部分,而复制则是不显而易见的途径。如果你还在苦苦挣扎,你通常可以直接使用 `rayon` ,而使用 `std::execution::par` 则无法做到这一点。
> 错误处理
除了最简单的情况,我还没见过谁能展示 Rust 错误处理的优雅。这一切都充满了乐趣、游戏和问号……直到你碰到这个:
然后你开始调查,结果发现该错误值来自未知调用堆栈深处的某个地方,被作者用’?’随处丢弃了。
是的,我知道 anyhow、thiserror 和 eyre 等等……;关键是这些 “看看错误处理多么优雅 ”的帖子中从未展示过这些。拜托,让我们对结果<T, E>和’?’诚实一点吧–它并不是错误处理的完整解决方案。两年过去了,我相信你已经明白了这一点。
将错误写入代码却从不实际处理它们是一种非常糟糕的做法。事实上,这和根本不做错误检查一样糟糕。误用机制并不能成为反对语言的理由。
错误检查之所以好,是因为你能正确使用它,而且它没有 C++ 的 try/catch 那么繁琐。
>拜托,让我们对 Result<T, E> 和’?’诚实一点吧–它并不是错误处理的完整解决方案。两年过去了,我相信你已经明白了这一点。
没有什么是完整的解决方案。但如果不进行比较,谈论这个问题是毫无意义的。你认为 try/catch 一直以来都是优越的解决方案吗?
> 只接收错误而从不实际处理它们是一种糟糕的做法。
事实并非如此。这要看情况。
可以是完全正确的,这取决于你的需要。
Rust 在错误处理方面所做的就是给你灵活性。的确,这意味着你可以把事情搞得一团糟。我自己目前的代码库中就有一个 TODO,我对自己目前所做的一切并不十分满意。但它也可以非常优雅,更重要的是,它不会强迫你采用一种范式,而这种范式可能并不适合你的需求,而是允许你做出决定,这在 Rust 的概念空间中非常重要。例如,我可不想被迫在 no_std 上下文中使用上述签名。
>可以是很好的签名,这取决于你的需要。
不过,这会将错误信息提供给调用进程。从某种意义上说,这意味着错误已被处理。
我并不反对,但我认为我的观点仍然成立。如果你不考虑错误在哪一点上得到解决,那么你的程序就没有正确的错误处理。如果你在代码中间解包,你就必须接受在那里崩溃的可能性,即使是来自很远的错误。
> 在出现错误后却从不进行实际处理是一种非常糟糕的做法。
我同意,但这正是我的观点–这些赞美 Rust 的文章所展示的就是这些:)。
> 但是,如果不进行比较,谈论这些是毫无意义的。
不过,不与其他语言/方法进行比较可以让我们继续讨论 Rust 以及如何让事情变得更好,而不是又一次毫无结果地讨论哪种语言或方法更好。我对证明 Rust 优于 C++ 或反之亦然不感兴趣,我感兴趣的是 Rust 本身的优点。
>我同意,但这正是我的观点–这些赞扬 Rust 的文章所展示的就是这些:)。
他们完全歪曲了错误处理的本质。Rust 的错误处理很好,因为程序只会在你允许它们崩溃的地方崩溃。如果你正确地使用了错误处理,你只会在你认为不可恢复的错误发生时崩溃。
>然而,不与其他语言/方法进行比较可以让我们继续讨论 Rust 以及如何把事情做得更好,而不是又一次毫无结果地讨论哪种语言或方法更好。
如果你在抱怨某种语言的错误处理,那么最重要和最有成效的事情肯定是将其与其他语言的范式进行比较。如果你不愿意考虑别人做得对而你做得错,你就无法改进。尤其是在有两种主要范式的情况下,讨论另一种范式似乎很重要。
你到底想改变 Rusts 的错误处理方式?
> 那么他们完全歪曲了错误处理的含义
这很好,但评论者并非凭空捏造。它就在文章中。你的回复让人觉得你没读过,好像 OP 给出的是一个罕见的假设,大多数 Rust 程序员都不支持。这是 Rust 社区常见的错误处理建议。
这也代表了你在阅读别人的代码或试图为别人的库做贡献时可能会遇到的问题。
我认为你没有像我一样理解对话内容。
Rust 的错误处理是通过解包来定义潜在的崩溃。程序绝不会在这里意外崩溃,因为这正是你期望它崩溃的地方。这种一般模式很好,而且被广泛使用,但另一位评论者并不理解,这种行为是在你确实不想解除封装的地方解除封装的结果。
> 另一位评论者没有理解[……]
不是这样的,我非常理解当前的问题。请不要这样做。
我没有回复你的另一条评论,是因为我们的对话没有任何进展。
哦,我猜你没有,因为即使我问你,你也没有指出为什么你不喜欢它,你会改进什么,或者它与其他产品相比如何。
> 就我个人而言,我没有遇到过借用检查器的问题,但那是因为在我开始工作使用 Rust 之前,我已经设计并构建了自己的借用检查器。我不知道这是否是一种可扩展的教学方法。
是啊……也许不是,但我觉得这可以作为本科课程的一个项目。
使用依赖注入和模拟行为。这种技术在包括 Rust 在内的几种编程语言中都有效。
Rust 有模块、crate 和工作空间。为了优化构建,你最终会将共享资源移到它们自己的 crate(s)中。
我觉得在 Rust 中,在引入和处理 Trait 时要比其他有接口的语言更谨慎。作者将此归咎于生命周期,但我认为事实是因为没有垃圾回收器,所以并非所有东西都是胖指针,而胖指针无论如何都不能使用泛型方法,因为泛型方法是单态的,所以即使你想使用它们,也会觉得有点蹩脚。
因此,你几乎肯定需要参数多态性,而描述的其他语言会使用实现/接口/继承/duck 多态性。参数多态性如果不加判断就会迅速爆炸,而且感觉不是很敏捷。
一旦你要处理 Trait,那 Trait 是否有副本绑定,还是我需要借用一下,还要在我的 Trait 参数旁边抓一个生命周期?或者,我是否应该在我的模拟中加入一个`impl Trait for Arc<RefCell<Mock>>` 或类似的东西来隐藏这一切?
我目前是这样做的: 我使用存储库模式。我使用 Trait:
我是 “纵向”(又称按特征)而不是 “横向”(又称按层)拆分的。因此,“库 ”是我的应用程序的一个功能,而 “供应商 ”是该功能中的一个概念。该调用最终会接收 CreateRequest 中的信息并将其插入数据库。
我的实现过程是这样的
where Sqlite is
You’ll notice this basically:
The inherent method has this signature:
因此,我可以选择测试的方式:使用真实数据库或不使用真实数据库。
如果我想使用真实数据库编写测试,我可以通过测试固有方法并将测试线束准备好的事务传递给它来实现。
如果我正在测试其他函数,而我想模拟数据库,我可以创建 LibraryService 的模拟实现,然后将其注入其中。这样就不会与数据库发生任何交互。
实际上,我的应用程序现在 95% 都是端到端测试,因为其中很多都是 CRUD,没有什么逻辑,但这种结构意味着,当我想做一些更细粒度的测试时,它是微不足道的。代价是现在有很多模板。我正在考虑减少这些模板,但现在我还能接受,因为这些模板非常无聊:最糟糕的事情就是我复制/粘贴了其中一个方法的实现,却忘了更改该格式的信息!我还不能完全确定我是否可以使用这些模板。我也不能完全确定我是否喜欢在这里使用 anyhow!,因为我觉得我抹去了太多的错误上下文。不过现在效果还不错。
我的这个想法来自 https://www.howtocodeit.com/articles/master-hexagonal-archit……,我很想看看它的最终部分。(还有,我觉得它的语气很讨厌,但想法很好,也很全面)。我不是百分之百确定我喜欢这个具体实现的每个方面,但到目前为止,它对我来说还不错。
你回避了我抱怨的重点,因为你没有提供一个需要注入结构体来测试实现的例子。此外,如果你使用 `Send + Sync + ‘static’,那么你当然可以避免我所暗示的问题:你已经承诺永远不会有生命周期,也就不必处理借用检查器了。
在你的例子中加入一只短命的蜜蜂。一个只能存活有限时间的数据库。
> 你回避了我抱怨的重点,因为你没有举例说明需要注入结构体来测试实现。
当然,“博士,这很疼……那就别再这么做了”。有时,你可以绕过这个问题进行设计。我并没有说这种特定模式适用于所有情况,只是说我在现实世界中的应用程序就是这样构建的。
> 此外,如果你采用 `Send + Sync + ‘static’,那么你肯定可以避免我所暗示的问题:你已经承诺永远不会有生命周期,也就不必处理借用检查器了。
是的,有时候,启动时的一次原子增量值得你不使代码变得更复杂。
> 例如,一个数据库的生命周期是有限的。
对于网络应用程序来说,情况并非如此。
非常感谢你提供的详细示例。我已将其加入书签,以备不时之需……
没问题!我也在 reddit 上给一个人留了这个回复,他也说了类似的话:
很好。我希望有一天能把我的经验写出来,但我也有一些简单的想法:
我的版本库文件很大,我需要把它们分开。更多的子模块可以奏效,在与 Trait 实现不同的模块中定义固有方法。
我已经找到了这样的目录结构、
当你按功能拆分东西时就会变得有点奇怪,因为你最终会在所有三个子模块中重新做相同的目录。我想看看是否可以改用更类似于
感觉更好。当然,这也是一种重复,但我觉得如果按功能拆分,将每个功能放在自己的目录中,重复的域/入站/出站层会更合理。
我也很好奇一致性是否能让我将其转移到每个功能都有自己的 crates。编译时间现在并不可怕,但随着事情的发展……我们会看到的。
关于模拟数据库事务: 我当时就在现场,并代表与我推理的一位大语言模型(LLM),将该部分重构为一个执行事务的角色,并传递消息(同时还有一个用于返回值的一次性通道)。这样做的好处不仅在于你不需要管理事务,还在于你不需要在多个异步路径之间协调事务,而在这些路径上,生命周期和所有权都会爆炸!
无论好坏,我都没有使用演员框架,而是自己开发了一个。
写得很好!
“它还不太适合的两个领域是网络前端(尽管你可以尝试)和原生 macOS 应用程序”。
能否请您详细说明一下?
在 Oxide,我们以 Rust 命名公司,但我们在前端使用的是 TypeScript,而不是 Rust。Rust 是我们对大多数新代码的默认技术选择,但 TypeScript 是针对前端代码的。
Rust 在网络前端的应用还不够成熟。你可以用它来做一些事情,它非常酷,但 TypeScript 在这一点上是非常成熟的技术,它提供了很多与 Rust 类似的优势。而且它可以在浏览器环境中原生运行,无需复杂的绑定。
我没有开发过 macOS 原生应用程序,但我想应该差不多: Objective-C 或 Swift 是我们所期待的,因此你最终需要绑定的 API 并不总是那么自然。我明白你为什么想做类似的事情:用 Rust 编写核心,但 UI 部分使用 Swift,然后在 Swift 中调用。
喜欢听到 Oxide 民间人士的声音:)你们都做了很棒的事情,写了很棒的文章。
谢谢!
请不要误会,我只是个编辑。
在 “错误处理 ”部分末尾有一个错别字:
当需要明确处理错误时,可以省略问号运算符,直接使用结果值。
谢谢!已修正。
@zetalyrae,鉴于你在 OCaml 中实现了 Austral,我很想知道你对 Rust 与 OCaml 的对比有何看法;-)