跳转到内容

面向 Java 开发者的 Rust:实用指南

如果你和 Java 打了很多年交道,学 Rust 需要的东西你大多已经有了。你习惯用类型思考,你懂泛型、接口、lambda 和 Stream 流水线。这些 Rust 全都有:trait 很像带默认方法的接口,迭代器读起来就像 Stream,Cargo 干的是 Maven 或 Gradle 的活,只是配置少得多。

新东西是以前 JVM 替你包办的那一切。Rust 没有垃圾回收器,也没有虚拟机。你的代码会编译成原生二进制文件,什么时候释放内存,由编译器在程序运行之前检查所有权和借用来决定。Rust 也没有 null(用的是 Option)、没有异常(用的是 Result),也没有类继承。你的大部分学习时间都会花在这四点上。

Java 开发者为什么会学 Rust?通常是因为下面某个原因:服务需要又低又稳的内存占用,而且不能有 GC 停顿;命令行工具和 Serverless 函数要秒启动,不想带一个 JVM;Java 应用里有一条热点路径想搬到原生代码里(Java 可以通过 JNI 或较新的 Foreign Function & Memory API 调用 Rust);或者你只是好奇,Rust 为什么总能在“最受推崇的语言”调查里拿第一。这一页把你在 Java 里学到的东西一一对应到 Rust,好让你专心对付真正不一样的部分。

如果你想看 Rust 和 C++、Go、Python 的整体对比,请看 Rust 与 C++、Go、Python 对比。如果你也懂 .NET,还有单独的一页:面向 C# 开发者的 Rust。

把这张表当成一本“短语手册”来用。最后一列链接到本站讲解 Rust 这一边的课程。

Java Rust 延伸学习
带字段和方法的 class 用 struct 存数据,再用 impl 块写方法 Rust 结构体
构造函数和 new 一个返回 Self 的普通函数,按约定叫 new Rust 结构体
interface trait Rust trait
接口里的 default 方法 trait 里的默认方法(用法一样) Rust trait
abstract class、extends、super 没有继承。共享行为用带默认方法的 trait,共享数据用组合(结构体里套结构体) Rust trait、设计模式
装着不同类的 List<Shape> Vec<Box<dyn Shape>>(trait 对象) trait 对象(dyn)
sealed interface + record + 模式 switch(Java 17/21) 变体携带数据的 enum,再加上 match Rust 枚举、Rust match
带字段和构造函数的 enum 每个变体都能存放不同数据的 enum Rust 枚举
record Point(int x, int y) #[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } Rust 结构体、常用 trait
toString()、equals()、hashCode()、compareTo() Display / Debug、PartialEq / Eq、Hash、PartialOrd / Ord(通常用派生) 常用 trait
==(同一个对象)与 .equals()(值相同) == 永远比较值。不存在“比较的是引用”这个坑 Rust 运算符
null 和 Optional<T> Option<T>:要么是 Some(value),要么是 None Rust Option
orElse、orElseGet、orElseThrow、map、flatMap、ifPresent unwrap_or、unwrap_or_else、ok_or / expect、map、and_then、if let Some(...) Rust Option、if let / while let
受检异常和 throws 返回类型里写 Result<T, E>,再加上 ? 运算符 Rust Result、自定义错误类型
表示真正 bug 的非受检异常(IllegalStateException) panic!(终止当前线程) Rust panic
异常链(new ConfigException(msg, cause)) map_err,或者 thiserror 的 #[source] 和 anyhow 的 .context(...) anyhow 与 thiserror
try-with-resources 和 AutoCloseable Drop,所有者离开作用域时自动运行 Rust 作用域、Rust 智能指针
垃圾回收器 所有权和借用,在编译时检查 Rust 所有权、Rust 借用
final 局部变量 普通的 let(默认不可变);要允许修改就写 let mut Rust 变量
static final 常量 const Rust 变量
可变的 static 字段、单例 配合 LazyLock 或 OnceLock 的 static,需要修改时再包一层 Mutex 共享状态
带 <T extends Comparable<T>> 的泛型 带 trait 约束的泛型:T: Ord 或 where T: Ord Rust 泛型
类型擦除,要写 List<Integer> 而不是 List<int> 单态化:泛型代码会为每种类型各编译一份,Vec<i32> 里存的就是普通数字 Rust 泛型、高级泛型
lambda、Function<T, R>、Supplier<T>、Consumer<T> 闭包,由 Fn、FnMut、FnOnce 这几个 trait 描述 Rust 闭包、Fn、FnMut 与 FnOnce
Stream:filter、map、reduce、collect 迭代器适配器:filter、map、fold、sum、collect Rust 迭代器
Iterable<T> 和 Iterator<T> IntoIterator 和 Iterator(只有一个方法:next,返回一个 Option) 自定义迭代器
parallelStream() rayon crate 里的 par_iter() Rust 并发
Thread、ExecutorService std::thread::spawn 和 std::thread::scope;线程池用 rayon 或 tokio Rust 并发
synchronized、ReentrantLock Mutex<T>,它把要保护的数据包在里面 共享状态
AtomicInteger、AtomicLong AtomicI32、AtomicI64、AtomicUsize 共享状态
线程之间的 BlockingQueue 信道(std::sync::mpsc) 信道(channel)
CompletableFuture、虚拟线程 async fn 和 .await,由 tokio 这样的运行时来执行 async/await
String(不可变)和 StringBuilder &str(借用的文本视图)和 String(拥有所有权、可增长) Rust 字符串
char(一个 UTF-16 码元) char(任意一个 Unicode 字符,4 个字节);字符串是 UTF-8 编码的 Rust 字符串
int、long、double 和装箱的 Integer i32、i64、f64,以及从 u8 到 u128 的无符号类型。从不装箱 Rust 数据类型
整数悄悄溢出 debug 构建里会 panic;用 checked_add、wrapping_add、saturating_add 自己选择行为 Rust 整数溢出
(int) x 这样的强制转换 数字之间用 as,安全转换用 From / TryFrom Rust 类型转换、From 与 Into 转换
BigDecimal、BigInteger rust_decimal 和 num-bigint 这两个 crate Rust 数据类型
包和 import 模块(mod)和 use Rust 模块
public、private、protected、包级私有 默认私有;用 pub 和 pub(crate)。没有 protected Rust 模块
JAR / Maven 构件 crate(库或可执行程序) Cargo 与 crate
注解(@Override、Lombok 的 @Data) #[derive(Debug)]、#[test] 这样的属性 Rust 宏
反射、运行时注解处理 运行时基本没有。改用宏在编译时生成代码 过程宏
方法重载 没有重载。用不同的名字,或者用泛型参数 Rust 函数
instanceof 判断和向下转型 对枚举做 match(用 Any 向下转型也行,但很少见) Rust 模式匹配
JNI、Foreign Function & Memory API extern "C" 函数 FFI(调用 C)

Java 集合框架可以很整齐地对应到 std::collections。最大的区别是:Rust 的集合从不返回 null,查找操作给你的是一个 Option。

Java Rust 延伸学习
ArrayList<T> Vec<T> Rust vector(Vec)
int[]、String[] 长度在编译时已知就用 [T; N],否则用 Vec<T>。两者都可以作为 slice &[T] 传递 Rust 数组、Rust slice(切片)
List.of(...)(不可修改) 数组,或者一个只以 &Vec<T> / &[T] 形式交出去的 Vec(通过共享引用谁都改不了它) Rust 借用
HashMap<K, V> HashMap<K, V>(get 返回 Option<&V>) Rust HashMap
map.merge(word, 1, Integer::sum) *map.entry(word).or_insert(0) += 1 Rust HashMap
TreeMap<K, V> BTreeMap<K, V>(键始终保持有序) Rust HashMap
LinkedHashMap<K, V>(保持插入顺序) indexmap crate 里的 IndexMap
HashSet<T>、TreeSet<T> HashSet<T>、BTreeSet<T> HashSet 与 BTreeMap
ArrayDeque<T> VecDeque<T> VecDeque 与 BinaryHeap
Stack<T>,或者当栈用的 Deque 用 push / pop 的 Vec<T> Rust vector(Vec)
PriorityQueue<T>(最小的先出) BinaryHeap<T>(最大的先出;包一层 Reverse 就变成最小的先出) VecDeque 与 BinaryHeap
LinkedList<T> 有 LinkedList<T>,但 Vec 或 VecDeque 几乎总是更快 VecDeque 与 BinaryHeap
ConcurrentHashMap<K, V> Mutex<HashMap<K, V>>,或者 dashmap crate 共享状态
list.sort(Comparator.comparing(...)) v.sort_by_key(...) 或 v.sort_by(...) 排序与查找
list.removeIf(...) v.retain(...)(保留符合条件的元素) Rust vector(Vec)

和 Java 的 HashMap 一样,Rust 的 HashMap 没有固定顺序,而且每次运行的顺序都可能不同。需要有序输出,就用 BTreeMap,或者先给键排序。

工具链:从 Maven 和 Gradle 到 Cargo

Section titled “工具链:从 Maven 和 Gradle 到 Cargo”

大多数 Java 开发者很快就会爱上 Cargo。只有一个工具、一种配置格式,没有插件 XML。构建、依赖、测试、文档、格式化和 lint 全都出自同一个地方。

Java Rust 说明
Maven / Gradle cargo 构建、运行、测试、添加依赖、发布。见 Cargo 与 crate
pom.xml / build.gradle.kts Cargo.toml 很短,普通项目不需要构建脚本
Gradle 依赖锁定 Cargo.lock 自动生成和更新
多模块构建 Cargo 工作空间 顶层的一个 Cargo.toml 列出所有成员 crate
Maven Central crates.io 每个已发布 crate 的 API 文档都在 docs.rs(英文)上
添加一个 <dependency> cargo add serde
mvn archetype:generate / gradle init cargo new my_app(库用 cargo new --lib)
mvn compile / mvn test / mvn package cargo build / cargo test / cargo build --release debug 构建运行起来慢得多;测性能要用 release
java -jar app.jar ./target/release/app 单个原生二进制文件。服务器上不用装 JVM
JDK 版本、SDKMAN!、toolchain rustup 和一个 rust-toolchain.toml 文件 见 Rust 工具与环境配置
javac rustc Cargo 会替你调用它;你很少需要自己运行
JShell 浏览器里的 Rust Playground,或者 evcxr_repl
Checkstyle、SpotBugs、PMD、Error Prone cargo clippy 数百条官方 lint
google-java-format、Spotless cargo fmt 一套标准风格;没人再为大括号吵架
IntelliJ IDEA、Eclipse JetBrains RustRover,或者 VS Code + rust-analyzer rust-analyzer 提供补全、跳转到定义和行内报错
Javadoc 用 Markdown 写的 /// 文档注释加 cargo doc 文档里的代码示例会作为测试运行。见 Rust 文档
JUnit 5 内置的 #[test] 和 cargo test(cargo nextest 是一个流行的、更快的测试运行器) 见 Rust 测试
@ParameterizedTest rstest
Mockito mockall,或者手写一个实现了你的 trait 的假对象 见 测试进阶
AssertJ / Hamcrest assert!、assert_eq!,以及让差异更易读的 pretty_assertions
WireMock、Testcontainers wiremock、testcontainers 思路相同,Rust 版本
JaCoCo cargo llvm-cov 或 cargo tarpaulin
JMH criterion 见 性能优化
JFR、async-profiler、VisualVM cargo flamegraph(用 cargo install flamegraph 安装)、perf 没有 GC 或 JIT 需要调优
GraalVM native-image 不需要 每次 Rust 构建产出的本来就是原生代码

下面是一个小小的 Cargo.toml。拿它和同样三个依赖所需的 pom.xml 比一比:

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

库和框架:“那我们用什么来代替……?”

Section titled “库和框架:“那我们用什么来代替……?””

这是每个 Java 团队最先问的问题:“我们用的是 Spring Boot 和 Hibernate,Rust 里对应的是什么?”Rust 没有 Spring,没有一个大包大揽的框架。你要把几个各管一摊的 crate 组合起来用。下面是最接近的替代品。

Java Rust 说明
Spring Boot(Spring MVC / WebFlux) axum(我们在项目:用 Axum 写 Web API 里做了一个)、actix-web、Rocket axum 和 actix-web 在生产环境中用得很广
@RestController + @GetMapping axum 路由指向普通的 async fn 处理函数 没有注解,也没有类路径扫描;路由在代码里接好
Servlet 过滤器、Spring 拦截器 tower layer 和 tower-http crate(CORS、压缩、追踪、超时) axum 直接使用 tower
Spring 依赖注入(@Autowired) axum 里共享的 State、普通的构造函数和 trait 见下面依赖注入那一行
springdoc-openapi utoipa
Thymeleaf askama(模板在编译时检查)或 tera
Spring WebSocket axum 里的 WebSocket(ws feature),或者 tokio-tungstenite
Spring for GraphQL async-graphql
gRPC Java tonic(protobuf 用 prost)
java.net.http.HttpClient、OkHttp、WebClient reqwest 默认异步,也可以选用阻塞式客户端
Netty tokio + hyper
Spring Security(JWT、密码哈希) jsonwebtoken、argon2 没有一站式的安全框架
Java Rust 说明
Hibernate / JPA / Spring Data JPA SeaORM:异步 ORM,支持实体、关系和迁移 感觉最接近 JPA;构建在 sqlx 之上
看重严格类型的 Hibernate 用法 Diesel:ORM 和查询构建器;查询在编译时做类型检查 成熟(2.x),默认是同步的(另有 diesel-async)
JDBC、JdbcTemplate、jOOQ、MyBatis sqlx:直接写 SQL,query! 宏会在编译时拿你的真实数据库来检查它 异步,非常流行,不是 ORM
Flyway / Liquibase sqlx migrate(sqlx-cli)、diesel migration 或 refinery
Jedis / Lettuce(Redis) redis
Kafka 客户端、Spring Kafka rdkafka(基于 librdkafka)
RabbitMQ Java 客户端 lapin
AWS SDK for Java 官方的 AWS SDK for Rust:aws-config、aws-sdk-s3……

如果你喜欢 JPA 的实体,先试试 SeaORM。如果你更喜欢自己写 SQL(jOOQ 和 MyBatis 那一派),就试试 sqlx。如果你想要最强的编译时保证,就试试 Diesel。数据库这一页用能跑的代码演示了这三个库。

Java Rust 说明
Jackson / Gson serde + serde_json 事实标准,还支持 TOML、YAML 等格式。见 用 Serde 处理 JSON
JAXB、Jackson XML quick-xml(打开它的 serialize feature 就能配合 serde)
SLF4J + Logback / Log4j 2 tracing(+ tracing-subscriber),或 log + env_logger 见 日志
Lombok 内置的派生(Debug、Clone、PartialEq、Default……);builder 用 bon 或 derive_builder 没有字节码黑魔法,全是普通的宏
MapStruct From / Into 实现 手写,由编译器检查。见 From 与 Into 转换
Spring 的 IoC 容器 通常什么都不用:把依赖传进构造函数,需要在测试里替换的东西就用 trait Rust 项目很少用 DI 容器
Bean Validation(@NotNull、@Size) validator 或 garde(基于派生的校验) 不需要 @NotNull:只在值可能缺失的地方用 Option
Resilience4j backon,或者 tower 的重试和超时中间件
Caffeine(缓存) moka
Guava、Apache Commons 大多用标准库就够了,再加上 itertools
java.time chrono 或 jiff 见 日期与时间
picocli clap 见 项目:用 clap 写命令行工具
@Scheduled、Quartz tokio-cron-scheduler
Micrometer metrics 或 opentelemetry
Jakarta Mail lettre
Java Rust 说明
JavaFX / Swing Tauri(Web 界面 + Rust 后端)、Slint、egui、Iced Tauri 2 和 Slint 1.x 最稳定。见 GUI 应用
LibGDX、jMonkeyEngine Bevy、macroquad Bevy 还没到 1.0,每次发布都会改 API。见 游戏开发
Android 应用代码 界面继续用 Kotlin 或 Java;共享的逻辑放进一个用 Android NDK 构建的 Rust 库 Mozilla 的 uniffi 能生成 Kotlin 绑定
从 Java 调用原生代码 导出 extern "C" 函数,再通过 FFM API(JDK 22+)调用它们;或者用 jni crate 写 JNI 函数 见 FFI(调用 C)

下面是一个经典的 Java 继承结构:一个抽象基类,有一个抽象方法和一个共享方法。

abstract class Employee {
protected final String name;
Employee(String name) { this.name = name; }
abstract int monthlyPay();
String payslip() { return name + " 每月收入 " + monthlyPay(); }
}
class Salaried extends Employee {
private final int yearly;
Salaried(String name, int yearly) { super(name); this.yearly = yearly; }
@Override int monthlyPay() { return yearly / 12; }
}
class Contractor extends Employee {
private final int hours, rate;
Contractor(String name, int hours, int rate) { super(name); this.hours = hours; this.rate = rate; }
@Override int monthlyPay() { return hours * rate; }
}
List<Employee> staff = List.of(new Salaried("Ada", 60000), new Contractor("Linus", 120, 50));
for (Employee e : staff) System.out.println(e.payslip());

Rust 没有 extends。共享的方法搬进一个 trait,成为默认方法,每个结构体各自保管自己的数据:

trait Employee {
fn name(&self) -> &str;
fn monthly_pay(&self) -> u32;
// 默认方法:相当于抽象类里那个具体的 payslip()。
fn payslip(&self) -> String {
format!("{} 每月收入 {}", self.name(), self.monthly_pay())
}
}
struct Salaried {
name: String,
yearly: u32,
}
struct Contractor {
name: String,
hours: u32,
rate: u32,
}
impl Employee for Salaried {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.yearly / 12
}
}
impl Employee for Contractor {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.hours * self.rate
}
}
fn main() {
// Box<dyn Employee> 扮演的就是“某个 Employee 的引用”这个角色。
let staff: Vec<Box<dyn Employee>> = vec![
Box::new(Salaried { name: "Ada".to_string(), yearly: 60_000 }),
Box::new(Contractor { name: "Linus".to_string(), hours: 120, rate: 50 }),
];
for e in &staff {
println!("{}", e.payslip());
}
}

输出:

Ada 每月收入 5000
Linus 每月收入 6000
▶ 动手试一试

trait 不能有字段,所以 protected name 变成了每个结构体都要实现的一个小方法 name()。一开始会觉得要多打几个字,但它消除了一整类问题:没有脆弱的基类,没有会被忘掉的 super 调用,也没有要翻来翻去的深层继承结构。如果你懂 Java 8 接口里的 default 方法,你就已经知道 Rust 是怎么共享行为的了。另外,当类型的集合是固定的(就像 Java 17 的 sealed interface),用枚举加 match 往往比 trait 更简单。

Java 的 Optional 是一大进步,但它只用在返回值上,map.get() 或任何字段仍然可能是 null:

static Optional<String> findEmail(int userId) {
return userId == 1 ? Optional.of("[email protected]") : Optional.empty();
}
for (int id : new int[] {1, 2}) {
System.out.println(findEmail(id).map(String::toUpperCase).orElse("没有登记邮箱"));
}
findEmail(1).ifPresent(email -> System.out.println("发送给 " + email));

Rust 的 Option 有几乎一样的 API,但凡是值可能缺失的地方,处处都用它,包括集合的查找。而且它背后没有藏着 null:

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
for id in [1, 2] {
// map + orElseGet
let shown = find_email(id)
.map(|email| email.to_uppercase())
.unwrap_or_else(|| "没有登记邮箱".to_string());
println!("{shown}");
}
// ifPresent
if let Some(email) = find_email(1) {
println!("发送给 {email}");
}
// flatMap 在这里叫 and_then
let domain = find_email(1).and_then(|email| email.split_once('@').map(|(_, d)| d.to_string()));
println!("{domain:?}");
}

输出:

没有登记邮箱
Some("example.com")
▶ 动手试一试

在处理好 None 之前,编译器不让你在 Option<String> 上调用 .to_uppercase(),所以 NullPointerException 根本不存在。Option 也没有额外开销:Option<&T> 和一个普通引用一样大。辅助方法可以在 Rust Option 这一页学。

受检异常的初衷是好的:看签名就知道可能出什么错。下面是一个典型的 Java 写法:

class InsufficientFundsException extends Exception {
InsufficientFundsException(int needed) { super("还差 " + needed); }
}
static int withdraw(int balance, int amount) throws InsufficientFundsException {
if (amount > balance) throw new InsufficientFundsException(amount - balance);
return balance - amount;
}
static int pay(int balance, int amount) throws InsufficientFundsException {
int left = withdraw(balance, amount);
System.out.println("已支付 " + amount + ",剩余 " + left);
return left;
}
try { pay(100, 80); pay(50, 80); }
catch (InsufficientFundsException e) { System.out.println("错误:" + e.getMessage()); }

Rust 留下了这个好点子,扔掉了其余的部分。错误是返回类型的一部分(Result<u32, BankError>),? 做的就是 throws 做的事:“如果这一步失败了,就把错误交给我的调用者”。错误通常是一个 enum,这样一个类型就能列出某个操作失败的所有方式:

use std::fmt;
#[derive(Debug)]
enum BankError {
InvalidAmount,
InsufficientFunds { needed: u32 },
}
// Display 扮演的就是 getMessage() 的角色。
impl fmt::Display for BankError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BankError::InvalidAmount => write!(f, "金额必须大于零"),
BankError::InsufficientFunds { needed } => write!(f, "还差 {needed}"),
}
}
}
fn withdraw(balance: u32, amount: u32) -> Result<u32, BankError> {
if amount == 0 {
return Err(BankError::InvalidAmount);
}
if amount > balance {
return Err(BankError::InsufficientFunds { needed: amount - balance });
}
Ok(balance - amount)
}
fn pay(balance: u32, amount: u32) -> Result<u32, BankError> {
let left = withdraw(balance, amount)?; // 就像调用一个会“抛异常”的方法
println!("已支付 {amount},剩余 {left}");
Ok(left)
}
fn main() {
for (balance, amount) in [(100, 80), (50, 80), (100, 0)] {
if let Err(e) = pay(balance, amount) {
println!("错误:{e}");
}
}
}

输出:

已支付 80,剩余 20
错误:还差 30
错误:金额必须大于零
▶ 动手试一试

注意少了什么:没有 try 块,没有栈展开,也不用为了让异常穿过 lambda 而把它包进 RuntimeException。错误就是普通的值,所以在闭包和迭代器链里也能正常使用。函数每个可能提前返回的地方,都有一个看得见的 ? 标记。手写 Display 很快就会让人烦;thiserror crate 能帮你生成它,anyhow 则给应用程序提供一个兜底的错误类型(见 anyhow 与 thiserror)。至于真正的 bug,也就是你在 Java 里会用非受检异常表示的那种,Rust 有 panic!。

一个带 groupingBy 收集器的 Stream 流水线:

record Order(String customer, String city, int total) {}
List<Order> orders = List.of(
new Order("Ada", "London", 120), new Order("Grace", "New York", 80),
new Order("Linus", "Helsinki", 200), new Order("Ken", "New York", 150));
List<String> big = orders.stream()
.filter(o -> o.total() > 100)
.sorted(Comparator.comparingInt(Order::total).reversed())
.map(Order::customer)
.toList();
System.out.println(String.join(", ", big));
Map<String, Integer> byCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::total)));
byCity.forEach((city, sum) -> System.out.println(city + ": " + sum));

在 Rust 里,.iter() 扮演 .stream() 的角色,适配器的名字也都很眼熟:

use std::cmp::Reverse;
use std::collections::BTreeMap;
struct Order {
customer: String,
city: String,
total: u32,
}
fn order(customer: &str, city: &str, total: u32) -> Order {
Order { customer: customer.to_string(), city: city.to_string(), total }
}
fn main() {
let orders = vec![
order("Ada", "London", 120),
order("Grace", "New York", 80),
order("Linus", "Helsinki", 200),
order("Ken", "New York", 150),
];
// filter + toList
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// sorted(...reversed()):排序是在 Vec 上做的,而不是在迭代器里。
big.sort_by_key(|o| Reverse(o.total));
// map
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// groupingBy(city, TreeMap::new, summingInt(total))
let mut by_city: BTreeMap<&str, u32> = BTreeMap::new();
for o in &orders {
*by_city.entry(o.city.as_str()).or_insert(0) += o.total;
}
for (city, sum) in &by_city {
println!("{city}: {sum}");
}
}

输出:

Linus, Ken, Ada
Helsinki: 200
London: 120
New York: 230
▶ 动手试一试

城市名特意保留了英文:BTreeMap 按键的字母顺序排列,用英文才能看清这个效果。

和 Stream 一样,Rust 的迭代器是惰性的,只有在被消费时(collect、sum、for 循环)才会运行。和 Stream 不同的是,Rust 没有 Collectors 类:collect() 会看你要的是什么类型(Vec、HashMap、String……),然后把它构建出来。分组通常就用一个普通循环加 entry API;如果你想念 groupingBy,itertools crate 提供了一些辅助方法。而且迭代器链会被编译成和手写循环一样的机器码,中间也不会把 int 装箱。更多内容请看 Rust 迭代器和排序与查找。

Java 的泛型只存在于编译时。到了运行时 T 就被擦除了,所以你没法写 new T(),也没法不装箱就把 int 放进 List:

static <T> List<T> filled(int n) {
List<T> list = new ArrayList<>();
for (int i = 0; i < n; i++) list.add(new T()); // 编译不过:T 被擦除了
return list;
}
List<int> ids; // 这也编译不过:你得写 List<Integer>

Rust 会为你使用的每一种类型,单独编译一份泛型函数(这叫作单态化,monomorphization)。所以函数清楚地知道 T 是什么,可以调用 T::default():

use std::mem::size_of;
// “T 必须有默认值,而且可以克隆”
fn filled<T: Default + Clone>(n: usize) -> Vec<T> {
vec![T::default(); n]
}
fn main() {
let ids: Vec<i32> = filled(3);
let names: Vec<String> = filled(2);
let flags: Vec<bool> = filled(2);
println!("{ids:?} {names:?} {flags:?}");
// 不装箱:Vec<i32> 里存的就是一个挨一个的 4 字节数字。
println!("一个 i32 占 {} 个字节", size_of::<i32>());
println!("一个 bool 占 {} 个字节", size_of::<bool>());
}

输出:

[0, 0, 0] ["", ""] [false, false]
一个 i32 占 4 个字节
一个 bool 占 1 个字节
▶ 动手试一试

代价是:代码的副本多了,二进制文件会更大,编译时间也更长;但每次调用都是直接调用,还能被内联。当你想要只有一份共享的代码时(就像 Java 那样),就用 Box<dyn Trait> 这样的 trait 对象。冒号后面的约束(T: Default + Clone)就是 Rust 版的 <T extends ...>。见 Rust 泛型和高级泛型。

对照看:synchronized 变成 Arc 和 Mutex

Section titled “对照看:synchronized 变成 Arc 和 Mutex”

四个线程给一个共享计数器加一。在 Java 里,锁挂在对象上,记不记得写 synchronized 全靠你自己:

class Counter {
private int count = 0;
synchronized void increment() { count++; }
synchronized int get() { return count; }
}
Counter counter = new Counter();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> { for (int j = 0; j < 1000; j++) counter.increment(); });
t.start();
threads.add(t);
}
for (Thread t : threads) t.join();
System.out.println("计数:" + counter.get());

在 Rust 里,数字归 Mutex 所有。不先加锁,就没有任何办法碰到它。Arc(原子引用计数指针)让多个线程可以共享这个互斥器:

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
// 每个线程都拿到一个指向同一个 Mutex 的句柄。
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
// lock() 返回一个守卫;守卫被丢弃时,锁就会释放。
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("计数:{}", *counter.lock().unwrap());
}

输出:

计数:4000
▶ 动手试一试

这是 Java 开发者最喜欢的部分:在 Java 里忘了写 synchronized,代码照样能编译,只是偶尔给出错误的数字。在 Rust 里,如果你想在线程之间共享并修改一个普通的 i32 或 Vec,程序根本编译不过。数据竞争由类型系统(通过 Send 和 Sync 这两个 trait)抓出来。如果只是一个计数器,AtomicUsize 会比互斥器更轻量,就像 AtomicInteger。见 Rust 并发和共享状态。

每个 Java 开发者都会遇到同样的几个坎。提前知道,坎就会小一些。

  • 把值传给函数会移动它。 在 Java 里,传对象传的是引用,之后你还能继续用这个变量。在 Rust 里,按值传递 String 或 Vec 会把它移动进函数:

    fn greet(name: String) {
    println!("你好,{name}!");
    }
    fn main() {
    let name = String::from("Ada");
    greet(name);
    greet(name);
    }
    error[E0382]: use of moved value: `name`
    --> src/main.rs:8:11
    |
    6 | let name = String::from("Ada");
    | ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
    7 | greet(name);
    | ---- value moved here
    8 | greet(name);
    | ^^^^ value used here after move

    编译器说的是:name 的值在第 7 行已经移动进了 greet,第 8 行又被使用了。通常的解决办法是让函数借用:写成 fn greet(name: &str),调用时写 greet(&name)。请先读 Rust 所有权,那一页讲的就是这条规则;E0382 这一页详细讲解了这个错误。

  • 借用检查器。 同一时间,你可以有多个读者(&T),或者一个写者(&mut T),但不能两者兼有。在 Java 里很常见的对象图,比如子对象指回它的父对象,或者很多对象一起持有并修改同一个共享对象,在 Rust 里需要用智能指针或者换一种设计。头几周可能会有点受挫。刚开始觉得别扭很正常,之后真的会轻松很多。

  • 没有继承,没有 protected,没有 super。 你会忍不住想用 trait 重建类继承结构。先试试枚举和组合;它们通常更合适。

  • == 比较的是值。 难得的好消息:对两个 String 写 a == b,比较的是文本,所以经典的 == 与 .equals() 的 bug 不可能发生。对你自己的结构体,要先派生或实现 PartialEq,== 才能用。

  • BinaryHeap 弹出的是最大的元素。 Java 的 PriorityQueue 先给你最小的。在 Rust 里,把元素包进 std::cmp::Reverse 就能得到小顶堆的效果。见 VecDeque 与 BinaryHeap。

  • 整数溢出不会悄无声息。 在 Java 里,Integer.MAX_VALUE + 1 会默默回绕。在 Rust 的 debug 构建里,它会 panic。用 checked_add、wrapping_add 或 saturating_add 把你的意图写清楚。见 Rust 整数溢出。

  • String 与 &str,以及没有 charAt。 Rust 的字符串是 UTF-8 编码的,所以 s[0] 编译不过。用 s.chars() 逐个遍历字符。函数参数通常用 &str,结构体字段通常存 String。见 Rust 字符串。

  • 没有方法重载。 你不能有两个参数不同的 print 方法。用不同的名字(print、print_with_color),或者用泛型参数。

  • 没有运行时魔法。 没有类路径扫描,没有 @Autowired,也没有在你的方法外面套上事务的代理。一切都在代码里显式地接好,宏在编译时运行。要多打一些字,但你永远看得清发生了什么。

  • 没有 null,也没有异常。 一开始,到处都是 Option 和 Result 会让人觉得多费功夫。过几周你就会发现,自己再也没见过 NullPointerException 或者突如其来的堆栈跟踪。学习阶段调用 .unwrap() 没问题,但在真实代码里,它就相当于 Rust 版的“崩了就崩了吧”。

  • 编译时间。 从零开始构建会从源码拉取并编译所有依赖,所以比 javac 慢。增量构建快得多,而且改代码时你最常运行的是 cargo check(它跳过代码生成)。另一头也有好消息:程序秒启动,没有 JVM 预热。

  • async 需要运行时。 Rust 没有虚拟线程。async fn 在执行器运行它之前什么都不会做,所以 Web 项目要加上 tokio,并在 main 上标注 #[tokio::main]。见 async/await。

你不必按顺序读完整个网站。下面这条路线会跳过 Java 已经教会你的东西,把时间花在 Rust 不一样的地方:

  1. Rust 快速上手和 Rust 工具与环境配置:安装 Rust,选一个编辑器,运行 cargo new。
  2. Rust 变量和 Rust 数据类型:快速浏览,主要是学会 let mut 和各种数字类型。
  3. Rust 所有权,然后是 Rust 借用,再然后是 Rust slice(切片):对你来说最重要的几页,慢慢来。
  4. Rust 字符串:String 与 &str。
  5. Rust 结构体和 Rust 枚举,然后是 Rust match 和 Rust 模式匹配。
  6. Rust Option 和 Rust Result,然后是自定义错误类型和 anyhow 与 thiserror。
  7. Rust trait、Rust 泛型、常用 trait 和 trait 对象(dyn):你的接口和抽象类。
  8. Rust 闭包和 Rust 迭代器:你的 lambda 和 Stream。
  9. Rust vector(Vec)、Rust HashMap 和 VecDeque 与 BinaryHeap。
  10. Rust 模块、Cargo 与 crate 和 Rust 测试。
  11. Rust 生命周期和 Rust 智能指针。
  12. Rust 并发、共享状态、信道(channel)和 async/await。
  13. 用 Serde 处理 JSON、日志和数据库,然后动手做 Web API 项目。
  14. 想给现有的 Java 应用提速?读一读 FFI(调用 C),看看 Rust 怎么导出 extern "C" 函数。

两道题都会给你一段 Java 代码,请把它移植到 Rust。

1. 移植一个 record 和一条 Stream 流水线

Section titled “1. 移植一个 record 和一条 Stream 流水线”

下面这段代码打印及格的学生、有没有人拿到满分,以及最高分。提示:迭代器有 any(相当于 anyMatch)和 max(返回一个 Option,相当于 OptionalInt)。对于 Collectors.joining,先收集成一个 Vec<&str>,再调用 .join(", ")。

record Student(String name, int score) {}
List<Student> students = List.of(
new Student("Ada", 91), new Student("Brian", 42),
new Student("Grace", 100), new Student("Ken", 67));
String passed = students.stream()
.filter(s -> s.score() >= 50)
.map(Student::name)
.collect(Collectors.joining(", "));
boolean perfect = students.stream().anyMatch(s -> s.score() == 100);
int best = students.stream().mapToInt(Student::score).max().orElse(0);
System.out.println("及格:" + passed);
System.out.println("有满分吗:" + perfect);
System.out.println("最高分:" + best);
查看答案
#[derive(Debug, Clone, PartialEq)]
struct Student {
name: String,
score: u32,
}
fn student(name: &str, score: u32) -> Student {
Student { name: name.to_string(), score }
}
fn main() {
let students = vec![
student("Ada", 91),
student("Brian", 42),
student("Grace", 100),
student("Ken", 67),
];
let passed: Vec<&str> = students
.iter()
.filter(|s| s.score >= 50)
.map(|s| s.name.as_str())
.collect();
let perfect = students.iter().any(|s| s.score == 100);
let best = students.iter().map(|s| s.score).max().unwrap_or(0);
println!("及格:{}", passed.join(", "));
println!("有满分吗:{perfect}");
println!("最高分:{best}");
}

输出:

及格:Ada, Grace, Ken
有满分吗:true
最高分:100
▶ 动手试一试

这几个派生给了你 Java record 白送的那些功能:调试字符串({:?})、复制(.clone())和值相等(==)。student 这个辅助函数扮演了 record 构造函数的角色。和 Java 的 Stream 不同,students.iter() 想调用多少次都行,因为每次调用都会在同一个 Vec 上创建一个新的迭代器。

下面这个方法从配置文件里读取一行 port=<number>。它会抛出受检的 ConfigException,并把 Integer.parseInt 抛出的 NumberFormatException 包装起来。请把它移植过来,让 read_port 返回 Result<u16, String>。提示:line.split_once('=') 返回一个 Option<(&str, &str)>;用 .ok_or_else(...) 把它变成 Result。.parse::<u16>() 返回一个 Result,.map_err(...) 能把它的错误换成你自己的信息,就像把一个异常包进另一个异常。

class ConfigException extends Exception {
ConfigException(String message) { super(message); }
}
static int readPort(String line) throws ConfigException {
String[] parts = line.split("=", 2);
if (parts.length != 2 || !parts[0].trim().equals("port"))
throw new ConfigException("应为 port=<数字>,实际是“" + line + "”");
try {
return Integer.parseInt(parts[1].trim());
} catch (NumberFormatException e) {
throw new ConfigException("端口号无效:" + parts[1].trim());
}
}
for (String line : List.of("port = 8080", "port=八十", "host=localhost")) {
try { System.out.println("端口是 " + readPort(line)); }
catch (ConfigException e) { System.out.println("配置错误:" + e.getMessage()); }
}
查看答案
fn read_port(line: &str) -> Result<u16, String> {
let expected = || format!("应为 port=<数字>,实际是“{line}”");
let (key, value) = line.split_once('=').ok_or_else(expected)?;
if key.trim() != "port" {
return Err(expected());
}
let value = value.trim();
value
.parse::<u16>()
.map_err(|_| format!("端口号无效:{value}"))
}
fn main() {
for line in ["port = 8080", "port=八十", "host=localhost"] {
match read_port(line) {
Ok(port) => println!("端口是 {port}"),
Err(e) => println!("配置错误:{e}"),
}
}
}

输出:

端口是 8080
配置错误:端口号无效:八十
配置错误:应为 port=<数字>,实际是“host=localhost”
▶ 动手试一试

throws ConfigException 子句变成了 Result 返回类型,包在 parseInt 外面的 try/catch 变成了一个 map_err,throw 变成了 Err(...)。端口用 u16 还有个小好处:大于 65535 的数字会直接被 parse 拒绝,Java 的 int 可做不到这一点。那个小小的 expected 闭包只在需要时才构建错误信息,就像 orElseThrow(() -> ...) 里的 supplier。

  • Java 的很多东西都能直接带过来:泛型、接口(变成 trait)、lambda(变成闭包)和 Stream 流水线(变成迭代器)。
  • 最大的转变是:用所有权代替垃圾回收器,用 Option 代替 null,用 Result 和 ? 代替异常,用 trait 加组合代替继承。
  • 泛型会为每种类型各编译一份(单态化),所以没有类型擦除,数字也不用装箱。
  • 线程之间共享的数据放在 Mutex 里(再包一层 Arc),忘了加锁是编译错误,而不是数据竞争。
  • Cargo 取代了 Maven 或 Gradle;clippy 和 rustfmt 取代了 Checkstyle、SpotBugs 和各种格式化工具;测试是内置的。
  • 想找 Spring Boot 就看 axum 或 actix-web,想找 JPA 就看 SeaORM 或 Diesel,想找 JDBC 和 jOOQ 就看 sqlx,Jackson 的位置交给 serde。

小测验

1. 一个 Java 抽象类有一个抽象方法,还有一个调用它的具体方法。在 Rust 里通常用什么代替?

2. Java 的 PriorityQueue 先返回最小的元素。在 Rust 里,BinaryHeap::pop 返回什么?

3. 为什么 Rust 的泛型函数能调用 T::default(),而 Java 却没法写 new T()?

4. 你在线程之间共享一个普通的 Vec,并且不用 Mutex 就往里 push。在 Rust 里会怎样?

得分: 0 / 4

  • 《Rust 程序设计语言》简体中文版:官方教程。对 Java 开发者来说,第 4 章(所有权)、第 9 章(错误处理)、第 10 章(泛型和 trait)和第 16 章(并发)最重要。
  • std::collections(英文):标准库自己写的指南,教你怎么在 Vec、VecDeque、HashMap、BTreeMap 等集合之间做选择。
  • JEP 454: Foreign Function & Memory API(英文):JDK 22 的新特性,不写 JNI 代码就能从 Java 调用原生库,包括用 Rust 写的库。
  • docs.rs 上的 jni crate(英文):JNI 的 Rust 绑定,适合需要从较老的 JDK 调用 Rust,或者从 Rust 回调 Java 的情况。
  • Are We Web Yet?(英文):社区整理的概览,涵盖 Rust 的 Web 框架、数据库库等等,适合正在为自己的 Spring Boot 技术栈找替代品的你。