本文是对 I am a Java, C#, C or C++ developer, time to do some Rust 的整理与翻译
许多有着丰富 Java、C# 或 C/C++ 开发经验的工程师在第一次接触 Rust 时,往往抱着一种过于乐观的态度。大家通常认为,凭借多年在面向对象编程和系统架构设计上的深厚积累,掌握一门新语言不过是熟悉一下新语法、看几篇官方文档的事情。
然而,正如知名技术博客作者 Amos 在其长文《I am a Java, C#, C or C++ developer, time to do some Rust》中所指出的那样,这种思维定势会在 Rust 严格的编译器面前被无情打破。Rust 的核心优先级与传统的系统语言或基于虚拟机的语言截然不同。
本文将为你深入且详尽地解读这篇经典博客的核心内容。为了保证大家能完整吸收作者的思考过程,本文不会对原贴中的推演和试错步骤做任何删减或省略,我们将一步步还原开发者在初学 Rust 时遭遇的真实困境与最终的破局之道。
内容结构概览
- 惯性思维的开局:从试图用传统方式构建图形应用开始,遇到全局变量与结构体设计的冲突。
- 基础概念的碰撞:类型不匹配的第一道坎——深入解析
String与&str的本质区别。 - 架构层面的水土不服:当面向对象设计模式遇到没有“类”与“空指针(Null)”的 Rust。
- 深入核心痛点:无处不在却又隐形的“生命周期(Lifetimes)”及其编译期校验机制。
- 回归工程实用主义:放弃硬扛借用检查器,拥抱智能指针与引用计数(Rc/Arc)以获得类 GC 体验。
- 总结语:放下旧习惯,重新建立基于所有权的内存安全哲学。
一、惯性思维的开局:试图用传统方式构建应用
在文章的开篇,作者设定了一个非常贴近日常开发的需求:构建一个带有窗口的图形应用程序。
在任何现代编程语言中,管理一个应用程序的状态,第一步通常是管理其窗口尺寸。作者最初写下了定义宽高的全局常量,但作为一名受过良好工程训练的 Java/C#/C++ 开发者,直觉告诉我们:全局变量是邪恶的。因此,合理的做法是创建一个名为 App 的结构体(Struct)来封装状态:
struct App {
width: usize,
height: usize,
}
紧接着,应用逻辑变得稍微复杂了一些。作者希望将宽和高的属性进一步内聚,于是抽离出了一个 Dimensions 嵌套结构体。这一切在 Rust 中运转良好,代码看起来整洁且结构化。此时的开发者感觉非常舒适:Rust 似乎和其他语言一样,非常符合常规的架构审美,而其附带的 cargo 包管理工具更是让习惯了 C++ 繁杂构建系统的开发者感到惊艳。
二、基础概念的碰撞:字符串与所有权
好景不长,麻烦很快就来了。为了让窗口具有描述性,我们需要给 App 结构体增加一个名为 title 的字符串字段。在 Java 或 C# 中,这无非就是声明一个 String 类型的变量,然后赋予字面量值 "My app"。
当开发者尝试在 Rust 中复现这一操作时:
struct App {
width: usize,
height: usize,
title: String,
}
// ...
let app = App {
// ...
title: "My app",
};
Rust 编译器发出了严厉的报错:expected struct std::string::String, found &str(期望得到 String 结构体,却得到了 &str)。
在这里,开发者迎来了思维模式的第一次碰撞。在大多数传统语言中,字符串类型的内部实现细节是被高度隐藏的。但在 Rust 中,语言强制开发者区分“堆上分配且拥有所有权的字符串(String)”与“硬编码在二进制文件中、仅仅是借用切片的字符串视图(&str)”。
为了安抚编译器,开发者按照编译器的建议,老老实实加上了 .to_string() 方法。虽然代码跑通了,但这一刻其实已经埋下了伏笔:在 Rust 中,你必须时刻清楚数据存放在哪里,以及谁拥有它的所有权。
三、架构层面的水土不服:没有类与继承的世界
有了窗口,接下来就该构建应用的业务逻辑框架了。
按照作者在使用 Java、C# 或 C++ 时的丰富经验,标准套路是这样的:写一个基类(比如 Client),其中包含空的 update 和 render 虚方法。接着,核心的引擎类 App 会持有一个 Client 的实例引用(或指针)。每当主循环开始,App 就负责处理底层的输入和视窗化工作,然后调用 this.client.update() 将控制权交还给具体的子类实现。如果某个项目还没写具体的 Client 逻辑,传入个 null 占位也未尝不可。
然而,当作者试图将这种熟悉的“基于回调和虚函数覆盖”的设计模式搬进 Rust 时,直接碰壁了:
- Rust 没有类(Class)。所以你不能简单地写一个父类然后去继承它。
- Trait(特征)不是接口(Interface)。很多初学者以为只要把 Java 的 Interface 关键字换成 Rust 的 Trait 就万事大吉了,其实它们在内存布局和分发机制上有本质区别。
- Rust 没有 Null。你不能随随便便把尚未初始化的客户端指针设为空。即使你使用
std::ptr::null_mut()退回到 C 语言的裸指针范畴,所有的解引用操作也会被强制标记为unsafe,这违背了我们使用 Rust 的初衷。
这意味着,面向对象的“基类-子类”多态设计在 Rust 中不仅不顺手,而且举步维艰。
四、深入核心痛点:无处不在却又隐形的生命周期
在经历了直接移植框架的挫折后,文章把焦点转向了 Rust 最核心、也是让无数传统开发者抓狂的概念:生命周期(Lifetimes)。
作者指出,生命周期不是 Rust 的一个“可选安全特性”,它不是事后追加的补丁,而是深植于这门语言最底层的基石。
为了解释清楚这个问题,作者首先展示了一个看似普通的函数,它接收一个引用作为参数。在多数情况下,开发者在代码里根本“看”不到生命周期,因为编译器为了减少视觉噪音,帮我们把生命周期参数推导并隐藏了。但如果在代码中显式写出来,它其实是一个带有泛型生命周期参数的函数,比如:
fn show_ticks<'wee>(mc: &'wee MyClient) {
// ...
}
这里 <'wee> 就代表引用的有效作用域。只要你是“用完即走”的传参调用,一切都很简单。但真正的灾难发生在你试图长期持有一个引用的情况下。
文章给出了一个经典的例子:尝试编写一个 Journal(日志)结构体,让它内部持有一个数组,数组里存放的是对字符串的引用:
struct Journal<'a> {
messages: Vec<&'a str>,
}
一旦结构体内包含引用类型,Rust 编译器就会强迫你在结构体上显式声明生命周期 <'a>。这代表着一个庄严的契约:Journal 这个结构体实例的存活时间,绝对不能长于它内部所引用的那些字符串的存活时间。
为了测试这个契约,作者写了一个极其典型的错误示例:在函数内部创建一个局部变量字符串 s,然后把它塞进新建的 Journal 里,最后试图把 Journal 返回给调用者:
fn get_journal<'a>() -> Journal<'a> {
let s = String::from("Tis a dark night...");
let mut journal = Journal::new();
journal.log(&s);
journal // 编译器报错!
}
编译器的拦截如约而至:cannot return value referencing local variable s。
在 Java 或 C# 开发者的潜意识里,这根本不叫问题:局部变量就算出了作用域,只要外面还有对象引用它,垃圾回收器(GC)自然会让它继续活在堆内存里。在 C 或 C++ 里,这更不是编译问题,只是你会在运行时荣幸地喜提一个野指针,引发段错误。而在 Rust 中,由于缺乏 GC,同时又必须保证内存绝对安全,编译器会在编译阶段无情地扼杀任何产生悬垂指针的可能。
五、回归工程实用主义:拥抱智能指针与引用计数
看到这里,很多有着深厚架构经验的开发者可能会感到沮丧:如果不允许自由传递和存储引用,如果不让用 Null,如果连把局部变量丢给一个长期存活的对象都不行,那稍微复杂一点的系统该怎么架构?难道我所有的代码都要写在一个巨大的函数里吗?
这就引出了文章最高潮、也最具实用价值的部分:如何在 Rust 中找回类似 Java/C# 的开发体验?
答案是智能指针——特别是引用计数指针 Rc(单线程)和 Arc(线程安全)。
当我们无法在编译期理清一张庞大错综的生命周期网时,或者数据的生命周期必须要在运行期动态决定时,我们就应该放下对“原生借用(&)”的执念。
作者将 Journal 的设计彻底重构,把内部的引用类型换成了智能指针:
use std::sync::Arc;
struct Journal {
messages: Vec<Arc<String>>,
}
奇迹发生了:所有那些让人头皮发麻的 <'a> 生命周期标注统统消失了!
因为智能指针的引入改变了所有权的运作方式。Arc<T> 提供了一种基于运行时的共享所有权机制。只要还有一个 Arc 指向这份数据,这片内存区域就不会被释放。这几乎等同于开发者手动接管了一个精准、无停顿的“微型垃圾回收器”。
并且,Rust 提供了极其优秀的开发体验:Arc<T> 与普通的引用 &T 并不是对立的。当你在 Journal 内部存储了 Arc<Event> 时,如果你仅仅需要读取它的属性(比如调用打印函数),得益于 Rust 的自动解引用(Deref Coercion)机制,你可以直接把它当作 &Event 来使用,或者使用 .as_ref() 方法平滑转换。
如果需要把这个事件传递给另外一个持有者?你不需要深度拷贝整个大对象,只需要执行 .clone() 方法。对于智能指针而言,clone 只是让引用计数的数字加一,代价微乎其微。
六、总结语:重新建立对内存安全的思考方式
通读整篇文章,原作者其实在传递一个强烈的核心理念:Rust 并不是在刻意为难开发者。
如果你带着 Java、C# 或者 C++ 的惯性思维去写 Rust,你会觉得处处是阻碍。因为其他语言要么让你用性能(垃圾回收)换取开发心智的解放,要么让你用潜在的灾难(指针错误)换取自由。而 Rust 既想要 C/C++ 级别的控制力,又想要超越 Java 的绝对安全性。
为了达到这个目标,Rust 要求开发者必须转变思维:
- 不要试图在 Rust 里写面向对象的继承树;
- 深刻理解数据的生存期(生命周期)并对谁拥有它(所有权)负责;
- 当遇到难以梳理的网状状态管理时,熟练运用
Rc/Arc智能指针来代替原生引用。
当你真正放下了“怎么在 Rust 里写 Java”的执念,开始按照 Rust 的规则思考数据流动时,那面曾经阻挡你的编译器高墙,就会变成保护你免受无数并发错误与内存漏洞侵扰的最强护盾。