跳转到主内容从 main 函数和 Cargo 开始 | 物联网民工

从 main 函数和 Cargo 开始

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/argv
  • fs::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 的类型系统接上。