面向 Go 开发者的 Rust:实用指南
如果你写 Go,你会发现 Rust 里熟悉的东西比想象中多。两门语言都编译成一个又快又独立的二进制文件。两者都用带方法的结构体代替类,都没有继承,都把错误当作值返回而不是抛异常,都有一个人人都用的标准格式化工具,也都有一个负责构建、测试和拉取包的命令行工具。一个带几个方法的 Go struct,几乎可以逐行翻译成 Rust。
新东西主要在内存和“把话说清楚”这两方面。Rust 没有垃圾回收器:每个值都只有一个所有者,编译器会在程序运行之前检查所有权和借用。Rust 没有 nil(用的是 Option),if err != nil 变成了 Result 加 ? 运算符。接口依然存在(叫作 trait),但类型必须明确声明自己实现了哪个 trait。你的大部分学习时间会花在所有权上。其余部分会让你觉得像是一个更严格、更细致的 Go。
Go 开发者为什么要学 Rust?通常是因为下面某个原因:某个服务开始在意垃圾回收停顿或内存占用;有 CPU 密集型的工作;代码要在没有操作系统的环境里运行(嵌入式),或者在浏览器里运行(WebAssembly);或者只是想让编译器在上线之前就抓住数据竞争和 nil 之类的崩溃。如果你想看 Rust 和 Go、C++、Python 的整体对比,请看 Rust 与 C++、Go、Python 对比。这一页讲得更深入,是专门写给你这位 Go 开发者的。
语言概念:从 Go 到 Rust
Section titled “语言概念:从 Go 到 Rust”把这张表当成一本“短语手册”来用。最后一列链接到本站讲解 Rust 这一边的课程。
| Go | Rust | 延伸学习 |
|---|---|---|
| 垃圾回收器 | 所有权和借用,在编译时检查;所有者离开作用域时内存就会释放 | Rust 所有权、Rust 借用 |
struct + 方法 |
用 struct 存数据,再用 impl 块写方法 |
Rust 结构体 |
值接收者 func (a Account) |
&self(只读,不复制)或 self(拿走所有权) |
Rust 结构体 |
指针接收者 func (a *Account) |
&mut self(可以修改这个值) |
Rust 结构体 |
NewAccount(...) 构造函数 |
关联函数,按约定写成 Account::new(...) |
Rust 结构体 |
| 首字母大写 = 导出 | pub 关键字 |
Rust 模块 |
零值(var c Config) |
没有隐式的零值;每个字段都必须赋值。用 #[derive(Default)] 加 Config::default() |
Rust 结构体、常用 trait |
| 结构体嵌入 | 没有嵌入。把结构体放进一个字段,再写几个转发的小方法,或者用 trait 共享行为 | Rust trait、设计模式 |
interface(隐式满足) |
trait(用 impl Trait for Type 显式实现) |
Rust trait |
装着不同类型的 []Shape |
Vec<Box<dyn Shape>>(trait 对象) |
trait 对象(dyn) |
any / interface{} 加类型 switch |
每种情况对应一个变体的 enum,再用 match |
Rust 枚举、Rust match |
泛型 func Max[T cmp.Ordered](a, b T) T |
带 trait 约束的泛型:fn max<T: PartialOrd>(a: T, b: T) -> T |
Rust 泛型、高级泛型 |
nil 指针、nil map、nil 接口 |
Option<T>:要么是 Some(value),要么是 None |
Rust Option |
v, ok := m[k] |
m.get(&k) 返回一个 Option |
Rust HashMap |
(T, error) 加 if err != nil { return err } |
Result<T, E> 加 ? 运算符 |
Rust Result |
errors.New、哨兵错误、errors.Is / errors.As |
一个可以 match 的错误 enum(thiserror crate 能帮上忙) |
自定义错误类型、anyhow 与 thiserror |
fmt.Errorf("...: %w", err) |
anyhow 配合 .context("..."),或者 map_err |
anyhow 与 thiserror |
panic / recover |
真正的 bug 用 panic!;很少需要恢复(std::panic::catch_unwind) |
Rust panic |
defer |
Drop,值离开作用域时运行(RAII) |
Rust 作用域、Rust 智能指针 |
go f() |
std::thread::spawn(...),异步任务用 tokio::spawn(...) |
Rust 并发、async/await |
chan T、make(chan T, n) |
std::sync::mpsc::channel()(无界)或 sync_channel(n);异步代码里用 tokio::sync::mpsc |
信道(channel) |
select |
异步代码里用 tokio::select!,或者 crossbeam-channel 里的 select! |
深入 async |
放在数据旁边的 sync.Mutex |
Mutex<T> 把数据包在里面;守卫被丢弃时自动解锁 |
共享状态 |
sync.WaitGroup |
JoinHandle::join(),或者 std::thread::scope |
Rust 并发 |
sync.Once |
std::sync::OnceLock / LazyLock |
标准库惯用法 |
context.Context |
没有单一的对应物:丢弃 future 就能取消它,还有 tokio::time::timeout,或者 tokio-util 里的 CancellationToken |
深入 async |
func 值和闭包 |
闭包,由 Fn、FnMut、FnOnce 这几个 trait 描述 |
Rust 闭包、Fn、FnMut 与 FnOnce |
for i, v := range s |
for (i, v) in s.iter().enumerate() |
Rust for 循环、Rust 迭代器 |
唯一的那个 for 循环 |
loop、while 和 for |
Rust loop 循环 |
函数式 range 迭代器(iter.Seq) |
Iterator trait,以及 filter、map、sum、collect 这些适配器 |
Rust 迭代器、自定义迭代器 |
switch |
match(编译器会检查你是否覆盖了所有情况) |
Rust match、Rust 模式匹配 |
const + iota |
enum(可以显式指定数值) |
Rust 枚举 |
:= |
let(默认不可变;要允许修改就写 let mut) |
Rust 变量 |
string(字节序列,通常是 UTF-8) |
String(拥有所有权)和 &str(借用),永远是合法的 UTF-8 |
Rust 字符串 |
rune、for _, r := range s |
char、for c in s.chars() |
Rust 字符串 |
[]byte |
Vec<u8> 或 &[u8] |
Rust slice(切片) |
fmt.Println、%v、%+v |
println!、{}(Display)、{:?} 和 {:#?}(Debug,需要 #[derive(Debug)]) |
Rust 输出 |
String() string(Stringer 接口) |
实现 Display(然后 .to_string() 就白送了) |
常用 trait |
结构体标签 `json:"user_name"` |
属性:#[serde(rename = "user_name")] |
用 Serde 处理 JSON |
int(x)、float64(n) 转换 |
数字之间用 as,还有 From/Into,可能失败时用 TryFrom |
Rust 类型转换、From 与 Into 转换 |
| 整数溢出时悄悄回绕 | debug 构建里 panic,release 构建里回绕;用 checked_add、wrapping_add、saturating_add 说清楚你要什么 |
Rust 整数溢出 |
*T 和 &x |
&T 和 &mut T 引用(永远不为空,也不能比值活得更久);堆上拥有所有权的值用 Box<T> |
Rust 借用、Rust 智能指针 |
| 包(package) | crate 里的模块(mod);用 use 代替 import |
Rust 模块 |
go generate |
宏和 build.rs 构建脚本 |
Rust 宏、过程宏 |
unsafe 包 |
unsafe 块和裸指针 |
unsafe Rust |
| cgo | extern "C" 和 FFI(bindgen 能从 C 头文件生成绑定) |
FFI(调用 C) |
构建标签 //go:build linux |
#[cfg(target_os = "linux")] 和 Cargo feature |
Cargo feature 与 cfg |
Go 把切片和 map 内置在语言里。Rust 把它们放在标准库里,另外还多了几种。
| Go | Rust | 延伸学习 |
|---|---|---|
你拥有、会往里 append 的 []T |
Vec<T> |
Rust vector(Vec) |
传给只读函数的 []T |
&[T](从 Vec 或数组借来的 slice) |
Rust slice(切片) |
[N]T |
[T; N] |
Rust 数组 |
append(s, x) / append(s, other...) |
v.push(x) / v.extend(other) |
Rust vector(Vec) |
s[a:b] |
&v[a..b] |
Rust slice(切片) |
len(s)、cap(s)、make([]T, 0, n) |
v.len()、v.capacity()、Vec::with_capacity(n) |
Rust vector(Vec) |
map[K]V |
HashMap<K, V> |
Rust HashMap |
当集合用的 map[K]struct{} 或 map[K]bool |
HashSet<T> |
HashSet 与 BTreeMap |
| 打印前先收集 map 的键再排序 | BTreeMap<K, V>(键始终保持有序) |
Rust HashMap |
container/list |
VecDeque<T>(push_back / pop_front) |
VecDeque 与 BinaryHeap |
container/heap(要实现 5 个方法) |
BinaryHeap<T>:只需 push 和 pop;最大的先出,包一层 Reverse 就变成最小的先出 |
VecDeque 与 BinaryHeap |
slices.Sort、sort.Slice |
v.sort()、v.sort_by(...)、v.sort_by_key(...) |
排序与查找 |
slices.Contains、slices.Index |
v.contains(&x)、v.iter().position(...) |
Rust vector(Vec) |
strings.Builder |
一个配合 push_str 或 write! 使用的 String |
Rust 字符串 |
sync.Map |
Mutex<HashMap<K, V>>、RwLock,或者 dashmap crate |
共享状态 |
有两点要知道。第一,和 Go 的 map 一样,Rust 的 HashMap 没有固定顺序,所以打印前先排序,或者改用 BTreeMap。第二,Go 那个有名的切片陷阱(两个切片共享同一个底层数组,一次 append 改掉了另一个切片的内容)在 Rust 里不可能发生:只要代码的某一部分持有一个 Vec 的 &mut 借用,其他地方就都碰不到它。
工具链:从 go 到 cargo
Section titled “工具链:从 go 到 cargo”好消息:你习惯了一个工具包办一切,Rust 也有这样一个工具。Cargo 涵盖了 go build、go run、go test、go mod 和 go install。
| Go | Rust | 说明 |
|---|---|---|
go 命令 |
cargo |
构建、运行、测试、添加包、发布。见 Cargo 与 crate |
go mod init |
cargo new my_app(库用 cargo new --lib) |
创建目录、Cargo.toml、src/main.rs 和一个 git 仓库 |
go.mod |
Cargo.toml |
|
go.sum |
Cargo.lock |
自动生成和更新 |
go get github.com/gin-gonic/gin |
cargo add axum |
|
go get -u |
cargo update |
在 Cargo.toml 写明的版本范围内更新 |
| pkg.go.dev | 包在 crates.io 上,API 文档在 docs.rs(英文)上 | |
go build / go run / go test |
cargo build / cargo run / cargo test |
加上 --release 得到优化构建;debug 构建运行起来慢得多 |
go vet、staticcheck、golangci-lint |
cargo clippy |
官方出品,有数百条实用的 lint |
gofmt / goimports |
cargo fmt(rustfmt) |
一套标准风格,和 gofmt 一样 |
go doc |
cargo doc --open |
文档注释里的代码示例会作为测试运行。见 Rust 文档 |
go test -bench |
criterion(用 cargo bench 运行) |
见 性能优化 |
go test -cover |
cargo llvm-cov 或 cargo tarpaulin |
|
go test -race |
安全代码不需要:数据竞争是编译错误。unsafe 代码用 Miri 检查 |
|
go test -fuzz |
cargo fuzz |
用 cargo install cargo-fuzz 安装 |
| testify | 内置的 assert! 和 assert_eq!;想要更好看的差异对比就用 pretty_assertions |
见 Rust 测试和测试进阶 |
| gomock / mockery | mockall,或者手写一个实现了你的 trait 的假对象 |
|
go install pkg@latest |
cargo install name |
|
go work(工作区) |
Cargo 工作空间 | 顶层的一个 Cargo.toml 列出所有成员 crate |
go.mod 里的 toolchain 那一行 |
rustup 和一个 rust-toolchain.toml 文件 |
见 Rust 工具与环境配置 |
GOOS=linux GOARCH=arm64 go build |
rustup target add aarch64-unknown-linux-gnu 加 cargo build --target ... |
通常还需要目标平台的链接器;cross 或 cargo-zigbuild 能让这件事简单些 |
| govulncheck | cargo audit(RustSec 安全公告)、cargo deny |
|
| gopls | rust-analyzer | 支持 VS Code、Neovim、Helix、Zed 等编辑器 |
| GoLand | JetBrains RustRover | |
| Delve | rust-gdb / rust-lldb,或者 VS Code 里的 CodeLLDB |
|
| pprof | cargo flamegraph、perf,或任何原生性能分析器 |
|
| air(热重载) | bacon 或 cargo watch |
把一个小小的 Cargo.toml 和你熟悉的 go.mod 放在一起看:
module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0[package]name = "orders-api"version = "0.1.0"edition = "2021"
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }有一个区别你马上就会注意到:Go 的标准库自带 HTTP 服务器、JSON、模板和加密。Rust 的标准库有意保持精简,所以 JSON、HTTP、异步、随机数这些都来自 crate。加一个 crate 只要一条 cargo add,而且有那么几个 crate(serde、tokio、clap、regex、rand)几乎人人都在用。
库和框架:“那我们用什么来代替……?”
Section titled “库和框架:“那我们用什么来代替……?””这是第一个实际问题:“我们用 Gin 和 GORM,Rust 里对应的是什么?”下面是最接近的替代品。和 Go 一样,Rust 通常有两三个优秀的社区选择,而不是一个官方答案。
Web 与网络
Section titled “Web 与网络”| Go | Rust | 说明 |
|---|---|---|
| Gin、Echo、Chi、Fiber | axum(我们在项目:用 Axum 写 Web API 里做了一个)、actix-web、Rocket | axum 和 actix-web 在生产环境中用得很广 |
net/http 服务端 |
hyper(底层),通常通过 axum 来用 |
标准库里没有 HTTP 服务器 |
http.Handler 中间件 |
tower layer 和 tower-http(CORS、压缩、追踪、超时) |
axum 直接使用 tower |
net/http 客户端 |
reqwest |
默认异步,也可以选用阻塞式客户端。见 网络编程 |
| grpc-go | tonic(protobuf 用 prost) |
|
| gorilla/websocket | tokio-tungstenite,或者 axum 的 ws feature |
|
html/template、text/template |
askama(在编译时检查)、tera、minijinja |
|
net(TCP/UDP) |
std::net,异步用 tokio::net |
见 网络编程 |
| errgroup | tokio::task::JoinSet |
| Go | Rust | 说明 |
|---|---|---|
encoding/json + 结构体标签 |
serde + serde_json |
还支持 TOML(toml crate)等格式。见 用 Serde 处理 JSON |
database/sql + pgx |
sqlx(异步,直接写 SQL,在编译时拿你的数据库检查)、tokio-postgres |
虽然名字一样,但和 Go 的 jmoiron/sqlx 没有关系 |
| sqlc | sqlx 的 query! 宏 |
两者都从你写的 SQL 出发,给你带类型的结果 |
| GORM | SeaORM(异步、实体风格)、Diesel(带类型检查的查询构建器) | 见 数据库 |
| go-sqlite3 | rusqlite |
|
| go-redis | redis |
命令行、配置、日志与日常工具
Section titled “命令行、配置、日志与日常工具”| Go | Rust | 说明 |
|---|---|---|
cobra、urfave/cli、flag |
clap(从结构体派生出命令行参数) |
见 命令行参数和项目:用 clap 写命令行工具 |
| viper | config 或 figment |
把配置文件和环境变量叠加起来,再用 serde 反序列化 |
log/slog、zap、zerolog |
tracing + tracing-subscriber,或 log + env_logger |
结构化日志。见 日志 |
regexp |
regex |
和 Go 的 RE2 思路相同:线性时间匹配,不支持反向引用。见 正则表达式 |
time |
时长用 std::time;日期和时区用 chrono 或 jiff |
见 日期与时间 |
embed |
include_str! / include_bytes!,整个目录用 rust-embed |
|
| bubbletea | ratatui |
终端界面 |
| Prometheus client_golang | prometheus 或 metrics |
|
sync/atomic |
std::sync::atomic |
桌面、游戏与嵌入式
Section titled “桌面、游戏与嵌入式”| Go | Rust | 说明 |
|---|---|---|
| Wails | Tauri(Web 界面 + Rust 后端) | 思路非常相似。见 GUI 应用 |
| Fyne | egui、Iced、Slint |
没有官方的 GUI 工具包 |
| Ebitengine | macroquad、Bevy |
Bevy 还没到 1.0,每次发布都会改 API。见 游戏开发 |
| TinyGo | embedded-hal、Embassy |
Rust 可以在没有操作系统、没有堆的环境里运行。见 嵌入式 Rust |
GOOS=js GOARCH=wasm |
wasm-bindgen、Leptos、Yew、Dioxus |
二进制文件小,不用附带 GC。见 Rust 与 WebAssembly |
对照看:结构体和方法(几乎一样)
Section titled “对照看:结构体和方法(几乎一样)”Go 开发者在这里会有回家的感觉。下面是一个用 Go 写的小账户类型:
type Account struct { Owner string // 导出:首字母大写 balance int // 不导出}
func NewAccount(owner string, balance int) *Account { return &Account{Owner: owner, balance: balance}}
func (a *Account) Deposit(amount int) { a.balance += amount }func (a *Account) Balance() int { return a.balance }func (a *Account) String() string { return fmt.Sprintf("%s: %d", a.Owner, a.balance) }
func main() { account := NewAccount("Ada", 100) account.Deposit(50) fmt.Println(account) fmt.Println("余额是", account.Balance())}再看 Rust 版:
use std::fmt;
// `pub` 干的就是 Go 里首字母大写的活。pub struct Account { pub owner: String, balance: i64, // 私有,就像 Go 里小写开头的字段}
impl Account { // 相当于 Go 的 NewAccount:一个普通函数。`new` 只是一种约定。 pub fn new(owner: &str, balance: i64) -> Self { Account { owner: owner.to_string(), balance } }
// 相当于指针接收者:这个方法会修改账户。 pub fn deposit(&mut self, amount: i64) { self.balance += amount; }
// 只读。和 Go 的值接收者不同,这里不会复制。 pub fn balance(&self) -> i64 { self.balance }}
// Go 的 String() 方法(Stringer 接口)变成了 Display。impl fmt::Display for Account { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.owner, self.balance) }}
fn main() { let mut account = Account::new("Ada", 100); account.deposit(50); println!("{account}"); println!("余额是 {}", account.balance());}输出:
Ada: 150余额是 150结构是一样的:数据放在结构体里,方法挂在结构体上,构造函数只是一个普通函数。区别虽小,却很重要。方法都集中在一个 impl 块里。接收者明确说明了方法要做什么:&self 只读,&mut self 修改,单独的 self 则拿走这个值并把它用掉。而且因为 deposit 会修改账户,变量必须声明成 let mut。在 Go 里,谁拿着 *Account 谁就能改它;在 Rust 里,有没有修改的权利,在代码里一目了然。
对照看:隐式接口变成显式 trait
Section titled “对照看:隐式接口变成显式 trait”在 Go 里,一个类型只要有对应的方法,就满足了某个接口:
type Shape interface { Area() float64 Name() string}
type Circle struct{ R float64 }type Rect struct{ W, H float64 }
// 哪儿都没写“implements”:有这些方法就够了。func (c Circle) Area() float64 { return math.Pi * c.R * c.R }func (c Circle) Name() string { return "圆形" }func (r Rect) Area() float64 { return r.W * r.H }func (r Rect) Name() string { return "矩形" }
func describe[T Shape](s T) { fmt.Printf("%s:%.2f\n", s.Name(), s.Area())}
func main() { describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}} total := 0.0 for _, s := range shapes { total += s.Area() } fmt.Printf("总面积:%.2f\n", total)}在 Rust 里,你要用 impl Shape for Circle 把这层关系明明白白地写下来:
use std::f64::consts::PI;
trait Shape { fn area(&self) -> f64; fn name(&self) -> &str;}
struct Circle { r: f64,}
struct Rect { w: f64, h: f64,}
// Go 省掉的就是这一行。impl Shape for Circle { fn area(&self) -> f64 { PI * self.r * self.r } fn name(&self) -> &str { "圆形" }}
impl Shape for Rect { fn area(&self) -> f64 { self.w * self.h } fn name(&self) -> &str { "矩形" }}
// 泛型,相当于 Go 的 describe[T Shape](s T)。fn describe<T: Shape>(s: &T) { println!("{}:{:.2}", s.name(), s.area());}
fn main() { describe(&Circle { r: 1.0 });
// 相当于 Go 的 []Shape:不同的类型,藏在同一个 trait 后面。 let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { r: 1.0 }), Box::new(Rect { w: 2.0, h: 3.0 }), ]; let total: f64 = shapes.iter().map(|s| s.area()).sum(); println!("总面积:{total:.2}");}输出:
圆形:3.14总面积:9.14显式声明每个类型只多花一行,却换来一些 Go 做不到的事。trait 可以有默认方法,这样类型就能免费获得共享的行为。你可以为别人写的类型实现自己的 trait,哪怕是 i32 或 String(Go 只允许你给自己包里的类型加方法)。而且泛型的 describe 会为每个具体类型各编译一次,所以调用时没有运行时查找。Box<dyn Shape> 则是 Go 接口的风格:一个值指向“某个实现了 Shape 的类型”,方法在运行时查找。见 Rust trait和 trait 对象(dyn)。
对照看:nil 变成 Option
Section titled “对照看:nil 变成 Option”在 Go 里,一个可能不存在的指针就是 nil,你得记得检查:
type User struct { Name string Manager *User // nil 表示“没有上级”}
func findUser(users []*User, name string) *User { for _, u := range users { if u.Name == name { return u } } return nil}
func main() { ada := &User{Name: "Ada"} users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} { u := findUser(users, name) if u == nil { fmt.Println(name + ":没有这个用户") continue } boss := "无" if u.Manager != nil { boss = u.Manager.Name } fmt.Println(name + " 的上级:" + boss) }}在 Rust 里,“可能不存在”是类型的一部分。Option<&User> 要么是 Some(user),要么是 None,在处理好 None 之前,编译器不让你使用这个用户:
struct User { name: String, manager: Option<String>, // Go 里是 Manager *User,可能为 nil}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> { users.iter().find(|u| u.name == name)}
fn main() { let users = vec![ User { name: "Ada".to_string(), manager: None }, User { name: "Bob".to_string(), manager: Some("Ada".to_string()) }, ];
for name in ["Bob", "Ada", "Eve"] { match find_user(&users, name) { Some(user) => { let boss = user.manager.as_deref().unwrap_or("无"); println!("{name} 的上级:{boss}"); } None => println!("{name}:没有这个用户"), } }}输出:
Bob 的上级:AdaAda 的上级:无Eve:没有这个用户同样的思路也取代了 Go 的 comma-ok 查找。if email, ok := emails[name]; ok { ... } 变成了 if let Some(email) = emails.get(name) { ... }。有一点要注意:在 Go 里,emails["missing"] 会悄悄给你零值 ""。在 Rust 里,emails["missing"] 会 panic,所以只要键可能不存在,就用 .get()。find_user 里的 'a 是一个生命周期:它告诉编译器,返回的 &User 指向的是 users 里面的数据。你很快就会碰到它们。
对照看:if err != nil 变成 Result 和 ?
Section titled “对照看:if err != nil 变成 Result 和 ?”Go 的错误处理以它那三行固定写法出名:
func parsePort(s string) (int, error) { n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("端口 %q 无效:%w", s, err) } if n < 1 || n > 65535 { return 0, fmt.Errorf("端口 %d 超出范围", n) } return n, nil}
func address(host, port string) (string, error) { p, err := parsePort(port) if err != nil { return "", err } return fmt.Sprintf("%s:%d", host, p), nil}
func main() { for _, port := range []string{"8080", "abc", "70000", "0"} { addr, err := address("localhost", port) if err != nil { fmt.Println("错误:", err) continue } fmt.Println("正在监听", addr) }}Rust 保留了这个思路(错误是你返回的值),但把那套写法浓缩成了一个字符:?:
fn parse_port(s: &str) -> Result<u16, String> { // u16 装不下 70000,所以 parse() 会替我们抓住“超出范围”。 let port: u16 = s.parse().map_err(|e| format!("端口 {s:?} 无效:{e}"))?; if port == 0 { return Err("端口 0 是保留端口".to_string()); } Ok(port)}
fn address(host: &str, port: &str) -> Result<String, String> { // `?` 的意思是:如果这是一个错误,就立刻把它返回。 let port = parse_port(port)?; Ok(format!("{host}:{port}"))}
fn main() { for port in ["8080", "abc", "70000", "0"] { match address("localhost", port) { Ok(addr) => println!("正在监听 {addr}"), Err(e) => println!("错误:{e}"), } }}输出:
正在监听 localhost:8080错误:端口 "abc" 无效:invalid digit found in string错误:端口 "70000" 无效:number too large to fit in target type错误:端口 0 是保留端口invalid digit found in string(“字符串里有无效的数字”)和 number too large to fit in target type(“数字太大,目标类型装不下”)是标准库自带的英文错误信息。
比起少写几行,还有一个更深层的区别。在 Go 里,n, err := strconv.Atoi(s) 会同时给你两个值,没有什么能阻止你在 err 不是 nil 的时候使用 n。而 Result 要么是值,要么是错误,所以不先处理错误,你就拿不到值。如果你完全忽略了一个 Result,编译器还会给出警告。
在真实项目里,String 错误会被换成更丰富的东西。一个错误 enum(通常借助 thiserror crate 来写)扮演哨兵错误和 errors.Is 的角色:调用者对变体做 match。anyhow crate 提供了 .context("loading config")?,这就是 Rust 版的 fmt.Errorf("loading config: %w", err)。见 自定义错误类型和 anyhow 与 thiserror。
对照看:goroutine 和 channel 变成线程、mpsc 和任务
Section titled “对照看:goroutine 和 channel 变成线程、mpsc 和任务”下面是经典的 Go 扇入写法:启动几个 goroutine,把结果发进一个 channel,全部完成后关闭它:
func main() { results := make(chan int) var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} { wg.Add(1) go func(n int) { defer wg.Done() results <- n * n }(n) }
go func() { wg.Wait() close(results) }()
total := 0 for r := range results { total += r } fmt.Println("总计:", total)}用线程和标准库信道写的 Rust 版还要短一点:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] { let tx = tx.clone(); // 每个线程都有自己的发送端 thread::spawn(move || { tx.send(n * n).unwrap(); }); }
// 丢弃我们自己手里的发送端。所有发送端都没了,信道就会关闭, // 下面的循环也随之结束:不需要 WaitGroup。 drop(tx);
let total: i32 = rx.iter().sum(); println!("总计:{total}");}输出:
总计:14有几处对应关系不太一样。mpsc 的意思是“多生产者、单消费者”(multi-producer, single-consumer):发送端可以克隆,接收端不行(想要 Go 那种多个读者的 channel,就用 crossbeam-channel)。mpsc::channel() 是无界的,Go 里没有这种 channel;mpsc::sync_channel(n) 才相当于 Go 的 make(chan T, n),sync_channel(0) 则是无缓冲的 channel。没人调用 close:最后一个发送端被丢弃时,信道就关闭了。move 让每个线程拿到自己的那份 n 和自己的 tx,Rust 正是靠这种方式保证线程之间永远不会不安全地共享数据。见 信道(channel)和 Rust 并发。
thread::spawn 启动的是真正的操作系统线程,比 goroutine 重。几个工作线程没问题。要处理成千上万个并发连接,Rust 会在 tokio 这样的运行时上跑异步任务,这才和 goroutine 更接近:
#[tokio::main]async fn main() { let mut handles = Vec::new();
for n in [1, 2, 3] { // 相当于 `go func() { ... }()`,只不过你会拿回一个句柄。 handles.push(tokio::spawn(async move { n * n })); }
let mut total = 0; for handle in handles { total += handle.await.unwrap(); } println!("总计:{total}");}输出:
总计:14说句实话:在这一点上 Go 更简单。在 Go 里,每个函数都可以阻塞,运行时会处理好一切。在 Rust 里,你要把函数标成 async,用 .await 调用它们,还要挑一个运行时。回报是:编译器拒绝构建那些可能让两个线程同时修改同一份数据的代码,所以 go test -race 在 Rust 里没什么可找的。见 async/await、深入 async,以及讲 Arc<Mutex<T>> 的共享状态,Arc<Mutex<T>> 就是 Rust 版的“数据旁边放一个 sync.Mutex 的结构体”。
对照看:defer 变成 Drop
Section titled “对照看:defer 变成 Drop”在 Go 里,你用 defer 安排清理工作,函数返回时,被 defer 的调用会按相反的顺序运行:
func process() { fmt.Println("打开文件") defer fmt.Println("关闭文件")
fmt.Println("加锁") defer fmt.Println("解锁")
fmt.Println("处理中")}
func main() { process() fmt.Println("完成")}在 Rust 里,清理工作属于值本身。当一个值离开作用域时,它的 Drop 实现就会运行,而且值会按相反的顺序被丢弃,就像你的 defer 一样:
struct Guard(&'static str);
impl Drop for Guard { fn drop(&mut self) { println!("{}", self.0); }}
fn process() { println!("打开文件"); let _file = Guard("关闭文件");
println!("加锁"); let _lock = Guard("解锁");
println!("处理中");} // _lock 在这里被丢弃,然后是 _file
fn main() { process(); println!("完成");}输出:
打开文件加锁处理中解锁关闭文件完成在真实代码里,你很少需要自己写 Drop:File 会自己关闭,MutexGuard 会自己解锁,TcpStream 会自己断开连接。没有什么可忘的。和 defer 相比,有两个区别值得知道。Drop 在代码块结束时运行,而不是在函数结束时,所以 Go 里 defer 在循环中越堆越多的陷阱并不存在:每次循环迭代都会自己收拾干净。另外要小心 let _ = Guard(...):光秃秃的 _ 根本不会保留这个值,所以它会被立刻丢弃。请用 _lock 这样的名字。
Go 开发者容易踩的坑
Section titled “Go 开发者容易踩的坑”从 Go 转过来的人都会遇到同样的几个坎。提前知道会轻松不少。
-
传值可能会移动它。 在 Go 里,把切片传给函数会复制一个小小的切片头,两边都能继续用。在 Rust 里,把
Vec传进函数会把它移动进去,之后你就不能再用它了:fn total(prices: Vec<u32>) -> u32 {prices.iter().sum()}fn main() {let prices = vec![3, 4, 5];let sum = total(prices);println!("{sum} from {} items", prices.len());}error[E0382]: borrow of moved value: `prices`--> src/main.rs:8:37|6 | let prices = vec![3, 4, 5];| ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait7 | let sum = total(prices);| ------ value moved here8 | println!("{sum} from {} items", prices.len());| ^^^^^^ value borrowed here after move编译器说的是:
prices的值在第 7 行已经移动进了total,第 8 行又被借用了。解决办法就是 Go 习惯的 Rust 版本:让函数借用一个 slice,写成fn total(prices: &[u32]),调用时写total(&prices)。请先读 Rust 所有权,那一页讲的就是这条规则;E0382 这一页详细讲解了这个错误。 -
借用检查器。 同一时间,你可以有多个读者(
&T),或者一个写者(&mut T),但不能两者兼有。在 Go 里,很多结构体持有指向同一个共享结构体的指针并修改它,这样的代码在 Rust 里需要换一种设计,或者用Rc<RefCell<T>>、Arc<Mutex<T>>这样的智能指针。做好心理准备,头几周会有点受挫。刚开始觉得别扭很正常,之后会轻松很多。 -
没有零值。 写了
let x: i32;再println!("{x}"),是编译错误;创建结构体时,每个字段都必须赋值。想要 Go 那样的默认值,就派生Default,然后写Config { port: 8080, ..Default::default() }。 -
实现 trait 要显式声明。 有一个
area方法,并不会让你的类型变成Shape。你需要写impl Shape for Circle。好处是:搜一下就能找到所有的实现。 -
String与&str。 Go 只有一种string类型。Rust 有两种:String拥有它的文本,&str借用文本。函数参数通常用&str,结构体字段通常存String。而且你不能像在 Go 里那样用s[0]索引字符串;用s.chars()或s.as_bytes()[0]。见 Rust 字符串。 -
没用到的东西只是警告,不是错误。 Go 遇到没用的导入或变量会拒绝编译。Rust 只会警告,你还可以在名字前面加
_,表示“故意不用”。 -
整数溢出。 Go 会悄悄回绕。Rust 在 debug 构建里 panic,在 release 构建里回绕,所以可能溢出时,请选用
checked_add、wrapping_add或saturating_add。整数字面量默认是i32,索引是usize。见 Rust 整数溢出。 -
标准库更小。
std里没有 HTTP 服务器,没有 JSON,也没有异步运行时。你要用cargo add把它们加进来。一开始会觉得别扭,但流行的 crate 都很稳定,文档也很完善。 -
async 需要运行时,而且会扩散。
async fn只有在 tokio 这样的运行时里被 await 时才会运行,而且只有异步代码才能.await。Go 没有这种分裂。先从普通线程开始;需要大量并发连接时再用 async。 -
编译时间。 Go 生来就是为了编译得飞快。Rust 编译得慢一些,尤其是依赖很多 crate 的项目从零开始构建时。改代码的时候用
cargo check;它跳过代码生成,快得多。 -
交叉编译要多做一点配置。
GOOS=linux go build直接就能用。在 Rust 里,你要用 rustup 添加目标平台,而且通常还需要一个对应的链接器。cross这样的工具能帮你搞定。
Go 还是 Rust?各自适合什么时候用
Section titled “Go 还是 Rust?各自适合什么时候用”两者都是好选择,很多团队两个都用。下面是一条公道的经验法则。
选 Go:你想快速交付网络服务和内部工具;你的团队看重“任何人学一周就能读懂”的代码;构建速度非常重要;或者你身处云原生的世界(Kubernetes、Docker、Terraform),Go 在那里是通用语言。Go 的标准库和简单的并发模型,让很多服务端程序写起来很轻松。
选 Rust:你需要没有垃圾回收停顿、可预测的延迟,需要低内存占用,或者要榨干每一分 CPU(解析器、数据库、游戏引擎、视频、压缩)。对于没有操作系统的代码、WebAssembly,以及想让其他语言调用的库,Rust 也更合适。当一次数据竞争或一个漏掉的 nil 检查导致的崩溃代价很高时,Rust 也值得。Discord 写过一篇很有名的文章,讲他们因为垃圾回收引起的延迟尖峰,把一个服务从 Go 迁移到了 Rust(英文)。
Rust 的代价是学习时间和更慢的构建。Go 的代价是垃圾回收器,以及在运行时而不是编译时才暴露的 bug。哪种代价更痛,取决于你的项目。
Go 开发者的学习路线
Section titled “Go 开发者的学习路线”你不必按顺序读完整个网站。作为 Go 开发者,下面这条路线会跳过你已经会的东西,把时间花在 Rust 不一样的地方:
- Rust 快速上手和 Rust 工具与环境配置:安装 Rust,配好 rust-analyzer,运行
cargo new。 - Rust 变量和 Rust 数据类型:快速浏览,主要是学会
let mut和各种数字类型。 - Rust 所有权,然后是 Rust 借用,再然后是 Rust slice(切片):对你来说最重要的几页,慢慢来。
- Rust 字符串:
String与&str。 - Rust 结构体和 Rust 枚举,然后是 Rust match 和 Rust 模式匹配。带数据的 Rust 枚举能取代很多 Go 的类型 switch。
- Rust Option 和 Rust Result,然后是自定义错误类型和 anyhow 与 thiserror。
- Rust trait、Rust 泛型、常用 trait 和 trait 对象(dyn)。
- Rust 闭包和 Rust 迭代器:Rust 用迭代器链的地方比 Go 多得多。
- Rust vector(Vec)、Rust HashMap 和 HashSet 与 BTreeMap。
- Rust 模块、Cargo 与 crate 和 Rust 测试。
- Rust 生命周期和 Rust 智能指针。
- Rust 并发、信道(channel)、共享状态,然后是 async/await。
- 用 Serde 处理 JSON 和日志,然后动手做 Web API 项目。
两道题都会给你一段 Go 代码,请把它移植到 Rust。
1. 移植一个单词计数器
Section titled “1. 移植一个单词计数器”下面这个 Go 程序统计单词出现的次数,并按字母顺序打印出来。请把它移植到 Rust。提示:用一个 HashMap<&str, u32>,Go 的 counts[w]++ 就写成 *counts.entry(word).or_insert(0) += 1。text.split_whitespace() 就是 strings.Fields。
func main() { text := "the cat and the dog and the bird"
counts := map[string]int{} for _, w := range strings.Fields(text) { counts[w]++ }
keys := make([]string, 0, len(counts)) for k := range counts { keys = append(keys, k) } sort.Strings(keys)
for _, k := range keys { fmt.Printf("%s: %d\n", k, counts[k]) }}查看答案
use std::collections::HashMap;
fn main() { let text = "the cat and the dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split_whitespace() { *counts.entry(word).or_insert(0) += 1; }
let mut keys: Vec<&str> = counts.keys().copied().collect(); keys.sort();
for key in keys { println!("{key}: {}", counts[key]); }}输出:
and: 2bird: 1cat: 1dog: 1the: 3这里没有零值可以依赖,所以 entry(word).or_insert(0) 表示“如果是新单词,就从 0 开始”。用 counts[key] 索引是安全的,因为每个键都来自这个 map。还有更短的写法:用 BTreeMap 代替 HashMap,键出来时就已经排好序了,keys 这个 vector 也就可以省掉了。这段文本特意保留了英文,因为要按字母顺序排序。
2. 移植一个哨兵错误
Section titled “2. 移植一个哨兵错误”下面这段 Go 代码返回一个哨兵错误,并用 errors.Is 检查它。请把它移植过来,让 withdraw 返回 Result<(), WithdrawError>,其中 WithdrawError 是一个有两个变体的 enum。提示:给这个枚举实现 fmt::Display,这样就能打印它;在 Go 用 errors.Is 的地方,对变体做 match。
var ErrInsufficientFunds = errors.New("余额不足")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error { if n <= 0 { return fmt.Errorf("金额无效:%d", n) } if n > a.Balance { return ErrInsufficientFunds } a.Balance -= n return nil}
func main() { acc := &Account{Balance: 100} for _, n := range []int{30, 0, 500} { if err := acc.Withdraw(n); err != nil { if errors.Is(err, ErrInsufficientFunds) { fmt.Printf("已拒绝:%v\n", err) } else { fmt.Printf("错误:%v\n", err) } continue } fmt.Printf("成功,余额 %d\n", acc.Balance) }}查看答案
use std::fmt;
enum WithdrawError { InvalidAmount(i64), InsufficientFunds,}
impl fmt::Display for WithdrawError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { WithdrawError::InvalidAmount(n) => write!(f, "金额无效:{n}"), WithdrawError::InsufficientFunds => write!(f, "余额不足"), } }}
struct Account { balance: i64,}
impl Account { fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> { if n <= 0 { return Err(WithdrawError::InvalidAmount(n)); } if n > self.balance { return Err(WithdrawError::InsufficientFunds); } self.balance -= n; Ok(()) }}
fn main() { let mut acc = Account { balance: 100 }; for n in [30, 0, 500] { match acc.withdraw(n) { Ok(()) => println!("成功,余额 {}", acc.balance), Err(e @ WithdrawError::InsufficientFunds) => println!("已拒绝:{e}"), Err(e) => println!("错误:{e}"), } }}输出:
成功,余额 70错误:金额无效:0已拒绝:余额不足哨兵错误和 fmt.Errorf 生成的错误,变成了同一个枚举的两个变体,errors.Is 变成了一个 match 分支。e @ WithdrawError::InsufficientFunds 既匹配这个变体,又把整个值绑定到 e 上,方便打印。还有个额外的好处:以后再加第三个变体,编译器会把所有需要更新的 match 都指给你看。thiserror crate 可以替你写好 Display 实现。
- Go 的很多东西都能直接带过来:带方法的结构体、没有继承、错误即值、一个标准格式化工具,以及一个负责构建和测试的工具。
- 最大的转变是:用所有权代替垃圾回收器,用
Option代替nil,用Result加?代替if err != nil。 - 接口变成 trait,而且实现 trait 要显式声明(
impl Trait for Type)。defer变成在作用域结束时自动运行的Drop。 - goroutine 和 channel 变成
thread::spawn加mpsc(工作线程不多时),或者 tokio 任务(数量很多时);编译器会排除数据竞争。 - Cargo 取代了
go build、go test和go mod;clippy 和 rustfmt 取代了go vet和gofmt。想找 Gin 就看 axum 或 actix-web,想找 GORM 就看 SeaORM 或 Diesel,想找 pgx 和 sqlc 就看 sqlx,想找 cobra 就看 clap。
小测验
1. 在 Go 里,类型只要有对应的方法就满足了接口。Rust 的类型怎样实现一个 trait?
2. 在返回 Result 的调用后面加 ?,会做什么?
3. 使用 std::sync::mpsc 时,for msg in rx(或 rx.iter())什么时候停止?
4. 你们 Go 团队用 Gin 写 HTTP API。哪个 Rust crate 最接近?
得分: 0 / 4
- 《Rust 程序设计语言》简体中文版:官方教程。对 Go 开发者来说,第 4 章(所有权)、第 9 章(错误处理)、第 10 章(泛型和 trait)和第 16 章(并发)最重要。
- Tokio 教程(英文):大多数 Rust 服务端程序使用的异步运行时,也是最接近 Go 的 goroutine 调度器的东西。
- axum 文档(英文):如果你来自 Gin、Echo 或 Chi,第一个该看的 Web 框架。
- Why Discord is switching from Go to Rust(英文):一个真实团队把对延迟敏感的服务从 Go 迁移到 Rust 的故事。
- Are We Web Yet?(英文):社区整理的 Rust Web 库和框架概览。