本文就是一时兴起,想写一篇给 Javaer 的 Rust 入门,很多 Java 开发者,都对这门语言感兴趣,但是可能因为它的学习路线非常陡峭而放弃。事实也确实如此,这门语言里的不少概念,比如所有权、借用检查、生命周期,对 Java 程序员来说是完全陌生的,但反过来想,Rust 里也有大量的东西,我们其实在 Java 里早就见过了,只是名字不同而已。还有这几年 Java 的升级,其实也借鉴了很多 Rust 上的东西。
而且现在有了 AI Coding 的加持,其实我们不必像过去一样,非常精通一门语言才能开始使用它,只要能看得懂语法,知道它的玩法,它就可以成为你的一个技能点。
我工作这么多年,主力语言一直是 Java 和前端的一些技术栈。两三年前开始使用 Rust,我觉得它是非常有趣的语言,是那种能带给我们新的思考的语言。
我希望本文是一篇有趣的文章,也是一篇有用的文章。我会通过对比大家熟悉的 Java,来帮助大家理解 Rust 的各种内容。
作为本文的读者,默认你写过几年 Java,对 JVM、Maven、泛型、lambda、并发这些都了解。本文不会讲“在 Rust 里面什么是变量?”这种东西,但是会和 Java 做不少的对比,再加上要学习的概念确实不少,所以本文不会太短。感兴趣的可以按章节慢慢看,不一定要一口气读完,还有就是很多细节可能看第一遍的时候是不太懂的,这其实也没太大的关系,看完全文再倒回去看可能就豁然开朗了。
Rust 和 Java 是不同的设计方向
学 Rust 的时候最大的感受不是语法难,而是它老是在逼你换一种写代码的方式。
写 Java 代码,大家脑子里都是这些:
- 对象统统在堆上,变量里放的是引用。
- 对象什么时候释放,交给 GC,我们不关心。
- 参数传来传去,本质都是引用拷贝,多个变量可以指向同一个对象。
- 多线程共享对象,靠
synchronized、Lock、volatile、并发容器兜底。 - 空值用
null,异常用try/catch。
这些东西太自然了,自然到我们平时根本不会多想。Rust 麻烦就麻烦在这里,它几乎把这些全部改掉了:
- 对象默认放栈上,要在堆上分配得显式用
Box、Vec、String这类。 - 没有 GC,靠所有权 + 借用检查在编译期把内存安全管好。
- 参数传递可能会把值“拿走”,原变量就不能用了。
- 多线程共享和可变性都不是想 share 就 share,类型系统(
Send/Sync)会拦你。 - 没有 null,没有 checked exception。空值用
Option<T>,错误用Result<T, E>。
Rust 的设计其实在做一件很朴素的事:把 Java 里很多运行时才会暴露的问题,提前到编译期解决掉:
- Java 里可能 NPE,Rust 用
Option<T>逼你处理。 - Java 里可能忘记 catch,Rust 用
Result<T, E>逼你处理。 - Java 里可能两个线程同时改一个对象,Rust 用所有权和
Send/Sync逼你说清楚。 - Java 里对象什么时候被释放靠 GC,Rust 在编译期就把每个值的销毁点算清楚。
也就是说,这两门语言架构上的核心差别就一句话:Java 中间有 JVM 和 GC 帮你兜底,而 Rust 让编译器在编译期把规则检查完。这也是为什么 Rust 的编译器看起来很烦,初学者会一直在跟编译器做斗争,想要写出可编译的代码可能就已经要了老命了。
大家把这几条记心里就行,后面我们会逐步介绍其细节。
一、工具链:rustup 就是 Rust 的 SDKMAN
我们先来看看 Rust 的工具链,这部分其实最容易上手,因为基本就是 Java 生态那一套换了个名字。
Java 这边我们装环境,先有 JDK,里面带 javac、java、jar、javadoc、jshell 这些工具;如果要管理多个 JDK 版本,会用 SDKMAN 或者 jenv。
Rust 这边对应的关系是这样的:
rustup:版本管理工具,对应 SDKMAN/jenv,负责下载、切换 Rust 工具链rustc:编译器,对应 javaccargo:构建+包管理,对应 Maven 或 Gradle(这个非常重要,后面单独讲)rustfmt:格式化,对应 google-java-formatclippy:lint 工具,对应 SonarLint / SpotBugsrust-src、rust-docs、rust-std:源码、文档、标准库
这一整套东西打包在一起叫 toolchain,统一通过 rustup 来管理。我们可以看到,Rust 把“代码风格一致性”当成语言体验的一部分,直接在 toolchain 中包含了 format、lint 等工具,让大家可以更好地管理代码风格与约束。
Rust 还分 stable、beta、nightly 三个版本,大致可以这么理解:
- stable:对应 Java 的 LTS,正常项目用这个
- beta:下一个 stable 候选版本
- nightly:每天构建的最新版,对应 Java 的 EA(early access),有些实验特性只在 nightly 上能用
常用命令我们看一眼:
# 安装 rustup(顺带把 stable 工具链装上)
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
# 看版本,这个跟 java -version 一个意思
rustc --version
# 升级,rustup 会顺便把 cargo 也升了
rustup update
# 看当前用的是哪个 toolchain
rustup show
# 看当前 toolchain 装了哪些组件
rustup component list --installed
# 装 clippy(默认其实就装好了)
rustup component add clippy
# 切换版本,类似 sdk use java xxx
rustup install 1.90.0
rustup default 1.90.0
rustup default beta # 切换到 beta
rustup default nightly # 切换到 nightly
# 只在当前目录用某个版本,类似项目级别的版本配置
rustup override set nightly
rustup override set 1.95.0
工具链装好以后,rustfmt 和 clippy 就可以直接用了:
cargo fmt # 代码格式化
cargo clippy # 代码质量检查
我们是可以给 rustfmt 和 clippy 定制规则的,这是后话了,不做介绍。
到这里,工具链层面我们就跟 Java 对上号了。下面我们看看怎么写第一个程序。
二、Hello World
老规矩,写完 Hello World,就算入门 Rust 了。
写个 main.rs:
fn main() {
println!("Hello, world!");
}
然后编译:
rustc main.rs
这里要注意三个点:
- 入口函数叫
main,写法是fn main(),跟 Java 几乎一样。 println!后面有个感叹号,说明它不是普通函数,是个宏(macro),后面会讲。- 编译出来的是直接可执行的二进制文件,不像 Java 的
.class字节码,需要 JVM 才能跑。
./main
这就是 Rust 的第一个核心区别:它没有运行时虚拟机,没有 GC,跟 C/C++ 一样编译成原生代码直接跑。这也是为什么 Rust 适合做系统编程、底层服务、CLI 工具,而 Java 由于 JVM 的存在,在启动速度和资源占用上一直是劣势。
不过,真实工程里我们基本不会直接用 rustc 来编译,就跟我们在 Java 里几乎不会直接用 javac 一样,都是用构建工具来组织。Rust 的构建工具叫 cargo。
三、Cargo:Rust 世界的 Maven
在装 rust 的时候顺手就装好了 cargo,跑 rustup update 升级 Rust 的时候 cargo 也跟着升。
我们直接看用法:
cargo new hello_cargo
这个相当于 mvn archetype:generate,生成一个项目骨架。它其实只是帮我们创建 src/main.rs 和 Cargo.toml。
Cargo.toml 跟 pom.xml 的角色完全一样,都是项目配置文件,只不过格式是 TOML。看一眼:
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2021"
[dependencies]
# 在 java 里我们叫 jar 包,在 rust 里叫 crate
# 版本格式是强制要求,不像 java 那么自由,因为它涉及到版本自动升级
rand = "0.8.5"
接着是构建命令:
cargo build # 类似 mvn compile,默认编译出一个 debug 版本
cargo build --release # 类似 mvn package -P release,会做优化、去掉调试信息等,生成一个最终可用版本
cargo run # 类似 mvn exec:java,build + run 一条龙
cargo check # 只检查能不能过编译,不生成产物,速度快得多
cargo clean # 类似 mvn clean,把 target 目录删掉
构建产物在 target/debug/ 或 target/release/ 下面。
这里要特别提一下 cargo check。Rust 的编译比 Java 慢得多(因为它要做单态化、借用检查这一堆事),所以日常写代码时常用 cargo check 看类型和借用规则能不能过,速度会快很多。等到要真正跑了再 cargo build。
依赖管理这块我们重点看一下:
cargo add axum@0.7.2 # 类似 mvn dependency:add(其实 javaer 都是手动复制粘贴依赖的),加依赖到 Cargo.toml
cargo update # 按 SemVer 兼容范围升级:1.2.3(即 ^1.2.3)会升到 <2.0.0;0.8.5(即 ^0.8.5)会升到 <0.9.0
cargo tree # 类似 mvn dependency:tree
cargo doc --open # 生成依赖的文档并打开,介绍各个API,其实没啥用,至少Java的docs,我们现在几乎是不看的
这里有个 Cargo.lock 文件,作用跟 npm 的 package-lock.json 一样,确保依赖版本固定。Maven 没有这个东西,因为 Maven 的版本号本身就是固定的(除了 SNAPSHOT),但 Cargo 的版本号是语义化的范围("0.8.5" 实际表示 ">=0.8.5, <0.9.0"),所以需要 lock 文件来固化。
应用项目一般要把 Cargo.lock 提交到 git,库项目通常不太关心这个,按项目习惯来。
如果要用私有 registry,类似 Maven 的私服:
[dependencies]
my_crate = { version = "1.0", registry = "private-registry" }
[registries]
private-registry = { index = "https://your-private-cargo-registry.com" }
这里有一个跟 Java 差异很大的地方需要特别说一下:Rust 不允许你"白嫖"上游的传递依赖。
什么意思呢?即使 a 依赖了 c,你的项目想用 c 的 API,也必须自己也在 Cargo.toml 里再写一遍 c。这样做的好处是,上游用了哪些依赖是它自己的实现细节,以后它升级或者换掉 c,都不会破坏你的代码。
再来看版本冲突的场景:假设你的项目依赖了 a 和 b,a 和 b 都依赖了 c,但是版本不一样。
在 Java 里,Maven 会做 dependency mediation,最终选一个版本,然后大家都用这个版本,可能就引发各种 NoSuchMethodError。
在 Rust 里:
- Cargo 会下载两个版本的 c,a 和 b 在链接的时候各自用各自的版本
- 不会有 Java 那种"依赖地狱"
跟 Java 还有一点很不一样的是包的设计风格。Rust 生态更喜欢把一个包做大、提供大量 features,使用方按需开启,比如 tokio:
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
这个习惯的成因是:Rust 是静态编译的,编译器会做 dead code elimination,没用到的 feature 在最终产物里根本不存在。所以一个大包加 features 反而更高效,依赖管理也更简单。Java 那边就没这个传统,大家都做小包,比如 Spring 就有非常多的 jar 包。
Clippy 我们前面提过一嘴,它就是 Rust 的 SonarLint,做静态分析、检查各种 idiom、发现潜在 bug:
cargo clippy
可以通过 clippy.toml 配置规则。一般来说 IDE 插件会自动集成,不需要你手动跑。
最后看一个文件:rust-toolchain.toml。这个文件放在项目根目录,作用是锁定项目用的 Rust 版本:
[toolchain]
channel = "1.90.0"
targets = ["x86_64-unknown-linux-gnu"]
components = ["rustfmt", "clippy"]
如果别人 clone 你的项目,本地没有 1.90.0,cargo 会自动下载安装。这个有点像 Java 项目里的 .sdkmanrc 或者 Maven 的 enforcer 插件,但更原生、更可靠。
好了,工具链就到这里。下面我们正式进入语言本身。
四、基础语法:跟 Java 大同小异
Rust 的语法表面上跟 C/Java 系还是比较像的,只不过 Rust 的类型放在变量名后面,主要原因应该还是能推导就推导,这样不用显式写类型。
变量定义用 let,默认不可变(跟 Java 反过来,Java 默认可变,要加 final 才不可变):
let x = 5;
x = 6; // 编译错误,因为 x 是不可变的
let mut y = 5; // 加 mut 表示可变
y = 6; // OK
在 Java 中,我现在也喜欢用 var 关键字,让编译器自动做类型推导,个人觉得 Java 还是肯吸收别的语言的好东西的。
mut 这个关键字以后会反复出现,它就是"可变"的意思。为什么 Rust 要这么设计?简单说,Rust 希望你默认写出"少共享、少修改"的代码。变量能不改就不改,引用能只读就只读。后面讲并发时你会发现,这个习惯和 Rust 的安全模型是连在一起的。
Rust 还有一个 Java 没有的概念叫 shadowing,就是同名变量复用:
let x = 5;
let x = x + 1; // 这是一个新变量,跟原来的 x 没关系,只是名字一样
let x = "hello"; // 类型甚至可以变
shadowing 在处理“同一个数据,类型变了”的场景特别有用。Java 里我们经常这样写:
String text = "123";
int value = Integer.parseInt(text);
int result = value + 1;
这个代码很讨厌的就是,我们一直在取新的名字。Rust 允许你在同一条处理链上一直用 x 这个名字,对应类型可以变。这个东西刚开始看有点怪,用多了会觉得还挺顺。
数据类型这块,Rust 的整数类型比 Java 细:
i8, u8 // 有符号/无符号 8 位
i16, u16
i32, u32 // 默认整数类型
i64, u64
i128, u128
isize, usize // 跟平台位宽一致,用作下标的就是 usize
f32, f64 // 浮点,默认 f64
bool, char // 注意 char 是 4 字节的 Unicode 标量值,不是 ASCII 字符!
Java 没有无符号整数类型,Rust 这里就完整多了。另外 char 这块,Java 的 char 是 2 字节、只能表示 BMP,Rust 的 char 直接是 4 字节,能塞下中文、emoji。
复合类型:
// 元组:异构
let tup: (i32, u32) = (100, 1);
let first = tup.0; // 通过 .0 .1 访问
// 数组:定长、同构
let a: [i32; 5] = [1, 2, 3, 4, 5];
let b = a[1]; // 越界访问会 panic(相当于 Java 抛 ArrayIndexOutOfBoundsException,但 Rust 用的是 panic 机制,不是受检异常)
在 java 中,可以用 record 实现类似 tuple 的效果。
字面量这里有几个 Rust 特有的写法:
123_456 // 下划线分隔,可读性
0xff // 十六进制
0o123 // 八进制
0b11100 // 二进制
b'A' // 单字节字符(u8)
b"abc" // 字节字符串字面量,类型是 &[u8; 3],可以强转为 &[u8]
函数定义:
fn plus_one(x: i32) -> i32 {
x + 1
}
这里有个非常 Rust 特色的细节:函数最后一行不带分号,就是返回值。
fn main() {
let y = {
let x = 3;
x + 1 // 注意没分号,整个块的值是 4
};
println!("y = {y}");
}
还有 {} 块本身就是表达式,上面这个例子里面 x + 1 就是这个块的返回值,赋给了 y。Java 里 {} 是语句块,没这个语义。
带分号的情况下,那一行就变成语句,值是 (),可以简单理解为 Java 的 void。所以下面这段就编译不过:
fn add(a: i32, b: i32) -> i32 {
a + b; // 错误,函数声明返回 i32,但这里返回了 ()
}
if 在 Rust 里也是表达式(再次和 Java 不同):
let number = if condition { 5 } else { 6 };
这种东西在 Java 里我们用三元运算符 condition ? 5 : 6 来表达。
循环用 loop、while、for,最好用的是 for:
let a = [10, 20, 30, 40, 50];
for element in a {
println!("the value is: {element}");
}
常量用 const,跟 Java 的 static final 类似:必须显式指定类型,必须在编译期能确定值,不能用 mut:
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
全局变量用 static,跟 Java 的差不多,必须声明时初始化:
static G1: i32 = 10;
// 也可以 mut,但这样的话,读写都得用 unsafe(后面讲 unsafe)
static mut G2: i32 = 0;
unsafe {
G2 = 5;
}
需要补充一句:static mut 在 Rust 2024 edition 已经被强烈不建议使用了,对它取引用会直接报错。如果真的需要共享可变全局状态,更推荐 Mutex、RwLock、OnceLock 这些类型,知道有这么个东西就行,先不深入。
跟 const 的区别:const 可能被编译器内联进每个使用点,static 就是个真实的内存地址。
注释用 // 或 /* */,跟 Java 一样。
到这里,跟 Java 大同小异的部分基本介绍完了。下面进入 Rust 的核心,也是 Java 程序员最不熟悉的部分。
五、Ownership:Rust 最有特色、也是最劝退的概念
Java 程序员一上来最容易被劝退的就是这块。我们慢慢来。
Java 是怎么管理内存的?
我们先回忆一下 Java。Java 的对象都在堆上,栈上只放引用:
User u1 = new User("javadoop");
User u2 = u1;
这两行做的事,我们都很熟悉:堆上有一个 User 对象,栈上 u1 和 u2 这两个引用指向它。
栈上:
u1 ─┐
├──> 堆上的 User("javadoop")
u2 ─┘
对象什么时候被回收?GC 决定。GC 通过可达性分析判断哪些对象没人引用了,然后回收它们的内存。
GC 的好处是开发者不用关心内存释放,写起来爽。坏处是:
- 有 STW,对延迟敏感的场景不友好
- 内存占用偏大
- 不够确定,你不知道一个对象什么时候真正被释放
所以 Java 程序员习惯了一个事实:引用可以随便复制,对象释放不用自己管。
但是 Rust 没有 GC,它也不像 C 那样让你 malloc/free,那样太容易出错。Rust 的方案是编译期就确定每个值什么时候被释放,靠的是一套叫所有权(ownership)的规则。
Rust 必须回答一个问题:一块堆内存,到底谁负责释放?
如果有多个变量都觉得自己"拥有"这块内存,那就麻烦了。释放一次,另一个变量就成了悬垂指针;释放两次,就是 double-free;都不释放,就是内存泄漏。Rust 的答案非常直接:一个值,在任意时刻只能有一个 owner。
三条规则,先背下来:
- Rust 中的每一个值都有一个所有者(owner)
- 值在任意时刻有且只有一个所有者
- 当所有者(变量)离开作用域,这个值就会被丢弃
是不是看起来还挺简单?我们看具体例子。
移动(move)
对基础类型,没什么好说的,直接拷贝:
let x = 5;
let y = x; // 栈上有两个 5,x 和 y 都能用
但是对于 String 这种堆上分配的类型:
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}"); // 编译错误!s1 已经被 move 给 s2 了
这里发生了什么?String 内部是一个 (指针, 长度, 容量) 的结构,let s2 = s1 这一行,从 Java 视角看就是一次浅拷贝(s1 和 s2 都指向同一块堆数据)。但 Rust 不允许两个变量同时拥有同一份堆数据(规则 2),所以它让 s1 失效,这个操作叫 move。
图大概这样:
move 之前:
s1 ───> String { ptr, len, capacity } ───> 堆内存 "hello"
move 之后:
s1 失效
s2 ───> String { ptr, len, capacity } ───> 堆内存 "hello"
注意,这里堆上的 "hello" 没有复制一份,只是 owner 变了。
为什么要这么做?想象一下,如果 s1 和 s2 都指向同一块堆内存,s1 离开作用域时它要不要释放堆内存?如果释放了,s2 就指向了悬垂指针。如果不释放,那要靠谁来释放?这就是 C++ 里 double-free 问题的根源。Rust 通过"单一所有者"直接绕开了这个问题。
如果你真的想要两份独立的数据,用 clone:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("{s1} and {s2}"); // OK,s1 还能用
这就是 Java 里的深拷贝。
Copy trait
那基础类型为什么不用 clone 也能继续用呢?因为它们实现了 Copy trait(先别管 trait 是什么,类似 Java 的接口),只要类型实现了 Copy,赋值操作就是在栈上的按位复制,不发生 move。
i32、bool、char、f64 这些基础类型都实现了 Copy。元组只要里面的元素都是 Copy,元组本身也是 Copy。
简单记几条:
i32、bool、char这类基础类型通常是Copy。String、Vec这类拥有堆内存的类型通常不是Copy。- 不是
Copy的类型,赋值、传参、返回值都可能发生 move。
引用和借用 borrow
每次都 move 来 move 去,写起来简直是噩梦。来看这段代码:
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}.", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length) // 因为 s1 已经被 move 进函数了,所以得返回回去
}
这种写法多多少少有点大病。我只是想知道字符串的长度,结果还得把字符串本身传出来再传回去。所以 Rust 提供了引用:
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // 传引用
println!("The length of '{}' is {}.", s1, len); // s1 还能用
}
fn calculate_length(s: &String) -> usize {
s.len()
}
&s1 表示"我借给你 s1,但所有权还在我这里"。这个动作叫 borrow(借用),符号是 &。
简单类比:在 Java 里,方法参数传对象,本质就是传一个引用,方法内部可以访问对象,但对象的"主人"还是外面的代码。Rust 的引用大体上就是这个意思,只不过多了一层规则约束。
默认引用是不可变的。要修改,得用可变引用:
fn main() {
let mut s = String::from("hello");
change(&mut s); // 注意 mut 关键字到处都要写
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
可变引用这块,mut 写了两次,初学时很烦:
let mut s表示变量s可以被修改。&mut s表示把它以可变引用的方式借出去。
Rust 的借用规则可以用一句话概括:
同一时间,要么有任意多个不可变引用,要么只有一个可变引用,但不能同时有两者。
Rust 借用规则就是把这种事情提前拦住。只要有人在读,就不能同时拿一个可变引用去改;只要有人在改,就不能再让别人读或改。
多个只读引用可以:
let mut s = String::from("hello");
let r1 = &s; // 不可变引用
let r2 = &s; // 不可变引用,可以
println!("{r1}, {r2}");
一个可变引用也可以:
let mut s = String::from("hello");
let r = &mut s;
r.push_str(", world");
但是读写混在一起不行:
let mut s = String::from("hello");
let r1 = &s; // 不可变引用
let r2 = &mut s; // 编译错误!有不可变引用时不能要可变引用
println!("{r1}");
两个可变引用也不行:
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 编译错误!同时两个可变引用
这个模型跟读写锁长得很像,但它不是运行时真的加了一把锁,它是编译器在检查引用关系。
引用的作用域是从声明开始到最后一次使用为止,叫做 NLL(Non-Lexical Lifetime):
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} and {r2}"); // r1, r2 最后一次用在这里
let r3 = &mut s; // OK,因为 r1, r2 已经不再使用
读到这里,所有权和借用的核心规则就讲完了。这部分初学的时候必然会跟编译器打架,习惯就好。Rust 编译器的报错信息写得相当友好,按提示改基本能过。
最后强调一点:借用检查全部是编译期完成的,运行时不会有任何额外开销,这是 Rust 一个非常重要的卖点。Java 那边为了实现线程安全,运行时要加各种锁、做各种 happens-before 同步,性能上是要付出代价的。Rust 把这些事情提前到编译期解决,运行时的代码就是干干净净的原生代码。
六、Struct:Rust 的"类"
Struct 就是 Rust 用来组织数据的方式,相当于 Java 的 class 但是只有字段没有方法。方法是在 impl 块里定义的。
定义和使用:
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
let mut user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};
// 结构体更新语法,类似 Java 里 Lombok 的 @With
let user2 = User {
email: String::from("another@example.com"),
..user1 // 其余字段从 user1 取
};
注意:..user1 这个操作可能会发生 move,比如 username 是 String,会被 move 到 user2,user1.username 之后就不能用了。
字段速记法(field init shorthand),跟 ES6 一样:
fn build_user(email: String, username: String) -> User {
User {
active: true,
username, // 等价于 username: username
email,
sign_in_count: 1,
}
}
Rust 还有几种特殊的 struct:
// 元组结构体,没有字段名
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
// 单元结构体,没有任何字段,类比 Java 的标记类
struct AlwaysEqual;
如果 struct 字段是引用,必须标注生命周期(后面讲),下面这个会报错:
struct User {
username: &str, // 编译错误:missing lifetime specifier
}
因为 username 是引用类型,而我们没有给它标注生命周期。先知道有这么个东西就行,我们先不要在 struct 上涉及引用,直接让 struct 拥有值。
方法和 self、&self、&mut self
Java 里方法跟字段都写在 class 里面,混在一起。Rust 不这么搞,数据放 struct,而方法放 impl 块里:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
let rect = Rectangle { width: 30, height: 50 };
rect.area();
这里我们重点看一下 &self —— 它是 Rust 方法签名里最关键的部分。
类比 Java:Java 方法里的 this 是个隐式参数,永远是一个引用,而且方法默认就能改字段。Rust 里的 self 是显式参数,并且分了三种形式:
self:拿走所有权(method 调用完就消费掉对象了)&self:不可变借用(最常用)&mut self:可变借用
struct Foo(i32);
impl Foo {
fn new() -> Self { // 没有 self,叫"关联函数",类比 Java 静态方法
Self(0)
}
fn consume(self) -> Self { // 拿走所有权,调用后原对象不能再用
Self(self.0 + 1)
}
fn get(&self) -> &i32 { // 不可变借用,最常见
&self.0
}
fn get_mut(&mut self) -> &mut i32 { // 可变借用,需要修改字段时用
&mut self.0
}
}
调用方式:
let foo = Foo::new(); // 关联函数用 :: 调用
foo.get(); // 实例方法用 . 调用,自动加引用
Foo::get(&foo); // 等价写法
&self 其实就是 self: &Self 的缩写,方法调用时编译器自动加 &。这跟 Java 的 this 自动传入是一个意思。
Java 里 this 永远是引用,方法默认就能改字段。Rust 把这件事拆开了:
- 只是读,就写
&self - 要改,就写
&mut self - 要消费整个对象,就写
self
我们看几个标准库的例子:
String::len(&self):只读取长度,用&selfString::push_str(&mut self, ...):在原字符串上追加,用&mut selfString::into_bytes(self):把 String 拆成Vec<u8>,原 String 没用了,用self
这个设计刚开始有点啰嗦,但它让 API 的意图非常清楚。你看到方法签名,就知道它会不会修改对象,会不会拿走对象。这是 Rust API 设计上一个非常贴心的地方。
跟 Java 不一样的还有一点,一个 struct 可以有多个 impl 块。比如你可以把方法按功能分到不同的 impl 块里,编译器最终会合并。
方法调用时 Rust 会自动引用和解引用,下面两个等价:
p1.distance(&p2);
(&p1).distance(&p2);
关联函数(associated function)
不带 self 的就是关联函数,类比 Java 的 static 方法:
impl Rectangle {
fn square(size: u32) -> Self {
Self { width: size, height: size }
}
}
let r = Rectangle::square(10);
String::from、Vec::new 这些都是关联函数。最常见的用途就是构造函数,但也可以是和这个类型相关的工具函数(比如 i32::from_str_radix,把字符串按指定进制解析成整数)。
打印结构体
Java 里我们重写 toString 来打印对象。Rust 这边我们用 Debug 或 Display trait(trait 后面讲,先认住语法):
#[derive(Debug)] // 自动派生 Debug
struct Rectangle {
width: u32,
height: u32,
}
let rect = Rectangle { width: 30, height: 50 };
println!("{rect:?}"); // Rectangle { width: 30, height: 50 }
println!("{rect:#?}"); // 多行展开,更清晰
#[derive(...)] 是属性宏,类似 Lombok 的 @Data,自动给你生成代码。上面这种 :? 或者 :#? 使用的就是 Debug trait 的输出。
当然如果我们给 Rectangle 实现 Display trait,我们也可以这样:
use std::fmt;
impl fmt::Display for Rectangle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} x {}", self.width, self.height)
}
}
println!("{}", rect); // 使用 Display trait 打印结构体
println!("{rect}"); // 或者这样写,它们是等价的
七、Enum:比 Java 强大得多的枚举
Java 的 enum 你可以理解成"值就这几个的常量类"。Rust 的 enum 远不止于此,每个枚举变体可以带数据,而且数据形式可以不同。
enum Message {
Quit, // 不带数据
Move { x: i32, y: i32 }, // 带具名字段(像 struct)
Write(String), // 带一个 String
ChangeColor(i32, i32, i32), // 带元组
}
是不是很强?这种 enum 在 Java 里只能用 sealed interface + record 来模拟(Java 17+),相当啰嗦:
sealed interface Message permits Quit, Move, Write, ChangeColor {}
record Quit() implements Message {}
record Move(int x, int y) implements Message {}
record Write(String text) implements Message {}
record ChangeColor(int r, int g, int b) implements Message {}
enum 也可以加方法:
impl Message {
fn call(&self) {
// ...
}
}
let m = Message::Write(String::from("hello"));
m.call();
Option:替代 null 的存在
Rust 没有 null。要表达"可能有值,可能没有",用 Option<T>:
enum Option<T> {
None,
Some(T),
}
这跟 Java 的 Optional<T> 思路一样,但 Java 的问题是:就算用了 Optional,别人还是可以给你传 null,编译器是没有任何保证的。Rust 不一样,普通类型就是普通类型,Option<T> 就是可能为空的类型,二者在类型系统里分得很清楚。
这点对写代码的影响非常大。Java 里你看到:
User findUser(long id);
你不知道它找不到时会怎么样:
- 返回 null?
- 抛异常?
- 返回一个空对象?
Rust 里如果可能找不到,签名通常会写成:
fn find_user(id: u64) -> Option<User>;
这就很清楚了:调用方必须处理 None。
Option 是 prelude 里默认导入的,直接用:
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None; // 赋空值,类似 Java 中的 Integer absent_number = null;
要拿出 Option 里的值,得用 match:
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1), // 解构里面的数据
}
}
match:增强版 switch
match 大致对应 Java 的 switch,但更强:
- 必须穷尽所有可能(编译期检查)
- 可以解构(destructure)出数据
- 是表达式,可以赋值
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
other => move_player(other), // 通配符,绑定到 other
// _ => ... // 不关心值时用 _
}
Java 17 里加的 pattern matching 跟 Rust 思路相似,其实都是在借鉴 ML/Haskell/Scala 这些函数式语言的成熟设计,目前 Java 的完整度还差不少。
if let
只关心一个分支的时候,用 match 显得啰嗦:
let a = Some(10);
match a {
Some(v) => println!("v: {v}"), // 有值的时候进行打印
_ => (), // 没值的时候直接忽略
}
这种情况用 if let 简化:
let a = Some(10);
if let Some(v) = a {
println!("v: {v}");
}
if let 是 match 的语法糖,代价是放弃了穷尽性检查。带 else 分支也行:
if let Some(v) = a {
// ...
} else {
// ...
}
while let 同理,常用于循环消费 Option:
while let Some(top) = stack.pop() {
println!("{top}");
}
Result:替代异常的存在
前面我们说过,Rust 没有 Java 的 try/catch。可能失败的操作返回的是 Result<T, E>:
enum Result<T, E> {
Ok(T),
Err(E),
}
Ok(T) 表示成功并带上结果,Err(E) 表示失败并带上错误信息。Result 跟 Option 思路完全一样,只不过 Option 不关心"为什么没值",Result 关心"失败的原因"。
举个例子,文件读取在 Java 里要么 throws IOException,要么调用方 try/catch:
String content = Files.readString(Path.of("hello.txt"));
到 Rust 里,签名长这样:
fn read_to_string(path: &Path) -> Result<String, io::Error>;
调用方必须处理 Result:
match std::fs::read_to_string("hello.txt") {
Ok(content) => println!("{content}"),
Err(e) => eprintln!("读取失败:{e}"),
}
每次都写 match 也挺啰嗦的,所以 Rust 提供了 ? 操作符:
fn read_username() -> Result<String, io::Error> {
let content = std::fs::read_to_string("user.txt")?; // 出错就提前 return Err
Ok(content.trim().to_string())
}
? 后缀的语义是:拿到 Ok 就把值解包出来继续往下走,拿到 Err 就把错误直接 return 给调用方。这相当于 Java 里的 throws 自动传播,只不过 Rust 把它做成了表达式级别的语法糖,而且必须显式标记每一处可能失败的调用,不会出现"忘了 catch"的情况。
? 也可以作用在 Option 上,含义是"是 None 就直接 return None",思路一致。
八、集合:Vec、HashMap
// todo
我会慢慢补充完整,大家也可以提提意见。
0 条评论