不要像写 Java 那样写 Rust


我在工作中编写了相当多的 Java 代码。虽然 Java 不是我最喜欢的语言,但编译时检查功能非常强大。重大重构并不像 Python 或 Ruby 那样可怕。编译器就在你身边!错误或缺失的 import 语句不会在运行时导致程序停止运行。我们通常会进行测试来发现这些问题,是的,但将这些检查嵌入到语言中还是有好处的。

然而,Java 编译器并不完美。它无法防范一系列错误,其中最臭名昭著的是空引用。Java 中(几乎)所有内容都可能为空,而您直到运行时才会发现。另一方面,Rust 有结构来指导您处理未知值。您当然可以选择忽略这些指导,但编译器会迫使您做出慎重的决定。

那么 Rust 是更好的 Java 吗?它确实有很多值得喜欢的地方。我发现 Rust 的前景非常诱人。但我的 Rust 之旅并非一帆风顺。尽管有相似之处,但 Rust 不是Java。直到我停止尝试让这种语言成为它不是的东西时,我才发现编写 Rust 代码的乐趣。

一切都必须是接口
虽然并不完全准确,但 Java 开发人员需要将所有内容都变成接口的说法确实有一定道理(我就是这样的开发人员)。

Java 中的接口使用起来很有趣。您的应用程序由小的工作单元组成,其中没有一个工作单元直接了解另一个工作单元的内部工作原理。引导您的依赖关系树需要一些前期工作,但一旦完成,您就会拥有一支随时待命的独立服务大军。

Rust 中没有接口,只有trait。它们在很多方面与 Java 中的接口相似。但是,试图将Rust 中的所有内容都变成trait并不好玩。还记得 Rust 内存安全的一大特点吗?但代价是无法轻松“注入”实现trait的东西

trait Named {
    fn name(&self) -> String;
}

struct Service {
    named: Named
}

上面的代码无法编译,因为Named在编译时无法确定 的大小。

为了解决这个问题,我们可以“Box”trait,允许我们指向堆上动态分配的内存(称为trait对象)。

  • Box本身的大小是已知的,允许我们的程序编译。

trait Named {
    fn name(&self) -> String;
}

struct Service {
    named: Box<dyn Named>
}

Box不是我最喜欢的模式,因为它们用起来很别扭。如果可能的话,我会避免使用它们。我们可以改用泛型来指定trait类型。

trait Named {
    fn name(&self) -> String;
}

struct Service {
    named: T
}

这有什么不同?乍一看,结果是一样的。区别在于动态调度与静态调度。

  • 对于trait对象,具体类型在运行时解析。
  • 对于泛型,具体类型在编译时解析。

实际上,这意味着只要我们能在编译时推断出所有类型,我们就可以不用使用泛型。
如果直到运行时才能推断出类型,则需要使用 box。

那么所有权呢?
所有权问题仍然存在。

如果我们的Named trait是应用程序中其他服务的必需依赖项怎么办?

我们是否要创建一个单独的“主”Named并将它传递&Named给每个依赖项,从而引入生命周期?

struct Service<'a> {
    named: &'a dyn Named
}

或者我们使用一个Arc,使得我们的依赖服务持有一个Arc,从而允许并发访问所拥有的资源?

struct Service {
    named: Arc<dyn Named>
}

我尝试了这两种方法。它们都有效,但并不令人愉快,尤其是当我们应用程序中的每个服务都受到影响时。

使用函数也没问题
强迫 Rust 成为一种纯粹的面向对象语言并不好玩。虽然我仍然像上面的例子一样编写“服务对象”,但我尝试只在必要时使用它们,而我更喜欢函数。

考虑一个用于处理 Stripe 结帐会话完成事件的函数,该函数会更新我们系统中的 Stripe 客户 ID。

async fn handle_session_completed(
    user_repo: &mut impl UserRepo,
    session: &CheckoutSession,
) -> anyhow::Result<()> {

    let user_id = session
        .client_reference_id
        .clone()
        .context("Missing client reference ID")?;

    let customer_id = session
        .customer_id
        .clone()
        .context("Missing customer ID")?;

    user_repo
        .update_stripe_customer_id(user_id, &customer_id)
        .await?;

    Ok(())
}

虽然我们可以将其编写为服务,其中UserRepo是注入值,,但这样做会带来我们已经探讨过的复杂性。
也没有理由将其编写为服务,因为我们仍然可以轻松注入UserRepo 的不同实现:例如提供不影响实时数据库的实现。

缺点是我们的函数签名可能会有点繁忙,但与其他替代方案相比,这种程度的“痛苦”微不足道。

拥抱 Rust
接受现有的习语对于掌握 Rust 很重要。Rust 需要思维转变。不要因为 Rust 不是它而与它作对,而要接受它本来的样子。