跳转至

2. 结构体与集合篇

对应章节:第5-6章。
本篇掌握结构体封装、方法、Vec 与 HashMap 集合,以及携带数据的枚举与递归。


项目 4:待办事项管理器

目标:用结构体封装数据和方法,实现交互式待办列表。

创建项目

cargo new todo
cd todo

功能要求

命令:add <内容> / remove <编号> / list / quit
  • add:添加待办
  • remove:删除指定编号
  • list:显示全部
  • quit:退出

知识点

知识点 说明
struct 定义结构体
impl 方法 关联函数
&mut self 可变方法(修改数据)
&self 只读方法
Vec<String> 动态数组
.enumerate() 带索引迭代
stdin + match 交互循环
splitn(2, ' ') 最多切 2 段

代码

src/main.rs:

use std::io;

// 结构体:封装"待办列表"这个数据
struct TodoList {
    items: Vec<String>,   // Vec:动态扩容的数组
}

// impl 块:为结构体定义方法
impl TodoList {
    // 关联函数(无 self):构造新实例,约定用 new 命名
    fn new() -> TodoList {
        TodoList { items: Vec::new() }
    }

    // &mut self:修改自身数据需要可变借用
    fn add(&mut self, task: String) {
        self.items.push(task);
        println!("已添加");
    }

    // &mut self:删除也要修改
    fn remove(&mut self, index: usize) {
        // index 是 1-based,转 0-based 需减 1
        // 先做边界检查,否则 remove 越界会 panic
        if index >= 1 && index <= self.items.len() {
            self.items.remove(index - 1);
            println!("已删除");
        } else {
            println!("编号无效");
        }
    }

    // &self:只读,不修改数据
    fn list(&self) {
        if self.items.is_empty() {
            println!("(暂无待办)");
        }
        // enumerate() 产生 (索引, 元素) 对,从 0 开始
        for (i, item) in self.items.iter().enumerate() {
            println!("{}. {}", i + 1, item);   // 显示时 +1 变成 1-based
        }
    }
}

fn main() {
    let mut todos = TodoList::new();
    loop {
        println!("\n请输入命令:");
        let mut input = String::new();
        io::stdin().read_line(&mut input).expect("读取失败");
        let input = input.trim();   // trim 去除换行符

        // splitn(2, ' '):最多切成 2 段,内容里的空格不受影响
        // 例如 "add 买 牛奶" → ["add", "买 牛奶"]
        let parts: Vec<&str> = input.splitn(2, ' ').collect();
        match parts[0] {
            "add" => {
                if parts.len() > 1 {
                    todos.add(parts[1].to_string());
                }
            }
            "remove" => {
                if parts.len() > 1 {
                    if let Ok(idx) = parts[1].parse::<usize>() {
                        todos.remove(idx);
                    }
                }
            }
            "list" => todos.list(),
            "quit" => break,   // 退出循环结束程序
            _ => println!("未知命令"),
        }
    }
}

运行

cargo run

注意事项

  1. splitn(2, ' '):只把输入切成「命令 + 剩余内容」两部分,内容里带空格也不受影响。
  2. &mut self vs &self:add/remove 修改数据用 &mut self;list 只读用 &self。
  3. remove 边界检查:items.remove(i) 越界会 panic,先检查 index >= 1 && index <= len。

项目 5:联系人通讯录

目标:用 HashMap + 结构体存储联系人,支持增删查。

创建项目

cargo new address_book
cd address_book

功能要求

命令:add <姓名> <电话> / find <姓名> / remove <姓名> / list / quit

知识点

知识点 说明
HashMap 键值对集合
struct Contact 联系人结构
Option get 返回 Option
遍历 values() 读取全部值
name.clone() 避免所有权冲突
.is_some() Option 判存在

代码

src/main.rs:

use std::collections::HashMap;
use std::io;

// 单个联系人
struct Contact {
    name: String,
    phone: String,
}

// 通讯录:用 HashMap,键 = 姓名,值 = Contact
struct AddressBook {
    contacts: HashMap<String, Contact>,
}

impl AddressBook {
    fn new() -> AddressBook {
        AddressBook { contacts: HashMap::new() }
    }

    fn add(&mut self, name: String, phone: String) {
        // 关键:name 既要做 key 又要放进 Contact
        // 直接 insert(name, Contact{name, ...}) 会导致 name 被移动两次 → 编译错误
        // 所以 key 用 name.clone()
        self.contacts.insert(name.clone(), Contact { name, phone });
        println!("已添加");
    }

    // get 返回 Option<&Contact>:存在返回 Some(借用),不存在返回 None
    fn find(&self, name: &str) -> Option<&Contact> {
        self.contacts.get(name)
    }

    // remove 返回被删的值(Option),is_some() 判断是否真的删了
    fn remove(&mut self, name: &str) -> bool {
        self.contacts.remove(name).is_some()
    }

    // values() 迭代所有值
    fn list(&self) {
        for c in self.contacts.values() {
            println!("{}: {}", c.name, c.phone);
        }
    }
}

fn main() {
    let mut book = AddressBook::new();
    loop {
        println!("\n请输入命令:");
        let mut input = String::new();
        io::stdin().read_line(&mut input).expect("读取失败");
        // 这里用 split_whitespace(按任意空白切,姓名电话各占一段)
        let parts: Vec<&str> = input.trim().split_whitespace().collect();

        match parts[0] {
            "add" => {
                if parts.len() >= 3 {
                    book.add(parts[1].to_string(), parts[2].to_string());
                }
            }
            "find" => {
                if parts.len() >= 2 {
                    // 用 match 处理 Option,避免索引越界
                    match book.find(parts[1]) {
                        Some(c) => println!("{}: {}", c.name, c.phone),
                        None => println!("未找到:{}", parts[1]),
                    }
                }
            }
            "remove" => {
                if parts.len() >= 2 {
                    if book.remove(parts[1]) {
                        println!("已删除");
                    } else {
                        println!("未找到");
                    }
                }
            }
            "list" => book.list(),
            "quit" => break,
            _ => println!("未知命令"),
        }
    }
}

运行

cargo run

注意事项

  1. insert(name.clone(), Contact{name, ...}):key 和 value 都要用 name,必须先 clone,否则 name 被移进结构体后 key 用不了。
  2. get 返回 Option<&Contact>:用 match 处理存在/不存在,避免索引越界。
  3. remove(...).is_some():HashMap 的 remove 返回被删的值(Option),用 is_some() 判断是否真的删掉了。

项目 6:表达式求值器

目标:支持 + - * /、括号、优先级和一元负号的表达式求值,核心是"携带数据的枚举 + 递归下降解析"。

创建项目

cargo new calculator
cd calculator

💡 calculator——项目2的进阶版,与项目2同名。

功能要求

cargo run -- "1 + 2 * 3"       # 7(优先级正确)
cargo run -- "(1 + 2) * 3"     # 9(括号改变优先级)
  • 支持 + - * / 与小数
  • 支持括号嵌套改变优先级
  • 支持一元负号(如 -3)
  • 先打印词法切分结果,再输出计算值

知识点

知识点 说明
Number(f64) 变体存储数值 携带数据的枚举
#[derive(Debug, Clone, Copy)] 自动派生常用 trait
match 分发 按 Token 类型分支处理
递归函数 括号内是完整表达式 → 递归求解
&mut usize 多个函数共享"当前解析位置"
peekable() / peek() 预读下一个字符但不消费
'0'..='9' 模式 字符范围匹配
递归下降解析 三层函数对应三层优先级

代码

src/main.rs:

use std::env;

// 词法单元(Token):表达式被切分后的最小组成部分
// Number 携带数据(数值本身),其余变体表示运算符/括号
// derive 自动实现:Debug(打印)、Clone/Copy(按值复制,匹配时不移动)
#[derive(Debug, Clone, Copy)]
enum Token {
    Number(f64),
    Plus,
    Minus,
    Star,
    Slash,
    LParen,
    RParen,
}

// 第一步:词法分析——把字符串切成 Token 序列
// "1 + 2*3" → [Number(1.0), Plus, Number(2.0), Star, Number(3.0)]
fn tokenize(s: &str) -> Vec<Token> {
    let mut tokens = Vec::new();
    // peekable:给迭代器装上"预读"能力,peek() 看下一个字符但不消费
    let mut iter = s.chars().peekable();
    while let Some(&c) = iter.peek() {
        match c {
            ' ' => {
                iter.next();   // 跳过空格
            }
            '+' => {
                tokens.push(Token::Plus);
                iter.next();
            }
            '-' => {
                tokens.push(Token::Minus);
                iter.next();
            }
            '*' => {
                tokens.push(Token::Star);
                iter.next();
            }
            '/' => {
                tokens.push(Token::Slash);
                iter.next();
            }
            '(' => {
                tokens.push(Token::LParen);
                iter.next();
            }
            ')' => {
                tokens.push(Token::RParen);
                iter.next();
            }
            // 数字或小数点:连续读取组成完整数字(如 12.5)
            '0'..='9' | '.' => {
                let mut num = String::new();
                while let Some(&c) = iter.peek() {
                    if c.is_ascii_digit() || c == '.' {
                        num.push(c);
                        iter.next();
                    } else {
                        break;
                    }
                }
                tokens.push(Token::Number(num.parse().unwrap()));
            }
            _ => panic!("无法识别的字符:{}", c),
        }
    }
    tokens
}

// 第二步:语法分析(递归下降)——三层函数对应三层优先级
// parse_expr 处理 + -(最低优先级,最后结合)
fn parse_expr(tokens: &[Token], pos: &mut usize) -> f64 {
    let mut value = parse_term(tokens, pos);

    while *pos < tokens.len() {
        match tokens[*pos] {
            Token::Plus => {
                *pos += 1;
                value += parse_term(tokens, pos);
            }
            Token::Minus => {
                *pos += 1;
                value -= parse_term(tokens, pos);
            }
            _ => break,   // 不是 +/-,交回上层处理
        }
    }
    value
}

// parse_term 处理 * /(中优先级,比 +/- 结合更紧)
fn parse_term(tokens: &[Token], pos: &mut usize) -> f64 {
    let mut value = parse_factor(tokens, pos);

    while *pos < tokens.len() {
        match tokens[*pos] {
            Token::Star => {
                *pos += 1;
                value *= parse_factor(tokens, pos);
            }
            Token::Slash => {
                *pos += 1;
                let divisor = parse_factor(tokens, pos);
                if divisor == 0.0 {
                    panic!("除数不能为 0");
                }
                value /= divisor;
            }
            _ => break,
        }
    }
    value
}

// parse_factor 处理数字 / 括号 / 一元负号(最高优先级)
fn parse_factor(tokens: &[Token], pos: &mut usize) -> f64 {
    match tokens[*pos] {
        Token::Number(n) => {
            *pos += 1;
            n
        }
        Token::LParen => {
            *pos += 1;   // 吃掉 '('
            // 括号里是一个完整表达式 → 递归调用 parse_expr
            let value = parse_expr(tokens, pos);
            match tokens.get(*pos) {
                Some(Token::RParen) => {
                    *pos += 1;   // 吃掉 ')'
                    value
                }
                _ => panic!("语法错误:预期右括号"),
            }
        }
        Token::Minus => {
            // 一元负号:如 -3、-(1+2),取负后面的因子
            *pos += 1;
            let value = parse_factor(tokens, pos);
            -value
        }
        _ => panic!("语法错误:预期数字"),
    }
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let expr = &args[1];

    // 先词法分析,打印切分结果便于观察
    let tokens = tokenize(expr);
    println!("{:?}", tokens);

    // 再语法分析求值;pos 是所有 parse_* 函数共享的"游标"
    let mut pos = 0;
    let result = parse_expr(&tokens, &mut pos);
    println!("{}", result);
}

运行

cargo run -- "1 + 2 * 3"
# [Number(1.0), Plus, Number(2.0), Star, Number(3.0)]
# 7
cargo run -- "(1 + 2) * 3"      # 9
cargo run -- "-3 + 5"           # 2
cargo run -- "10 / (2 + 3)"     # 2
cargo run -- "1 / 0"            # panic:除数不能为 0

⚠️ 表达式必须加引号:否则 shell 会把 * 展开成文件名、拆散括号。

注意事项

  1. 三层函数 = 三层优先级:parse_expr(+ -)调用 parse_term(* /),后者再调用 parse_factor(数字/括号/负号)。低优先级层在高优先级层之上,乘法自然比加法先结合。
  2. &mut usize 共享游标:所有 parse_* 函数操作同一个位置索引,*pos += 1 消费 token——这是多个函数协作修改同一状态的经典写法。
  3. Copy 让匹配不移动:match tokens[*pos] 按值取出 Token;若没有 #[derive(Copy)],Number(f64) 里的 f64 虽可 Copy,但枚举含非 Copy 字段时就会报移动错误。加上 derive 后整个 Token 可按位复制。
  4. 括号 → 递归:parse_factor 遇到 ( 就递归调用 parse_expr——嵌套表达式天然适合递归结构。
  5. 错误处理是 panic:非法输入直接崩溃。学完第9章后,可回头把这些 panic! 改造成 Result<f64, ParseError>——很好的复习练习。
  6. 改进练习:不给参数直接运行会因 args[1] 越界而 panic。参考项目2的做法,自己加上参数检查与用法提示。

评论