外观
第 3 章 童话字数统计
小螃蟹说
前面两章,我们做了两个玩游戏的小程序。这一章我们要做一件"正经事":当一个小图书管理员!
我们把一篇童话故事写进电脑,让 Rust 帮我们数一数:这篇故事有多少字?多少行?多少句话?最长的一句话有多长?小螃蟹在里面出现了几次?
不过我要提前告诉你:这一章会碰到 Rust 最特别、别的语言都没有的知识——所有权(ownership)。它是 Rust 的灵魂,也是很多初学者第一次"撞墙"的地方。别怕,我准备了一大堆比喻,保证让你撞墙之前先看到墙上的门。
好消息是:这一章我们不用请任何外部帮手!读文件是标准库(std)自带的能力。第 1 章我们请过 rand,第 2 章又请了一次——这一章我们自己动手,丰衣足食。
3.1 预览:我们要做什么
- 在项目里放一个文本文件,里面是一篇小故事
- 程序读入故事,统计各种数字
- 打印一张"故事体检报告"
这一章我们统计的是我自己的一篇小传记——《小螃蟹的生日》。你也可以换成任何你喜欢的童话文本(比如《小红帽》),程序照常工作。
运行结果
text
== 童话小统计 ==
总字数(含标点):184
行数:7
句子数:9
最长的一句话:36 个字
小螃蟹出现的次数:3运行结果仅供参考
你换成别的故事,数字当然不一样。但程序本身是"通用"的——给它任何文本文件,它都能数。这就是写程序的乐趣:做一件通用的事,永远不吃亏。
这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
| 文件读取 | 让程序读电脑里的文件 | fs::read_to_string 读进故事 |
| 所有权 | 每个值只有一个主人 | 故事 String 交给函数会发生什么 |
| 移动 | 把值送人,主人换人 | 传参时悄悄发生,还会报错! |
| 借用 | 借来看看,不拿走 | &story,让函数"看"而不是"拿" |
| 切片 | 一段连续的"看" | &str,函数的文字参数 |
for 循环 | 一个一个地拿 | 遍历每一句话,找出最长句 |
3.2 动手做
步骤一:新建项目,放好故事
老规矩,建项目:
bash
cargo new story_stats
cd story_stats这次项目里除了代码,还要有一个故事文件。在 VS Code 里,对着左侧的 story_stats 文件夹点右键 → 新建文件,取名叫:
text
童话.txt把下面这篇小故事整个粘贴进去:
text
今天是小螃蟹 Ferris 的生日。
它早早起了床,穿上最喜欢的红色新衣裳。
朋友们都来啦!小乌龟带来一篮海藻,小海马带来一串亮晶晶的泡泡,寄居蟹带来一颗漂亮的贝壳。
大家一起唱生日歌,小螃蟹开心得钳子都合不拢了。
许愿的时候,小螃蟹悄悄说:"希望明年还能和大家一起过生日!"
吃完蛋糕,大家在海边玩了一下午。海水拍打着沙滩,笑声传得好远好远。
这真是最棒的一天!注意!中文不乱码的秘密
文件保存时要用 UTF-8 编码。看 VS Code 右下角,如果写着 UTF-8 就没事;如果写着别的(比如 GBK),点它,选择"通过编码保存",改成 UTF-8。
Windows 自带的记事本保存时,在"编码"一栏也要选 UTF-8。不然程序读出来全是乱码,故事就变成天书啦。
步骤二:把故事读进电脑
打开 src/main.rs,替换成下面这样:
rust
use std::fs;
fn main() {
let story = fs::read_to_string("童话.txt").expect("找不到 童话.txt 文件");
println!("{}", story);
}运行:
运行结果
text
今天是小螃蟹 Ferris 的生日。
它早早起了床,穿上最喜欢的红色新衣裳。
朋友们都来啦!小乌龟带来一篮海藻,小海马带来一串亮晶晶的泡泡,寄居蟹带来一颗漂亮的贝壳。
大家一起唱生日歌,小螃蟹开心得钳子都合不拢了。
许愿的时候,小螃蟹悄悄说:"希望明年还能和大家一起过生日!"
吃完蛋糕,大家在海边玩了一下午。海水拍打着沙滩,笑声传得好远好远。
这真是最棒的一天!故事进来了!这一行代码有两个新朋友:
use std::fs; —— fs 是"文件系统"(filesystem)的缩写,它是 std 里专门管文件的部门。和 io 一样,要用 use 请进门。
fs::read_to_string("童话.txt") —— "把 童话.txt 这个文件,整个读成一个 String"。注意它的结果装在一个 Result 信封里(第 2 章的老朋友),所以后面跟着 .expect(...):Ok 就拆出故事,Err 就大声说"找不到文件"。如果读不到文件,检查一下:文件名是不是一模一样?文件是不是放在项目根目录(和 Cargo.toml 同一个文件夹)?
步骤三:数一数总字数
图书管理员的第一项工作:数一数这篇故事有多少个字。
把 main.rs 改成:
rust
use std::fs;
fn main() {
let story = fs::read_to_string("童话.txt").expect("找不到 童话.txt 文件");
println!("总字数(含标点):{}", count_chars(story));
}
fn count_chars(text: String) -> usize {
text.chars().count()
}运行:
运行结果
text
总字数(含标点):184认识两个新朋友:
chars() 是"一个一个地数文字"。中文一个字算一个,英文一个字母算一个。.count() 是数一共有几个。所以 text.chars().count() 念起来就是:"把文字一个一个排好队,然后数一数有多少个。"
usize 是"计数专用"的数字类型。它的名字是 "unsigned size" 的缩写——"没有负号的尺寸"。字数的统计结果、数组的长度,都用它。第 1 章的 u32 是它的亲戚,现在记住:看到"数几个东西"的答案,基本都是 usize。
步骤四:糟糕!故事被端走了
图书管理员还想数数故事有多少行。加一个函数和一行打印:
rust
use std::fs;
fn main() {
let story = fs::read_to_string("童话.txt").expect("找不到 童话.txt 文件");
println!("总字数(含标点):{}", count_chars(story));
println!("行数:{}", count_lines(story));
}
fn count_chars(text: String) -> usize {
text.chars().count()
}
fn count_lines(text: String) -> usize {
text.lines().count()
}lines() 和 chars() 差不多,不过它把文字按换行排队,数出来就是行数。看起来天衣无缝,对不对?运行一下:
运行结果
text
error[E0382]: use of moved value: `story`
--> src\main.rs:7:35
|
4 | let story = fs::read_to_string("童话.txt").expect("找不到 童话.txt 文件");
| ----- move occurs because `story` has type `String`,
| which does not implement the `Copy` trait
6 | println!("总字数(含标点):{}", count_chars(story));
| ----- value moved here
7 | println!("行数:{}", count_lines(story));
| ^^^^^ value used here after move(你的行号可能略有不同,没关系,看关键的话就行。)
编译失败了!而且失败得很奇怪:明明第二行 count_chars(story) 已经成功跑过,怎么第三行就"value moved here"了?
这就是所有权在起作用了。让我用一个比喻讲清楚:
想象
story是一个大箱子,里面装着整本故事书。count_chars是个厨师,它把箱子整个端进了厨房——不是看一眼就还回来,而是整个拿走!端走之后,外面的
story手里就空了。这时候你还想把箱子递给第二个厨师count_lines,可是箱子已经不在了!于是编译器大喊:"use of moved value——你拿一个已经搬走的东西!"
这个"把箱子整个搬走"的动作,就叫移动(move)。传参给函数,箱子就会移动:箱子原来的主人变成函数里的 text,外面的 story 两手空空。
那为什么之前猜数字游戏里,数字传进函数没事?因为我们还要教一个新的词:复印件。
数字、字符、布尔值这些东西,都自带复印机(编译器里管它叫 Copy):传参时"咔"地复印一份,原件留在外面,复印件送进函数。两个人都有一份,谁也不缺。
而 String 装着可能很长很长的文字,复印整本故事书太贵了,所以它没有复印机,只能搬原件。这就是报错里那句话的意思:String, which does not implement the Copy trait——"String 没有实现复印功能"。
小贴士
这个报错是 Rust 新手最容易撞的墙,恭喜你,你撞到了——而且没受伤!现在你已经知道墙后面是怎么回事了。
步骤五:借来看看
问题来了:我们只是想让函数看一眼故事,不想把它搬走。怎么办?
答案就在第 1 章那个我们一直没细说的符号里:&(和)。
& 的意思是"借来看看":把箱子的位置告诉厨师,厨师隔着窗户看一眼内容,箱子还在外面,谁也没搬走谁。
改三处,就修好了:
rust
fn main() {
let story = fs::read_to_string("童话.txt").expect("找不到 童话.txt 文件");
println!("总字数(含标点):{}", count_chars(&story));
println!("行数:{}", count_lines(&story));
}
fn count_chars(text: &str) -> usize {
text.chars().count()
}
fn count_lines(text: &str) -> usize {
text.lines().count()
}改动有三处,别漏:
- 两个调用处:
count_chars(story)→count_chars(&story) - 两个函数签名:
text: String→text: &str
&str 是"文字的切片"——& 是借,str 是文字,合起来就是"借来看的一段文字"。它是第 1 章见过的老朋友("石头" 字面量就是 &str),今天终于正式认识它的名字了。
现在运行:
运行结果
text
总字数(含标点):184
行数:7成功!而且注意一个妙处:给 &str 的函数传 &story(借 String),完全没问题——Rust 会自动把"借来的 String"当成"借来的文字"。以后你写统计类函数,参数用 &str,谁来借都欢迎。
小贴士
记住口诀:String 是整本故事书,&str 是"借来看书"。 函数只需要"看"的时候,就借(&str);函数真的要"拿走、改、保存"的时候,才要整本(String)。
步骤六:句子数和最长的一句话
图书管理员的工作继续。加两个新统计:
rust
use std::fs;
fn main() {
let story = fs::read_to_string("童话.txt").expect("找不到 童话.txt 文件");
println!("总字数(含标点):{}", count_chars(&story));
println!("行数:{}", count_lines(&story));
println!("句子数:{}", count_sentences(&story));
println!("最长的一句话:{} 个字", longest_sentence(&story));
}
fn count_chars(text: &str) -> usize {
text.chars().count()
}
fn count_lines(text: &str) -> usize {
text.lines().count()
}
fn count_sentences(text: &str) -> usize {
text.split(['。', '!', '?', '!', '?']).count().saturating_sub(1)
}
fn longest_sentence(text: &str) -> usize {
let mut longest = 0;
for sentence in text.split(['。', '!', '?', '!', '?']) {
let length = sentence.chars().count();
if length > longest {
longest = length;
}
}
longest
}运行:
运行结果
text
总字数(含标点):184
行数:7
句子数:9
最长的一句话:36 个字split(['。', '!', '?', '!', '?']) 是"切菜":按句号、感叹号、问号,把故事切成一段一段,每段是一句话。这个函数告诉我们故事有 9 句话,最长的一句有 36 个字。
longest_sentence 里藏着一个今天的新朋友——for 循环:
rust
for sentence in text.split(['。', '!', '?', '!', '?']) {
// 每一圈,sentence 就是一句话
}for 念"对于":对于切出来的每一句话,都做一遍大括号里的事。它像一个传送带,一句话一句话地送过来。
找最长句的算法,是一场"擂台赛":
- 先立一个擂主:
let mut longest = 0; - 每来一句话,数它的字数(
length) - 如果
length比擂主大,就换擂主 - 全部比完,
longest就是全场冠军
小贴士
count_sentences 里有个小细节:切出来的段数比句子数多 1(故事最后还有个"空尾巴"),所以要减一。我们用了 .saturating_sub(1),它的意思是"减一,但绝不减成负数"——和普通的 - 1 不一样,它更保险。以后看到 saturating 开头的词,都是"加了保险"的意思。
步骤七:小螃蟹出现几次
最后一问:故事里"小螃蟹"出现了几次?加一个统计:
rust
println!("小螃蟹出现的次数:{}", count_word(&story, "小螃蟹"));再在文件末尾加这个函数:
rust
fn count_word(text: &str, word: &str) -> usize {
text.matches(word).count()
}matches 是"找相同":把故事从头到尾扫一遍,找到几个和 word 一模一样的地方,就数出几。运行:
运行结果
text
== 童话小统计 ==
总字数(含标点):184
行数:7
句子数:9
最长的一句话:36 个字
小螃蟹出现的次数:3(等等,报告最上面的 == 童话小统计 == 是哪来的?对了,你还要在 main 的第一行打印加一个标题:println!("== 童话小统计 ==");。报告当然要有抬头!)
故事体检报告出炉了!去把 童话.txt 换成《小红帽》,或者你自己写的故事,看看它们的报告长什么样吧。
3.3 知识深挖
这一节是全书最重要的一节。所有权是 Rust 的灵魂,值得好好认识。
3.3.1 栈和堆:小格子和货架
电脑的内存(记忆),可以想成一个大仓库。仓库里有两片区域:
栈(stack)是一排小格子储物柜,规矩特别严:先放进去的,后拿出来。就像叠盘子:你叠在最上面的盘子,总是最先被拿走。放小件的东西——数字、字符、布尔值,全都住这里。又快又整齐。
堆(heap)是大货架,想放哪儿放哪儿,没有先来后到的规矩。住大件的东西——String 的文字内容就住在堆上,因为文字可长可短,小格子放不下。
那 String 这个变量本身住在哪儿?它是个"小名片",上面记着三件事:内容在堆的哪个货架、内容有多长、还有多少空位能继续写。名片很小,住在栈上。
为什么我们要关心这些?因为所有权管的就是堆上的货架:值住进堆里,总得有人负责"用完了放回原位"。负责的人,就是它的主人。
3.3.2 所有权:每个玩具只有一个主人
所有权(ownership)是 Rust 管理内存的一套规则。它的核心就三条:
- 每一个值,都有一个主人(owner,通常是一个变量)
- 同一时间,一个值只有一个主人
- 主人离开作用域,值就被清理(放回原位)
第三条最妙。还记得作用域吗?一对大括号就是一个天地:
rust
fn main() {
let story = String::new(); // story 出生,成为主人
// ……中间随便用……(出了大括号,故事就没了)
} // story 离开作用域,故事被清理,货架腾出来了不用你手动收拾,也不用垃圾回收器到处巡逻——主人一走,值自动消失。这就是 Rust 的魔法:内存安全,不靠运气,靠规则。
3.3.3 移动:把玩具送出去
值的"主人"不是固定的。把值交给别人(传参、赋值),主人就换了——这叫移动(move)。
rust
let story = fs::read_to_string("童话.txt")?;
count_chars(story); // story 的主人变成函数里的 text
println!("{}", story); // 报错!story 手里已经空了移动之后,旧主人手里是空的,再用就会报错。这是 Rust 故意的:一个人手里只有一个玩具,就不会出现"两个人都以为自己在管它"的混乱。
但注意,不是所有东西都会移动。带复印机(Copy)的类型是例外:
rust
let number = 42;
print_number(number); // 复印件送进函数
println!("{}", number); // 没事!原件还在数字、字符、布尔值都是复印件一族。判断标准很简单:大小固定、一秒钟就能复制的东西,都带复印机;String 这种"可能很大"的,只能搬原件。
想复制 String?有办法,.clone():
rust
count_chars(story.clone()); // 复印一本,送复印件进函数
println!("{}", story); // 原件还在不过复印一本故事书可不便宜,能借就借,别老复印。
3.3.4 借用:借来看看
借用(borrowing)就是 & 符号:把"位置"告诉别人,让别人看,自己不撒手。
rust
let story = fs::read_to_string("童话.txt")?;
count_chars(&story); // 借!看完还回来
println!("{}", story); // 故事还在!借来的东西默认只能看,不能改。想改?要借"可变钥匙" &mut:
rust
fn add_exclamation(text: &mut String) {
text.push('!'); // push 是"往文字末尾加一个字"
}借用有两条铁规矩,违反任何一条,编译都过不去:
- 同一时间,只能有一把可变钥匙(一个
&mut) - 有不可变钥匙在的时候,可变钥匙不能出现(有了
&,就不能有&mut)
用玩具来想:一个玩具,要么一个人拿在手里玩(&mut),要么好几个人一起围着看(多个 &)——但绝不能"有人正在改着玩,还有人凑在边上看"。看一眼还好,万一正改着的时候别人看到了半个修改结果,就会出错。
这条规矩让 Rust 在编译时就消灭了一整类 bug(数据竞争)。别的语言要靠运气和仔细,我们在编译器这里就免费拿到了。
3.3.5 切片:故事里的一段
&str 的全名叫字符串切片(string slice)。切片是什么?是从一个长长的文字里,"借"出连续的一段来看:
rust
let story = String::from("小螃蟹的生日");
let slice = &story[0..3]; // 借前三个字:"小螃蟹"&story[0..3] 里的 0..3 又是半开区间:从第 0 个字符开始,到第 3 个字符之前。slice 自己不拥有文字,它只是一个"视角"——主人(story)还在,文字还在货架上。
发现了吗?我们整个第三章的函数,参数都是 &str。它有两个好处:
- 不搬家:借来看,不动所有权
- 来者不拒:不管是
&String还是直接写"石头",都能变成&str
其实 "石头"、"小螃蟹" 这种字符串字面量本身就是 &str——它们从第 1 章就在我们身边,今天终于知道全名了!
除了字符串,别的也能切。数组可以切出 &[u32],向量可以切出 &[T]……规则都是一样的:"借一段来看"。第 8 章我们遇到集合时,还会再见。
3.3.6 第一次见面:for 循环
for 是"一个一个地拿":
rust
for sentence in text.split(['。', '!', '?']) {
println!("{}", sentence);
}它和 loop 不一样:loop 是"转圈转到你喊停",for 是"从一堆东西里,一件一件拿,拿完就停"。
for 拿东西的对象,叫迭代器(iterator)。你可以把它想成一个小窗口:排队的东西(句子、字符、行……)一件一件地从窗口递出来,递完就关窗。split、chars、lines 这些方法,返回的都是迭代器——它们自己不动手,等着 for 来取,或者等 .count() 来数。
迭代器是个大宝藏,能做的比 for 多得多:筛选、加工、累加……第 11 章我们会专门学它,现在先混个脸熟。
3.4 动脑筋练习
这一章的练习,都和"故事体检报告"有关。
练习一:最短的一句话
我们找到了最长的一句话,再来找最短的。写一个 shortest_sentence 函数,和 longest_sentence 长得像,但方向相反。
提示:有两个坑——擂主的初始值要想好(最短的擂台,擂主一开始应该有多大?),还有空句子("")不能参赛。
点开看答案
rust
fn shortest_sentence(text: &str) -> usize {
let mut shortest = usize::MAX;
for sentence in text.split(['。', '!', '?', '!', '?']) {
let length = sentence.chars().count();
if length > 0 && length < shortest {
shortest = length;
}
}
shortest
}两个坑的答案:
- 擂主初始值:找最短,擂主一开始要是"无穷大",谁来都比它短。
usize::MAX就是 usize 能装的最大数,相当于"无穷大"。 - 空句子:故事结尾有个空尾巴,它是
"",一个字都没有。length > 0把它挡在门外——否则最短的就是它,可它根本不算一句话。
main 里记得加一行打印,换成你的故事,看看最短的一句话有几个字。
练习二:再数一个词
count_word 是个通用函数。现在报告里加一行:"开心出现的次数"。只改 main 函数就行,一个函数都不用写。
点开看答案
在 main 里,小螃蟹出现的次数 那一行的下面加:
rust
println!("开心出现的次数:{}", count_word(&story, "开心"));运行,应该能看到"开心出现的次数:1"。《小螃蟹的生日》里,"开心"只出现了一次——就是我"开心得钳子都合不拢"那一次!
试着把"开心"换成别的词:"生日"、"小海马"、"蛋糕",看看它们分别出现几次。
练习三:数一数"生日"出现在几行
这次不是数词,而是数行:看看故事里有几行提到了"生日"。
提示:需要一个新的函数,用 for 遍历 text.lines(),用 line.contains(word) 判断这一行里有没有这个词。contains 是"包含"的意思——文字里有这个词就返回真。
点开看答案
rust
fn count_lines_with_word(text: &str, word: &str) -> usize {
let mut count = 0;
for line in text.lines() {
if line.contains(word) {
count += 1;
}
}
count
}main 里加:
rust
println!("提到生日的行数:{}", count_lines_with_word(&story, "生日"));运行,应该看到"提到生日的行数:3":第 1 行(标题)、第 4 行("唱生日歌"——这里也有生日!)、第 5 行(许愿)。
仔细数一遍会发现,第 3 行和第 7 行都没有"生日"这两个字。这种"我觉得好像有"和"程序告诉我"之间的差别,正是我们写程序统计的原因——电脑永远不会数错。
3.5 完整代码清单
项目结构:
text
story_stats/
├── Cargo.toml
├── 童话.txt
└── src/
└── main.rs文件:Cargo.toml
toml
[package]
name = "story_stats"
version = "0.1.0"
edition = "2024"注意到没有?
这一章的 Cargo.toml 里没有 [dependencies]——我们一个外部帮手都没请。标准库,包打天下。
文件:童话.txt
text
今天是小螃蟹 Ferris 的生日。
它早早起了床,穿上最喜欢的红色新衣裳。
朋友们都来啦!小乌龟带来一篮海藻,小海马带来一串亮晶晶的泡泡,寄居蟹带来一颗漂亮的贝壳。
大家一起唱生日歌,小螃蟹开心得钳子都合不拢了。
许愿的时候,小螃蟹悄悄说:"希望明年还能和大家一起过生日!"
吃完蛋糕,大家在海边玩了一下午。海水拍打着沙滩,笑声传得好远好远。
这真是最棒的一天!文件:src/main.rs
rust
use std::fs;
fn main() {
let story = fs::read_to_string("童话.txt").expect("找不到 童话.txt 文件");
println!("== 童话小统计 ==");
println!("总字数(含标点):{}", count_chars(&story));
println!("行数:{}", count_lines(&story));
println!("句子数:{}", count_sentences(&story));
println!("最长的一句话:{} 个字", longest_sentence(&story));
println!("小螃蟹出现的次数:{}", count_word(&story, "小螃蟹"));
}
fn count_chars(text: &str) -> usize {
text.chars().count()
}
fn count_lines(text: &str) -> usize {
text.lines().count()
}
fn count_sentences(text: &str) -> usize {
text.split(['。', '!', '?', '!', '?']).count().saturating_sub(1)
}
fn longest_sentence(text: &str) -> usize {
let mut longest = 0;
for sentence in text.split(['。', '!', '?', '!', '?']) {
let length = sentence.chars().count();
if length > longest {
longest = length;
}
}
longest
}
fn count_word(text: &str, word: &str) -> usize {
text.matches(word).count()
}怎么运行:
bash
cd story_stats
cargo run运行检查单:
能打印出"故事体检报告"(字数、行数、句子数、最长句、小螃蟹次数)
把 童话.txt 的内容改成你自己的故事,报告数字会变
试试把 count_chars(&story) 里的 & 去掉——你还能修好它吗?(复习移动和借用!)
在 main.rs 里故意少写一个函数(比如 count_lines),看看编译器怎么提醒你