C 开发者第一次写 Rust,真正陌生的往往不是 if、for 或函数调用。更早出现的变化是工程入口:C 程序通常从 main.c、gcc、Makefile 和 argc/argv 开始;Rust 程序通常先进入一个 Cargo 项目。
这不是单纯的工具差异。Cargo 会同时管理源码组织、依赖、构建、测试和发布元数据。main 也不只是返回一个整数就结束,错误处理、标准输出、标准错误和退出码会更早进入代码结构。
先看一个很小的工具:读取一个文件,统计行数,输出结果。
linecount README.md
它不复杂,但足够覆盖命令行工具最早会遇到的几件事:参数、文件、错误、输出和退出码。
C 里通常怎么开始
C 里可以直接写一个 main.c:
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE *fp;
int ch;
unsigned long lines = 0;
if (argc != 2) {
fprintf(stderr, "usage: linecount <file>\n");
return 2;
}
fp = fopen(argv[1], "r");
if (fp == NULL) {
perror("linecount");
return 1;
}
while ((ch = fgetc(fp)) != EOF) {
if (ch == '\n') {
lines++;
}
}
if (ferror(fp)) {
perror("linecount");
fclose(fp);
return 1;
}
fclose(fp);
printf("%lu\t%s\n", lines, argv[1]);
return 0;
}
这段代码的结构很熟悉:
argc/argv负责参数fopen取得文件资源fgetc逐字节读取fprintf(stderr, ...)和perror输出错误return 0/1/2表示退出状态fclose负责释放文件资源
真正麻烦的不是代码看不懂,而是每个资源和错误路径都要人工配对。打开文件以后,任何失败路径都要记得关闭。以后如果增加多个 buffer、多个文件或更多返回点,cleanup 路径会很快变长。
Rust 不是从单个源文件开始
Rust 也可以直接用 rustc main.rs 编译单文件,但实际写工具和库时通常从 Cargo 项目开始:
cargo new linecount
cd linecount
cargo run -- README.md
项目里最少会出现两个文件:
linecount/
├── Cargo.toml
└── src/
└── main.rs
Cargo.toml 类似项目清单。它不是 Makefile 的直接替代品,但承担了 C 项目里常分散在 Makefile、包管理脚本和发布说明里的部分信息:
[package]
name = "linecount"
version = "0.1.0"
edition = "2024"
src/main.rs 是二进制程序入口。Rust 的入口函数同样叫 main,但工程习惯已经不同:源码目录、包名、依赖、构建命令和测试入口先被 Cargo 固定下来。
这对 C 开发者有一个直接好处:第一天写小工具,后面要拆库、加测试、引入参数解析库、做 release build,不需要重新设计项目骨架。
先写一个能工作的版本
下面是对应的 Rust 版本:
use std::{env, fs, io, process};
fn main() {
if let Err(err) = run() {
eprintln!("linecount: {err}");
process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let path = env::args().nth(1).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "usage: linecount <file>")
})?;
let text = fs::read_to_string(&path)?;
let lines = text.lines().count();
println!("{lines}\t{path}");
Ok(())
}
先不用急着把每个类型都拆开。按 C 的视角看,这段代码做了几件对应的事:
env::args()对应argc/argvfs::read_to_string对应打开文件并读取内容Result对应“这个函数可能失败”?对应“失败就返回给上一层”eprintln!对应写标准错误println!对应写标准输出process::exit(1)对应非零退出码
这里有两个明显变化。
第一,文件没有显式 close。read_to_string 返回时,内部打开的文件已经结束使用。后面讲资源释放时会看到,Rust 不靠手写 fclose 分散在每条错误路径里,而是用所有权和 Drop 管理资源释放。
第二,错误路径没有一层层 if。? 不会吞掉错误,它只是把当前错误返回给调用方。run 负责描述工作流程,main 负责把错误转成人能看到的 stderr 和进程退出码。
main 可以很薄
C 程序里常把大部分逻辑直接写在 main 里,尤其是小工具。Rust 里也能这么写,但更常见的做法是让 main 只处理进程边界:
fn main() {
if let Err(err) = run() {
eprintln!("linecount: {err}");
process::exit(1);
}
}
run 才是实际业务:
fn run() -> Result<(), Box<dyn std::error::Error>> {
// parse args, read file, print result
Ok(())
}
这个拆法对命令行工具很有用。main 处理进程级问题:stderr、退出码、最终错误展示。run 保持普通函数形态,后面更容易测试,也更容易把逻辑挪进库。
这和 C 里的经验并不冲突。很多 C 项目也会把 main 写薄,把核心逻辑放进库函数。Rust 只是让错误返回和资源生命周期更早进入函数签名。
参数先用标准库就够了
Rust 生态里有 clap 这类成熟的命令行参数解析库。但第一篇不需要马上引入。
let path = env::args().nth(1).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "usage: linecount <file>")
})?;
这行代码只做一件事:取第一个参数,没有就返回一个错误。
C 里通常写成:
if (argc != 2) {
fprintf(stderr, "usage: linecount <file>\n");
return 2;
}
两者的目标一样,区别在错误的流向。C 版本直接在 main 里打印并返回。Rust 版本先把“参数不对”表示成一个错误,再由 main 统一输出。
小工具可以直接用标准库。等参数变多,出现子命令、默认值、帮助文本和校验规则,再引入 clap 更自然。
stdout、stderr 和退出码仍然重要
Rust 不会替你决定命令行工具该怎样和 shell 交互。C 开发者熟悉的规则仍然成立:
- 正常结果写 stdout
- 错误信息写 stderr
- 成功返回 0
- 失败返回非 0
所以示例里正常结果用:
println!("{lines}\t{path}");
错误信息用:
eprintln!("linecount: {err}");
process::exit(1);
这不是 Rust 语法细节,而是命令行工具的接口。以后这个工具被 shell 脚本、CI 或其他程序调用时,stdout、stderr 和退出码就是它的外部契约。
Cargo 先解决工程入口
对 C 开发者来说,Cargo 最容易先理解成“构建入口”,但它不止于此。
常用命令很少:
cargo run -- README.md
cargo build
cargo build --release
cargo test
-- 后面的内容会传给程序本身,而不是传给 Cargo:
cargo run -- README.md
等价于构建后运行:
./target/debug/linecount README.md
这和 C 项目里先 make 再运行可执行文件很像。差别在于 Cargo 从一开始就知道 package 名字、源码入口、依赖版本、测试目标和 release 构建方式。
第一篇先记住什么
这篇文章还没有真正进入所有权、借用和生命周期。它只建立 C 开发者写 Rust 工具时的第一层模型:
C:main.c + gcc/Makefile + argc/argv + return code
Rust:Cargo.toml + src/main.rs + env::args + Result + process exit
Rust 不是让 C 开发者忘掉原来的工程经验。命令行参数、文件、错误输出和退出码这些边界仍然存在。变化在于 Rust 会更早要求你把错误路径、资源释放和 API 形状写清楚。
下一步再看基本类型、变量和常量。那时 C 里的 int32_t、size_t、const 和局部变量直觉,会开始和 Rust 的类型系统接上。