给 Java 开发者的 Rust 语法速览
前言
如果你已经熟悉 Java,读 Rust 时往往会觉得「像 C++ 又像函数式」,但细节处处不同。
本文以语法与写法对照为主,另用一节纲要式说明所有权、借用与生命周期;借用检查器实现细节与并发专题不展开。
正文按「基本语法 → 类型与错误 → 核心机制 → 工程与抽象 → 桌面备忘」分块,便于按需查阅。
读完你应能更快把「Java 里怎么写」映射到「Rust 里对应什么」,并少踩几处入门阶段的坑。
基本语法
与 Java 最接近的一层:入口、绑定、函数、控制流、自定义类型上的调用约定,以及字符串字面写法。
程序入口与打印
Java 的 public static void main 在 Rust 里是顶层函数 main,且没有「类包住方法」这一层。
下面展示最小可编译的 main 与控制台输出写法。
1 | fn main() { |
println! 末尾的 ! 表示这是宏而不是普通函数调用,格式化占位与 Java 的 String.format 思路相近。
变量与可变性
Java 里引用指向的对象常常可变;Rust 默认变量绑定不可变,需要写 mut 才能改。
下面演示不可变与可变的区别。
1 | let x = 1; |
类型通常由编译器推断;需要显式标注时把类型写在变量名后,与 Java「类型在前」的习惯相反。
1 | let n: i32 = 42; |
常量使用 const,且必须标注类型;适合编译期已知、全局共享的值。
1 | const MAX: u32 = 100; |
函数与返回值
Rust 用 fn 定义函数;没有 void,「无返回值」写作单元类型 ()。
函数体最后一个表达式(无分号)作为返回值,这与 Java 必须写 return 不同。
1 | fn add(a: i32, b: i32) -> i32 { |
早返回仍使用 return,但日常更习惯「最后一行表达式」风格。
控制流是表达式
if 在 Rust 里是表达式,可以赋给变量;分支类型必须一致。
下面把 if 的结果赋给 label。
1 | let score = 85; |
Java 里类似写法要用三元运算符或单独赋值;Rust 则把 if 当成表达式统一处理。
循环
for 通常配合迭代器使用,而不是手写下标;需要下标时可用 enumerate。
下面遍历区间与带索引的字符。
1 | for i in 0..3 { |
loop 是无限循环,用 break 携带值跳出时很方便。
1 | let mut n = 0; |
结构体与实现块
Rust 没有 class,常见数据组合用 struct;行为放在 impl 块里。
下面定义一个带字段的结构体并添加方法。
1 | struct Point { |
&self 类似 Java 的实例方法接收者;Self 表示当前类型别名。
方法调用
Java 里实例方法写成 obj.method(),静态方法写成 ClassName.method(),Rust 读起来接近,但谁挂在点号前、谁挂在双冒号前的规则与 Java 不完全相同。
实例上的调用仍用点号;不通过某个值、而是针对类型本身的「构造函数式或类静态式」调用,习惯写成 类型::名称,中间是双冒号而不是点号。
下面沿用上一节的 Point,展示关联函数与实例方法两种调用面。
1 | let p = Point::new(1, 2); |
Point::new 没有 self 参数,地位接近 Java 里带 static 的工厂方法或构造语义,只是语法固定为 类型::函数。p.sum() 与 Java 写法一致,编译器会把接收者 p 按 sum 的第一个参数约定(此处为 &self)传进去。
同一个实例方法往往还能写成「类型名加双冒号,再把接收者当显式参数」,便于在闭包或泛型场景消除歧义。
下面两种写法在常见 impl 下等价,第二行把借用写清楚。
1 | let p = Point::new(1, 2); |
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 | fn greet(name: &str) { |
这与 Java 几乎一切皆引用、String 一种主类型的体验不同,需要习惯「借用 vs 拥有」在 API 上的分裂。
类型与错误处理
代数类型、可空与可失败结果,是 Rust 类型系统的显眼特征,也和 Java 的 null、异常风格形成对照。
枚举与模式匹配
enum 可以携带数据,相当于把 Java 里「继承树 + 多态」的一种紧凑写法换成代数数据类型。match 必须穷尽所有分支,类似强化的 switch。
1 | enum Message { |
若只关心部分情况,可用 if let 简化。
1 | let m = Message::Write(String::from("hi")); |
Option 与空值
Rust 没有 null;可能缺省的值用 Option<T> 表示。
下面演示安全取值与在提供默认值时解包。
1 | fn len_or_zero(s: Option<&str>) -> usize { |
直接 .unwrap() 会在 None 时 panic,类似 unchecked;生产代码更常用 ? 或显式分支。
Result 与错误传递
可恢复错误用 Result<T, E>;与 Java 受检异常不同,错误通过返回值显式出现在类型里。
在返回 Result 的函数里,? 会在出错时提前返回并把错误向上传。
1 | fn read_number() -> Result<i32, std::num::ParseIntError> { |
把 ? 读成「出错就返回」有助于衔接异步与链式调用时的错误流。
核心机制
Rust 与带 GC 语言差别最大的部分:编译期通过所有权、借用与生命周期约束内存与引用安全。
所有权、借用与生命周期
下面依次说明所有权、借用与生命周期。
前两者约定堆上数据的唯一所有者以及读写的别名规则;生命周期则约定引用在静态分析下可以「活多久」,与悬垂引用检查配套。
Java 里堆对象多由垃圾回收器在运行时回收。
Rust 没有 GC,所有权在编译期约定「谁负责释放」,与调用栈、作用域绑定。
每个堆上值(非 Copy 类型)在同一时刻只有一个所有者。
绑定离开作用域时会按析构语义回收资源,类似 Java 里 try-with-resources 的确定性释放,但是编译器强制、默认自动插入。
赋值或传参给「会拿走值」的接口时,常发生移动,原变量不再可用,避免二次释放。
下面演示 String 赋值后不能再使用旧变量。
1 | let s = String::from("hi"); |
整数等实现了 Copy 的类型按位复制,不发生移动,行为更接近 Java 里小类型按值传递的直觉。
借用允许在不转移所有权的前提下临时使用数据:&T 为共享引用,只读;&mut T 为独占引用,可写。
同一时刻对同一数据要么存在多个共享引用,要么存在一个独占引用,与上述规则冲突的借用组合会被编译器拒绝,从而在编译期避免数据竞争。
下面用 Vec 示意:同时两个共享引用合法,若再借可变则与共享借用冲突。
1 | let v = vec![1, 2, 3]; |
函数参数里的 &str、&self 等都是在借用,调用结束后所有权仍留在调用方。
在所有权与借用规则之上,还要说明「引用与它所指向的数据谁活得更久」。
引用本身不拥有数据,若数据先被释放、引用却仍在使用,就会形成悬垂引用,类似在 Java 里持有已失效对象上的指针(GC 下多数情况被掩盖,Rust 则要在编译期杜绝)。
为什么要单独讲生命周期
Rust 没有 GC 在运行时替你守住「引用是否还指向有效内存」。
编译器必须在检查代码时推断或让你标注:每个引用「从哪一行开始有效、到哪一行必须结束」,以及它指向的那块数据至少要比引用活得更久。
把这套有效区间形式化写出来,就是生命周期;记号常写作 'a、'static 等,'static 大致对应「程序全程都合法」的引用(例如字符串字面量)。
下面展示一种不能通过编译的意图:想把指向局部 String 的切片返回给调用方,局部变量在函数返回时会被释放,返回的 &str 将悬垂。
注释里保留错误写法便于对照,不要照抄运行。
1 | // fn broken() -> &str { |
与 Java 的对照可以这么记:Java 里若把局部变量装箱或传出引用,对象往往仍留在堆里,由 GC 决定何时回收;Rust 里栈上局部带所有权的值按作用域结束,引用不能越过这个边界悄悄溜出去。
入参与返回同寿
函数可能返回「指向调用方已有数据」的引用,例如从两个字符串切片里二选一返回。
此时返回值必须同时满足:不会比 x 指向的数据活得更久,也不会比 y 指向的数据活得更久(因为返回的要么是 x 要么是 y)。
写成同一个生命周期参数 'a,意思是「这三个引用在同一个寿命捆绑里协商」:调用处传入的实参若会在某处结束,返回的切片也必须在那之前停止使用。
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
若两个实参来自不同长度的借用,编译器会取较短的那段公共可用区间来检查,因此有时你会遇到「需要更长寿命却传了短寿命」的报错,那是在保护你不产生悬垂引用。
什么时候可以省略
简单函数里编译器常能根据「参数与返回值的引用关系」自动补全生命周期(称为省略规则),所以你初学阶段大量示例里看不到 'a。
一旦结构体里存引用、或返回引用与多个入参的对应关系不直观,就需要手写标注帮助编译器。
更细的规则与常见错误提示仍以官方文档与《The Rust Programming Language》相关章节为准。
工程组织与抽象
模块拆分、可见性与泛型约束,以及 trait 与 impl 的抽象方式,对应 Java 的包、访问修饰符与接口式实现。
模块与可见性
可见性默认私有;pub 才对外暴露,类似去掉 Java 默认包可见但仍更偏「文件与模块边界」。
下面在同一文件内用模块划分并导出函数。
1 | mod inner { |
跨文件的模块树由 mod 与文件系统共同约定,细节可按项目结构查阅 Cargo 约定。
泛型与约束
泛型用尖括号标注;约束写在 where 或简短形式里,类似 Java 的 extends 与多重边界组合。
下面为「可比较」元素求最大值示意。
1 | fn max<T: Ord>(a: T, b: T) -> T { |
上一节的 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 | trait Summarize { |
trait 里也可以带默认方法体,实现类型若不覆盖则沿用默认,类似 Java 8 起的接口 default 方法。
下面用另一个 trait 演示:只要求实现 msg,loud 由默认实现拼出来。
1 | trait Notify { |
泛型与函数参数里常见 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 |
#![...] 是作用于整个 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 |
|
实际还需要在 tauri::Builder 上用 invoke_handler 把命令挂进应用,例如 tauri::generate_handler![greet],具体以你使用的 Tauri 主版本文档为准。
若命令返回 Result<T, E>,错误分支通常会按 Tauri 的规则传到前端便于展示或记录。
没有该属性时,普通 fn 只是本地函数,前端无法按命令名直接调用。
小结
Rust 把可变性、空值与错误都写进类型,语法上则大量依赖表达式与模式匹配。
对照 Java 时少问「类在哪里」,多问「所有权与借用在这个 API 上长什么样」,会更容易读顺示例代码。
下一步建议在官方书《The Rust Programming Language》里把所有权章节配合小规模练习跑一遍,比死记语法更有效。