一个猜数字游戏会在 1 - 100 之间随机生成一个数字。玩家需要在 10 次机会用尽之前猜到正确的数字。我们把 Rust 和 Java 版本的游戏都跑一遍试试。两个版本的游戏都采用了相同逻辑进行实现,方便我们进行对比学习。代码已经放在 GitHub:github.com/AspadaX/jav…
每当我们需要执行一段 Java 代码,我们需要先通过 Java 编译器 javac 来对 .java 文件进行编译,生成对应的 .class 字节码文件。最后通过 java 命令执行生成的字节码文件执行代码。在 Rust 当中,我们可以通过 Rust 编译器 rustc 来对 .rs 文件进行编译。编译结果就是一个在当前平台可以执行的二进制文件。只要在命令行里面执行即可。
为什么 Java 需要先生成一个中间产物 .class 文件?这是因为 Java 语言需要通过 JVM,也就是 Java Virtual Machine (Java 虚拟机),进行执行。原始的 Java 代码,Java虚拟机并不认识,操作系统也不认识。所以我们需要先将原先的 Java 代码翻译成 JVM 能看懂的格式,也就是 .class,最后再交给 JVM 执行。而在 Rust 当中,由于所有 Rust 代码都会直接编译为对应平台可执行的二进制文件。使得 Rust 的代码只需要执行编译即可运行,无需生成中间文件。
# Java 先将代码编译成字节码,再用虚拟机执行
javac Main.java
java Main
# Rust 编译完成后直接执行二进制文件
rustc main.rs
./main
为了能够让代码在不同平台执行,Java 通过将代码编译为与平台无关的字节码(.class 文件),再由目标平台上的 JVM 解释执行。不同平台需要安装对应的 JVM 实现,以支持 Java 程序运行。Rust 通过编译器(rustc)将代码直接编译为特定平台的二进制文件。编译时需指定目标平台(如 --target 参数),Rust 工具链会自动处理不同平台的依赖和兼容性。然而,管理这些不同平台的虚拟机和编译器对于开发者来说负担比较重,于是就有了 Java 的 sdkman 来安装各个版本的 JDK,Rust 则有 rustup 来管理各个平台和版本的编译器。只是 Rust 的工具链在安装 Rust 的时候就会一起安装,所以程序员们无需自行手动单独配置。
真实项目的文件数量通常不会是单一的文件,如果采用编译器进行直接编译,那么依赖管理就会成为一个大麻烦。为了解决这个麻烦,各个语言一般都会推出自己的包管理器,或者构建工具。比如 Typescript 有 npm,Python 有 Conda 和 uv。咱们的 Java 有 Maven 和 Graddle,Rust 则有 Cargo。在 Maven 当中,我们会采用 mvn archetype:generate 去创建一个新的项目,如果已经创建好目录,我们会用 mvn archetype:create-from-project。而在 Cargo 当中,我们会使用 cargo new <project_name> 来创建新的项目,如果需要在当前目录下启动项目,可以使用 cargo init 命令。如果已经写好代码,想要运行一下,在 Maven 当中可以配置执行命令,而在 Cargo 里面只要输入 cargo run 命令。
# Java
# 使用 Maven archetype 创建和运行项目
mvn archetype:generate \
-DgroupId=com.aspadax.demo \
-DartifactId=maven-demo-app \
-DarchetypeArtifactId=maven-archetype-quickstart \
-DarchetypeVersion=1.4 \
-DinteractiveMode=false
cd maven-demo-app
mvn exec:java
# Rust
# 使用 Cargo 创建和运行项目
cargo new cargo-demo-app
cd ./cargo-demo-app
cargo run
main.rs
一般我在看新项目源代码的时候,会首先查看项目的程序主入口,因为里面会包含一个程序的主要逻辑,为熟悉整个项目提供一张全局地图。那么我们先来看看猜数字游戏的主入口是什么样的。在 Java 中,我们需要创建一个 Main 类并在其中实现特定的 public static void main(String[] args) 方法。而在 Rust 中,我们在项目根目录的 main.rs 文件中定义一个 fn main() 函数即可。这里的 fn 关键字表示函数定义,括号内可以声明参数(虽然在这个简单示例中为空),后面跟着花括号包裹的函数体。这个函数的返回类型对应 Java 中的 void 类型,因为 Rust 的返回类型如果为 None 也就是 Java 当中的 void 的话,就是留空。不过 Rust 也支持显式声明返回值类型,这个我们后面会详细讨论。
public class Main {
public static void main(String[] args) {
Session session = new Session(1, 100, 10);
try {
session.run();
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
}
}
use session::Session;
mod generator;
mod input;
mod session;
fn main() {
let mut session: Session = Session::new(1, 100, 10);
session.run().unwrap();
}
在 Java 中,方法必须存在于类中,这是面向对象语言的典型特征。但 Rust 采取了不同的设计哲学:函数可以独立存在,不需要依附于任何结构体。比如这里的 fn main() 就直接位于模块顶层,而在 Java 中我们必须把它放在 Main 类里面。这种设计让 Rust 在编写简单脚本或入口逻辑时更加轻量。
在对象创建上,Rust 代码 Session::new(1, 100, 10) 的写法看起来像是调用静态方法,实际上这是 Rust 创建对象的常见方式。Rust 没有 Java 的 new 关键字,也没有默认构造函数的概念。每个结构体的构造方式完全由程序员定义。如果你没有为结构体实现 new 函数,那么就没有直接创建该结构体实例的标准方式。这与 Java 不同,Java 中每个类都有默认构造函数(即使你不显式声明),而且必须使用 new 关键字来实例化对象。
Session session = new Session(1, 100, 10);
let mut session: Session = Session::new(1, 100, 10);
细心的观众朋友还会注意到 Rust 代码开头的 use 和 mod 声明。这是 Rust 管理代码组织的核心机制。比如在这个猜数字游戏中,我们除了 main.rs 外还创建了 session.rs、generator.rs 和 input.rs 等文件。要使用这些文件中的功能,首先需要用 mod 关键字声明它们属于当前项目:"mod generator;" 告诉 Rust 编译器:"这个项目包含 generator 模块"。这类似于在 Java 项目中创建新文件,但在 Rust 中必须显式声明才能使用。
use session::Session;
mod generator;
mod input;
mod session;
声明模块后,我们可以通过 use 关键字引入特定类型,比如 use session::Session; 这行代码让当前文件可以访问 session 模块中的 Session 结构体。这种用法类似 Java 的 import 语句,比如 Java 中的 import com.example.Session;。不过 Rust 的模块系统更加严格——如果你忘记声明 mod 就直接 use,编译器会报错。这样保证你在拿到别人的 Rust 库的时候,一定是依赖结构清晰的。
变量声明中的 mut 关键字是 Rust 的一大特性。在 Rust 中,变量默认是不可变的(immutable)。当我们写 let mut session 时,明确告诉编译器这个 session 变量需要被修改,需要对应其进行相关的检查。相比之下,Java 对象默认是可变的,除非显式标记为 final。实际上,在我们实际编写 Rust 项目的时候,很多变量并不需要 mut,这一点我们会在写越来越多 Rust 代码时体会到。
session.rs
在 session.rs 的实现中,我们看到 Rust 采用了与 Java 不同的代码组织范式。与 Java 的 class 概念对应,Rust 用 struct 封装数据,用独立的 impl 块定义方法。这种分离设计让结构定义和方法实现更模块化,类似于 Java 的接口与实现分离思想,但采用了不同的语法形式。
#[derive(Debug)]
pub struct Session {
attempted: usize,
max_attempts: usize,
correct_number: usize,
}
impl Session {
pub fn new(start: usize, end: usize, max_attempts: usize) -> Self {
// 实现逻辑
}
}
public class Session {
private int attempted;
private int maxAttempts;
private int correctNumber;
public Session(int start, int end, int maxAttempts) {
// 实现逻辑
}
}
Rust 的 GuessResult 枚举与 Java 的 enum 都提供了类型安全的选项表示,但 Rust 通过 #[derive(Debug)] 实现了编译期自动生成调试输出,這类似于 Java 的 Lombok 库的 @ToString 注解,属于语言原生支持的特性。另外,Rust 采用蛇形命名法(SmallerThanTarget),而 Java 枚举常量使用大写下划线分隔(SMALLER_THAN_TARGET)。
#[derive(Debug)]
pub enum GuessResult {
Correct,
LargerThanTarget,
SmallerThanTarget
}
public enum GuessResult {
CORRECT,
LARGER_THAN_TARGET,
SMALLER_THAN_TARGET
}
构造函数的错误处理体现了两种语言的哲学差异。Rust 使用 match 表达式处理 Result 类型,将错误作为值的一部分,强制开发者显式处理;而 Java 使用 try-catch 异常机制,将错误作为控制流的异常情况。Rust 的 Self {} 初始化语法提供了字段简写功能,类似于 JavaScript 的对象字面量简写,而 Java 需要显式的 this.field = value 赋值。
let mut rng = match SimpleRNG::new() {
Ok(result) => result,
Err(error) => panic!("{}", error)
};
Self { attempted: 1, max_attempts, correct_number: rng.generate_digit(start, end) }
try {
SimpleRNG rng = new SimpleRNG();
this.attempted = 1;
this.maxAttempts = maxAttempts;
this.correctNumber = rng.generateDigit(start, end);
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
在方法可变性方面,Rust 当中所有变量在声明时均默认不可变。Rust 的 &mut self 要求提供了编译期的可变性检查,这个设计与 Java 相反:Rust 要求声明变量可变,而 Java 要求声明变量不可变(通过 final)。当我们在 Rust 中写方法时,如果需要修改 self 的状态,就必须声明 &mut self,否则编译器会在修改时报错。
fn increment_attempt(&mut self) {
self.attempted += 1
}
private void incrementAttempt() {
this.attempted += 1; // Java 默认允许修改对象状态
}
错误处理的类型签名展现了两种语言的核心差异。Rust 的 Result<GuessResult, Box<dyn std::error::Error>> 将错误纳入类型系统,类似 Java 的 checked exception,但更优雅地将错误作为返回值的一部分。Java 的 throws Exception 子句则是传统的异常声明方式。
fn determine_success(&self, input_number: usize) -> Result<GuessResult, Box<dyn std::error::Error>> {
if self.attempted == self.max_attempts {
return Err(format!("Maximum attempts reached. Correct number is {}", self.correct_number).into());
}
Ok(self.trial(input_number))
}
private GuessResult determineSuccess(int inputNumber) throws Exception {
if (this.attempted == this.maxAttempts) {
throw new Exception("Maximum attempts reached. Correct number is " + this.correctNumber);
}
return this.trial(inputNumber);
}
控制流的处理上,现代 Java 的 switch (Java 14+) 与 Rust 的 match 都支持模式匹配,但 Rust 的穷尽检查特性更严格,编译器会确保所有可能的情况都被处理。Rust 使用 loop 关键字创建无限循环,而 Java 传统上使用 while (true)。Rust 的 ? 操作符提供了简洁的错误传播机制,等价于 Java 中让异常自动向上抛出。
loop {
let input_number: usize = get_input_digit("Please input a number: ")?;
match self.determine_success(input_number) {
Ok(GuessResult::Correct) => {
println!("Your guess is correct!");
return Ok(());
},
Ok(GuessResult::LargerThanTarget) => println!("Your guess is larger than target!"),
Ok(GuessResult::SmallerThanTarget) => println!("Your guess is smaller than target!"),
Err(error) => panic!("{}", error)
}
self.increment_attempt();
}
while (true) {
int inputNumber = Input.getInputDigit("Please input a number: ");
GuessResult result = this.determineSuccess(inputNumber);
switch (result) {
case CORRECT:
System.out.println("Your guess is correct!");
return;
case LARGER_THAN_TARGET:
System.out.println("Your guess is larger than target!");
break;
case SMALLER_THAN_TARGET:
System.out.println("Your guess is smaller than target!");
break;
}
this.incrementAttempt();
}
Input.rs
与 Java 不同,Rust 不需要把每个函数都封装在类里。get_input_digit 就是一个直接存在于模块中的独立函数,不需要依附于任何结构体或类。这种设计理念类似于 Java 的静态方法,但更加轻量级。在 Java 中,即使像 getInputDigit 这样不操作对象状态的纯工具函数,也必须放在 Input 类中作为静态方法。而 Rust 则允许函数直接存在于模块中,减少了不必要的封装。
public class Input {
public static int getInputDigit(String promptMessage) throws Exception {
// 函数必须在类中定义
}
}
pub fn get_input_digit(prompt_message: &str) -> Result<usize, Box<dyn std::error::Error>> {
// 函数可以直接存在于模块中
}
Java通常使用驼峰格式(camelCase),即变量和方法名用首个单词小写,后续单词首字母大写,例如 promptMessage 或 getInputDigit;类名则多用帕斯卡命名法(PascalCase),即每个单词首字母大写例如 Input。常量通常使用全大写下划线分隔,如 MAX_COUNT。Rust使用蛇形命名法(snake_case)来命名函数、变量和模块,例如 prompt_message 或 get_input_digit。结构体和枚举类型则使用首字母大写的帕斯卡命名,如 UserProfile。常量和静态变量则通常使用全大写字母,单词之间以下划线连接,比如 MAX_SIZE 或 DEFAULT_CONFIG。Rust语言甚至带有编译器的命名规则检查,使开发者更加严格地遵守语言约定和规范。
public static int getInputDigit(String promptMessage) throws Exception
pub fn get_input_digit(prompt_message: &str) -> Result<usize, Box<dyn std::error::Error>>
Rust 的 ? 操作符提供了一种链式错误处理机制,将可能的错误向上传播。这与 Java 的 throws Exception 声明类似,但 Rust 通过 Result 类型使错误路径更显式,而 Java 依赖异常机制。在 Rust 中,std::io::stdout().flush()? 和 std::io::stdin().read_line(&mut input_buffer)? 以及最后的 Ok(input_buffer.trim().parse()?) 都使用了 ? 操作符,任何一步出错都会立即返回错误。Java 则通过 throws Exception 让异常自然向上冒泡。
System.out.flush();
inputBuffer = reader.readLine();
return Integer.parseInt(inputBuffer.trim());
std::io::stdout().flush()?;
std::io::stdin().read_line(&mut input_buffer)?;
Ok(input_buffer.trim().parse()?)
Java 中已经封装好在接收用户输入后,变量存在哪里。而在 Rust 当中,程序员需要手动创建一个 buffer 来保存用户输入的内容。这样做有一个好处在于这个 buffer 可以重复使用。如果用户需要反复进行输入,那么就可以重复使用先前创建好的 buffer,减少重复申请资源的操作。当然在这么做的同时也不可避免地让代码编写者需要考量更多情况。比如在复用 buffer 之前是否需要先清理内容等。
inputBuffer = reader.readLine(); // 返回新的 String 对象
let mut input_buffer = String::new(); // 手动创建缓冲区
std::io::stdin().read_line(&mut input_buffer)?; // 向缓冲区写入内容
字符串处理上也体现了两种语言的不同设计哲学。Rust 区分了字符串切片 &str 和拥有所有权的字符串 String。函数参数 prompt_message: &str 表示这是一个借用的字符串切片,不拥有数据的所有权,这样可以避免不必要的内存分配。而 Java 中的 String promptMessage 则是对字符串对象的引用,所有字符串都是堆上分配的对象。Rust 这种设计让程序员能够更精确地控制内存使用和性能。
public static int getInputDigit(String promptMessage) // String 对象引用
pub fn get_input_digit(prompt_message: &str) // 借用的字符串切片
generator.rs
在 Rust 中,错误处理是语言设计的核心特性之一,与 Java 的异常机制形成鲜明对比。当我们查看随机数生成器的构造函数时,可以看到 Rust 使用 Result<Self, Box<dyn std::error::Error>> 作为返回类型,这是一个枚举类型,要么包含成功的值(Ok(Self)),要么包含错误(Err)。这种设计强制调用者必须处理可能的错误情况。相比之下,Java 使用检查异常(checked exceptions),通过 throws Exception 声明方法可能抛出异常,但调用者可以选择捕获或继续向上抛出异常。
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
seed: SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as usize
})
}
public SimpleRNG() throws Exception {
this.seed = System.nanoTime();
}
Rust 中的 ? 操作符是错误处理的语法糖,当遇到错误时会立即返回该错误。在 duration_since(UNIX_EPOCH)? 这行代码中,如果时间计算失败,函数会立即返回错误而不是继续执行。这种早期返回机制让错误处理变得更加简洁和安全。Java 中没有直接对应的语法,需要使用 try-catch 块或者让异常向上传播来实现类似效果。
在方法定义中,Rust 明确区分了可变和不可变的方法调用。get_seed(&self) 使用了不可变引用,表明这个方法不会修改对象状态,而 generate_digit(&mut self, start: usize, end: usize) 使用了可变引用,因为它需要更新内部的 seed 值。Java 中所有方法默认都可以修改对象状态(除非对象本身被声明为 final),这种设计在编译时无法保证方法是否会修改对象。
pub fn get_seed(&self) -> usize {
self.seed
}
pub fn generate_digit(&mut self, start: usize, end: usize) -> usize {
// 修改 self.seed 的操作
self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
start + (self.seed % range)
}
public long getSeed() {
return this.seed;
}
public int generateDigit(int start, int end) {
// 可以修改 this.seed
this.seed = (this.seed * 1664525L + 1013904223L) & 0xFFFFFFFFL;
return start + (int)(this.seed % range);
}
在类型系统方面,Rust 使用 usize 作为平台相关的无符号整数类型,它在 64 位系统上是 64 位,在 32 位系统上是 32 位。Java 则使用固定大小的 long(64 位有符号整数)。这种差异在跨平台代码中很重要,Rust 的 usize 通常用于数组索引和内存地址,保证了在不同平台上的一致性,而 Java 的固定大小类型保证了数值计算的一致性但在处理平台相关操作时可能需要额外转换。
最后,Rust 的 impl 块将所有相关的方法组织在一起,这类似于 Java 的类定义,但提供了更大的灵活性。你可以为同一个结构体编写多个 impl 块,甚至可以为外部类型实现方法(trait implementation)。Java 中所有方法必须定义在类内部,这种设计更加紧凑但灵活性较低。
impl SimpleRNG {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> { ... }
pub fn get_seed(&self) -> usize { ... }
pub fn generate_digit(&mut self, start: usize, end: usize) -> usize { ... }
}
public class SimpleRNG {
// 所有方法都必须在类内部定义
public SimpleRNG() throws Exception { ... }
public long getSeed() { ... }
public int generateDigit(int start, int end) { ... }
}
结尾
希望这篇文章和示例能给大家对 Rust 建立一个更清晰的认识。如果有问题,可以在下方留言,我会进行解答。欢迎大家在评论区讨论和交流。