【外评】不要把 Rust 写成 Java

几年前,我就对 Rust 的理念很感兴趣。类型安全、内存安全、强调正确性。有什么理由不喜欢呢?

我在开发 Apollo(一个 Python 应用程序)时遇到的错误中,本可以被 Rust 编译器捕捉到的错误比例相当高(我不敢说 100%,但也相当接近了)。一般来说,在使用动态语言(如 Python 或 Ruby)时,编译器可以捕捉到很多可能在生产中出现的问题,但并非所有编译器都是一样的。类型安全固然很好,但 Rust 对正确性的强调才是最吸引我的地方。

在工作中,我一直在编写大量的 Java 代码。虽然Java不是我最喜欢的语言,但其编译时检查功能很强大。重大的重构不像 Python 或 Ruby 那么可怕。编译器就在你身边!错误或缺失的导入语句不会让你的程序在运行时停滞不前。是的,我们通常有测试来捕捉这些问题,但将这些检查嵌入到语言中也是有道理的。

不过,Java 编译器并不完美。Java 编译器并不完美,它无法防止各种错误,其中最臭名昭著的就是空引用。(在 Java 中,(几乎)所有东西都可能是空的,而且直到运行时你才会发现。另一方面,Rust 提供了一些构造来指导你处理未知值。当然,你可以选择忽略这些指导,但编译器会强制你做出这样的决定。

那么,Rust 是更好的 Java 吗?当然有很多地方值得称道。Rust 的承诺让我觉得无比诱人。但我的 Rust 之旅并不都是阳光和彩虹。尽管有相似之处,但 Rust 并不是 Java。直到我不再试图让 Rust 语言变得面目全非,我才发现了编写 Rust 代码的乐趣。

一切都必须是接口

Java 开发人员需要所有东西都是接口(我就是这样的开发人员),这种说法虽然不完全准确,但也有一定道理。Java 中的接口非常有趣。您的应用程序由多个小工作单元组成,其中任何一个工作单元都无法直接了解另一个工作单元的内部运作情况。引导您的依赖关系树需要一些前期工作,但一旦完成,您就拥有了一支独立的服务大军,随叫随到。

Rust 中没有接口,我们有 trait。它们在很多方面与 Java 中的接口类似。不过,在 Rust 中试图把所有东西都变成 trait 并不有趣。还记得 Rust 的内存安全这一伟大特性吗?它的代价就是不能轻易 “注入 “实现了 trait 的东西。

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

struct Service {
    named: Named
}

上述代码将无法编译,因为 Named 的大小无法在编译时确定。为了解决这个问题,我们可以将 Trait “box “起来,这样就可以指向堆上动态分配的内存(称为 Trait 对象)。框本身的大小是已知的,因此我们的程序可以编译。

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

struct Service {
    named: Box<dyn Named>
}

boxing 不是我最喜欢的模式,因为它们很难驾驭。如果可能,我会避免使用。我们可以使用泛型来指定 trait 类型。

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

struct Service<T: Named> {
    named: T
}

这有什么不同?乍一看,结果是一样的。区别在于动态派发和静态派发。对于 trait 对象,具体类型是在运行时解析的。而对于泛型,具体类型是在编译时解析的。

实际上,这意味着只要我们能在编译时推断出所有类型,我们就可以使用泛型。如果在运行时才能推断出类型,那么就需要一个框。

所有权如何?

所有权问题依然存在。如果我们的 Named Trait 是应用程序中其他服务的必备依赖项怎么办?我们是否要创建一个单一的 “主 “Named,并向每个依赖关系传递一个 &Named,从而引入生命周期?

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

或者,我们是否使用一个 Arc,使我们的从属服务与 Arc<dyn Named> 保持联系,从而允许并发访问所拥有的资源?

struct Service {
    named: Arc<dyn Named>
}

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

使用函数

强迫 Rust 成为纯粹的面向对象语言并不好玩。虽然我仍在编写 “service objects”,如上述示例所示,但我尽量只在必要时使用它们,而更喜欢使用函数。

考虑一下处理 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 代码也很重要。Rust 需要思维方式的转变。不要为 Rust 的不是而与之抗争,要为它的是而拥抱它。

本文文字及图片出自 Don't write Rust like it's Java

你也许感兴趣的:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注