给 Java 开发者的 Rust 语法速览

前言

如果你已经熟悉 Java,读 Rust 时往往会觉得「像 C++ 又像函数式」,但细节处处不同。
本文以语法与写法对照为主,另用一节纲要式说明所有权借用生命周期;借用检查器实现细节与并发专题不展开。
正文按「基本语法 → 类型与错误 → 核心机制 → 工程与抽象 → 桌面备忘」分块,便于按需查阅。
读完你应能更快把「Java 里怎么写」映射到「Rust 里对应什么」,并少踩几处入门阶段的坑。

基本语法

与 Java 最接近的一层:入口、绑定、函数、控制流、自定义类型上的调用约定,以及字符串字面写法。

程序入口与打印

Java 的 public static void main 在 Rust 里是顶层函数 main,且没有「类包住方法」这一层。
下面展示最小可编译的 main 与控制台输出写法。

1
2
3
fn main() {
println!("Hello, {}", "world");
}

println! 末尾的 ! 表示这是宏而不是普通函数调用,格式化占位与 Java 的 String.format 思路相近。

变量与可变性

Java 里引用指向的对象常常可变;Rust 默认变量绑定不可变,需要写 mut 才能改。
下面演示不可变与可变的区别。

1
2
3
4
5
let x = 1;
// x = 2; // 编译错误

let mut y = 1;
y = 2;

类型通常由编译器推断;需要显式标注时把类型写在变量名后,与 Java「类型在前」的习惯相反。

1
2
let n: i32 = 42;
let s: String = String::from("hi");

常量使用 const,且必须标注类型;适合编译期已知、全局共享的值。

1
const MAX: u32 = 100;

函数与返回值

Rust 用 fn 定义函数;没有 void,「无返回值」写作单元类型 ()
函数体最后一个表达式(无分号)作为返回值,这与 Java 必须写 return 不同。

1
2
3
4
5
6
7
fn add(a: i32, b: i32) -> i32 {
a + b
}

fn noop() {
// 等价于返回 ()
}

早返回仍使用 return,但日常更习惯「最后一行表达式」风格。

控制流是表达式

if 在 Rust 里是表达式,可以赋给变量;分支类型必须一致。
下面把 if 的结果赋给 label

1
2
let score = 85;
let label = if score >= 60 { "pass" } else { "fail" };

Java 里类似写法要用三元运算符或单独赋值;Rust 则把 if 当成表达式统一处理。

循环

for 通常配合迭代器使用,而不是手写下标;需要下标时可用 enumerate
下面遍历区间与带索引的字符。

1
2
3
4
5
6
7
8
for i in 0..3 {
println!("{}", i);
}

let chars = vec!['a', 'b'];
for (idx, c) in chars.iter().enumerate() {
println!("{}: {}", idx, c);
}

loop 是无限循环,用 break 携带值跳出时很方便。

1
2
3
4
5
6
7
let mut n = 0;
let found = loop {
n += 1;
if n == 3 {
break n;
}
};

结构体与实现块

Rust 没有 class,常见数据组合用 struct;行为放在 impl 块里。
下面定义一个带字段的结构体并添加方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Point {
x: i32,
y: i32,
}

impl Point {
fn new(x: i32, y: i32) -> Self {
Point { x, y }
}

fn sum(&self) -> i32 {
self.x + self.y
}
}

&self 类似 Java 的实例方法接收者;Self 表示当前类型别名。

方法调用

Java 里实例方法写成 obj.method(),静态方法写成 ClassName.method(),Rust 读起来接近,但谁挂在点号前、谁挂在双冒号前的规则与 Java 不完全相同。
实例上的调用仍用点号;不通过某个值、而是针对类型本身的「构造函数式或类静态式」调用,习惯写成 类型::名称,中间是双冒号而不是点号。
下面沿用上一节的 Point,展示关联函数与实例方法两种调用面。

1
2
let p = Point::new(1, 2);
let s = p.sum();

Point::new 没有 self 参数,地位接近 Java 里带 static 的工厂方法或构造语义,只是语法固定为 类型::函数
p.sum() 与 Java 写法一致,编译器会把接收者 psum 的第一个参数约定(此处为 &self)传进去。

同一个实例方法往往还能写成「类型名加双冒号,再把接收者当显式参数」,便于在闭包或泛型场景消除歧义。
下面两种写法在常见 impl 下等价,第二行把借用写清楚。

1
2
3
let p = Point::new(1, 2);
let a = p.sum();
let b = Point::sum(&p);

String::from("hi")"hi".to_string() 都能构造 String,前者是典型的 类型::关联函数,后者是切片 str 上的实例方法,可对照理解「双冒号对类型、点对值」的习惯。

Rust 没有 Java 那种仅参数列表不同的方法重载;同名函数在同一作用域会冲突,通常改用不同函数名、用 Option 表达可选入参,或借助 trait 分派。
多级路径里常出现一串 ::,例如 std::fs::read_to_string,整体感觉像 Java 里「包名、外层类、内层类」逐级限定,只是分隔符统一成双冒号而不是点号。

字符串与切片

Rust 里与字符串相关的常见类型是 String(拥有、堆分配)与 &str(字符串切片,常借用)。
从字面量得到的通常是 &str;需要拥有所有权时再 to_string()String::from

1
2
3
4
5
6
fn greet(name: &str) {
println!("{}", name);
}

let owned: String = "hello".to_string();
greet(&owned);

这与 Java 几乎一切皆引用、String 一种主类型的体验不同,需要习惯「借用 vs 拥有」在 API 上的分裂。

类型与错误处理

代数类型、可空与可失败结果,是 Rust 类型系统的显眼特征,也和 Java 的 null、异常风格形成对照。

枚举与模式匹配

enum 可以携带数据,相当于把 Java 里「继承树 + 多态」的一种紧凑写法换成代数数据类型。
match 必须穷尽所有分支,类似强化的 switch

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
}

fn handle(m: Message) {
match m {
Message::Quit => {}
Message::Move { x, y } => println!("{} {}", x, y),
Message::Write(s) => println!("{}", s),
}
}

若只关心部分情况,可用 if let 简化。

1
2
3
4
let m = Message::Write(String::from("hi"));
if let Message::Write(text) = m {
println!("{}", text);
}

Option 与空值

Rust 没有 null;可能缺省的值用 Option<T> 表示。
下面演示安全取值与在提供默认值时解包。

1
2
3
fn len_or_zero(s: Option<&str>) -> usize {
s.map(|x| x.len()).unwrap_or(0)
}

直接 .unwrap() 会在 None 时 panic,类似 unchecked;生产代码更常用 ? 或显式分支。

Result 与错误传递

可恢复错误用 Result<T, E>;与 Java 受检异常不同,错误通过返回值显式出现在类型里。
在返回 Result 的函数里,? 会在出错时提前返回并把错误向上传。

1
2
3
4
5
fn read_number() -> Result<i32, std::num::ParseIntError> {
let s = "42";
let n: i32 = s.parse()?;
Ok(n)
}

? 读成「出错就返回」有助于衔接异步与链式调用时的错误流。

核心机制

Rust 与带 GC 语言差别最大的部分:编译期通过所有权借用生命周期约束内存与引用安全。

所有权、借用与生命周期

下面依次说明所有权借用生命周期
前两者约定堆上数据的唯一所有者以及读写的别名规则;生命周期则约定引用在静态分析下可以「活多久」,与悬垂引用检查配套。

Java 里堆对象多由垃圾回收器在运行时回收。
Rust 没有 GC,所有权在编译期约定「谁负责释放」,与调用栈、作用域绑定。
每个堆上值(非 Copy 类型)在同一时刻只有一个所有者。
绑定离开作用域时会按析构语义回收资源,类似 Java 里 try-with-resources 的确定性释放,但是编译器强制、默认自动插入。
赋值或传参给「会拿走值」的接口时,常发生移动,原变量不再可用,避免二次释放。
下面演示 String 赋值后不能再使用旧变量。

1
2
3
let s = String::from("hi");
let t = s;
// println!("{}", s); // 错误:所有权已移到 t

整数等实现了 Copy 的类型按位复制,不发生移动,行为更接近 Java 里小类型按值传递的直觉。

借用允许在不转移所有权的前提下临时使用数据:&T 为共享引用,只读;&mut T 为独占引用,可写。
同一时刻对同一数据要么存在多个共享引用,要么存在一个独占引用,与上述规则冲突的借用组合会被编译器拒绝,从而在编译期避免数据竞争。
下面用 Vec 示意:同时两个共享引用合法,若再借可变则与共享借用冲突。

1
2
3
4
5
let v = vec![1, 2, 3];
let r1 = &v;
let r2 = &v;
println!("{} {}", r1[0], r2[1]);
// let m = &mut v; // 若取消注释:与 r1、r2 重叠,编译失败

函数参数里的 &str&self 等都是在借用,调用结束后所有权仍留在调用方。

在所有权与借用规则之上,还要说明「引用与它所指向的数据谁活得更久」。
引用本身不拥有数据,若数据先被释放、引用却仍在使用,就会形成悬垂引用,类似在 Java 里持有已失效对象上的指针(GC 下多数情况被掩盖,Rust 则要在编译期杜绝)。

为什么要单独讲生命周期

Rust 没有 GC 在运行时替你守住「引用是否还指向有效内存」。
编译器必须在检查代码时推断或让你标注:每个引用「从哪一行开始有效、到哪一行必须结束」,以及它指向的那块数据至少要比引用活得更久。
把这套有效区间形式化写出来,就是生命周期;记号常写作 'a'static 等,'static 大致对应「程序全程都合法」的引用(例如字符串字面量)。

下面展示一种不能通过编译的意图:想把指向局部 String 的切片返回给调用方,局部变量在函数返回时会被释放,返回的 &str 将悬垂。
注释里保留错误写法便于对照,不要照抄运行。

1
2
3
4
// fn broken() -> &str {
// let owned = String::from("tmp");
// owned.as_str() // owned 离开作用域后,返回的切片不再有效
// }

与 Java 的对照可以这么记:Java 里若把局部变量装箱或传出引用,对象往往仍留在堆里,由 GC 决定何时回收;Rust 里栈上局部带所有权的值按作用域结束,引用不能越过这个边界悄悄溜出去。

入参与返回同寿

函数可能返回「指向调用方已有数据」的引用,例如从两个字符串切片里二选一返回。
此时返回值必须同时满足:不会比 x 指向的数据活得更久,也不会比 y 指向的数据活得更久(因为返回的要么是 x 要么是 y)。
写成同一个生命周期参数 'a,意思是「这三个引用在同一个寿命捆绑里协商」:调用处传入的实参若会在某处结束,返回的切片也必须在那之前停止使用。

1
2
3
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() >= y.len() { x } else { y }
}

若两个实参来自不同长度的借用,编译器会取较短的那段公共可用区间来检查,因此有时你会遇到「需要更长寿命却传了短寿命」的报错,那是在保护你不产生悬垂引用。

什么时候可以省略

简单函数里编译器常能根据「参数与返回值的引用关系」自动补全生命周期(称为省略规则),所以你初学阶段大量示例里看不到 'a
一旦结构体里存引用、或返回引用与多个入参的对应关系不直观,就需要手写标注帮助编译器。

更细的规则与常见错误提示仍以官方文档与《The Rust Programming Language》相关章节为准。

工程组织与抽象

模块拆分、可见性与泛型约束,以及 traitimpl 的抽象方式,对应 Java 的包、访问修饰符与接口式实现。

模块与可见性

可见性默认私有;pub 才对外暴露,类似去掉 Java 默认包可见但仍更偏「文件与模块边界」。
下面在同一文件内用模块划分并导出函数。

1
2
3
4
5
6
7
mod inner {
pub fn f() {}
}

pub fn entry() {
inner::f();
}

跨文件的模块树由 mod 与文件系统共同约定,细节可按项目结构查阅 Cargo 约定。

泛型与约束

泛型用尖括号标注;约束写在 where 或简短形式里,类似 Java 的 extends 与多重边界组合。
下面为「可比较」元素求最大值示意。

1
2
3
fn max<T: Ord>(a: T, b: T) -> T {
if a >= b { a } else { b }
}

上一节的 T: Ord 里的 Ord 本身就是标准库定义的 trait,表示「可排序」这类能力;泛型参数通过 : 挂上 trait 约束,语义接近 Java 里 T extends Comparable 一类写法。

trait 与 impl

trait 描述一组可被不同类型实现的行为,地位接近 Java 的 interface:只定义「能做什么」,由具体类型用 impl 特征名 for 类型名 来填空。
与 Java 不同的是,Rust 里实现写在独立的 impl 块里,而不是像 class Article implements Summary 那样写在类型声明旁;一个类型可以为多个 trait 分别写多个 impl 块。
下面先声明一个只含方法签名的 trait,再为某个结构体提供实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
trait Summarize {
fn summarize(&self) -> String;
}

struct Article {
title: String,
}

impl Summarize for Article {
fn summarize(&self) -> String {
format!("{}", self.title)
}
}

trait 里也可以带默认方法体,实现类型若不覆盖则沿用默认,类似 Java 8 起的接口 default 方法。
下面用另一个 trait 演示:只要求实现 msgloud 由默认实现拼出来。

1
2
3
4
5
6
7
trait Notify {
fn msg(&self) -> String;

fn loud(&self) -> String {
format!("!! {} !!", self.msg())
}
}

泛型与函数参数里常见 T: Clone + Debug,表示 T 必须同时满足多个 trait,写法上比 Java 的多重 extends 边界更偏「能力组合」。
需要「按接口类型做运行时多态」时,可用 dyn Trait 与指针配合(如 Box<dyn Trait>),编译器用虚表做动态派发,概念上接近持有 Java 里「接口引用」指向不同实现类。
Rust 还有孤儿规则:你不能为「既非本 crate 定义的类型、也非本 crate 定义的 trait」写 impl,以免不同库各自给 Vec 实现同一外来 trait 导致冲突;为第三方类型扩展能力时,常用 newtype 包装等惯用法,细节可查官方文档。

桌面应用备忘

常见于 Tauri 等桌面壳项目:链接子系统与前后端命令注册,与业务逻辑正交,单独成块便于检索。

无控制台窗口

桌面 GUI 程序在 Windows 上若以控制台子系统链接,运行时常会多出一个黑色命令行窗口。
下面这行写在 main.rs 最上方,只在非调试构建时把子系统切到图形界面,发布版通常不再弹出控制台,调试版仍方便用 println! 看输出。

1
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

#![...] 是作用于整个 crate 的内部属性,不是贴在某个函数上的注解。
cfg_attr(条件, 属性) 表示仅当条件成立时才启用后面的属性,等价于「满足条件时才写上 #![windows_subsystem = "windows"]」。
debug_assertions 在 debug 构建时为真、在 release 构建时为假,因此 not(debug_assertions) 为真时通常对应你打 release 包的场景。
windows_subsystem = "windows" 会告诉链接器使用 Windows 图形子系统而非默认的控制台子系统,因而避免额外附着命令行窗口。
若始终加上该属性而不加条件,调试阶段也可能看不到控制台,不利于排查问题。
Tauri、winit、egui 等带窗口的项目里这类写法很常见,可与项目文档中的链接选项对照理解。

可从前端调用的命令

#[tauri::command] 写在函数上方,属于外部属性 #[...],作用对象是紧跟其后的那一个项,与 crate 顶部的内部属性 #![...] 不同。
在 Tauri 里它会把该 Rust 函数注册成可由前端通过 invoke 调用的 IPC 命令,参数与返回值由框架做序列化,常见类型需满足相应约定。
从 Java 视角粗比:有点像把后端里的一个方法暴露给同进程里的「前端脚本层」,但走的是桌面壳提供的通道而不是 HTTP。
下面是一个最小示例,函数名会对应前端调用时使用的命令名。

1
2
3
4
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}", name)
}

实际还需要在 tauri::Builder 上用 invoke_handler 把命令挂进应用,例如 tauri::generate_handler![greet],具体以你使用的 Tauri 主版本文档为准。
若命令返回 Result<T, E>,错误分支通常会按 Tauri 的规则传到前端便于展示或记录。
没有该属性时,普通 fn 只是本地函数,前端无法按命令名直接调用。

小结

Rust 把可变性、空值与错误都写进类型,语法上则大量依赖表达式与模式匹配。
对照 Java 时少问「类在哪里」,多问「所有权借用在这个 API 上长什么样」,会更容易读顺示例代码。
下一步建议在官方书《The Rust Programming Language》里把所有权章节配合小规模练习跑一遍,比死记语法更有效。