rust基础(一)
Rust基础(一)
rust编程语言,一个极其现代性的编程语言,由于其特殊的语法规则以及函数式概念,劝退了大量的来自OOP或者是第一门语言是OOP并且没有接触过函数式编程的新手。基于此,本文探讨如何编写rust程序。本文是一个系列文章,主要探讨的是rust这门语言如何从OOP基础上入门,而且本系列不做具体的项目分析和构建,而是采用从OOP的视角和添加一些函数式编程概念去解释rust语法和核心思想。所以,这个系列并不会很长,而且需要有一定的OOP编程经验才可以理解(如果只学过js或者其他脚本类型或可视化的编程语言,建议亲自编写OOP项目再行学习)。 本系列所有使用的例子,不一定都是符合rust编程理念的,但是一定符合新手学习的,而且,再看本系列文章之前,需要普及一个概念,就是:
Make it work,Make it better,Make it faster.
系列提供所有的例子都是可以运行的(需要注意哪些是片段哪些是例子),但是不一定都是最好的。
前言
本系列将从四篇文章帮助具有OOP基础的新手入门rust,这四篇分别是:
- Rust基础(一):基础语法与核心概念
- Rust基础(二): 从对象和继承到Struct和Trait
- Rust基础(三): 告别垃圾回收-理解所有权、借用与生命周期
- Rust基础(四): 从异常处理到Result 系列目的在于让只有一些
OOP基础的新手也可以理解rust,理解rust的理念,以此进一步的学习rust和编写rust程序。接下来我们从基础语法下手。
基础语法
在我们正式从 OOP 的视角深入 Rust 的世界之前,让我们先快速熟悉一下 Rust 的一些基础语法。这会像是一份速查表,帮助你无障碍地理解后续章节中的代码示例。我们会特别指出那些与常见 OOP 语言(如 Java, C#, C++)有所不同的地方。
0. 初始化项目
这里默认使用的环境是Linux和已经安装了rust环境,具体的环境安装可以查看官网:rust官网 生成项目:
cargo new study
生成完之后,我们会看到以下目录结构:
study
├── .git
├── .gitignore
├── Cargo.toml
└── src
└── main.rs
也许你知道另一个命令:
cargo new study --lib
这两个有什么区别呢?第一条命令会创建一个二进制程序 (binary program) 项目,而第二条命令会创建一个库 (library) 项目。在这里你可以简单的理解为一个可以执行,而另一个不能执行,只能作为一个工具箱供其他人调用。
1. 程序入口:main 函数
和 C++、Java 类似,Rust 程序的执行始于 main 函数。
fn main() {
// 你的代码从这里开始执行
println!("Hello, Rust!");
}
2. 变量与可变性:let 和 let mut
这是 Rust 与许多 OOP 语言的第一个核心区别:变量默认是不可变的。
let: 用于声明一个不可变变量。一旦绑定,就不能再修改。
// 这是一个代码片段
let x = 5; // x 是不可变的
// x = 6; // 这行代码会编译错误
let mut: 如果你需要一个可以修改的变量,必须明确使用mut关键字。
// 这是一个代码片段
let mut y = 10; // y 是可变的
y = 20; // 这是允许的
println!("y 的值是: {}", y);
与 OOP 对比: 把 let 想象成 Java/C# 中的 final 或 C++ 中的 const 变量。在 Rust 中,不可变是默认状态,这鼓励你编写更安全、更易于推理的代码。
3. 函数定义与返回值
函数使用 fn 关键字定义。参数类型必须明确标注。
fn add(a: i32, b: i32) -> i32 {
// 函数体
a + b // 这是表达式,也是返回值
}
- 参数:
(参数名: 类型)的格式。 - 返回值: 使用
->指定返回类型。 - 一个关键区别: Rust 是一种基于表达式的语言。函数体中的最后一行如果是一个没有分号(
;)的表达式,它将自动成为该函数的返回值。你也可以使用return关键字提前返回。
4. 基本数据类型与字符串
Rust 有常见的原生数据类型,如:
- 整数:
i32(默认),u64,isize等。 - 浮点数:
f64(默认),f32。 - 布尔值:
bool(true或false)。 - 字符:
char。 关于字符串,这是一个重点: &str(字符串切片):- 通常是不可变的引用,指向一段 UTF-8 编码的字符串数据。
- 字符串字面量(如
"hello") 的类型就是&'static str。 - 把它看作是对某处字符串数据的只读“视图”或“指针”。
String:- 在堆上分配的、可增长的、拥有所有权的字符串。
- 当你需要修改字符串或拥有字符串的所有权时使用。
- 与
OOP对比: 可以把String类比于 Java 的StringBuilder或 C++ 的std::string。
let s1: &str = "这是一个不可变的字符串切片";
let mut s2: String = String::from("这是一个可增长的字符串");
s2.push_str(",可以向其中添加内容。");
println!("{}", s2);
5. 控制流:if 表达式和 for 循环
if表达式: Rust 中的if不仅是语句,还是一个表达式,可以有返回值。
let number = 10;
let message = if number > 5 {
"大于5"
} else {
"小于或等于5"
};
println!("消息是: {}", message);
for循环: 遍历任何实现了IntoIteratortrait 的集合。(至于这个IntoIterator暂时理解为只要实现了这个trait都可以使用for循环,具体的可以看官方文档)
let numbers = [10, 20, 30, 40, 50];
for num in numbers {
println!("数字是: {}", num);
}
6. 使用 println! 打印输出
println! 是一个宏(macro),用于将文本打印到控制台。注意它后面的感叹号 !,这是宏调用的标志。
{}是格式化占位符。{:?}用于调试打印,它会以一种对开发者友好的格式输出变量。相当于自动解构结构体,当然这个不是所有结构体都可以。
let name = "Alice";
let user_data = (101, "Alice"); // 元组 (Tuple)
println!("你好, {}!", name);
println!("调试信息: {:?}", user_data);
核心理念
我们学过OOP的都知道,当我们设计OOP程序的时候,都是用类起手,将每一个类作为业务实体,类的成员方法作为业务实体关系,一切操作围绕类而来,就像以下这个例子: 在这个例子中,BankAccount 类就是一个业务实体。它封装了账户持有人、账号和余额这些数据,并提供了存款、取款、查询余额等行为(方法)。 BankAccount.java (业务实体类) 这个文件定义了 BankAccount 类本身。
// BankAccount 类,代表一个银行账户实体
public class BankAccount {
// 私有字段,封装了账户的数据
private String accountHolderName;
private String accountNumber;
private double balance;
// 构造函数:用于创建 BankAccount 对象实例
public BankAccount(String accountHolderName, String accountNumber) {
this.accountHolderName = accountHolderName;
this.accountNumber = accountNumber;
this.balance = 0.0; // 初始余额为 0
}
// 成员方法:定义了账户可以执行的操作(业务实体关系)
/**
* 存款
* @param amount 存款金额
*/
public void deposit(double amount) {
if (amount > 0) {
this.balance += amount;
System.out.println("存款成功。当前余额: " + this.balance);
} else {
System.out.println("存款失败:金额必须为正数。");
}
}
/**
* 取款
* @param amount 取款金额
*/
public void withdraw(double amount) {
if (amount <= 0) {
System.out.println("取款失败:金额必须为正数。");
} else if (this.balance < amount) {
System.out.println("取款失败:余额不足。");
} else {
this.balance -= amount;
System.out.println("取款成功。当前余额: " + this.balance);
}
}
/**
* 获取当前余额
* @return 余额
*/
public double getBalance() {
return this.balance;
}
/**
* 显示账户详情
*/
public void displayAccountDetails() {
System.out.println("--------------------");
System.out.println("账户持有人: " + this.accountHolderName);
System.out.println("账号: " + this.accountNumber);
System.out.println("当前余额: " + this.balance);
System.out.println("--------------------");
}
}
Main.java (使用实体的主程序) 这个文件展示了如何创建和使用 BankAccount 对象。所有操作都围绕 myAccount 这个实例进行。
public class Main {
public static void main(String[] args) {
System.out.println("创建一个新的银行账户...");
// 1. 创建一个 BankAccount 类的实例(对象)
BankAccount myAccount = new BankAccount("张三", "123456789");
// 2. 一切操作都围绕 myAccount 对象而来
myAccount.displayAccountDetails();
System.out.println("\n进行一些操作...");
// 调用存款方法
myAccount.deposit(500.00);
// 调用取款方法
myAccount.withdraw(200.00);
// 尝试无效操作
myAccount.withdraw(400.00); // 余额不足
myAccount.deposit(-50.00); // 无效金额
// 再次显示最终的账户状态
System.out.println("\n显示最终账户详情:");
myAccount.displayAccountDetails();
}
}
从这个例子上,我们得到了这几点:
- 类作为业务实体:
BankAccount类清晰地定义了“银行账户”这一概念,它包含了所有相关的属性和功能。 - 封装: 账户数据(如
balance)是private的,外部代码不能直接修改,只能通过公共方法(deposit,withdraw)来与之交互,保证了数据的完整性和安全性。 - 方法作为业务关系:
deposit()和withdraw()这些方法定义了账户的核心业务逻辑。 - 围绕对象操作: 在
Main类中,所有的动作都是通过调用myAccount对象的成员方法来完成的 (myAccount.deposit(...),myAccount.withdraw(...)),这正是“一切操作围绕类(的对象)而来”的体现。
但是,在rust上,虽然也可以采用OOP的方法去编写程序,但是他会产生各种各样的生命周期和所有权的问题,假设我们直接将上面的例子直接的转换成Rust代码。 在这个 Rust 版本中,我们需要引入了两个外部组件:
TransactionProcessor:一个“交易处理器”,负责存款和取款,因此需要对账户的可变访问权限。Auditor:一个“审计员”,负责检查账户,只需要不可变访问权限。 在许多OOP设计中,让多个服务共享同一个对象实例是很常见的。现在我们看看在 Rust 中这样做会发生什么。
use std::fmt;
// 业务实体:BankAccount
// 结构体,只包含数据,行为在 impl 块中定义
#[derive(Debug)]
pub struct BankAccount {
account_holder_name: String,
account_number: String,
balance: f64,
}
impl BankAccount {
pub fn new(name: &str, number: &str) -> Self {
BankAccount {
account_holder_name: name.to_string(),
account_number: number.to_string(),
balance: 0.0,
}
}
// 方法需要修改 self,所以接收 &mut self
pub fn deposit(&mut self, amount: f64) {
if amount > 0.0 {
self.balance += amount;
}
}
// 方法需要修改 self,所以接收 &mut self
pub fn withdraw(&mut self, amount: f64) {
if amount > 0.0 && self.balance >= amount {
self.balance -= amount;
}
}
// 方法只需读取,接收 &self
pub fn get_balance(&self) -> f64 {
self.balance
}
}
// 组件1:交易处理器
// 需要修改 BankAccount,所以它尝试持有一个可变引用
// 这就立刻引入了生命周期参数 'a
// 这里如果看不懂的话可以直接理解成这里需要这个东西,
// 先把他放一边
struct TransactionProcessor<'a> {
account: &'a mut BankAccount,
}
impl<'a> TransactionProcessor<'a> {
fn execute_deposit(&mut self, amount: f64) {
println!("处理器:执行存款 {}...", amount);
self.account.deposit(amount);
}
}
// 组件2:审计员
// 只需要读取 BankAccount,所以它尝试持有一个不可变引用
// 同样,这也引入了生命周期参数 'a,理解同上。
struct Auditor<'a> {
account: &'a BankAccount,
}
impl<'a> Auditor<'a> {
fn audit(&self) {
println!("审计员:审计账户,当前余额:{}", self.account.get_balance());
}
}
//主程序:问题开始显现
fn main() {
// 创建一个 BankAccount 实例,注意它是 mut 的,因为我们要修改它
let mut my_account = BankAccount::new("张三", "123456789");
// 尝试让多个组件同时“共享”同一个账户
println!("尝试让多个组件同时工作");
// 1. 创建一个审计员。它对 my_account 进行了“不可变借用”。
// 这个借用的生命周期将持续到 auditor 变量离开作用域为止。
let auditor = Auditor { account: &my_account };
auditor.audit(); // 第一次审计,正常
// 2. 现在,我们尝试创建一个交易处理器。
// 为了创建 TransactionProcessor,我们需要对 my_account 进行“可变借用”。
/*
// 下面的代码将导致编译失败!
let mut processor = TransactionProcessor {
account: &mut my_account, // 编译错误!
};
processor.execute_deposit(100.0);
*/
// 为什么会编译失败?
// Rust 的核心规则:在一个作用域内,对一个变量,你不能同时拥有:
// - 一个或多个不可变引用 (`&`)
// - 和一个可变引用 (`&mut`)
// 在我们创建 `processor` 的时候,`auditor` 还存活着,它持有的不可变引用 `&my_account` 也还生效。
// 因此,编译器会阻止我们创建可变引用 `&mut my_account`。
// 错误信息会类似:`cannot borrow `my_account` as mutable because it is also borrowed as immutable`
// 无法将 `my_account` 作为可变借用,因为它已经被不可变地借用了
println!("\n为了绕过编译错误,我们必须严格分离借用的作用域");
// 我们必须确保 auditor 的生命周期在 processor 创建之前结束
// 审计员在这里完成它的工作
auditor.audit();
// drop(auditor); // 可以选择显式销毁,但当 auditor 离开作用域时会自动销毁
// 现在,auditor 的不可变借用已经失效,我们可以安全地创建 processor 了
{
let mut processor = TransactionProcessor { account: &mut my_account };
processor.execute_deposit(500.0);
} // processor 在这里离开作用域,可变借用结束
// 再次创建审计员来查看结果
let final_auditor = Auditor { account: &my_account };
final_auditor.audit(); // 审计修改后的结果
}
我们看到,直接将 OOP 中共享对象引用的模式搬到 Rust 中,会因为其严格的所有权和借用规则而处处碰壁。 而且,rust并不鼓励直接操作实体本身而是返回一个新的实体,以下例子也可以说明这一点:
use std::fmt::{self, Debug};
use thiserror::Error; // thiserror 是一个流行的第三方库,可以让我们更方便地定义错误类型
// 定义一个专门的错误类型来表示交易失败的原因
#[derive(Error, Debug, PartialEq)]
enum TransactionError {
#[error("金额必须为正数")]
// 我们将这个通过println!(TransactionError::InvalidAmount),会得到:金额必须为正数
InvalidAmount,
#[error("余额不足")]
InsufficientFunds,
}
// BankAccount 结构体本身可以保持不变,但我们会让它 Clone 更方便
#[derive(Debug, Clone)]
pub struct BankAccount {
account_holder_name: String,
account_number: String,
balance: f64,
}
// 实现块 (核心改动在这里)
impl BankAccount {
pub fn new(name: &str, number: &str) -> Self {
BankAccount {
account_holder_name: name.to_string(),
account_number: number.to_string(),
balance: 0.0,
}
}
// deposit 不再接收 &mut self,而是 &self,并返回一个 Result<Self, ...>
// Self (大写S) 是类型别名,指代 BankAccount 本身
pub fn deposit(&self, amount: f64) -> Result<Self, TransactionError> {
if amount <= 0.0 {
// 如果金额无效,返回一个错误,原始状态不变
return Err(TransactionError::InvalidAmount);
}
// 创建一个新的 BankAccount 实例来代表存款后的状态
Ok(BankAccount {
balance: self.balance + amount,
// 其他字段从 self 克隆
// 我们正在创建一个全新的账户实例,所以需要复制(clone)这些数据的所有权。
account_holder_name: self.account_holder_name.clone(),
account_number: self.account_number.clone(),
})
}
// withdraw 同样返回一个新的实例或一个错误
pub fn withdraw(&self, amount: f64) -> Result<Self, TransactionError> {
if amount <= 0.0 {
return Err(TransactionError::InvalidAmount);
}
if self.balance < amount {
return Err(TransactionError::InsufficientFunds);
}
// 创建一个新的 BankAccount 实例来代表取款后的状态
Ok(BankAccount {
balance: self.balance - amount,
account_holder_name: self.account_holder_name.clone(),
account_number: self.account_number.clone(),
})
}
pub fn get_balance(&self) -> f64 {
self.balance
}
}
fn main() {
println!("--- 风格二:状态转换演示 ---");
// 初始状态
let account_v1 = BankAccount::new("张三", "123456789");
println!("初始状态 (v1): {:?}", account_v1);
// 存款操作:它消耗 account_v1 的信息,产生一个新的状态 account_v2
let account_v2 = match account_v1.deposit(500.0) {
Ok(new_account) => new_account,
Err(e) => {
eprintln!("存款失败: {}", e);
account_v1 // 如果失败,状态回滚到 v1
}
};
println!("存款后状态 (v2): {:?}", account_v2);
// 取款操作:它消耗 account_v2 的信息,产生一个新的状态 account_v3
let account_v3 = match account_v2.withdraw(200.0) {
Ok(new_account) => new_account,
Err(e) => {
eprintln!("取款失败: {}", e);
account_v2
}
};
println!("取款后状态 (v3): {:?}", account_v3);
// 演示一次失败的交易
println!("\n演示一次失败的交易");
println!("当前状态 (v3): balance = {}", account_v3.get_balance());
let final_account = match account_v3.withdraw(400.0) { // 尝试取款400,会失败
Ok(new_account) => new_account,
Err(e) => {
eprintln!("取款 400.0 失败: {}", e);
account_v3 // 关键:交易失败,我们返回的是之前的状态 account_v3
}
};
println!("最终状态 (final): {:?}", final_account);
println!("最终余额: {}", final_account.get_balance());
}
从这个例子我们可以看出,采用修改状态时,直接返回新的实体这个方法,会大幅减少生命周期的使用,并且在前期可以减少理解负担(当然你可能会说如果我就是要修改本身怎么办,那么我的Rust随笔(五)就介绍了对应的方法,有兴趣的读者可以去阅读一下。)。这个方法在函数式编程里面叫做不可变性 (Immutability),这种不可变性,在前期Rust编程中,大幅降低理解门槛,并且,增强代码的可预测性和可理解性和提升代码的安全性,当然这么是有性能代价的,我们在前期学习时可以先不关心这一块的性能开销,而且,rust对此进行大量的性能优化。
接着,当我们将实体和操作分离出来之后,操作即可视为纯函数(Pure function)(这里需要假设一点,操作内部没有任何的外部操作,例如插入数据库,打开文件等等这种IO操作),那么我们思考一点,这些操作,能否通过某种形式把他们组合起来,由一个个简单的方法组合成一种复杂的操作,事实上,在rust编程中,很多标准库的工具类都实现了这一点,举个例子:
// 操作1:判断是否为偶数
fn is_even(n: &i32) -> bool {
*n % 2 == 0
}
// 操作2:将数字乘以2
fn double(n: i32) -> i32 {
n * 2
}
// 操作3:判断是否大于10
fn is_greater_than_10(n: &i32) -> bool {
*n > 10
}
// 操作4:转换为带前缀的字符串
fn to_string_with_prefix(n: i32) -> String {
format!("Result: {}", n)
}
fn traditional_approach(data: &[i32]) -> Vec<String> {
let mut results = Vec::new(); // 1. 创建一个可变的容器来存储结果
for &num in data { // 2. 遍历数据
if is_even(&num) { // 3. 嵌套的 if 判断
let doubled = double(num);
if !is_greater_than_10(&doubled) { // 4. 更多的嵌套和逻辑
let final_string = to_string_with_prefix(doubled);
results.push(final_string); // 5. 修改可变容器
}
}
}
results
}
fn main() {
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let results: Vec<String> = data
.into_iter() // 1. 将 Vec 转换为一个迭代器
.filter(|n| is_even(n)) // 2. 组合 `is_even` 操作 (筛选)
.map(double) // 3. 组合 `double` 操作 (转换)
.filter(|n| !is_greater_than_10(n)) // 4. 组合 `is_greater_than_10` 操作 (再次筛选)
.map(to_string_with_prefix) // 5. 组合 `to_string_with_prefix` 操作 (再次转换)
.collect(); // 6. 将最终结果收集到一个新的 Vec 中
println!("使用组合方式的结果: {:?}", results);
// 预期输出: ["Result: 4", "Result: 8"]
}
这里的,Vec<T>提供了一系列的组合方法,这种方法可以在设计时,只需要按照最小粒度进行设计,然后通过组合的方式构造出来复杂的方法,那么可以将代码尽可能的解耦,既提升了代码的可读性,并且尽可能的降低代码耦合度。
总而言之,Rust 的一个核心设计哲学是:尽可能地分离数据与行为,并全面地采用“组合优于继承”的原则——这不仅体现在数据结构层面(通过 Struct 组合),也体现在行为层面(通过函数组合)。
在这种思想的指导下,“实体”本身回归其作为纯粹数据载体 (Data Carrier) 的本质。因此,我们的设计重心便从构建一个包罗万象的“对象”,自然地转移到了对操作本身的精心设计上。我们致力于将复杂的业务逻辑分解为一系列独立的、可复用的原子操作(通常是纯函数,当然不纯的也有不纯的做法)。
最后,我们利用函数组合这一强大的模式,像搭建乐高积木一样,将这些原子化的操作灵活地拼装起来,构建出复杂而又清晰的业务流程。这个过程,就是将注意力从“可变的对象”转移到“不可变的数据流转换”上的过程,也是 Rust 代码健壮、高效且易于维护的关键所在。
总结
从 OOP 到 Rust,核心是一次编程思维的转变:我们不再围绕“可变的对象”来构建程序,而是将纯粹的数据 (Struct) 与其行为 (impl) 清晰地分离。
我们看到,Rust 鼓励将操作视为一次次状态的转换——接收一个旧状态,返回一个新状态,并通过组合这些原子操作来构建复杂的业务逻辑。这种模式是 Rust 实现内存安全与无畏并发的基石,而严格的借用规则正是其安全网的体现。
这里初步提到了Struct和Object的关系,在下一篇《Rust基础(二): 从对象和继承到Struct和Trait》中,我们将深入探讨 Rust 是如何用 Struct 和 Trait 来实现这一理念的,并且在这个过程中,尽可能的在OOP的视角中解释这些理论,更好的学习Rust编程。