Wayne Wei

Learning / Rust

Rust · 02

第一次运行 Rust

From a single file, to a managed project. · 从一个文件,到一个被管理的项目。

022026.08.2810 分钟阅读时间Rust 1.90Edition 2024
Rust · 02Learning in public
01Start, for Real 02First Rust Program 03Guessing Game 04Ownership 05Borrowing 06Structs 07Enums 08Traits 09Lifetimes 10Async

我原来以为,写 Rust 就是把 .rs 文件编译出来。

后来我发现,真正进入一个项目以后,事情完全不是这么简单。

01

我一开始其实不理解 Cargo

上一篇把 Rust 装好以后,我先做了一件最普通的事情:

写一个 main.rs。

main.rs RustRust 1.90 · Edition 2024
fn main() {
    println!("Hello, world!");
}

这件事情本身没有什么神奇的。

Rust 编译器 rustc 可以直接编译一个 Rust 源文件。官方 Cargo 文档也是从这里开始介绍的:一个 crate 最终由 rustc 编译,最简单的时候,甚至可以直接把文件交给 rustc。

所以我当时第一个疑问其实很朴素:

既然 rustc 已经能把代码编译出来了,那为什么 Rust 还需要 Cargo?

如果只是第一次写 Hello World,这个问题确实没什么感觉。

但只要项目稍微复杂一点,答案就开始出现了。

一个真正的项目不会永远只有一个 .rs 文件。

它可能有几十个模块,有外部依赖,有测试,有不同的构建配置,还要考虑不同机器上能不能得到一致的构建结果。

这时候,如果所有东西都靠自己调用 rustc,事情很快就会变得麻烦。

官方对 Cargo 为什么存在的解释其实很直接:直接使用 rustc 时,项目的编译参数、外部依赖以及依赖的依赖都需要自己处理;项目一复杂,手工管理这些东西就容易出错。

所以我后来才慢慢理解:

rustc 解决的是“怎么编译 Rust 代码”。

Cargo 解决的是“怎么管理一个 Rust 项目”。

这两个东西不是竞争关系。

Cargo 本身也不是替代 rustc 的另一个编译器。

它更像是站在编译器外面,把一个真实项目需要做的那些事情组织起来。

02-1 observation → conclusion
Observation

rustc main.rs 可以直接把 .rs 源文件编译成可执行文件。单文件时,一切都很简单。

Question

那为什么还需要 Cargo?为什么官方教程几乎不让你直接调用 rustc?

Investigation

Arustc

编译一个程序

BCargo

项目 · 依赖 · 构建 · 测试 · 发布

→结论

两者解决的是不同规模的问题

Conclusion
rustc 是编译器,而 Cargo 是围绕 Rust 项目建立起来的工作流。
02

第一个 Cargo 项目

于是我开始创建自己的第一个 Cargo 项目:

Terminal bash
$ cargo new hello_cargo
$ cd hello_cargo

Cargo 会直接帮我生成一个基本项目:

Project Tree cargo new
hello_cargo/
├── Cargo.toml
└── src/
    └── main.rs

这其实是我第一次明显感觉到:

哦,原来 Rust 的“项目”是有结构的。

官方 Cargo Book 目前的示例也是这个结构。cargo new 默认创建的是 binary package;如果要创建 library,则可以使用 --lib。

打开 src/main.rs,还是刚才那个 Hello world(没什么变化)。

真正让我开始产生疑问的是另外一个文件:

Cargo.toml

03

Cargo.toml 到底是什么?

第一次看到它的时候,我只知道:

这是 Cargo 的配置文件。

但这个解释其实没什么用。

因为真正重要的是:

Cargo 到底需要从这里知道什么?

一个刚创建出来的项目大概是这样:

Cargo.toml Tomlmanifest
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]

这里其实已经透露了很多东西。

[package] 描述的是这个 package 本身。

比如 name = "hello_cargo" 和 version = "0.1.0",就是项目名称和版本。

而 [dependencies] 是告诉 Cargo:

这个项目依赖哪些外部 crate。

现在它还是空的,所以看起来没什么感觉。

但等以后开始使用 Tokio、Serde、Reqwest 之类的库,你会发现这个区域会变成 Rust 项目里非常重要的一部分。

Cargo 官方把 Cargo.toml 称为 manifest,也就是描述 package 所需信息的清单。

04

edition = "2024" 不是 Rust 版本

这里有一个我觉得初学 Rust 很容易误解的东西:

Cargo.toml
edition = "2024"

第一次看到它,我脑子里的反应其实是:

Rust 2024?那 Rust 现在到底是哪个版本?

后来才知道:

Edition 不是 Rust compiler 的版本号。

Rust 使用 edition 来组织一些语言和生态层面的变化。官方目前提供 Rust 2015、2018、2021 和 2024 editions;项目在 Cargo.toml 中通过 edition 指定自己使用哪一个 edition。

版本和 edition 是两回事:

ARust 版本

编译器本身的发布版本

Rust 1.90.0

BRust Edition

语言层面的变化机制

edition = "2024"

所以 edition = "2024" 更准确的理解是:

这个项目按照 Rust 2024 Edition 的规则和习惯来解释代码。

它和你安装的 Rust compiler 版本不是同一个概念。

而当前官方 Rust Book 本身也已经以 Rust 2024 Edition 为例,并要求使用较新的 Rust 工具链。

这个区别现在记住就够了。

以后真正遇到 Edition 之间的语言变化,再单独研究。

05

第一次 cargo build

项目已经有了。

接下来当然是:

Terminal cargo build
$ cargo build
   Compiling hello_cargo v0.1.0 (file:///.../hello_cargo)
    Finished `dev` profile [unoptimized + debuginfo]

然后项目里多出来一个 target/:

Build Output target/
target/
└── debug/
    └── hello_cargo
注 Windows 下则是 target/debug/hello_cargo.exe

这时候我才真正开始理解 Cargo 和 rustc 的区别。

之前直接运行 rustc main.rs,得到的结果就在当前目录附近。

现在 cargo build,Cargo 会按照项目结构管理构建结果,把开发构建的产物放到 target/debug。官方文档也明确把这种 Debug 构建作为默认的开发构建。

所以项目目录慢慢变成:

Project Tree hello_cargo/
hello_cargo/
├── Cargo.toml
├── Cargo.lock
├── src/
│   └── main.rs
└── target/
    └── debug/
        └── hello_cargo

这已经和刚才那个只有一个 main.rs 的世界不太一样了。

06

cargo run:其实就是懒一点

如果只是想看看程序能不能跑:

Terminal cargo run
$ cargo run
   Compiling hello_cargo v0.1.0 (file:///.../hello_cargo)
    Finished `dev` profile [unoptimized + debuginfo]
     Running `target/debug/hello_cargo`
Hello, world!

Cargo 会帮你完成构建,然后运行生成的程序。

也就是说:

Acargo build

cargo build

更像:给我构建结果。

Bcargo run

cargo run

更像:构建一下,然后现在就跑起来。

官方文档也是这么定义 cargo run 的:它会编译 package,然后运行生成的 binary。

所以日常开发的时候,我其实很少需要自己去找 target/debug/hello_cargo,直接 cargo run 就够了。

这也是我觉得 Cargo 开始变得舒服的地方。

它没有改变 Rust 编译器本身。

它只是把很多重复的事情接起来了。

07

那 cargo check 又是干什么的?

这个命令一开始我反而觉得有点奇怪:

既然已经有 cargo build,为什么还需要一个 check?

后来发现这个命令其实非常实用。

cargo check 会检查当前 package 以及依赖是否存在编译错误,但会跳过最终的代码生成,因此通常比完整的 cargo build 更快。

简单理解,它就是先问一句“代码能不能通过编译检查?”,然后告诉我结果。它不会为了得到最终可执行文件而完成最后的代码生成。

所以在不断修改代码的时候,cargo check 非常方便:

改代码
cargo check
发现错误
继续改
cargo check

官方 Rust Book 也特别推荐在开发过程中周期性运行 cargo check,因为它通常比 cargo build 更快。

当然,它也不是“比 build 更严格”或者“完全等价于 build”。

Cargo 官方文档特别提醒,有些只有在代码生成阶段才会出现的诊断,cargo check 本身不会报告。

所以我现在更愿意把它理解成:

现在的用法

我现在还没准备好要一个最终程序,但我想先知道这堆代码能不能通过编译检查。

08

Debug 和 Release

然后又遇到了两个目录:

target/debug/ 和 target/release/。

刚开始看名字就大概能猜到是什么意思。

开发的时候 cargo build,默认是 Debug 构建。

如果准备做 release build,用 cargo build --release,结果会进入 target/release/。

A开发阶段

cargo build → target/debug

编译快,未优化,适合开发调试

B发布阶段

cargo build --release → target/release

启用优化,编译更慢,但运行性能适合正式使用

两者的主要区别之一是优化。

官方 Cargo 文档说明,Debug 构建默认面向开发,编译时间更短,因为不会进行 release 构建中的优化;Release 构建会花更多时间编译,但生成的程序通常运行得更快。

现在我还不需要研究 Cargo profile 里面几十个配置项。

知道这两个目录分别是干什么的,已经足够。

09

然后我发现了 Cargo.lock

运行 Cargo 之后,你可能还会看到一个新的文件:

Cargo.lock

这个文件第一次看到的时候,我其实挺容易把它和 Cargo.toml 搞混。

因为它们都和依赖有关。

但两者的角色完全不同。

我现在会这么记:

ACargo.toml

我告诉 Cargo:

“我需要什么依赖、允许什么版本范围”

BCargo.lock

Cargo 告诉项目:

“这次解析之后,具体用了哪些版本”

官方文档的表述也很明确:

Cargo.toml 描述依赖的大致要求,由开发者编写;Cargo.lock 保存精确的依赖信息,由 Cargo 维护。

这背后其实有一个非常现实的问题。

假设我的项目依赖一个库。

今天我构建的时候拿到的是某个版本。

过几个月,那个库出了新版本。

如果没有锁定信息,那么不同时间、不同环境重新解析依赖时,就可能得到不同的依赖组合。

Cargo.lock 的作用之一,就是把 Cargo 当时解析出来的具体依赖版本记录下来,从而帮助项目获得更可重复的构建结果。

所以它不是一个“我要不要手动维护的配置文件”。

它更像 Cargo 给项目留下的一份依赖解析结果。

通常也不应该直接手动编辑它。官方文档明确建议由 Cargo 维护这个文件。

10

到这里,我才开始理解 Cargo

如果把这一章压缩成几张图,其实没有那么复杂。

最开始:

最初 · 只是编译一段代码
main.rs
 ↓
rustc
 ↓
executable

这是:“我有一段 Rust 代码,怎么把它编译出来?”

但项目稍微认真一点以后:

Rust Project hello_cargo/
Rust project
├── Cargo.toml → 项目 + 依赖声明
├── Cargo.lock → 精确依赖解析结果
├── src/       → Rust source code
└── target/    → debug / release

这时候 Cargo 才真正开始有意义。

它帮我处理的已经不只是“运行一下 Rust”。

而是:

创建项目
管理依赖
检查代码
构建项目
运行程序
生成不同构建产物

这也是为什么 Cargo 不只是一个“命令行工具”。

它实际上是 Rust 项目工作流的一部分。

官方对 Cargo 的定位也很明确:它是 Rust 的 package manager,负责下载依赖、编译 package、生成可分发 package,并与 Rust 生态的 package registry 配合。

11

但我现在还不想把 Cargo 学得太深

这里其实有一个很容易犯的错误。

看到 Cargo 以后,我完全可以继续往下钻:

Cargo 里暂时还没学的
workspace
profiles
features
dependency resolver
crates.io
build scripts
target specification
Cargo configuration
publishing

这些东西当然都重要。

但如果现在全部塞进来,我估计最后又会变成:

我看完了很多 Cargo 文档,但还是不知道一个 Rust 项目到底怎么跑起来。

所以这一篇我暂时只停在这里。

我现在只需要建立一个足够实用的模型:

够用的模型 Cargo
Cargo
│
├── cargo new
│      创建项目
│
├── cargo check
│      快速检查代码
│
├── cargo build
│      构建项目
│
├── cargo run
│      构建并运行
│
└── cargo build --release
       构建 release 版本

剩下的以后遇到再学。

12

我真正想留下来的,不是这几个命令

如果只是记命令,这一篇其实没什么必要。

这些命令过两周我可能就忘了。

真正让我觉得有变化的是,我开始慢慢区分两个层次:

What I Actually Learned

这一章不是记住几个命令,
而是开始区分两个层次。

① Rust compiler
Rust compiler
rustc
代码怎么被编译
② Rust project
Rust project
Cargo
项目怎么被组织、检查、构建和运行

上一篇我还在想:Rust 代码到底怎么跑起来?这一篇开始,我的问题变成了: 一个 Rust 项目到底是怎么工作的? The important thing wasn't learning another command. It was understanding the system behind the command.

我觉得这两个问题还是不太一样的。

13

还有一个我现在特别想提醒自己的问题

我现在学 Rust,其实和以前学编程有一个挺大的不同:

我手边一直有 AI。

代码不会写?问 AI。

编译错误?贴给 AI。

不知道某个 crate 怎么用?问 AI。

甚至整个项目不知道怎么组织,也可以让 AI 给一个方案。

这当然非常方便。

但我越来越觉得,这里面有一个坑。

如果每次遇到错误,我都直接把 error[E....] 复制给 AI,然后把它给我的修改直接贴进去。

最后可能会出现一个挺奇怪的结果:

我不想要的结果
AI 越来越会 Rust
我会复制 Rust

所以我现在想给自己定一个很简单的规则。

先自己读错误。

先看 error[E...],再看 --> src/main.rs:xx,看看 compiler 到底在说什么。

先自己猜一次
能改就自己改
实在不知道,再问 AI

而且问 AI 的时候,我希望以后更多问:

“这个错误是什么意思?为什么 Rust 会这样检查?”

而不是:

“帮我改掉。”

因为前一种是在学习。

后一种只是让程序继续跑。

这两件事情,看起来结果一样,其实完全不是一回事。

14

下一步

到这里,我其实还没真正开始学 Rust 最难的部分。

Ownership 还没真正进入。

Borrowing 还没开始折磨我。

Lifetime 更不用说。

但我觉得这一篇还是有必要。

因为至少现在,当我看到一个 Rust 项目时,不再只会盯着 src/main.rs。

我开始知道 Cargo.toml、Cargo.lock、src/、target/ 这些东西各自是在干什么。

而这可能才是真正进入一个 Rust 项目的第一步。

下一篇继续。

这次应该就不能只靠 println!("Hello, world!"); 混过去了。