从 Go 迁移到 Rust
TL;DR · AI 摘要
从 Go 迁移到 Rust 需关注正确性保证、运行时开销和开发者体验,而非单纯性能差异。
核心要点
- Go 与 Rust 在后端服务迁移中需重点考虑正确性保障和运行时权衡
- Rust 的 cargo 工具链比 Go 更集成化,支持构建、测试、格式化等全流程
- 迁移建议采用渐进式方式,避免一次性重构
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Go to Rust 迁移指南
- 迁移动机
- 正确性保证
- 运行时开销
- 工具链对比
- Go 工具链
- Rust Cargo
- 迁移策略
- 渐进式迁移
- 场景适配
金句 / Highlights
值得收藏与分享的关键句。
Go 和 Rust 的讨论重点在于正确性保证、运行时权衡和开发者体验,而非简单的性能比较。
Rust 的 Cargo 工具链比 Go 更加集成,支持构建、测试、格式化等全流程操作。
Go 使用者占比约 17–19%,而 Rust 从 2% 增长至 11%,显示出稳步增长趋势。
在我帮助团队进行的所有迁移项目中,从 Go 迁移到 Rust 是一个相对特殊的案例。这并不是关于“Rust 是否更快?”或“Rust 是否有类型系统?”的问题,因为 Go 已经为你提供了大部分所需的功能。讨论的重点更多在于 正确性保证、运行时权衡 和 开发者体验。
在我们开始之前,先声明一下:本指南高度偏向后端开发。后端服务是 Go 最擅长的领域,包括小型静态二进制文件、专注于网络的标准化库,以及用于 HTTP 服务器、gRPC、数据库等的库生态系统。
这也是大多数考虑使用 Rust 的团队所来自的地方(至少那些联系我的团队),因此我认为这种对比在实际应用中才是最有价值的。如果你正在编写 CLI 工具、嵌入式固件或游戏引擎,其中一些内容仍然适用,但说实话,我担心这不是最适合你的资源。
为了提供背景信息,我之前写过关于 Go 和 Rust 的文章:《Go vs Rust?选择 Go》(2017 年),以及后来与 Shuttle 团队合作撰写的 《Rust vs Go:动手对比》,该文通过两种语言实现了一个小型后端服务。
- Go 和 Rust 的重叠部分及其分歧点。
- Go 的模式如何映射到 Rust。
- 你能从借用检查器中获得什么好处。
- 我建议人们继续使用 Go 的场景,以及 Rust 值得迁移的情况。
- 如何逐步迁移 Go 服务。
[我的立场](https://corrode.dev/learn/migration-guides/go-to-rust/#where-i-m-coming-from)
坦率地说:我不是 Go 的粉丝。我认为它是一个设计得糟糕的语言,尽管非常成功。它混淆了_易用性_与_简洁性_,并且它的几个核心设计权衡(例如无处不在的 nil、将错误处理视为一种纪律规则而非类型、长时间缺乏泛型)都指向了我不认同的方向。不过,成功很重要!Go 在开发者群体中占据着真实而持久的份额,在 JetBrains 开发者生态系统调查中,Go 的使用率稳定在 17–19% 左右。而 Rust 正在稳步增长,但仍占比较小:
Go 明显对很多人有效,而假装它无效的指南是没有帮助的。因此,我会在这份指南中尽量客观,而不是重新争论旧话题。但你应该了解我的立场,以便做出判断。
另一个值得披露的先验观点:我是 Rust 咨询公司的创始人;当然,我有偏见!更多人使用 Rust 对我的业务是有益的。但我也曾在两种语言中从事过专业工作,并将 Go 服务部署到生产环境中。
这份指南面向希望看到从 Go 转向 Rust 后真实变化的 Go 开发者。
如果你更喜欢观看而不是阅读,这里有一段视频,由 Primeagen 朗读并评论了上述 Shuttle 文章:
[初探最重要的命令](https://corrode.dev/learn/migration-guides/go-to-rust/#a-first-look-at-the-most-important-commands)
Go 开发者已经拥有了业界最干净的工具链之一。早些时候,Go 开始引领“电池包含”的工具链趋势,提供统一接口来构建、测试、格式化、检查和管理依赖项。我很高兴 Rust 也跟上了这一趋势,因为它是一个很棒的模型。这是两个生态系统中最让我喜爱的部分之一。
cargo 提供了更多的内置功能:
| Go 工具 | Rust 对应工具 | 备注 | | --- | --- | --- | | go.mod / go.sum | Cargo.toml / Cargo.lock | 项目配置和依赖清单 | | go get / go mod tidy | cargo add / cargo update | 添加并解析依赖 | | go build | cargo build | 编译项目 | | go run . | cargo run | 构建并运行 | | go test ./... | cargo test | 内置测试功能 | | go vet ./... | cargo clippy | 静态检查工具,Clippy 比 vet 更具主观性 | | gofmt / goimports | cargo fmt | 自动格式化工具,无需配置 | | golangci-lint run | cargo clippy -- -D warnings | 严格模式下的 lint | | go install ./cmd/foo | cargo install --path . | 安装二进制文件 | | go doc | cargo doc --open | 生成并查看 API 文档 | | pprof | cargo flamegraph / samply | CPU 性能分析 | | govulncheck | cargo audit | 基于漏洞数据库的扫描工具 |
主要区别在于,在 Go 中你通常需要借助第三方工具(如 golangci-lint、mockgen、air、goreleaser)来填补空白。而在 Rust 中,第一方生态已经覆盖了更多功能。那些确实需要外部 crate 的工具(例如 cargo watch、cargo nextest)只需一条命令即可安装,并且感觉像是原生工具,比如 cargo install cargo-nextest 就会立即给你 cargo nextest。
两个社区都达成了同样的共识:统一的格式风格,即使不是完美的,也比因风格问题引发的无休止争论更有价值。
Gofmt 的风格并非每个人喜欢,但 gofmt 是每个人的最爱。
—— Rob Pike,《Go 格言》(Go Proverbs)
rustfmt 也是如此:不是每个人都喜欢每一个细节,但在代码审查中避免风格争论的价值远大于偶尔可以不同格式化的偏好。
[Go 与 Rust 的主要区别](https://corrode.dev/learn/migration-guides/go-to-rust/#key-differences-between-go-and-rust)
| | Go | Rust | | --- | --- | --- | | 稳定版本发布 | 2012 | 2015 | | 类型系统 | 静态、结构化、自 1.18 起支持泛型 | 静态、名义化、支持泛型 + 特质 + 生命周期 | | 内存管理 | 垃圾回收(并发、低暂停) | 所有权与借用机制,无 GC | | 空安全 | nil 无处不在 | 无空值;使用 Option<T> 作为类型级别的替代方案 | | 错误处理 | error 接口,if err != nil { ... } | Result<T, E>,? 操作符,穷尽匹配 | | 并发模型 | 协程 + 通道(CSP) | async/await 结合 tokio + 通道 + 线程 | | 取消机制 | context.Context(约定而非强制) | CancellationToken / 显式、类型检查的取消机制 | | 数据竞争 | 运行时通过 -race 检测(概率性、运行时) | 在 编译时 由 Send/Sync 捕获 | | 编译时间 | 非常快 | 较慢,尤其是从头开始构建时 | | 运行时环境 | 约 2MB Go 运行时 + GC | 除 libc 外无运行时(或使用 MUSL 完全静态链接) | | 二进制大小 | 小到中等(几 MB) | 相当;启用 panic = "abort" + LTO 后非常小 | | 学习曲线 | 温和 | 较陡峭 | | 生态系统规模 | 超过 750,000 个模块 | 超过 250,000 个 crate |
核心观点是:Go 和 Rust 都是编译型、静态类型、单二进制部署的语言,并且都具有强大的并发能力。它们之间的差异在于你从编译器那里获得的 保证 以及你对运行时行为的 控制程度。
在继续之前,一个有助于理解的视角是:当你从 Go 转向 Rust 时,大多数变化都是将原本依赖于约定、工具(如 go vet、errcheck、golangci-lint、-race)或运行时检测的内容,纳入了类型系统中。空指针处理、错误传播、数据竞争、资源生命周期、取消机制、泛型等,在 Go 中依赖的是约定、工具或运行时检测来维持正确性。而在 Rust 中,这些都被编码为编译器直接强制执行的类型。
常见的反对意见认为这“增加了认知负担”。我对此提出挑战。确实,它在一开始需要更多思考,但同时也更难出错。Rust 中的 Mutex<T> 不仅说明数据需要加锁,而且让加锁成为访问数据的 唯一方式:你调用 .lock(),得到一个保护器(guard),只有这个保护器才能让你访问内部值。一旦释放保护器,锁也会自动释放。因为没有“忘记加锁”的路径,因为这种路径在类型系统中根本不存在。一旦你习惯了这种模式,并且发现它广泛应用于各种场景(如 Option、Result、&mut T、Send/Sync、RAII 保护器),Rust 就不再显得笨重,反而让人感觉编译器在帮你完成原本需要自己在脑海中完成的工作。
[为什么 Go 开发者会考虑 Rust](https://corrode.dev/learn/migration-guides/go-to-rust/#why-go-developers-consider-rust)
Go 开发者通常不会因为 Go “太慢”而转向 Rust。对于大多数后端工作负载来说,Go 已经足够快了。人们普遍对 Go 的冗长错误处理感到不满,对 nil 指针导致的段错误风险感到担忧,以及长期以来缺乏泛型或任何复杂的类型系统特性(如枚举或特质)感到困扰。接口并不能很好地替代特质,Go 标准库也存在一些奇怪的空白,比如缺少 Set 类型。(惯用的解决方法是使用 map[T]struct{},虽然在实践中可行,但也反映出类型系统尚未完全发挥作用。)
[生产环境中的 `nil` 异常](https://corrode.dev/learn/migration-guides/go-to-rust/#nil-panics-in-production)
你部署了一个 Go 服务,它运行了几个月都没问题,然后某个代码路径中有人忘了检查指针是否为 nil,协程就崩溃了。常见的情况是查找操作返回零值,或者反序列化后结构体的指针字段未被填充:
func (s *Service) Handle(req *Request) error {
// Find 返回 (*User, error)。当找不到用户时 error 是 nil;
// 调用方应检查 user != nil,但这很容易被遗忘。
user, err := s.repo.Find(req.UserID)
if err != nil {
return err
}
return user.Account.Notify() // 如果 user 为 nil 或 Account 为 nil 则崩溃
}静态检查工具和 IDE 提示能捕获其中一部分(如 nilaway、staticcheck),但它们是可选的、概率性的,并且无法跨包边界可靠地工作。Go 编译器本身并不会强制你考虑空值情况。而 Rust 的 Option<T> 则会:
fn handle(&self, req: &Request) -> Result<(), ServiceError> {
let user = self.repo.find(req.user_id)?; // 返回 Option<User>;? 操作符会在 None 时短路并返回错误
user.notify()
}你无法在不处理 None 情况的情况下解引用 Option。许多本该触发值班警报的问题从此消失。
[`-race` 未能捕获的数据竞争](https://corrode.dev/learn/migration-guides/go-to-rust/#data-races-that-race-didn-t-catch)
go test -race 是一个很棒的工具,但它是一个运行时检测器,只能发现测试期间实际执行的数据竞争。在没有加锁的情况下从两个协程中修改 map 在 Go 中可以正常编译,但在高负载下才会在生产环境中崩溃。
在 Rust 中,跨线程共享可变状态必须使用实现了 Send 和 Sync 的类型。尝试在线程间共享一个普通的 HashMap,程序将无法编译。你被迫将其包装在 Arc<Mutex<...>>、Arc<RwLock<...>> 中,或使用通道。这种数据竞争变成了类型错误。1
Paul Dix 对促使 InfluxDB 3.0 重写的原因非常坦率,而数据竞争的故事正是其中最突出的一点:
主要优势在于无畏并发(fearless concurrency)——基本上消除了之前存在的数据竞争问题。这在 Influx 的第一版中导致了非常棘手的 bug。
—— Paul Dix,InfluxData 创始人兼首席技术官,在 Rust in Production 中
[可组合的错误处理](https://corrode.dev/learn/migration-guides/go-to-rust/#composable-error-handling)
if err != nil { return err } 在短期内是可以接受的。几年之后,你会注意到三件事:
- 模板代码稀释了函数实际逻辑的表达。
- 使用
fmt.Errorf("doing X: %w", err)是一种纪律规范,而不是编译器规则。很容易忽略上下文信息。 - 通过
errors.Is/errors.As实现的哨兵错误可以工作,但编译器不会提醒你忘记处理新的变体。
在这里诚实地讨论反方观点是有价值的,因为这在 Lobste.rs 讨论帖 上我的 Shuttle 文章中提到了:经验丰富的 Go 开发者指出,errcheck 和 golangci-lint 实际上能捕获大部分“忘记处理错误”的情况,并且显式的 if err != nil 比密集的 ? 链更易于阅读。这两个观点都是合理的,显式风格是一种刻意的文化价值观,而非偶然:
我认为错误处理应该是明确的,这是语言的核心价值之一。
—— Peter Bourgon,GoTime #91,引自 Dave Cheney 的 Go 的禅意
我的看法是,lint 工具是一种需要记住设置的安全网,而 Rust 的 Result<T, E> 是类型签名本身,无法忘记。模板代码与可读性之间的权衡更加主观。
在 Rust 中:
#[derive(Debug, thiserror::Error)]
pub enum UserError {
#[error("user {0} not found")]
NotFound(UserId),
#[error("user already exists")]
AlreadyExists,
#[error(transparent)]
Repo(#[from] RepoError),
}
pub fn rename(id: UserId, name: &str) -> Result<User, UserError> {
let mut user = repo::get(id)?; // ? 自动将 RepoError 转换为 UserError
user.name = name.to_string();
Ok(user)
}? 操作符负责错误传播;#[from] 处理包装;对 UserError 的 match 是穷尽检查的。明天添加一个新的变体,编译器会告诉你所有需要更新的地方。
[不装箱的泛型](https://corrode.dev/learn/migration-guides/go-to-rust/#generics-that-don-t-box)
Go 在 1.18 版本引入了泛型,它们很有用,但实现有一些限制(不支持带类型参数的方法、GC 形状模板化、偶尔出现令人惊讶的性能特征)。Rust 泛型会进行单态化,每个实例都会生成零运行时开销的专用代码。结合 trait,这为你提供了真正的零成本抽象。
这在处理器代码中影响较小,但在共享基础设施(中间件、通用仓库、解码器、解析器)中更为重要,Go 往往迫使你回到 interface{}/any 加上类型断言。
[可预测的延迟](https://corrode.dev/learn/migration-guides/go-to-rust/#predictable-latency)
Go 的垃圾回收器(GC)非常出色,是并发的、低暂停时间的,并针对典型的服务工作负载进行了优化。但“低暂停”并不等于“无暂停”。在高分配负载下,P99 延迟尾部明显比 Rust 相关实现差,后者在热路径上完全不进行分配。
我不会夸大这一点,对于绝大多数服务来说,Go 的 GC 不是一个问题。但对于对延迟敏感的系统(交易、实时竞价、网络代理、高吞吐量数据摄取),没有 GC 暂停是一个真正的卖点。来自 PubNub 的 Stephen Blum 在节目中直接表达了这个观点:
Go 在我们规模下表现很好,但我们真的需要一种能提供我们所需价格/性能容量的东西,而 Rust 可以帮我们做到这一点。这就是为什么现在几乎所有东西都朝着 Rust 发展的原因。
—— Stephen Blum,PubNub 首席技术官,在 Rust in Production 中
[总结](https://corrode.dev/learn/migration-guides/go-to-rust/#in-summary)
Go 就像千刀万剐一样。它是一门非常实用的语言,如果你愿意忽略上述问题,你可以在其中非常高效地开发。但在代码库达到一定规模后,问题开始累积。Go 失去吸引力并没有一个明确的时刻,但团队会发现自己渴望更多(更多的安全性、更多的控制力、更多的表达能力),这时他们就会开始寻找替代方案。
[两种语言并列比较](https://corrode.dev/learn/migration-guides/go-to-rust/#comparing-both-languages-side-by-side)
最快熟悉 Rust 的方式就是映射你已知的模式。关于在两种语言中构建相同后端服务的完整示例,请参见 Shuttle 对比,下面的部分重点介绍最常出现的模式。
[错误处理:`if err != nil` vs `Result<T, E>`](https://corrode.dev/learn/migration-guides/go-to-rust/#error-handling-if-err-nil-vs-result-t-e)
Go:
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}Rust:
fn read_config(path: &Path) -> Result<Config, ConfigError> {
let data = fs::read_to_string(path)?;
let cfg = serde_json::from_str(&data)?;
Ok(cfg)
}? 操作符为你完成了 if err != nil { return err } 的操作,包括当实现了 From<E1> for E2 时的类型转换(使用 thiserror 的 #[from] 是惯用做法)。
[空值:`nil` 与 `Option<T>`](https://corrode.dev/learn/migration-guides/go-to-rust/#null-nil-vs-option-t)
Go:
func GetUser(id string) *User {
for _, u := range users {
if u.ID == id {
return &u
}
}
return nil
}
u := GetUser("123")
fmt.Println(u.Name) // 如果为 nil 则 panicRust:
fn get_user(id: &str) -> Option<User> {
users.iter().find(|u| u.id == id).cloned()
}
let user = get_user("123");
println!("{}", user.name); // 编译错误:`user` 是 Option<User>,而不是 User
// 你必须处理两种情况:
match get_user("123") {
Some(u) => println!("{}", u.name),
None => println!("not found"),
}在安全的 Rust 中没有 nil。引用不能为 null。指针可以为 null,但在应用代码中几乎从不使用原始指针。
[接口 vs 特征(Traits)](https://corrode.dev/learn/migration-guides/go-to-rust/#interfaces-vs-traits)
Go 的接口是结构化的,类型隐式满足接口:
type Reader interface {
Read(p []byte) (n int, err error)
}Rust 的特征是名义上的,你需要显式实现它们:
pub trait Reader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
}
impl Reader for MyType {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { /* ... */ }
}Go 风格非常适合临时的鸭子类型。Rust 风格则适合重构和可发现性,你可以通过 grep 查找某个特征的所有实现者。
Rust 中最接近 interface{} / any 的是 Box<dyn Any>,但你几乎永远不需要它。Go 社区也深知滥用 interface{} 的代价:
interface{} 什么都没说。
— Rob Pike, Go Proverbs
带有特征约束的泛型函数(fn handle<R: Reader>(r: R))覆盖了绝大多数场景,并且提供单态化而无需运行时分发。当 Go 在 1.18 之前迫使你回到 interface{} 加类型断言时,Rust 的特征 + 泛型 让你保持具体类型。
当你确实需要运行时分发(例如存储不同实现者的异构数据),可以使用 Box<dyn Trait> 或 Arc<dyn Trait>。这相当于在 Go 中持有 interface 值的直接 Rust 对应物。
[协程 vs 异步任务](https://corrode.dev/learn/migration-guides/go-to-rust/#goroutines-vs-async-tasks)
Go 的并发模型以简单著称:
go doWork(ctx, input) 协程非常轻量,运行时会在操作系统线程间调度它们,通道(chan T)是主要的协调原语。Go 的格言体现了这种哲学:
不要通过共享内存来通信;通过通信来共享内存。
— Rob Pike, Go Proverbs
这是 Go 真正擅长的领域,值得精确地说明其原因:在 Go 中,顺序代码和并行代码之间没有语法区别。任何函数都可以正常调用、放入 go 语句中,或在协程内部调用,而无需更改其签名、调用者或编写方式。没有 async fn、没有 .await、没有执行器需要选择、也没有 Send/Sync 约束需要满足。只要你不在线程间共享可变状态而不加同步,顺序和并发代码看起来完全一样。
这个特性——即函数没有“着色”——是 Go 相比 Rust 在日常开发中的最大生产力优势,也是 Go 开发者切换后最怀念的一点。在我关于 Shuttle 的文章在 Lobste.rs 上的讨论中,一些评论者明确指出了这一点,他们是对的。Rust 的异步功能更强大、检查更严格,但它在代码中更加显式,这种可见性带来了实际的工程成本。
Rust 使用 async/await,基于一个执行器(后端服务通常使用 tokio):
tokio::spawn(async move {
do_work(input).await;
});形式相似。区别在于:
- Rust 异步函数返回
Future。它们不会在被等待或启动前运行。 - 编译器会跟踪
.await点之间的Send/Sync。如果你在 await 点之间持有非Send值,编译器会给出明确的错误解释。 - 没有内置的协程风格抢占机制。长时间的 CPU 密集型任务在异步任务中会阻塞执行器;你应该使用
tokio::task::spawn_blocking或rayon来处理。 - 通道(
tokio::sync::mpsc、broadcast、watch)是一等公民,但位于库中而非语言本身。
对于大多数后端代码来说,日常体验是类似的:启动任务、通过通道通信、广泛使用超时。
[`context.Context` 与 `CancellationToken`](https://corrode.dev/learn/migration-guides/go-to-rust/#context-context-vs-cancellationtoken)
在 Go 中,你将 context.Context 传递给每个阻塞调用:
func (s *Service) Fetch(ctx context.Context, id string) (*User, error) {
return s.client.Get(ctx, "/users/"+id)
}Rust 没有内置的 context.Context。最接近取消操作的是 tokio_util::sync::CancellationToken:
pub async fn fetch(&self, token: CancellationToken, id: &str) -> Result<User, FetchError> {
tokio::select! {
_ = token.cancelled() => Err(FetchError::Cancelled),
res = self.client.get(&format!("/users/{id}")) => res,
}
}对于超时,tokio::time::timeout(dur, fut) 可包装任意 future。对于截止时间/值,通常作为显式参数传递,或通过 tracing span 而不是单一上下文对象。
一些 Go 开发者怀念 ctx 的隐式感觉。实际上,显式的 Rust 风格更容易推理,你总是知道哪些是可取消的,哪些不是。更深层的观点是:两种语言都不会免费提供取消功能,只是这种纪律体现在不同的层面上:
Go 没有办法告诉一个 goroutine 退出。没有 stop 或 kill 函数,这是有充分理由的。如果我们无法命令一个 goroutine 停止,那么我们必须礼貌地“请求”它。
— Dave Cheney, Go 的禅意
在 Go 中,“礼貌地请求”是通过约定在每个调用点传递的 context.Context。而在 Rust 中,则是通过 CancellationToken(或 watch channel)来实现,并且编译器实际上会在你忘记时提醒你。
[通道](https://corrode.dev/learn/migration-guides/go-to-rust/#channels)
两种语言都有通道。转换非常直接:
ch := make(chan int, 10)
go func() {
ch <- 42
}()
v := <-chlet (tx, mut rx) = tokio::sync::mpsc::channel::<i32>(10);
tokio::spawn(async move {
tx.send(42).await.unwrap();
});
let v = rx.recv().await.unwrap();Rust 的通道将发送者和接收者区分为不同的类型,在类型层面明确表达了所有权和 Send 属性。
[结构体与方法](https://corrode.dev/learn/migration-guides/go-to-rust/#structs-and-methods)
Go:
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}Rust:
pub struct Circle {
pub radius: f64,
}
impl Circle {
pub fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}Rust 的 &self 相当于 Go 的值接收者;&mut self 是带有可变性的指针接收者。而拥有所有权的 self(消耗该值)在 Go 中没有对应物,但偶尔非常有用(类型状态、构建器模式)。
[字符串:`string` vs `String` 和 `&str`](https://corrode.dev/learn/migration-guides/go-to-rust/#strings-string-vs-string-and-str)
Go 的 string 是一个具有拷贝赋值语义的 UTF-8 字节切片(头部被拷贝,底层字节共享且不可变)。2 Rust 将其拆分为两个类型:
String,拥有所有权,堆分配,可增长。相当于你打算修改的[]byte。&str,是对他人字符串数据的借用视图。大多数时候相当于 Go 的string参数。
一般来说,参数中使用 &str,当你生成新数据时返回 String。
fn greet(name: &str) -> String {
format!("Hello, {name}")
}一旦你熟悉了这一点,这基本上是无痛的。&str 与 String 的区分是 Rust 更广泛的“借用 vs 所有权”模型的一个缩影。
[Go 泛型太迟、太少](https://corrode.dev/learn/migration-guides/go-to-rust/#go-generics-are-too-little-too-late)
Go 在 1.18 版本(2022 年 3 月)引入了泛型,这比语言发布晚了十三年。虽然它们是有用的,但感觉像是后来加上去的,并且在实践中,它们几乎具备了泛型类型系统的所有缺点,却没有带来 Rust、Haskell,甚至现代 C++ 所预期的优点。
这是一个强有力的主张,让我来支撑这个观点。
[标准库几乎没有使用泛型](https://corrode.dev/learn/migration-guides/go-to-rust/#the-standard-library-barely-uses-them)
最能说明问题的是,三年前泛型功能上线后,Go 自身的标准库仍然主要避免使用它们。sort.Slice 仍然接受 func(i, j int) bool 类型的闭包而不是 cmp.Ordered 约束。sync.Map 仍然使用 any/any 类型。那些确实存在的泛型辅助函数只存在于少数几个包中:slices、maps、cmp,以及 sync 下的一些条目。
当然可以指出,向后兼容性是其中一部分原因:Go 1 兼容性承诺意味着现有的非泛型 API 不能被重构,因此任何泛型版本必须与现有版本共存(或者放在新的包中)。但这只是部分解释。三年时间足以引入泛型替代方案,而很少出现这种情况表明语言设计者并不像 Rust 那样把泛型作为主要工具来依赖。
相比之下,Rust 从第一天起就让泛型渗透到标准库中:Option<T>、Result<T, E>、Vec<T>、HashMap<K, V>、Iterator、From/Into、AsRef、Borrow、每一种集合、每一个智能指针。你不使用泛型就写不出地道的 Rust,因为标准库本身就是泛型的。
在 Go 中,泛型是库作者真正需要时才选择使用的特性。而在 Rust 中,它们是构建一切的基础。
[没有 trait 系统,只有结构化约束](https://corrode.dev/learn/migration-guides/go-to-rust/#no-trait-system-just-structural-constraints)
Rust 的泛型与 trait 绑定在一起,trait 同时也是语言支持适配多态、超 trait、关联类型、 blanket 实现和一致性机制的手段。
Go 的约束只是带有额外 ~ 操作符的接口,用于表示类型集成员关系。没有:
* **超 trait / 约束层次结构。** 在 Rust 中,你可以写 `trait Ord: Eq + PartialOrd`,任何满足 `T: Ord` 的类型自动满足 `Eq` 和 `PartialOrd`。Go 没有等价的机制;你只能通过嵌入接口来堆叠约束,但约束求解器不会像 Rust 的 trait 系统那样对层次结构进行推理。
* **关联类型。** Rust 的 `Iterator` 有 `type Item;`,因此 `T::Item` 是一个可以在边界中命名的一流类型。Go 最接近的等价物是一个额外的类型参数,它会泄漏到每个签名中。
* ** blanket 实现。** 在 Rust 中,`impl<T: Display> ToString for T` 自动为每个 `Display` 类型提供一个 `to_string()` 方法。Go 没有办法从定义包外部为类型添加方法,无论该类型是否为泛型。
* **带有自己类型参数的方法。** 这是 Go 中一个明确的、[已记录的](https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md#No-parameterized-methods) 非特性。你不能写 `func (s Set[T]) Map[U](f func(T) U) Set[U]`[3](https://corrode.dev/learn/migration-guides/go-to-rust/#fn-generic-methods)。在 Rust 中,在泛型类型上使用泛型方法是很常见的。
其实际后果是,一旦你的抽象需要“一个适用于任意 `T` 并具备这些操作的函数”之外的功能,Go 就会把你推回使用 `any` 加类型断言、代码生成或运行时反射。
### [类型推断在函数边界处停止](https://corrode.dev/learn/migration-guides/go-to-rust/#type-inference-stops-at-the-function-boundary)
Rust 使用一种类似 Hindley-Milner 的推断引擎,能够将类型信息传播到整个表达式中,包括跨越闭包、迭代器链和 `?` 操作符。你通常可以这样写:
`let evens: Vec<_> = (0..100).filter(|n| n % 2 == 0).collect();`
编译器会从范围中推断出 `_` 是 `i32`,并从 `collect` 目标推断出 `Vec<_>` 是 `Vec<i32>`。[4](https://corrode.dev/learn/migration-guides/go-to-rust/#fn-iterator-readability)
Go 的推断则浅得多。它通常可以从函数参数中推断出类型参数,但[无法从返回位置上下文中推断](https://go.dev/blog/type-inference),无法像 Rust 那样通过泛型构建器链式推断,并且经常迫使你在调用点显式指定类型参数:
`result := slices.Collect[int](iter) // 通常必需`
在 Rust 中这是例外情况;而在 Go 中这仍很常见。
### [单态化 vs GC 形状模板化](https://corrode.dev/learn/migration-guides/go-to-rust/#monomorphization-vs-gc-shape-stenciling)
泛型没有免费午餐:你必须在编译时、运行时付出代价,或者放弃特化(稍后会进一步讨论)。C++ 和 Rust 通过单态化在编译时付出代价。Java 则通过类型擦除加上 JIT 在运行时付出代价。Go 选择了中间路线,采用 [GCShape 模板化和字典](https://go.googlesource.com/proposal/+/refs/heads/master/design/generics-implementation-gcshape.md):共享“GC 形状”的类型共享同一个编译后的函数,并通过运行时字典进行调度。
Go 的选择保持了编译时间快速,这是一个真实而宝贵的属性。代价是泛型 Go 代码可能比等效的手写非泛型版本明显更慢,因为每个类型参数上的方法调用都经过一次间接访问。有一篇[著名的 PlanetScale 文章](https://web.archive.org/web/20220331073738/https://planetscale.com/blog/generics-can-make-your-go-code-slower)清楚地展示了这一点。
Rust 会进行单态化:每个 `Vec<i32>` 和 `Vec<String>` 都会产生专门的机器码,零运行时调度。泛型代码是 _最快_ 的路径,而使用 `dyn Trait`(相当于 Go 的接口调度)是一种你希望获得运行时多态性时做出的刻意选择。你为单态化付出的是编译时间,这正是 C++ 数十年来一直在支付的代价。这两种权衡都不是显然正确的;它们只是针对不同的目标进行了优化。
### [它们不会掩盖类型系统中的漏洞](https://corrode.dev/learn/migration-guides/go-to-rust/#they-don-t-plaster-over-holes-in-the-type-system)
这是我最困扰的部分。
一个好的泛型系统应该 _消除_ 使用逃生舱(escape hatches)的理由。在 Rust 中,泛型 + trait 消除了大多数你原本需要 `Box<dyn Any>` 或运行时反射的情况。类型系统变得更强大。
在 Go 中,泛型并没有消除 `any`,也没有消除 `reflect`,也没有消除代码生成作为 ORM、解码器和 mock 等场景的主要模式。`encoding/json` 仍然使用反射。`database/sql` 仍然使用 `any`。`mockgen` 仍然生成代码。真正需要泛型系统发光发热的地方,正是 Go 在 1.18 之前就依赖运行时机制的地方。
Go 中的泛型感觉是附加的,是工具箱中一个新的工具,仅在狭窄情况下有用。而 Rust 中的泛型则感觉是基础性的;如果去掉它们,语言就会崩溃。
这就是区别所在,也是我经验中泛型 Go 代码读起来并不比它所取代的基于 `interface{}` 的代码更好,只是读起来不同,多了更多标点符号。
## [流行的 Go 包及其对应的 Rust 版本](https://corrode.dev/learn/migration-guides/go-to-rust/#popular-go-packages-and-their-rust-counterparts)
| 关注点 | Go | Rust |
| --- | --- | --- |
| HTTP 服务器 | `net/http`, `chi`, `gin`, `echo`, `fiber` | `axum`(基于 `hyper`) |
| HTTP 客户端 | `net/http`, `resty` | `reqwest` |
| gRPC | `google.golang.org/grpc` + `protoc-gen-go` | `tonic` + `prost` |
| OpenAPI (代码生成) | `oapi-codegen` | `utoipa`(代码优先)或 `openapi-generator` |
| SQL | `database/sql`, `sqlc`, `sqlx`, `gorm` | `sqlx`, `sea-orm`, `diesel` |
| 数据库迁移 | `golang-migrate`, `goose` | `sqlx migrate`, `refinery` |
| JSON | `encoding/json`, `sonic`, `goccy/go-json` | `serde` + `serde_json` |
| 日志记录 | `log/slog`, `zerolog`, `zap` | `tracing` + `tracing-subscriber` |
| 指标监控 | `prometheus/client_golang` | `metrics` + `metrics-exporter-prometheus` |
| 配置管理 | `viper`, `koanf` | `config`(config-rs),`figment` |
| 命令行工具 | `cobra`, `urfave/cli` | `clap`(派生宏) |
| 数据验证 | `go-playground/validator` | `validator` |
| 错误处理 | `errors`, `pkg/errors` | `thiserror`(库),`anyhow`(二进制程序) |
| 测试 | `testing`, `testify`, `gomega` | 内置 `#[test]`, `rstest`, `assert_matches` |
| 模拟测试 | `mockgen`, `moq` | 手写模拟(惯用方式),`mockall` |
| HTTP 模拟测试 | `httptest` | `httpmock`, `wiremock-rs` |
| 测试中的真实依赖 | `testcontainers-go` | `testcontainers` |
| 重试/退避机制 | `cenkalti/backoff` | `backon` |
| 后台任务 | goroutines + `errgroup` | `tokio::spawn` + `JoinSet` |
如果你已经对 Go 有偏好,那么 Rust 生态系统也达到了类似的“默认选择”水平。对于典型的后端服务来说:`axum` + `sqlx` + `tokio` + `tracing` + `serde` + `clap` 能覆盖你所需的 90% 功能。
## [转向 Rust 的主要挑战](https://corrode.dev/learn/migration-guides/go-to-rust/#key-challenges-in-transitioning-to-rust)
我在这里要坦率地说。从 Go 转向 Rust,你会遇到一个“**墙**”。这个“墙”是有名字的。
### [借用检查器](https://corrode.dev/learn/migration-guides/go-to-rust/#the-borrow-checker)
Go 的运行时为你处理了内存管理和别名问题。而 Rust 将这些决策推到了类型系统中。在最初的几周里,你可能会写出“显然应该能工作”的代码,但编译器却拒绝它。
最容易让 Go 开发者受挫的模式包括:
1. **长期引用**。在 Go 中,你可以长时间持有来自 map 的 `*User` 指针。而在 Rust 中,这种借用会阻止整个生命周期内对该 map 的修改。通常的解决方法是克隆,或者更严格地限制借用的作用域。
2. **自引用结构体**。在 Go 中很常见(一个结构体同时包含数据和其上的迭代器)。在 Rust 中,这需要使用 `Pin`、`ouroboros` 或重新设计。几乎总是:重新设计。
3. **跨 goroutine 共享可变状态**。你在 Go 中写的 `mu sync.Mutex; data map[K]V` 在 Rust 中变成了 `Arc<Mutex<HashMap<K, V>>>`。虽然稍微啰嗦一些,但检查更严格。
4. **函数返回引用**。[生命周期标注](https://corrode.dev/blog/lifetimes/) 出现了。它们并没有听起来那么糟糕,但确实是新的概念。
有了这些规则,借用检查器听起来像是某种“守门员”,不断阻碍你,让人感到沮丧。但这并不是学习 Rust 时应有的心态。借用检查器真正揭示的是你代码中存在的实际且非常严重的 bug,如果不加以解决,程序就会面临安全问题。因此,每当 `rustc` 给出编译错误时,请停下来思考一下你的代码可能如何出错。你可以问自己几个问题:
* 如果一个值从一个地方“被移动”到另一个地方,如果原始位置再次尝试使用它会发生什么?
* 如果一个值在多个线程之间共享,如果一个线程在另一个线程使用它时修改了它会发生什么?
* 如果一个指针被“解引用”,如果它是空指针或悬垂指针会发生什么?
* 当一个值“离开作用域”时,如果它还在其他地方被使用会发生什么?
这就是你需要理解借用检查器的心态。人类在推理内存方面确实很糟糕。我们容易忘记指针可能是空的,旧的引用可能比其所指向的数据存活得更久,以及多个线程可能同时访问同一份数据。我们倾向于用一种“线性”的思维模型来理解数据在程序中的流动,但实际上它更像是一个复杂的图,包含许多路径和交互。每一个 `if` 条件都迫使你考虑两个分支的情况。每一个循环都迫使你考虑每一次迭代的情况。而这正是借用检查器为你设计的推理方式!它在编译期强制执行最佳实践,当你自己的思维模型与借用检查器冲突时(而后者通常是正确的 99% 时间),它可能会让你觉得烦人。确实存在借用检查器过于严格的场景,但这种情况很少见,作为初学者你几乎不会遇到。我在早期也多次在内存管理上犯错,但我以“学习者的心态”去面对,这帮助我问“我的代码哪里错了?”而不是“编译器哪里错了?”,这也是我在培训中经常看到的反应。
好消息是,一旦你掌握了借用的概念,它就不会再与你作对了。大多数经验丰富的 Rust 开发者都会告诉你,借用检查器在第 4 到第 12 周之间逐渐变成了他们的盟友。第一个月是最难熬的。PubNub 的 Stephen Blum 在最近的一次播客中很好地描述了这段经历:
> 当你开始深入学习时:是挫败感。它让我想起了第一次学编程时的感觉,因为差别太大了。面对借用检查器和生命周期,我不想处理这些东西——但我不得不这么做。
>
>
> —— Stephen Blum,PubNub 首席技术官,在 [Rustacean Station](https://rustacean-station.org/) 上
以下是 Ed Page(`clap` 的维护者)对这条曲线另一端的看法,这也是你应该优化的目标:
> 编译器借用检查器帮我避免了去思考这些问题,让我能够专注于更高层次的问题。它在我自己分析并失败时帮助我发现了问题。
>
>
> — Ed Page,在 [Rustacean Station: clap with Ed Page](https://rustacean-station.org/)
### [编译时间](https://corrode.dev/learn/migration-guides/go-to-rust/#compile-times)
对你团队诚实一点,Rust 的编译时间确实比 Go 差很多。一个中型服务的干净发布构建可能需要几分钟,而 Go 几乎是即时编译。增量构建和 `cargo check` 是合理的,而且这些年编译时间已经大大改善,但你仍然会感受到这种差异。
为了缓解这个问题,请在编辑循环中使用 `cargo check`,一旦收益明显就拆分成工作区,并将过程宏密集的 crate 放在自己的 crate 中,这样它们只会在更改时重新编译。想深入了解可以查看 [加快 Rust 编译时间的技巧](https://corrode.dev/blog/tips-for-faster-rust-compile-times/)。
### [异步着色](https://corrode.dev/learn/migration-guides/go-to-rust/#async-coloring)
正如 [Goroutines 与 Async Tasks](https://corrode.dev/learn/migration-guides/go-to-rust/#goroutines-vs-async-tasks) 中所讨论的那样,Rust 的 `async fn` / `fn` 分离是从 Go 迁移过来最大的人体工程学退步之一。自 Rust 1.75 起,异步 trait 已经稳定,但在与动态分发混合时仍存在一些棘手的问题,有时你会需要用到 `async-trait` crate 来掩盖这些问题。
### [某些细分领域生态系统较小](https://corrode.dev/learn/migration-guides/go-to-rust/#smaller-ecosystem-in-some-niches)
Rust 的 crate 生态系统正在增长,且整体库质量很高,但在一些后端相关领域,Go 有先发优势:Kubernetes operator、云提供商 SDK、特定小众数据库驱动。在你做出承诺之前,花一天时间检查你依赖的库是否有你愿意使用的 Rust 对应版本。我帮助的团队通常至少需要自己手动实现一到两个核心库。例如,他们可能需要更新一个已废弃的用于 XML schema 验证的 crate,或者为一个不太知名的协议编写自己的客户端。
## [集成策略](https://corrode.dev/learn/migration-guides/go-to-rust/#integration-strategies)
你不必一次性重写所有内容。我在播客上听到的所有成功的 Go 到 Rust 的迁移故事都是战术性的,而不是一次性的大改。来自微软的 Victor Ciura 表达得很清楚:
> 我们并不是疯狂地全面转向 Rust,只是为了好玩而重写所有东西。我们做的是战术性选择,我们会说:“好吧,这个新组件,用 Rust 来做更好。”
>
>
> — Victor Ciura,微软首席工程师,在 [Rust in Production](https://corrode.dev/podcast/s04e01-microsoft)
最有效的策略,按我通常推荐的顺序如下:
### [1. 将热点路径作为服务分离出来](https://corrode.dev/learn/migration-guides/go-to-rust/#1-carve-off-a-hot-path-as-a-service)
如果你的集群中有一个特定的服务一直是问题所在(高 CPU 使用率、对延迟敏感或频繁出现可靠性问题),那就只把它用 Rust 重写,同时保持相同的 API 合约。这是风险最低的迁移方式。其他 Go 服务继续通过 HTTP/gRPC 与其通信,对底层语言一无所知。Radar 的 Jeff Kao 描述了 Discord 的著名帖子是如何成为团队尝试的动力:
> 如果你在 Hacker News 上搜索“迁移到 Rust”,第一个结果总是关于 [Discord 从 Go 迁移到 Rust](https://discord.com/blog/why-discord-is-switching-from-go-to-rust) 的文章。这几乎激励我们去尝试 [是否也能做到同样的事情]。
>
>
> — Jeff Kao,Radar 首席技术官,在 [Rust in Production](https://corrode.dev/podcast/s05e08-radar)
### [2. 替换一个 Sidecar / Worker 进程](https://corrode.dev/learn/migration-guides/go-to-rust/#2-replace-a-sidecar-worker-process)
后台工作者、队列消费者、数据摄入管道和 CPU 密集型批处理作业是非常好的首选目标。它们通常具有清晰的输入/输出边界(一个队列、一个主题),并且与系统的其余部分没有共享的进程内状态。
### [3. cgo 可行但痛苦](https://corrode.dev/learn/migration-guides/go-to-rust/#3-cgo-is-possible-but-painful)
你可以通过 cgo 从 Go 调用 Rust,[有很好的指南介绍如何操作](https://blog.arcjet.com/calling-rust-ffi-libraries-from-go/)。(如果你对此感兴趣,可以联系我,我可以为你提供相关指南。)实际上,我很少建议在后端服务中使用这种方法。相比“直接启动一个 Rust 服务并通过网络调用”而言,构建复杂性和 FFI 开销通常得不偿失。对于库和 CLI 工具来说,这种方式更可行。
### [4. 在网关后使用 Strangler 模式](https://corrode.dev/learn/migration-guides/go-to-rust/#4-strangler-pattern-behind-a-gateway)
如果你有一个 API 网关或反向代理,你可以将特定端点路由到新的 Rust 服务,而其余部分仍留在 Go 中。当一个有界上下文(认证、搜索、计费)是合适的迁移单位时,这种方法特别有效。这个模式通常被称为 [“绞杀树”](https://martinfowler.com/bliki/StranglerFigApplication.html),因为新服务围绕旧服务逐渐成长,最终完全取代它。
## [实用迁移技巧](https://corrode.dev/learn/migration-guides/go-to-rust/#practical-migration-tips)
**从具有明确边界的某个服务开始。** 不要选择你集群中最中心、部署最多的那个服务。选择一个与系统其余部分契约定义良好且影响范围较小的服务。
**保持相同的 API 合约。** 如果你的 Go 服务暴露了一个 REST API,那么你的 Rust 服务也应当如此:相同的路径、相同的 JSON 结构、相同的错误封装。迁移对客户端来说是不可见的,你可以通过网关逐步切换流量。
**不要逐字翻译习语。** 避免写出带有 Go 风格的 Rust 代码。`if err != nil { return err }` 应该改为 `?`。每个请求一个 goroutine 的模式只有在真正需要时才使用 `tokio::spawn`(因为 axum 已经可以并发处理请求)。只有一个方法的接口通常会变成泛型上的 trait bound,而不是 `Box<dyn Trait>`。
**把编译器当作你的编程伙伴。** Rust 编译器的报错信息通常非常清晰。慢慢阅读这些错误信息。它们几乎总是告诉你正确的解决方案。那些在学习过程中挣扎最久的团队成员,往往是那些与编译器对抗而不是将其视为合作者的人。
**尽早投资培训。** 我见过一些团队试图“边做边学”地进行 Rust 迁移。这很少有好结果。这就像报名参加马拉松比赛后才开始跑步训练一样。你当然可以做到,但过程会很痛苦,而且可能无法完成。安排专门的时间用于学习:工作坊、[在线课程](https://course.corrode.dev/)、真实代码上的结对练习。一旦团队熟练掌握,前期投入将会得到数倍回报。(嘿,如果你想要聊聊培训选项,[我很乐意交流](https://corrode.dev/services)。)
## [保持 Go 的优势](https://corrode.dev/learn/migration-guides/go-to-rust/#keeping-go-s-strengths)
并非所有内容都应迁移到 Rust。Go 在以下方面表现出色:
* **原生 Kubernetes 工具链**:operator、controller、CRD。生态系统主要由 Go 构成。
* **命令行工具和开发工具**:快速编译、易于交叉编译、部署简单。
* **胶水服务**:轻量级 API 层、代理、格式转换器。在 Rust 中写样板代码并不值得。
* **任何团队效率比绝对正确性更重要的地方**。
这不是一个边缘立场。对于同时大规模使用这两种语言的公司来说,这个观点更加明显:
> Go 是网络服务非常好的选择。我们在 Canonical 大量使用 Go——Juju 就是一个庞大的 Go 代码库。
>
>
> —— Jon Seager,Canonical 工程副总裁,在 [Rust in Production](https://corrode.dev/podcast/s05e05-canonical)
混合策略是完全可以接受且常见的。我合作过的许多团队最终采用了多语言后端架构:Go 用于“平凡”的服务,而 Rust 用于那些可靠性与性能回报更高的场景。
## [预期改进](https://corrode.dev/learn/migration-guides/go-to-rust/#expected-improvements)
具体数字因负载不同而差异很大,所以请将这些作为大致参考。不是承诺!但基于我协助过的 Go 到 Rust 迁移项目,这里是一些粗略数据:
* CPU 使用率:减少 20–60%。不如 Python 到 Rust 那么显著,因为 Go 本身已经高效。收益主要来自没有 GC 和更紧凑的循环。
* 内存使用:减少 30–50%,主要是由于没有 GC 开销以及运行时更小。
* P99 延迟:显著更稳定。Rust 服务往往表现平滑,而 Go 服务则会出现明显的 GC 导致抖动。(自从 Go 引入低延迟 GC 后情况已大大改善,但在高负载下仍存在差异。)
* 生产事故:这是团队报告最多兴奋的方面。那些能绕过 `go test -race` 并进入生产环境的 bug 类型(数据竞争、空指针解引用、遗漏错误路径)在 Rust 中根本无法编译通过。Rust 迁移之后,值班轮岗通常变得非常无聊。Andrew Lamb 在 InfluxDB 重写后描述了这种效果:
> 我不再需要追踪崩溃、奇怪的多线程竞态条件,或者消耗我大量时间的其他问题。
>
>
> —— Andrew Lamb,InfluxData 高级工程师,在 [Rustacean Station: Rebuilding InfluxDB with Rust](https://rustacean-station.org/)
说实话,从 Go 转到 Rust 不像从 Python 转换那样能获得 10 倍的吞吐量提升。你得到的是更少的“愚蠢错误”和更平坦的延迟尾部,以及在保持同一语言的前提下扩展到嵌入式开发或系统编程领域的能力。这常常是迁移带来的最令人惊讶的效果:以前必须使用不同技术栈的团队现在可以共享代码。你可以用 Rust 做一切。
## [结论](https://corrode.dev/learn/migration-guides/go-to-rust/#conclusion)
从 Go 转向 Rust 是一种不同于从 [Python](https://corrode.dev/learn/migration-guides/python-to-rust) 或 [TypeScript](https://corrode.dev/learn/migration-guides/typescript-to-rust) 转换的迁移方式。从 Go 出发,你已经了解静态类型和编译型语言的优势。因此你并不是放弃动态类型或慢速运行时,而是用更健壮的代码库替代 `nil`,减少潜在陷阱,并采用一个更严格的编译器来在编译期捕获更多错误。不过,这也意味着更高的学习曲线。
对于 [基础服务](https://corrode.dev/blog/foundational-software/)(组织依赖的服务、高可用性要求、业务关键性的服务),这种权衡显然是值得的。而对于其他服务,Go 仍然是正确的选择。迁移的核心在于将每个问题交给最适合解决它的语言。
我帮助后端团队评估、规划并执行 Go 到 Rust 的迁移。无论你需要架构审查、培训,还是需要帮助移植关键服务,[让我们谈谈你的需求](https://corrode.dev/services)。
1. Rust 的类型系统并不能捕获所有的数据竞争,但那些在没有同步机制的情况下根本无法在线程间共享的类型将无法通过编译。你仍然可能在同步逻辑中存在错误,但不会再出现“哎呀,我忘了给这个加锁”这类问题,而这类问题常常导致无声的数据损坏。[↩](https://corrode.dev/learn/migration-guides/go-to-rust/#fr-races-1)
2. 值得澄清一下,因为这会让很多人困惑:Go 中的 `string` 是一个不可变的 _字节_ 序列,通常(但不保证)是有效的 UTF-8 编码。`rune` 是一个 Unicode 码点(即 `int32` 的别名),当你对 `string` 进行 range 操作时得到的就是它。`[]byte` 是一个可变的字节缓冲区。最接近的一对一映射是:`string`(Go)↔ `&str`(Rust)用于只读视图,`[]byte`(Go)↔ `Vec<u8>`(Rust)用于可变缓冲区。Rust 中的 `String` 是 `&str` 的所有者版本,且可增长,并额外保证其内容是有效的 UTF-8(而 Go 的 `string` 在类型层面并不强制这一点)。更多信息请参见 [Go 中的字符串、字节、符文和字符](https://go.dev/blog/strings)。[↩](https://corrode.dev/learn/migration-guides/go-to-rust/#fr-go-strings-1)
3. 准确地说,这指的是除了接收者类型外还引入了 _其自身_ 类型参数的方法。Go 自 1.18 起就支持泛型 _函数_ 和泛型类型,因此 `func Map[T, U any](s []T, f func(T) U) []U` 是完全合法的。但你不能将这个 `Map` 作为泛型 `Set[T]` 的方法来实现,并让调用者在每次调用时指定 `U`。Go 的提案明确地回避了这个问题,而且至今仍未加入该特性。[↩](https://corrode.dev/learn/migration-guides/go-to-rust/#fr-generic-methods-1)
4. 如果你来自 Go 语言,那行代码需要一点时间去理解:`(0..100)` 是一个惰性范围(lazy range),`.filter(|n| ...)` 是一个闭包(`|n|` 是参数列表,对于单个表达式不需要大括号),而 `.collect()` 将迭代器转换为左侧所要求的类型。Go 并不是一种特别函数式的语言,这种迭代器链的写法确实是一种需要适应的风格,而惯用的 Rust 非常依赖这种风格,在最初的几周可能会有些陌生。当然,你仍然可以在 Rust 中使用 `for` 循环,对于一次性使用的代码这通常是正确的选择,但一旦熟悉之后你会发现迭代器模式会变得非常自然,而且能够无中间变量地链式转换,这在你掌握后会显著提升代码的可读性。(至少这是我的体验。)[↩](https://corrode.dev/learn/migration-guides/go-to-rust/#fr-iterator-readability-1)