跳转至

猜数字游戏:第一个完整的 Rust 程序

这一章做什么: 带你从零写出一个能玩的猜数字游戏——程序想一个 1 到 100 的随机数, 你反复猜,它告诉你大了还是小了,猜中退出。 一路会碰上并解决五个真实问题:怎么读键盘输入、怎么生成随机数、类型对不上怎么编译错误、 怎么让用户猜多次、输错格式怎么不崩溃。读完这章,你提前见过全书一半的核心概念。

1. 顶层全景:一个程序的六次迭代

主线例子就是这个游戏的完整成长史。每一步对应后面某一章的正式内容:

  迭代 1:读入一行输入,原样打印          → let / String / read_line / expect(第 3、9 章)
  迭代 2:加 rand crate,生成随机数        → 外部依赖、版本号、Cargo.lock(第 14 章)
  迭代 3:比较 guess 和 secret_number    → 编译报错!类型不匹配(E0308)(第 3 章)
  迭代 4:trim().parse() 把字符串变数字   → 类型标注、变量遮蔽(第 3 章)
  迭代 5:loop 循环 + break 退出          → 控制流(第 3 章)
  迭代 6:match 处理 parse 的 Err          → 错误恢复,不再崩溃(第 6、9 章)

  图说:六次改动,五次是为了处理"出错的可能"。这就是 Rust 编程的日常节奏。

注意这张图里有两次"故意让它崩"和一次"从崩改成不崩"——这本书从第一个项目起就训练一件事: 错误不是例外路径,是主路径;Rust 强迫你在写代码时就把它们摆上台面(第 9 章专门展开)。

2. 迭代 1:读入并打印一行——六个新概念挤在一行里

第一步的程序体是:

use std::io;

fn main() {
    println!("Guess the number!");
    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

逐行拆,每行后面都挂着后面某一章:

use std::io; 把输入输出库引进来。 标准库(标准库是语言自带的函数库,std 是它的名字)有一小撮最常用的东西是默认引进的,这一小撮叫 prelude——不在 prelude 里的,就得用 use 显式引进来1。 io 不在 prelude 里,所以要写这一行。

let mut guess = String::new(); 声明一个可变变量,绑定到一个新的空字符串。 三个零件:let 声明变量;mut 让它可变——Rust 的变量默认不可变,不加 mut 就改不了,这是第 3 章细讲的设计2。 String::new() 里那个 :: 是"这个类型的关联函数"的意思。

关联函数(associated function)是定义在某个类型上的函数,用 类型::函数名 调用。 String::new 就是"String 这个类型自带的造新值函数"3。很多类型都有 new,这是约定俗成的名字。它和你熟悉的"对象的方法"是近亲,第 5 章会把这层关系讲透。

io::stdin().read_line(&mut guess) 读一行键盘输入,追加进 guess。 这里 &mut guess 的 & 表示引用(reference):把数据递给别人用,但不复制一份。 引用默认也不可变,所以要 &mut——read_line 得往里写内容,只借不给改是不行的4。引用的完整规则是全书的重头(第 4 章),现在只需记住这个形状。

.expect("Failed to read line") 处理"可能失败"。 read_line 除了读输入,还返回一个 Result 值。Result 是一个枚举(enumeration,一种"取值只可能是若干种之一"的类型),它的两个可能取值(variant)是 Ok 和 Err:Ok 表示操作成功、里面装着结果;Err 表示失败、里面装着失败原因5。 expect 是 Result 的方法:拿到 Err 就让程序崩溃并打印你给的这句话;拿到 Ok 就把里面装的值交给你6。

这里有一个不点破就会困惑的设计:不调用 expect,程序照样能编译,但你会收到警告——编译器警告你"这个 Result 没人理,它可能是个 Err"7。原书特意说:压掉这个警告的正确姿势不是忽略它,而是真的写错误处理8。这是 Rust 风格的第一次亮相:编译器盯着你对每个"可能失败"表态。

println!("You guessed: {guess}") 里的 {} 是占位符。 变量名可以塞进花括号;表达式值也行:println!("x = {x} and y + 2 = {}", y + 2) 会打印 x = 5 and y + 2 = 129。

3. 迭代 2:外部 crate 与版本号

Rust 标准库不带随机数。社区在 crates.io(Rust 生态发布开源项目的仓库网站)上维护了一个 rand crate10。用它分两步:

第 ① 步:在 Cargo.toml 里声明依赖。

[dependencies]
rand = "0.8.5"

第 ② 步:cargo build。

输出很能说明 Cargo 的价值——你只写了 rand 一个名字,它却拉进来 15 个包:

$ cargo build
    Updating crates.io index
     Locking 15 packages to latest Rust 1.85.0 compatible versions
      Adding rand v0.8.5 (available: v0.9.0)
   Compiling proc-macro2 v1.0.93
   Compiling libc v0.2.170
   …(中间 11 个都是 rand 的依赖的依赖)…
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s

"0.8.5" 是语义化版本号(semver,一套版本号写法标准),写它的真实意思是 ^0.8.5:任何不低于 0.8.5、又低于 0.9.0 的版本都行——Cargo 认为 0.8.x 系列内部的公开接口是互相兼容的,所以会帮你挑这个范围内最新的补丁版11。

Cargo.lock 保证重现构建(reproducible build)。 第一次 build 时,Cargo 把算出来的确切版本写进 Cargo.lock;以后再 build,它直接照锁文件来,不再重新挑12。书里的例子:下周 rand 出了 0.8.6——哪怕它有 bug,你的项目也停在 0.8.5,直到你主动 cargo update(它只会在 0.8.x 范围内往上挑,不会跳到 0.999.0)13。正因为这个性质,Cargo.lock 通常和代码一起提交进版本库。

随机数生成本身两行:use rand::Rng; 把 Rng 这个 trait 引进作用域——trait 定义了一组方法,随机数发生器都实现它;不引进来,它的方法就不能用14。然后:

let secret_number = rand::thread_rng().gen_range(1..=100);

1..=100 是区间表达式,两端都含(..= 表示包含右端),所以生成的是 1 到 100 的整数15。

4. 迭代 3 与 4:一次类型错误,和它的解法

加上比较逻辑,代码不编译了:

match guess.cmp(&secret_number) {
    Ordering::Less => println!("Too small!"),
    Ordering::Greater => println!("Too big!"),
    Ordering::Equal => println!("You win!"),
}

match 是 Rust 的多路分支:把值依次对每条"臂"(arm)的模式比对,命中哪条走哪条。Ordering 是标准库的枚举,三个值:Less / Greater / Equal——正好是比较的全部三种结果16。

但编译器报错:

error[E0308]: mismatched types
   --> src/main.rs:22:21
    |
22  |     match guess.cmp(&secret_number) {
    |                 --- ^^^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`

为什么错:guess 是 String(文本),secret_number 是数字。字符串和数字没法比大小。 Rust 有强静态类型系统(编译期确定每个值的类型,对不上就拒编),但也有类型推断——let mut guess = String::new() 时它自己推出 guess 是 String,不用你写;而 secret_number 你只给了 1..=100,它在几种数字类型里默认挑 i3217。

解法是转换,而不是抱怨:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

三个动作:trim() 掐掉用户按回车带进来的换行符(输入 5 再回车,实际进字符串的是 5\n 或 Windows 下的 5\r\n);parse() 把字符串解析成数字;: u32 是类型标注,显式告诉 Rust 要哪种数字(u32:无符号 32 位整数)18。

注意第一行的 let guess ——名字已经用过了。Rust 允许遮蔽(shadowing):用一个同名的新变量盖掉旧的,常用于"把值从一种类型换成另一种类型"这类转换19。有了 u32 标注,编译器反推 secret_number 也该是 u32,两边类型对齐,比较成立。

parse 会失败(用户敲 A% 就没法变成数字),所以它同样返回 Result——这里仍用 expect,失败就崩。先把"会崩"接受下来,迭代 6 再收拾它。

5. 迭代 5 与 6:循环、退出、以及"把崩溃改成继续"

loop 是无线循环。 把"提示输入—读入—比较"整段包进去,用户就能猜多次20。赢了的那个分支里加 break,猜中就跳出循环、程序结束。

于是只剩最后一个瑕疵:输非数字时程序直接 panic 崩溃。最后一版把 expect 换成 match:

let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => continue,
};

读法:parse 成功 → 值是 Ok(数字),匹配第一条臂,把数字取出来;失败 → 值是 Err(错误信息),匹配第二条臂。Err(_) 里的下划线是通配符:不管什么错误都算数;那条臂的执行体 continue 表示"跳过本轮,回循环开头再猜"21。

$ cargo run
Guess the number!
Please input your guess.
foo            ← 输入非数字
Please input your guess.   ← 没崩,继续
You guessed: 61
You win!

从 expect(崩)到 match(继续),这一改是全章的落点:错误处理从"祈祷别出错"变成"把每条路都写出来"。

6. 作者的判断与证据

这一章是教学项目章,判断成分少,但有两处可以标:

  • "压掉警告的正确方式是真正写错误处理"——这是 Rust 社区的价值主张,用编译器警告机制作为证据呈现8。
  • 版本号写成 0.8.5 而不是 =0.8.5,取舍是"自动拿到兼容范围内的补丁修复"换"偶尔踩到补丁回归"——书里没有展开这个权衡的另一面(0.8.6 也可能带回归,只能靠 lock 文件挡住),我们在第 5 节边界里补上。

判断(我们的,不是书里的): 这章的编排本身就是一种教学主张——先跑起来,再讲原理。 六个迭代里五个在处理错误和边界,等于在读者还没学理论之前就让他亲身体会 "Rust 把失败路径变成主路径"这门语言的性格。 如果错,会错在: 对完全没有编程经验的人,第一次编译就撞 E0308 类型错误可能劝退; 原书也意识到这点,在引言里建议 meticulous 型读者可以先跳过本章去读第 3 章。

7. 边界与局限

  • 代码版本敏感:rand = "0.8.5"、gen_range(1..=100) 都是该书写就时的 API;rand 0.9 起接口有变化,照抄旧版号最稳。这也正好演示了 semver 的作用。
  • "如何写好错误处理"这里只有 match/continue 一种形态;? 运算符、自定义错误类型等在第 9 章。
  • trait 只在"use 引进来才能用方法"的意义上出现了一次,机制完全留给第 10 章。

8. 可带走的

  1. 变量默认不可变,要改就 let mut。
  2. 类型::new() 是关联函数;名字! 是宏;&mut x 是可变引用。
  3. 可能失败的操作返回 Result(Ok/Err);不理它,编译器警告;expect 是"失败就崩"的偷懒写法。
  4. 标准库没有的东西去 crates.io 找;Cargo.toml 写 名字 = "版本",版本遵循 semver,"0.8.5" 意为 ^0.8.5。
  5. Cargo.lock 锁确切版本、保证重现构建,要进版本库;cargo update 只在兼容范围内升级。
  6. 字符串转数字:trim().parse(),记得类型标注(: u32);转换常用遮蔽复用变量名。
  7. 多路分支用 match,臂 = 模式 + 代码;_ 是通配符。
  8. loop 无限循环,break 跳出,continue 下一轮。

9. 原文地图

主题 原书章 原文位置
prelude 与 use Chapter 2: Programming A Guessing Game text/09-ch02-chapter-2-programming-a-guessing-game.txt:84(搜「called the prelude」)
关联函数与 :: 同上 text/09-ch02-chapter-2-programming-a-guessing-game.txt:137(搜「An associated function is a function」)
Result 与 expect 同上 text/09-ch02-chapter-2-programming-a-guessing-game.txt:184(搜「an enumeration」) · text/09-ch02-chapter-2-programming-a-guessing-game.txt:194(搜「expect method」)
未用 Result 的警告 同上 text/09-ch02-chapter-2-programming-a-guessing-game.txt:206(搜「unused」)
semver 与 ^0.8.5 同上 text/09-ch02-chapter-2-programming-a-guessing-game.txt:291(搜「semantic version specifier」)
Cargo.lock 与重现构建 同上 text/09-ch02-chapter-2-programming-a-guessing-game.txt:356(搜「Reproducible Builds」) · text/09-ch02-chapter-2-programming-a-guessing-game.txt:382(搜「cargo update」)
rand 与 gen_range 同上 text/09-ch02-chapter-2-programming-a-guessing-game.txt:415(搜「gen_range」)
E0308 类型不匹配 同上 text/09-ch02-chapter-2-programming-a-guessing-game.txt:536(搜「mismatched types」)
遮蔽与 parse 同上 text/09-ch02-chapter-2-programming-a-guessing-game.txt:581(搜「helpfully Rust allows us to shadow」)
loop / break / match 处理输入 同上 text/09-ch02-chapter-2-programming-a-guessing-game.txt:639(搜「infinite loop」) · text/09-ch02-chapter-2-programming-a-guessing-game.txt:740(搜「successfully turn the string into a number」)


  1. 出处:「Chapter 2: Programming A Guessing Game」第 84 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:84,搜「called the prelude」)。 ↩

  2. 出处:「Chapter 2: Programming A Guessing Game」第 118 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:118,搜「immutable by default」)。 ↩

  3. 出处:「Chapter 2: Programming A Guessing Game」第 137 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:137,搜「An associated function is a function」)。 ↩

  4. 出处:「Chapter 2: Programming A Guessing Game」第 162 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:162,搜「this argument is a reference」)。 ↩

  5. 出处:「Chapter 2: Programming A Guessing Game」第 184 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:184,搜「an enumeration」)与第 189 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:189,搜「Ok and Err」)。 ↩

  6. 出处:「Chapter 2: Programming A Guessing Game」第 194 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:194,搜「expect method」)。 ↩

  7. 出处:「Chapter 2: Programming A Guessing Game」第 206 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:206,搜「unused」)。警告全文:unused Result that must be used。 ↩

  8. 出处:「Chapter 2: Programming A Guessing Game」第 224 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:224,搜「The right way to suppress the warning」)。 ↩↩

  9. 出处:「Chapter 2: Programming A Guessing Game」第 247 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:247,搜「y + 2 =」)。 ↩

  10. 出处:「Chapter 2: Programming A Guessing Game」第 268 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:268,搜「yet include random number」)与第 332 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:332,搜「Crates.io is where」)。 ↩

  11. 出处:「Chapter 2: Programming A Guessing Game」第 291 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:291,搜「semantic version specifier」)与第 293 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:293,搜「shorthand for」)。 ↩

  12. 出处:「Chapter 2: Programming A Guessing Game」第 357 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:357,搜「rebuild the same artifact」)。 ↩

  13. 出处:「Chapter 2: Programming A Guessing Game」第 382 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:382,搜「cargo update」)。 ↩

  14. 出处:「Chapter 2: Programming A Guessing Game」第 432 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:432,搜「The Rng trait defines methods」)。 ↩

  15. 出处:「Chapter 2: Programming A Guessing Game」第 443 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:443,搜「inclusive on the lower and upper bounds」)。 ↩

  16. 出处:「Chapter 2: Programming A Guessing Game」第 513 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:513,搜「An arm consists of a pattern」)。 ↩

  17. 出处:「Chapter 2: Programming A Guessing Game」第 548 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:548,搜「strong, static type system」)与第 554 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:554,搜「defaults to an i32」)。 ↩

  18. 出处:「Chapter 2: Programming A Guessing Game」第 593 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:593,搜「newline character」)与第 601 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:601,搜「unsigned, 32-bit integer」)。 ↩

  19. 出处:「Chapter 2: Programming A Guessing Game」第 581 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:581,搜「helpfully Rust allows us to shadow」)。 ↩

  20. 出处:「Chapter 2: Programming A Guessing Game」第 639 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:639,搜「infinite loop」)。 ↩

  21. 出处:「Chapter 2: Programming A Guessing Game」第 748 段(text/09-ch02-chapter-2-programming-a-guessing-game.txt:748,搜「catch-all value」)。 ↩