学习 Rust:使用 StructOpt 解析命令行参数

更新于 2026-01-17

Ryan Moore 2019-05-08

最近,我一直在学习 Rust。和大多数人一样,我一开始是通过阅读《Rust 编程语言》(也被称为“the book”)并完成《Rust By Example》中的示例来入门的。这些资源非常棒,但如果学了一段时间后不能用所学知识去尝试制作一些酷炫的东西,我往往会感到无聊。

作为一个经常从事生物信息学工作的人,我几乎每天都在使用命令行应用程序。因此,我编写的程序都是一些小型命令行工具,用于执行各种(半)有用的任务。这些应用程序的一个关键部分就是对命令行参数的良好处理。事实证明,Rust 通过 clap 和 StructOpt 库对此提供了强大的支持。所以今天,我们将介绍如何使用 StructOpt 来解析命令行参数。你会发现,它让命令行参数解析变得轻而易举!

注意:本文主要面向 Rust 初学者(比如我自己!),但并不一定针对编程初学者。我会详细解释很多 Rust 相关的内容,包括我在学习过程中需要查阅和理解的许多知识点。如果你只想看最终结果,可以直接跳到文章底部。


创建一个新项目

如果你的计算机上还没有安装 Rust,请访问 Rust 官网的帮助页面 进行设置。

首先,让我们使用 Cargo(Rust 的包管理器)创建一个新项目:

$ cargo new parse_cli_args
     Created binary (application) 'parse_cli_args' package

这条命令会创建一个新的二进制程序。我们用 tree(一个用于递归列出目录结构的程序)来看看 Cargo 为我们生成了什么:

$ cd parse_cli_args
$ tree
.
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

Cargo 为我们创建了两个文件:Cargo.tomlsrc/main.rs


清单文件(Manifest file)

Cargo.toml 文件是一个清单(manifest),其中包含 Cargo 用来编译该包所需的元数据。它采用 TOML 格式编写,这是一种易于阅读的配置文件格式。

打开你的 Cargo.toml 文件,你应该会看到类似下面的内容:

[package]
name = "parse_cli_args"
version = "0.1.0"
authors = ["Your Name <your@email.com>"]
edition = "2018"

[dependencies]

[dependencies] 部分,你可以指定项目所依赖的 crate。Crate 本质上是由 Rust 社区其他成员开发的包,你可以用它们为你的程序引入各种酷炫的功能。


主文件(Main file)

另一个由 cargo new 命令创建的文件是 src/main.rs。它的内容如下:

fn main() {
    println!("Hello, world!");
}

对于这样一个简单的项目,我们所有的代码都将放在这里。在真实的项目中,你可能需要更注重代码的分离与组织,但现在我们可以保持简单,把所有代码都放在这个文件里。

若想了解更多关于使用 Cargo 管理 Rust 包的信息,请参阅 Cargo Book


定义一个结构体来保存命令行参数

为了解析命令行参数,我们将使用 StructOpt。StructOpt 是一个基于 clap(一个流行的命令行参数解析库)构建的 crate,它允许你通过定义一个结构体(struct)来解析命令行参数。StructOpt 让事情变得非常简单,但要使用它,我们需要先了解一点关于结构体(structs)和特性(traits)的知识。

在使用 StructOpt crate 之前,首先将以下内容添加到你的 Cargo.toml 文件中:

[dependencies]
structopt = "0.2.15"

现在,让我们学习一点关于结构体的知识。


结构体(Structs)

在 Rust 中,结构体(struct) 是一种自定义数据类型,用于将相关值组合成一个有意义的整体。此外,还可以为结构体关联方法和函数,以操作其内部数据。通过定义结构体及其相关方法,我们可以创建特定于程序领域的新型数据类型,并利用 Rust 编译器的类型检查功能。

因此,结构体用于将相关值组合在一起。在定义结构体时,我们应该思考哪些数据组合对我们的程序是有意义的。以这种方式使用结构体,可以通过标签为数据赋予额外含义。例如,与其用两个独立的变量分别保存矩形的高度和宽度,不如定义一个 Rectangle 结构体,将这两个数据绑定在一起。这样就清楚地表明这两个数据是相互关联的。然后,我们不再需要为每个矩形分别管理高度和宽度变量,而是只需使用一个 Rectangle 结构体的实例即可。

好了,现在让我们添加一个结构体来保存命令行参数!为此,我们首先需要用 use 关键字将 StructOpt 引入作用域。在 src/main.rs 文件顶部添加以下内容:

use structopt::StructOpt;

要实际定义结构体,我们使用 struct 关键字。让我们看看这是如何实现的:

// 定义一个名为 Opts 的结构体
struct Opts {
    // 创建一个名为 infile 的字段,类型为 PathBuf
    infile: PathBuf,

    // 创建一个名为 outfile 的字段,类型为 PathBuf
    outfile: PathBuf,
}

现在我们定义了一个名为 Opts 的结构体,它有两个字段:infileoutfile,类型都是 PathBufPathBuf 用于保存文件路径。path 模块提供了大量方法,可以使用本地平台的路径语法(例如,在 Mac 上使用 / 作为路径分隔符)来处理路径。

为了使用 PathBuf,我们还需要用 use std::path::PathBuf 将其引入作用域。此时,你的 src/main.rs 文件顶部应如下所示:

use std::path::PathBuf;
use structopt::StructOpt;

现在我们已经定义了结构体,让我们看看如何使用它!我们可以通过为每个字段指定具体值来创建结构体的实例,如下所示:

// 将 Opts 的一个实例赋值给 opts 变量
let opts = Opts {
    // 要存储在 infile 字段中的数据
    infile: PathBuf::from("/home/ryan/infile.txt"),

    // 要存储在 outfile 字段中的数据
    outfile: PathBuf::from("/home/ryan/outfile.txt"),
};

这将创建一个 Opts 结构体的新实例,其中 infileoutfile 字段分别存储了 PathBuf 类型的数据,并将其赋值给 opts 变量。

现在我们有了一个 Opts 的实例,那么当我们需要使用这些值时,该如何从中取出数据呢?要从结构体中获取数据,我们使用点号(.),格式为:变量名.字段名。因此,要获取我们刚刚创建的结构体中 infile 字段的数据,可以使用 opts.infile

如果你想更改某个字段中存储的值,可以这样做:opts.infile = new_value。让我们试试看:

opts.infile = PathBuf::from("file.txt");

如果你尝试编译这段代码,你会得到一个错误!

error[E0594]: cannot assign to 'opts.infile', as 'opts' is not declared as mutable
  --> src/main.rs:18:5
   |
13 |     let opts = Opts {
   |         ---- help: consider changing this to be mutable: 'mut opts'
...
18 |     opts.infile = PathBuf::from("file.txt");
   |     ^^^^^^^^^^^ cannot assign

由于 Rust 中的变量默认是不可变的,如果我们希望更改其值,就需要在定义结构体实例时使用 mut 关键字,如下所示:

let mut opts = Opts {
    infile: PathBuf::from("/home/ryan/infile.txt"),
    outfile: PathBuf::from("/home/ryan/outfile.txt"),
};

opts.infile = PathBuf::from("file.txt");

添加 mut 关键字后,我们的程序就能成功编译了。

到现在为止,我们已经掌握了足够的结构体知识,可以创建并使用一个简单的结构体来保存程序的命令行参数了!但要真正实现这一点,我们还需要在 Opts 结构体中添加一些额外内容,以便 StructOpt 知道如何用它来解析命令行参数。为此,我们需要了解一点关于**特性(Traits)**的知识。


特性(Traits)

特性(Traits) 用于告诉 Rust 编译器某个类型具备哪些功能。例如,Display 是一个用于格式化和打印的特性,可与 println!format! 等宏一起使用。任何实现了 Display 特性的类型都可以在上述宏中使用花括号({})进行格式化或打印(例如,你可以打印数字 47(println!("num = {}", 47)),因为它实现了 Display 特性)。

如果我们尝试用花括号打印 opts 会怎样?

println!("opts: {}", opts);

如果你编译这段代码,会得到如下错误:

error[E0277]: 'Opts' doesn't implement 'std::fmt::Display'
  --> src/main.rs:25:20
   |
25 |     println!("{}", opts);
   |                    ^^^^ `Opts` cannot be formatted with the default formatter
   |
   = help: the trait 'std::fmt::Display' is not implemented for 'Opts'
   = note: in format strings you may be able to use '{:?}' (or {:#?} for pretty-print) instead
   = note: required by 'std::fmt::Display::fmt'

如你所见,编译器告诉我们,Opts 的实例无法使用默认格式化器进行格式化,因为它没有实现 std::fmt::Display。Rust 编译器总是尽力提供帮助,因此它建议我们改用 {:?},这会告诉 println! 宏使用 Debug 输出格式。我们来试试!

println!("opts: {:?}", opts);

这次我们得到了一个不同的错误:

error[E0277]: 'Opts' doesn't implement 'std::fmt::Debug'
  --> src/main.rs:25:22
   |
25 |     println!("{:?}", opts);
   |                      ^^^^ 'Opts' cannot be formatted using '{:?}'
   |
   = help: the trait 'std::fmt::Debug' is not implemented for 'Opts'
   = note: add '#[derive(Debug)]' or manually implement 'std::fmt::Debug'
   = note: required by 'std::fmt::Debug::fmt'

哎呀,Opts 仍然无法被格式化!这次我们缺少的是 std::fmt::Debug 特性。编译器建议我们要么自己实现它,要么在代码中添加 #[derive(Debug)]

终于轮到 derive 属性 登场了!derive 属性可以应用于结构体定义,以生成实现某个特性的默认代码。因此,如果我们在 Opts 结构体上添加 #[derive(Debug)],它就会自动实现 Debug 特性,从而允许我们使用 {:?} 进行打印!使用 derive 属性配合像 Debug 这样的特性,是一种“选择加入”默认功能的方式,而无需自己手动实现该特性。

#[derive(Debug)]
struct Opts {
    infile: PathBuf,
    outfile: PathBuf,
}

现在当我们运行 println!("{:?}", opts) 时,程序将输出:

Opts { infile: "/home/ryan/infile.txt", outfile: "/home/ryan/outfile.txt" }

现在我们对特性有了一些了解,终于可以讨论如何将 Opts 结构体与 StructOpt 一起使用了。


使用 Opts 结构体解析参数

在 StructOpt 能够使用 Opts 结构体解析命令行参数之前,我们需要在现有的 derive 语句中添加 StructOpt,并告诉 StructOpt 如何解析字段类型。让我们看看这是如何实现的:

#[derive(Debug, StructOpt)]
struct Opts {
    #[structopt(parse(from_os_str))]
    infile: PathBuf,

    #[structopt(parse(from_os_str))]
    outfile: PathBuf,
}

#[structopt(parse(from_os_str))] 行告诉 StructOpt 使用一个自定义字符串解析器。在这种情况下,它会从 OsStr(操作系统原生表示的字符串的借用引用)解析参数。

现在,我们已经为 Opts 结构体添加了 derive 和自定义解析器,就可以通过使用 from_args 方法自动解析命令行参数了:

let opts = Opts::from_args();

from_args 方法使用命令行参数创建一个 Opts 实例。如果解析失败,它会自动打印错误消息并退出程序。以这种方式使用 StructOpt 会自动生成 --help 消息,并在用户未提供正确命令行参数时给出友好的错误提示。


测试参数解析

让我们暂停一下,汇总一下目前为止学到的所有内容。现在,我们的 src/main.rs 文件应包含以下代码:

use std::path::PathBuf;
use structopt::StructOpt;

#[derive(Debug, StructOpt)]
struct Opts {
    #[structopt(parse(from_os_str))]
    infile: PathBuf,

    #[structopt(parse(from_os_str))]
    outfile: PathBuf,
}

fn main() {
    let opts = Opts::from_args();

    println!("{:?}", opts);
}

我们可以使用 cargo run 来运行程序。先试试不带任何参数运行:

$ cargo run
error: The following required arguments were not provided:
    <infile>
    <outfile>

USAGE:
    parse_cli_args <infile> <outfile>

For more information try --help

太棒了!让我们看看 --help 消息是什么样子的。要向 cargo run 传递参数,我们需要在 -- 后面添加它们,如下所示:cargo run -- --help

$ cargo run -- --help
parse_cli_args 0.1.0
Ryan Moore <moorer@udel.edu>

USAGE:
    parse_cli_args <infile> <outfile>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

ARGS:
    <infile>     
    <outfile>    

我们可以看到,我们的程序接受两个必需的位置参数 <infile><outfile>。这已经很容易使用了,但如果我们希望使用 -o--outfile 来指定输出文件呢?我们只需要在之前添加的 parse 属性之外再添加更多属性即可。如果我们希望 outfile 同时支持短选项(-o)和长选项(--outfile),可以将 #[structopt(parse(from_os_str))] 改为 #[structopt(short, long, parse(from_os_str))],如下所示:

#[structopt(short, long, parse(from_os_str))]
outfile: PathBuf,

这样做之后,--help 消息将变为如下形式:

parse_cli_args 0.1.0
Ryan Moore <moorer@udel.edu>

USAGE:
    parse_cli_args <infile> --outfile <outfile>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -o, --outfile <outfile>    

ARGS:
    <infile>

最后,如果我们希望 outfile 参数是可选的怎么办?例如,我们可能希望在提供了 --outfile 时将输出写入文件,而在未提供时则输出到标准输出(stdout)。为此,我们需要将 outfile 字段的类型从 PathBuf 改为 Option<PathBuf>

#[structopt(short, long, parse(from_os_str))]
outfile: Option<PathBuf>,

当然,帮助消息会自动更新以反映这一变化:

$ cargo run -- --help
parse_cli_args 0.1.0
Ryan Moore <moorer@udel.edu>

USAGE:
    parse_cli_args [OPTIONS] <infile>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -o, --outfile <outfile>    

ARGS:
    <infile>    

注意Option 是一个枚举类型,用于表示某个值可能存在,也可能不存在。例如,当我们向 outfile 参数传递了某个值时,我们会得到 Some 值(例如 Some(PathBuf)),而如果没有传递 outfile 参数,则会得到 None。有关 Option 类型的完整解释,请参阅 Rust 书中的这一节


总结

最后,让我们测试完整的程序。src/main.rs 现在应该如下所示:

use std::path::PathBuf;
use structopt::StructOpt;

#[derive(Debug, StructOpt)]
struct Opts {
    #[structopt(parse(from_os_str))]
    infile: PathBuf,

    #[structopt(short, long, parse(from_os_str))]
    outfile: Option<PathBuf>,
}

fn main() {
    let opts = Opts::from_args();

    println!("{:?}", opts);
}

现在,让我们用 cargo build 构建应用程序,这将在 target/debug 文件夹中生成一个名为 parse_cli_args 的二进制文件。

最后,让我们看看在传入一些实际参数时程序的运行效果:

$ ./target/debug/parse_cli_args --outfile out.txt in.txt
Opts { infile: "in.txt", outfile: Some("out.txt") }

$ ./target/debug/parse_cli_args in.txt
Opts { infile: "in.txt", outfile: None }

以上就是使用 StructOpt 的全部入门内容!当然,这里没有涉及解析命令行参数的更多选项和设置。