跳转到内容

面向 C# 开发者的 Rust

如果你平时靠写 C# 吃饭,有个好消息:Rust 里很多东西你会觉得眼熟。Rust 有带约束的泛型、async/await、强大的模式匹配、读起来很像 LINQ 的迭代器链,还有一个出色的命令行工具(Cargo),干的就是 dotnet 干的活。你已经习惯用类型、接口和 lambda 来思考问题,这些经验都能直接带过来。

最大的转变在内存和错误处理上。Rust 没有垃圾回收器。取而代之的是:每个值都只有一个所有者,编译器会在程序运行之前检查所有权和借用。Rust 也没有 null(用的是 Option),没有异常(用的是 Result)。你的大部分学习时间都会花在这三点上。这一页把你在 C# 和 .NET 里学到的东西一一对应到 Rust,好让你把时间花在真正新的部分上。

如果你想看 Rust 和 C++、Go、Python 的整体对比,请看 Rust 与 C++、Go、Python 对比。

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

C# Rust 延伸学习
带字段和方法的 class 用 struct 存数据,再用 impl 块写方法 Rust 结构体
interface trait Rust trait
抽象类、继承 没有继承。用组合(结构体里套结构体)加带默认方法的 trait Rust trait、设计模式
装着不同类的 List<IShape> Vec<Box<dyn Shape>>(trait 对象) trait 对象(dyn)
扩展方法 自己定义一个 trait,为别人写的类型(比如 String)实现它 Rust trait
带 where T : IComparable<T> 的泛型 带 trait 约束的泛型:T: Ord 或 where T: Ord Rust 泛型
null、Nullable<int>、可为 null 的引用类型 Option<T>:要么是 Some(value),要么是 None Rust Option
?? 和 ?. .unwrap_or(...)、.map(...)、.and_then(...) 以及 ? Rust Option
异常、throw、try/catch Result<T, E> 加 ? 运算符 Rust Result、自定义错误类型
表示真正 bug 的异常(比如 InvalidOperationException) panic!(终止当前线程) Rust panic
IDisposable 加 using Drop,所有者离开作用域时自动运行(RAII) Rust 作用域、Rust 智能指针
垃圾回收器 所有权和借用,在编译时检查 Rust 所有权、Rust 借用
record 加上 #[derive(Debug, Clone, PartialEq)] 的 struct Rust 结构体、常用 trait
record 的 with 表达式 结构体更新语法:Order { total: 5, ..old } Rust 结构体
enum(只能是具名整数) 每个变体都能携带自己数据的 enum Rust 枚举
switch 表达式、is 模式 match(编译器会检查你是否覆盖了所有情况) Rust match、Rust 模式匹配
元组 (int, string) 元组 (i32, String) Rust 元组
ref / out 参数 &mut T,或者返回一个元组或 Option Rust 借用
lambda、Func<T>、Action<T>、委托 闭包,由 Fn、FnMut、FnOnce 这几个 trait 描述 Rust 闭包、Fn、FnMut 与 FnOnce
event 一个信道,或者一组装箱的闭包(回调) 信道(channel)、Fn、FnMut 与 FnOnce
LINQ:Where、Select、Sum、OrderBy、ToList 迭代器适配器:filter、map、sum、sort_by(用在 Vec 上)、collect Rust 迭代器、排序与查找
IEnumerable<T> 和 yield return impl Iterator<Item = T>,或者自己写一个实现了 Iterator 的类型 自定义迭代器
async Task<T> / await async fn 返回一个 Future;需要 tokio 这样的运行时来执行它 async/await
Task.Run、Thread、lock std::thread::spawn 或 tokio::spawn;Mutex<T> 把它保护的数据包在里面 Rust 并发、共享状态
属性(get; set;) 没有属性。用 pub 字段,或者 getter 和 setter 方法 Rust 结构体
string 和 StringBuilder &str(借用的文本视图)和 String(拥有所有权、可增长) Rust 字符串
字符串插值 $"Hi {name}" format!("Hi {name}") 和 println!("Hi {name}") Rust 输出
var let(默认不可变;要允许修改就写 let mut) Rust 变量
const、readonly const,以及普通的 let(本来就是只读的) Rust 变量
static readonly 字段 static,或者用 LazyLock 存放启动时才构建的值 Rust 变量
namespace 和 using 指令 模块(mod)和 use Rust 模块
程序集 / 项目 crate(库或可执行程序) Cargo 与 crate
[Serializable] 这样的特性(attribute) #[derive(Serialize)] 这样的属性和派生宏 Rust 宏、用 Serde 处理 JSON
反射 运行时基本没有。改用宏和 derive 在编译时生成代码 Rust 宏
unsafe 和指针 unsafe 块和裸指针 unsafe Rust
Span<T>、ReadOnlySpan<char> slice:&[T] 和 &str Rust slice(切片)
重写 ToString()、IFormattable 实现 Display(然后 .to_string() 就白送了) 常用 trait
IEquatable<T>、IComparable<T>、GetHashCode PartialEq/Eq、PartialOrd/Ord、Hash(通常用派生) 常用 trait
implicit / explicit 转换运算符 From/Into(可能失败时用 TryFrom) From 与 Into 转换
运算符重载(operator +) 实现 std::ops 里的 trait,比如 Add 高级 trait
checked / unchecked 算术 checked_add、wrapping_add、saturating_add Rust 整数溢出
decimal 没有内置的十进制类型;用 rust_decimal crate,或者用整数存“分” Rust 数据类型

System.Collections.Generic 里的东西,标准库几乎都有。名字变了,思路没变。

C# Rust 延伸学习
List<T> Vec<T> Rust vector(Vec)
T[](固定长度) 长度在编译时已知就用 [T; N],否则用 Vec<T> Rust 数组
Dictionary<K, V> HashMap<K, V> Rust HashMap
SortedDictionary<K, V> BTreeMap<K, V>(键始终保持有序) Rust HashMap
HashSet<T> HashSet<T> HashSet 与 BTreeMap
SortedSet<T> BTreeSet<T> HashSet 与 BTreeMap
Queue<T> VecDeque<T>(push_back / pop_front) VecDeque 与 BinaryHeap
Stack<T> 用 push / pop 的 Vec<T> Rust vector(Vec)
PriorityQueue<TElement, TPriority> BinaryHeap<T>(最大的先出;包一层 Reverse 就变成最小的先出) VecDeque 与 BinaryHeap
LinkedList<T> 有 LinkedList<T>,但很少用得上;VecDeque 通常更快 VecDeque 与 BinaryHeap
ConcurrentDictionary<K, V> Mutex<HashMap<K, V>>,或者 dashmap crate 共享状态

有一点要注意:和 Dictionary 一样,Rust 的 HashMap 没有固定顺序。如果要打印它的内容,先排序,或者改用 BTreeMap。

这是大多数 C# 开发者一上手就会喜欢的部分。Cargo 一个工具就包办了 dotnet CLI、MSBuild 和 NuGet 的活。

.NET Rust 说明
dotnet CLI cargo 构建、运行、测试、添加包、发布。见 Cargo 与 crate
.csproj Cargo.toml 简短,可以手写
packages.lock.json Cargo.lock 自动生成
包含多个项目的 .sln Cargo 工作空间 顶层的一个 Cargo.toml 列出所有成员 crate
NuGet / nuget.org crates.io 每个已发布 crate 的 API 文档都在 docs.rs(英文)上
dotnet add package Serilog cargo add tracing
dotnet new console cargo new my_app(库用 cargo new --lib)
dotnet run / build / test cargo run / cargo build / cargo test
-c Release --release debug 构建运行起来慢得多;做基准测试要用 release
dotnet watch cargo watch 或 bacon(用 cargo install 安装)
dotnet tool install -g cargo install
global.json(SDK 版本) rustup 加一个 rust-toolchain.toml 文件
Roslyn 分析器 cargo clippy 数百条实用的 lint,官方出品
dotnet format cargo fmt 所有人共用一套标准风格
Visual Studio / Rider VS Code + rust-analyzer,或者 JetBrains RustRover rust-analyzer 提供补全、跳转到定义和行内报错
XML 文档注释 /// 用 Markdown 写的 /// 文档注释加 cargo doc 文档里的代码示例会作为测试运行。见 Rust 文档
xUnit / NUnit / MSTest 内置的 #[test] 和 cargo test(cargo nextest 是一个流行的、更快的测试运行器) 见 Rust 测试
Moq mockall,或者手写一个实现了你的 trait 的假对象
Coverlet(代码覆盖率) cargo llvm-cov 或 cargo tarpaulin
BenchmarkDotNet criterion 见 性能优化
dotnet-trace、性能分析器 cargo flamegraph、perf,或任何原生性能分析器

下面是一个小小的 Cargo.toml,你可以拿它和 .csproj 比一比:

[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 “库和框架:“那我们用什么来代替……?””

这是每个 .NET 团队最先问的问题:“我们用的是 Entity Framework,Rust 里有什么?”下面是最接近的替代品。Rust 不像 .NET 背后有微软那样,有一家公司提供“默认技术栈”,所以你经常要在两三个优秀的社区 crate 之间做选择。

.NET Rust 说明
Entity Framework Core Diesel:ORM 和查询构建器;查询在编译时做类型检查。用 diesel_cli 做迁移 成熟(2.x),默认是同步的(另有 diesel-async)
Entity Framework Core(异步、实体风格) SeaORM:异步 ORM,支持实体、关系和迁移(sea-orm-cli) 感觉最接近 EF;构建在 sqlx 之上
Dapper sqlx:你直接写 SQL,query! 宏会在编译时拿你的真实数据库来检查它。用 sqlx-cli 做迁移 异步,非常流行,不是 ORM
ADO.NET sqlx、tokio-postgres、rusqlite(SQLite) 更底层的驱动
StackExchange.Redis redis

如果你喜欢 EF,先试试 SeaORM。如果你喜欢 Dapper、喜欢自己写 SQL,就试试 sqlx。如果你想要最强的编译时保证,就试试 Diesel。数据库这一页用能跑的代码演示了这三个库。

.NET Rust 说明
ASP.NET Core Web API axum(我们在项目:用 Axum 写 Web API 里做了一个)、actix-web、Rocket axum 和 actix-web 在生产环境中用得很广
Minimal API axum:路由就是普通的 async fn 处理函数 风格非常接近
中间件管道 tower layer 和 tower-http crate(CORS、压缩、追踪、超时) axum 直接使用 tower
内置依赖注入 axum 里的 State、普通的构造函数和 trait 见下面依赖注入那一行
Swashbuckle / OpenAPI utoipa
SignalR axum 里的 WebSocket(ws feature),或者 tokio-tungstenite 没有内置 SignalR 那样的协议层
gRPC(Grpc.AspNetCore) tonic(protobuf 用 prost)
HttpClient reqwest 默认异步,也可以选用阻塞式客户端
JWT bearer 认证 jsonwebtoken
.NET Rust 说明
System.Text.Json / Newtonsoft.Json serde + serde_json 事实标准;还支持 TOML、YAML 等格式。见 用 Serde 处理 JSON
ILogger / Serilog tracing(+ tracing-subscriber),或 log + env_logger 带 span 的结构化日志。见 日志
IConfiguration / appsettings.json config 或 figment 把配置文件和环境变量叠加起来,再用 serde 反序列化
DI 容器(IServiceCollection) 通常什么都不用:把依赖传进构造函数,需要在测试里替换的东西就用 trait 有 shaku,但很少有项目用容器
AutoMapper 手写 From / Into 实现 显式,并且由编译器检查。见 From 与 Into 转换
FluentValidation / DataAnnotations validator 或 garde(基于派生的校验)
Polly(重试) backon,或者 tower 的重试中间件
MediatR 没有直接对应的库 用普通的 trait,或者在任务之间用信道
Hangfire / 后台任务 进程内任务用 tokio::spawn;持久化任务队列用 apalis
CancellationToken 直接丢弃 future,或者用 tokio-util 里的 CancellationToken
.NET Rust 说明
Blazor WebAssembly Leptos、Yew、Dioxus 都还没到 1.0;API 仍在变化。见 Rust 与 WebAssembly
WPF / WinForms / MAUI Tauri(Web 界面 + Rust 后端)、Slint、egui、Iced Tauri 2 和 Slint 1.x 最稳定。没有官方的 GUI 工具包
Unity Bevy,或者通过 godot crate 在 Godot 里用 Rust Bevy 还没到 1.0,每次发布都会有破坏性的 API 变更
ML.NET candle、burn、linfa 比 Python 的机器学习工具年轻得多
Azure SDK for .NET 官方的 Azure SDK for Rust:azure_core、azure_identity、azure_storage_blob…… 覆盖的服务比 .NET SDK 少

我们来看真实的代码。下面是一个用 C# 写的小银行账户:

var account = new BankAccount("Ada", 100);
account.Deposit(50);
Console.WriteLine($"{account.Owner} 的余额:{account.Balance}");
public class BankAccount
{
public string Owner { get; }
private decimal balance;
public BankAccount(string owner, decimal balance)
{
Owner = owner;
this.balance = balance;
}
public void Deposit(decimal amount) => balance += amount;
public decimal Balance => balance;
}

再看 Rust 版:

struct BankAccount {
owner: String,
balance: i64,
}
impl BankAccount {
// `new` 只是一种约定,不是关键字。
fn new(owner: &str, balance: i64) -> Self {
BankAccount { owner: owner.to_string(), balance }
}
// `&mut self`:这个方法会修改账户。
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// `&self`:这个方法只读取数据。
fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new("Ada", 100);
account.deposit(50);
println!("{} 的余额:{}", account.owner, account.balance());
}

输出:

Ada 的余额:150
▶ 动手试一试

数据和行为分别放在两个块里。没有构造函数,只有返回 Self 的普通函数。每个方法都开门见山地说明自己是只读(&self)还是会修改(&mut self)这个值。而且因为 deposit 会修改账户,变量必须声明成 let mut。在 C# 里,任何方法都能改任何字段;在 Rust 里,有没有修改从函数签名上就看得出来。

在 C# 里,你可能会有一个 IShape 接口、两个类和一个 List<IShape>:

var shapes = new List<IShape> { new Circle { Radius = 1 }, new Rectangle { Width = 2, Height = 3 } };
foreach (var s in shapes) Console.WriteLine($"{s.Area():F2}");
public interface IShape { double Area(); }
public class Circle : IShape
{
public double Radius { get; init; }
public double Area() => Math.PI * Radius * Radius;
}
public class Rectangle : IShape
{
public double Width { get; init; }
public double Height { get; init; }
public double Area() => Width * Height;
}

在 Rust 里,接口就是 trait,每个结构体在各自的 impl 块里实现它:

trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
// Box<dyn Shape> 扮演的就是“某个 IShape 的引用”这个角色。
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 2.0, height: 3.0 }),
];
for shape in &shapes {
println!("{:.2}", shape.area());
}
}

输出:

3.14
6.00
▶ 动手试一试

在 C# 里,每次调用接口方法都要经过一次虚调用。在 Rust 里由你来选:Box<dyn Shape> 使用虚调用(见 trait 对象(dyn)),而像 fn print_area<T: Shape>(s: &T) 这样的泛型函数会为每种类型单独编译一份,完全没有虚调用。还有个额外的好处:你可以为别人写的类型实现自己的 trait,哪怕是 String 或 i32。C# 里扩展方法干的事,Rust 就是这样做的。

C# 开启可为 null 的引用类型后会提醒你注意 null,但 null 在运行时依然存在:

string? FindEmail(int userId) => userId == 1 ? "[email protected]" : null;
var email = FindEmail(2);
if (email is not null) Console.WriteLine(email.ToUpper());
else Console.WriteLine("没有登记邮箱");
Console.WriteLine(FindEmail(2) ?? "未知");

在 Rust 里,一个可能缺失的值的类型是 Option<T>。在处理好 None 的情况之前,你没法把它当普通值来用:

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
match find_email(1) {
Some(email) => println!("{}", email.to_uppercase()),
None => println!("没有登记邮箱"),
}
// 只关心一种情况时,`if let` 是更简短的写法。
if let Some(email) = find_email(2) {
println!("找到了 {email}");
} else {
println!("没有登记邮箱");
}
// unwrap_or 的作用和 C# 的 ?? 运算符一样。
let email = find_email(2).unwrap_or("未知".to_string());
println!("{email}");
}

输出:

没有登记邮箱
未知
▶ 动手试一试

Rust 里没有 NullReferenceException,因为根本没有 null 可以解引用。编译器干脆不让你在 Option<String> 上调用 .to_uppercase()。辅助方法可以在 Rust Option 这一页学,简短写法在 if let / while let 这一页。

在 C# 里,解析可能会抛异常,你要在调用栈上层的某个地方把它捕获:

static int TotalSeats(string a, string b) => int.Parse(a) + int.Parse(b);
try
{
Console.WriteLine(TotalSeats("12", "30"));
Console.WriteLine(TotalSeats("12", "三十"));
}
catch (FormatException e)
{
Console.WriteLine($"错误:{e.Message}");
}

在 Rust 里,可能失败的函数会在返回类型里写明。? 运算符的意思是“如果这一步失败了,就立刻把错误返回给我的调用者”。它起的作用就是让异常向上冒泡,只不过你在代码里看得见:

use std::num::ParseIntError;
fn total_seats(a: &str, b: &str) -> Result<i32, ParseIntError> {
let first: i32 = a.parse()?;
let second: i32 = b.parse()?;
Ok(first + second)
}
fn main() {
for (a, b) in [("12", "30"), ("12", "三十")] {
match total_seats(a, b) {
Ok(total) => println!("总数:{total}"),
Err(e) => println!("错误:{e}"),
}
}
}

输出:

总数:42
错误:invalid digit found in string
▶ 动手试一试

invalid digit found in string 是标准库自带的英文错误信息,意思是“字符串里有无效的数字”。

最大的区别在于:在 C# 里,任何方法都可能抛异常,签名上却看不出来。在 Rust 里,Result 是签名的一部分,调用者不可能忘记处理它。想定义自己的错误类型,请看 自定义错误类型。在应用程序里,anyhow crate 能给你一种接近“捕获任何异常”的体验(见 anyhow 与 thiserror)。

一个典型的 LINQ 查询:

var orders = new List<Order> { new("Ada", 120), new("Grace", 80), new("Linus", 200), new("Ken", 150) };
var bigSpenders = orders
.Where(o => o.Total > 100)
.OrderByDescending(o => o.Total)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", bigSpenders));
Console.WriteLine($"总收入:{orders.Sum(o => o.Total)}");
record Order(string Customer, int Total);

Rust 版本用的是迭代器适配器和闭包:

struct Order {
customer: String,
total: u32,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120 },
Order { customer: "Grace".to_string(), total: 80 },
Order { customer: "Linus".to_string(), total: 200 },
Order { customer: "Ken".to_string(), total: 150 },
];
// Where(...).ToList()
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// OrderByDescending(...):排序是在 Vec 上做的,而不是在迭代器上。
big.sort_by(|a, b| b.total.cmp(&a.total));
// Select(...)
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// Sum(...)
let revenue: u32 = orders.iter().map(|o| o.total).sum();
println!("总收入:{revenue}");
}

输出:

Linus, Ken, Ada
总收入:550
▶ 动手试一试

Rust 的迭代器是惰性的,和 LINQ 的延迟执行一样:在你调用 collect()、sum() 或者写 for 循环之前,什么都不会运行。collect() 就是你的 ToList()。和 LINQ 不同的是,迭代器链通常会被编译成和手写循环一样的机器码,所以漂亮的写法不需要你付出性能代价。排序的内容在排序与查找这一页。

在 C# 里,你得记得写 using,资源才能及时释放:

using (var conn = new Connection("orders-db"))
{
Console.WriteLine($"正在查询 {conn.Name}");
}
Console.WriteLine("代码块已结束");
class Connection : IDisposable
{
public string Name { get; }
public Connection(string name) => Name = name;
public void Dispose() => Console.WriteLine($"关闭到 {Name} 的连接");
}

在 Rust 里,清理是自动的。当一个值的所有者离开作用域时,Rust 会调用它的 Drop 实现。没有什么可忘的:

struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("关闭到 {} 的连接", self.name);
}
}
fn main() {
{
let conn = Connection { name: "orders-db".to_string() };
println!("正在查询 {}", conn.name);
} // `conn` 在这里离开作用域,所以 drop() 会运行。
println!("代码块已结束");
}

输出:

正在查询 orders-db
关闭到 orders-db 的连接
代码块已结束
▶ 动手试一试

这叫作 RAII,Rust 处理一切资源都靠它:文件、套接字、锁,乃至内存本身。File 值被丢弃时,文件就会关闭。守卫(guard)被丢弃时,Mutex 就会解锁。没有终结器(finalizer),也没有 GC 停顿;清理发生在一个可预测的时刻。

从 C# 转过来的人都会遇到同样的几个坎。提前知道会轻松不少。

  • 赋值是移动,而不是复制引用。 在 C# 里,对一个类写 var b = a;,会得到指向同一个对象的两个引用。在 Rust 里,let b = a; 会把 String 或 Vec 的所有权移动给 b,之后 a 就不能再用了:

    fn main() {
    let owner = String::from("Ada");
    let copy = owner;
    println!("{owner} and {copy}");
    }
    error[E0382]: borrow of moved value: `owner`
    --> src/main.rs:4:16
    |
    2 | let owner = String::from("Ada");
    | ----- move occurs because `owner` has type `String`, which does not implement the `Copy` trait
    3 | let copy = owner;
    | ----- value moved here
    4 | println!("{owner} and {copy}");
    | ^^^^^ value borrowed here after move

    编译器说的是:owner 的值已经移动到了 copy,移动之后又被借用了。解决办法是借用(let copy = &owner;),或者有意识地克隆(owner.clone())。先读 Rust 所有权,那一页讲的就是这条规则。

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

  • String 与 &str。 C# 只有一种 string 类型。Rust 有两种:String 拥有它的文本,&str 借用文本。函数参数通常用 &str,结构体字段通常存 String。见 Rust 字符串。

  • 没有继承。 你没法写一个带共享字段的基类再去重写方法。共享行为用 trait(带默认方法),共享数据就放进一个结构体,让其他结构体包含它。

  • 没有异常。 错误就是值。你会经常写 ?,而且 main 也可以返回 Result。学习阶段调用 .unwrap() 没问题,但它就相当于 Rust 版的“不管异常,直接崩溃”。

  • 没有 null。 一开始,到处都是 Option 会让人觉得麻烦。过一阵子你就会发现,自己再也没遇到过空引用 bug。

  • 显式克隆。 Rust 从不在背后偷偷复制堆上的值。看到 .clone(),就说明真的发生了一次复制。这是一个优点:代价一目了然。

  • 编译时间。 一个依赖很多 crate 的项目,从零开始构建要比 .NET 慢。增量构建和 cargo check(跳过代码生成)则快得多;改代码的时候用 cargo check 就好。

  • async 需要运行时。 在 .NET 里,运行时和线程池一直都在。在 Rust 里,async fn 在执行器运行它之前什么都不会做,所以你要加上 tokio,并在 main 上标注 #[tokio::main]。见 async/await。

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

  1. 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,然后是自定义错误类型。
  7. Rust trait、Rust 泛型、常用 trait 和 trait 对象(dyn)。
  8. Rust 闭包和 Rust 迭代器:你的 LINQ。
  9. Rust vector(Vec)、Rust HashMap 和 VecDeque 与 BinaryHeap。
  10. Rust 模块、Cargo 与 crate 和 Rust 测试。
  11. Rust 生命周期和 Rust 智能指针。
  12. Rust 并发、共享状态和 async/await。
  13. 用 Serde 处理 JSON 和日志,然后动手做 Web API 项目。

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

1. 移植一个 record 和一条 LINQ 查询

Section titled “1. 移植一个 record 和一条 LINQ 查询”

移植下面这段 C# 代码。它应该打印出有货且价格低于 20 的商品名称。提示:写一个带 #[derive(Debug, Clone, PartialEq)] 的 struct,然后用 filter、map,再 collect 成一个 Vec<&str>。Vec<&str> 有一个 .join(", ") 方法。

var products = new List<Product>
{
new("Notebook", 5, true),
new("Headphones", 45, true),
new("Pen", 2, false),
new("Mug", 12, true),
};
var cheap = products.Where(p => p.InStock && p.Price < 20).Select(p => p.Name);
Console.WriteLine(string.Join(", ", cheap));
record Product(string Name, int Price, bool InStock);
查看答案
#[derive(Debug, Clone, PartialEq)]
struct Product {
name: String,
price: u32,
in_stock: bool,
}
fn product(name: &str, price: u32, in_stock: bool) -> Product {
Product { name: name.to_string(), price, in_stock }
}
fn main() {
let products = vec![
product("Notebook", 5, true),
product("Headphones", 45, true),
product("Pen", 2, false),
product("Mug", 12, true),
];
let cheap: Vec<&str> = products
.iter()
.filter(|p| p.in_stock && p.price < 20)
.map(|p| p.name.as_str())
.collect();
println!("{}", cheap.join(", "));
}

输出:

Notebook, Mug
▶ 动手试一试

一个小小的辅助函数扮演了 record 位置构造函数的角色。这几个派生给了你 C# record 白送的那些功能:用 {:?} 打印、用 .clone() 复制、用 == 比较。

下面这段 C# 代码从库存字典里取出一件商品,出问题时就抛异常。请把它移植过来,让 take 返回 Result<u32, String>,而不是抛异常。提示:stock.get_mut(item) 返回一个 Option<&mut u32>。用 .ok_or(...) 把它变成 Result,然后用 ?。

var stock = new Dictionary<string, int> { ["苹果"] = 3, ["梨"] = 0 };
int Take(string item)
{
if (!stock.TryGetValue(item, out var count))
throw new KeyNotFoundException($"没有叫{item}的商品");
if (count == 0)
throw new InvalidOperationException($"{item}已经卖完了");
stock[item] = count - 1;
return count - 1;
}
foreach (var item in new[] { "苹果", "梨", "猕猴桃" })
{
try { Console.WriteLine($"拿走了一个{item},还剩 {Take(item)} 个"); }
catch (Exception e) { Console.WriteLine($"错误:{e.Message}"); }
}
查看答案
use std::collections::HashMap;
fn take(stock: &mut HashMap<String, u32>, item: &str) -> Result<u32, String> {
let count = stock
.get_mut(item)
.ok_or(format!("没有叫{item}的商品"))?;
if *count == 0 {
return Err(format!("{item}已经卖完了"));
}
*count -= 1;
Ok(*count)
}
fn main() {
let mut stock = HashMap::new();
stock.insert("苹果".to_string(), 3);
stock.insert("梨".to_string(), 0);
for item in ["苹果", "梨", "猕猴桃"] {
match take(&mut stock, item) {
Ok(left) => println!("拿走了一个{item},还剩 {left} 个"),
Err(e) => println!("错误:{e}"),
}
}
}

输出:

拿走了一个苹果,还剩 2 个
错误:梨已经卖完了
错误:没有叫猕猴桃的商品
▶ 动手试一试

带 out 参数的 TryGetValue 变成了返回 Option 的 get_mut。两个 throw 变成了 Err 值,try/catch 变成了一个 match。调用时写的 &mut stock 清楚地表明 take 会修改这个字典。

  • C# 的很多东西都能直接带过来:泛型、async/await、lambda、模式匹配,以及 LINQ 风格的迭代器,在 Rust 里都有很接近的对应物。
  • 最大的转变是:用所有权代替垃圾回收器,用 Option 代替 null,用 Result 加 ? 代替异常。
  • 类变成 struct + impl,接口变成 trait,继承变成组合加 trait。using/IDisposable 变成自动运行的 Drop。
  • Cargo 取代了 dotnet CLI、MSBuild 和 NuGet;clippy 和 rustfmt 取代了分析器和 dotnet format;测试是内置的。
  • 想找 EF Core 就看 SeaORM 或 Diesel,想找 Dapper 就看 sqlx,想找 ASP.NET Core 就看 axum 或 actix-web。GUI、游戏和机器学习方面的选择还比较年轻。

小测验

1. Rust 用什么来代替 null?

2. 你的团队用 Dapper,喜欢手写 SQL。哪个 Rust crate 最接近?

3. C# 里配合 IDisposable 的 using 块,在 Rust 里最接近的是什么?

4. 在 C# 里,对一个类写 var b = a; 会复制引用。在 Rust 里,对一个 String 写 let b = a; 会怎样?

得分: 0 / 4