枚举
目前, 我们的程序通过匹配 args[1] 实现 create 与 list 两个命令:
match args[1].as_str() {
"create" => { ... }
"list" => { ... }
_ => println!("unknown command"),
}
虽然简单直观, 但这种基于字符串的匹配存在如下问题:
- 命令数量增加后,
match分支会变得冗长。 - 容易因拼写错误而出错, 缺乏类型保障。
- 命令的参数结构难以统一组织和扩展。
- 无法自动生成 --help 等提示信息。
为解决此问题, 我们将结合 Rust 的枚举和引入第三方库 clap 来构建维护性和扩展性更强的 CLI 程序。
clap 是一个强大的 Rust 库, 用于解析命令行参数。它支持自动生成命令行参数的帮助信息, 并支持丰富的参数类型和校验规则。
在项目根目录执行命令:
cargo add clap --features derive # 增加依赖并启用 derive 功能
为什么使用枚举
枚举, 在各种编程语言中或多或少都有着它的身影。
它的作用是用于表示一组有限的、互斥的可能取值, 例如周一到周日, 性别等。
与其他语言的枚举相比, Rust 的枚举更加灵活和强大:
- 支持每个变体携带不同的数据。
- 可与模式匹配强结合, 做复杂的控制流。
- 可以和
trait、方法一起使用, 实现丰富的抽象设计。
这使得枚举天然适合表示 CLI 的命令结构: 每个命令对应一个枚举变体, 每个变体携带所需参数。
声明枚举
Rust 中的枚举使用 enum 关键字声明。
// src/todo/core.rs
use clap::Subcommand;
#[derive(Debug, Clone, Subcommand)]
pub enum TodoCommand {
/// Create a new todo item
Create,
/// List all todo items
List,
}
以上代码定义了一个名为 TodoCommand 的枚举, 它有两个枚举值, 分别是 Create 和 List。
我们使用了派生宏 #[derive(Debug, Clone, Subcommand)] 为枚举自动实现 Debug、Clone 和 Subcommand 三个特征。
Subcommand 特征告诉 clap 该枚举对应一个子命令。
解析命令行参数
//src/main.rs
use crate::todo::storage::{read_todo_list, save_todo_list};
use clap::Parser;
use todo::core::TodoCommand;
mod todo;
#[derive(Debug, Parser)]
#[command(version, about, long_about = "Todo Cli")]
struct Program {
#[command(subcommand)]
pub command: TodoCommand,
}
fn main() {
let args = Program::parse();
let save_file = "todo.json";
let mut todos = read_todo_list(save_file);
match args.command {
TodoCommand::Create => todo::create::create_todo(&mut todos),
TodoCommand::List => todo::list::list_todo(&todos),
}
save_todo_list(save_file, &todos);
}
以上代码中, 我们定义了一个 Program 结构体, 它有一个字段 command 用于接收子命令。
#[command(version, about, long_about = "Todo Cli")] 告诉 clap 自动生成 --version 和 --help 两个参数。
#[command(subcommand)] 告诉 clap 该字段对应一个子命令。
运行 cargo run -- --help 可以看到自动生成的帮助信息:
Todo Cli
Usage: cli.exe <COMMAND>
Commands:
create Create a new todo item
list List all todo items
help Print this message or the help of the given subcommand(s)
Options:
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
Rust 注释
在上面的例子中, 我们给 TodoCommand 的每个枚举项添加了文档注释。
但是当我们运行 --help 时, 注释内容却自动出现在帮助信息中。
这可能让人疑惑: 我们只是加了一些注释, 为什么这些注释会出现在运行时输出的帮助信息里?
这是因为在 Rust 中的注释有三种形式。
// 单行注释(不会被编译器解析)
// 在 // 的所有内容都会被注释
/*
多行注释
只有在 /* */ 范围内的内容才会被注释
(也不会被编译器解析)
*/
/// 文档注释(会被编译器和工具识别)
/**
* 这也是文档注释
* 以 /** 开头
*/
我们用到的就是 /// xxx 即文档注释。它是编译器可识别的元信息。
文档注释的内容会被编译器和第三方工具解析为注释对象的文档说明。
clap 通过它的派生宏 #[derive(Subcommand)] 来在编译期间获取结构体和枚举的元信息, 其中就有文档注释。
因此, 文档注释的内容会出现在运行时输出的帮助信息里。
枚举变体
Rust 的枚举是支持携带数据的。
改造 TodoCommand。
#[derive(Debug, Clone, Subcommand)]
pub enum TodoCommand {
Create {
#[arg(short, long)]
title: String,
#[arg(short, long)]
content: String,
},
List,
}
我们在 Create 枚举值中增加了两个字段 title 和 content。
分别对应 --title 和 --content 参数。
#[arg(short, long)] 告诉 clap 该字段对应一个参数, 并指定参数的短名称和长名称。
执行 cargo run -- create --help, 可以看到自动生成的帮助信息:
Create a new todo item
Usage: cli.exe create --title <TITLE> --content <CONTENT>
Options:
-t, --title <TITLE>
-c, --content <CONTENT>
-h, --help Print help
修改 create_todo 方法。
pub fn create_todo(todos: &mut Vec<TodoItem>, title: String, content: String) {
let mut inputs: Vec<String> = Vec::new();
if !title.is_empty() {
inputs.push(title);
}
if !content.is_empty() {
inputs.push(content);
}
let mut ok = inputs.len() == 0;
// ...
}
模式匹配是相当强大的, 可以将枚举值的字段解构出,
修改 main.rs:
// ...
match args.command {
TodoCommand::Create { title, content } => todo::create::create_todo(&mut todos, title, content),
TodoCommand::List => todo::list::list_todo(&todos),
}
// ...
随后, 我们就可以使用 cargo run -- create --title t --content c 来创建 Todo 而不需要进入交互式界面了。
可选参数
目前, 我们的 create 命令的参数都是必填的。
但是这样我们没法区分到底是使用命令行参数还是交互式界面来创建 Todo 项。
因此我们需要使用可选参数。
Rust 提供了一个 Option<T> 类型的枚举。
pub enum Option<T> {
None,
Some(T),
}
可以看见, 枚举 Option<T> 就有两个枚举值。分别是 Some(T) 和 None, 分别代表有值和无值。
将 TodoCommand 枚举改为如下内容:
#[derive(Debug, Clone, Subcommand)]
pub enum TodoCommand {
/// Create a new todo item
Create {
#[arg(short, long)]
title: Option<String>,
#[arg(short, long)]
content: Option<String>,
},
/// List all todo items
List,
}
随后修改 create_todo 方法。
pub fn create_todo(todos: &mut Vec<TodoItem>, title: Option<String>, content: Option<String>) {
let mut inputs: Vec<String> = Vec::new();
match title {
Some(arg_title) => {
if !arg_title.is_empty() {
inputs.push(arg_title);
}
}
_ => {}
}
match content {
Some(arg_content) => {
if !arg_content.is_empty() {
inputs.push(arg_content);
}
}
_ => {}
}
// ...
改造完毕后, 我们的 create 命令可以不指定参数进入交互式界面创建 Todo, 也可以指定参数直接创建 Todo 了。
泛型
在前面的例子中, 我们使用了 Option<String> 来表示将参数变得可选。
那么, Option<T> 中的 T 是从哪里来的?为什么我们只需要将 T 替换为 String, 就能让参数变得可选?
因为这里的 T 是一个泛型。它并不是某个具体的值, 而是作为一个占位符被用来指代一个将来会具体指定的类型。
Rust 是一门静态类型语言, 拥有强大而灵活的类型系统。 为了保证类型安全, Rust 要求在编译期间就确定所有变量和参数的类型。 这虽然增强了代码的可靠性, 但也带来了一个问题: 我们往往需要为不同的类型编写大量结构相似但类型不同的代码。
例如翻转一个元组, 如果不使用泛型, 将是这样的。
fn reverse_i8_tuple(tuple: (i8, i8)) -> (i8, i8) {
let (a, b) = tuple;
return (b, a);
}
fn reverse_u8_tuple(tuple: (u8, u8)) -> (u8, u8) {
let (a, b) = tuple;
return (b, a);
}
为了解决类型重复的问题, 许多静态类型语言都引入了泛型这一机制, Rust 也不例外。 泛型允许我们编写与具体类型无关的通用代码, 从而在保持类型安全的同时避免重复劳动。
当我们将 String 传入 Option<T>, Option<T> 就变成了 Option<String>。T 从一个广泛的类型收缩为一个明确的 String 类型。
我们需要为每个类型单独实现一个方法。
但如果使用泛型, 在需要泛型的内容后面追加一个 <T> 即可:
fn reverse<T>(args: (T, T)) -> (T, T) {
let (a, b) = args;
return (b, a);
}
于是, 我们就可以使用 reverse 方法来翻转任意类型的元组了。
let a = reverse((1, 2));
let b = reverse(("a", "b"));
需要注意的是, T 并不是固定的名称, 只是约定成俗将泛型参数命名为 T。
只要开发者愿意, 那么将泛型可以是任何符合变量命名规则的名称。
下面的示例展示了如何使用多个自定义命名的泛型参数:
fn reverse<Rust_1, Rust_2>(args: (Rust_1, Rust_2)) -> (Rust_2, Rust_1) {
let (a, b) = args;
return (b, a);
}
if let
在前面的代码中, 我们对可选参数进行了模式匹配。
match title {
Some(arg_title) => {
if !arg_title.is_empty() {
inputs.push(arg_title);
}
}
_ => {}
}
虽然功能正确, 但代码稍显冗长, 尤其当我们只关心某一个具体模式时。
Rust 提供了一个 if let 语法糖, 用来匹配并解构某个特定的枚举变体, 而忽略其他所有可能的枚举值。
于是我们可以将 create_todo 改为这样:
pub fn create_todo(todos: &mut Vec<TodoItem>, title: Option<String>, content: Option<String>) {
let mut inputs: Vec<String> = Vec::new();
if let Some(arg_title) = title {
if !arg_title.is_empty() {
inputs.push(arg_title);
}
}
if let Some(arg_content) = content {
if !arg_content.is_empty() {
inputs.push(arg_content);
}
}
// ...
这段代码的意思是如果 title 可以匹配出 Some(arg_title), 则将 arg_title 解构出并判断是否为空。
而如果无法匹配, 则什么都不做。
可以看见, 相较于之前, 代码简化了不少。