BEGINNING RUST PROGRAMMING
Author: Ric Messier
如果需要电子书的小伙伴,可以留下邮箱,看到了会发送的
Chapter 4 Hangman
虽然我们不会做任何图形界面,但我们仍然可以做一个刽子手游戏的基本内容。最终,刽子手的目标是猜一个单词,一次猜一个字母。
在此过程中,我们将仔细研究为struct
添加trait
,这是我们在前一章中开始探索的。在我们将使用的数据结构中添加trait
可以保持与struct
内部的数据的大量交互,而不是允许程序的其他部分直接访问struct
的内容。这是我们在上一章中讨论的数据封装思想。您将对数据的访问权限保持在最低限度。所有的东西都应该通过一个接口来访问,以维护数据的完整性。这个规则不能在Rust中强制执行,但这并不意味着我们不能在编程实践中尽力遵循这种方法。
然后是这个程序的完整代码,依赖的库需要书中的这个版本,我试着用新的0.8的版本,报错了
[dependencies]
rand = "0.7.2"
extern crate rand;
use std::fs::File;
use std::io::{self, BufRead};
use std::path::Path;
use rand::Rng;
struct Word {
answer: String,
length: usize,
correct_count: usize,
representation: String,
}
trait CheckLetter {
fn check_for_letter(&mut self, c: char) -> bool;
}
trait CheckComplete {
fn check_complete(&self) -> bool;
}
impl CheckComplete for Word {
fn check_complete(&self) -> bool {
self.correct_count == self.length
}
}
impl CheckLetter for Word {
fn check_for_letter(&mut self, c: char) -> bool {
let mut count: usize = 0;
let mut found: bool = false;
let mut response = String::with_capacity(self.length);
let mut index = 0;
for letter in self.answer.chars() {
if letter == c {
found = true;
count += 1;
response.push(c);
} else {
if self.representation.chars().nth(index) != Some('_') {
response.push(self.representation.chars().nth(index).unwrap());
} else {
response.push('_');
}
}
index += 1;
}
if found {
println!("Found a ")
}
self.representation = response;
self.correct_count += count;
count > 0
}
}
fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
where P: AsRef<Path>, {
let file = File::open(filename)?;
Ok(io::BufReader::new(file).lines())
}
fn read_list(filename: String) -> Vec<String> {
let mut v = Vec::<String>::new();
if let Ok(lines) = read_lines(filename) {
for w in lines {
let word: String = w.unwrap();
if word.len() > 4 {
v.push(word);
}
}
}
v
}
fn select_word() -> String {
let mut rng = rand::thread_rng();
let filename: String = "words.txt".to_string();
let words: Vec<String> = read_list(filename);
let word_count = words.len();
let selection = rng.gen_range(1, word_count);
let select: String = words[selection].clone();
select
}
fn main() {
let body = vec!["noose".to_string(), "head".to_string(), "neck".to_string(),
"torso".to_string(), "left arm".to_string(), "right arm".to_string(),
"right leg".to_string(), "left leg".to_string(), "left foot".to_string(),
"right foot".to_string()];
let mut body_iter = body.iter();
let result = select_word();
let mut answer = Word {
length: result.len(),
representation: String::from_utf8(vec![b'_'; result.len()]).unwrap(),
answer: result,
correct_count: 0,
};
let mut letter: char;
let mut body_complete: bool = false;
while !answer.check_complete() && !body_complete {
println!("Provide a letter to guess ");
let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(_) => {
letter = input.chars().nth(0).unwrap();
if answer.check_for_letter(letter) {
println!("There is at least one {}, so the word is {}", letter,
answer.representation);
} else {
let next_part = body_iter.next().unwrap();
println!("Incorrect! You are at {}", next_part);
if next_part == "right foot" {
body_complete = true;
}
}
}
Err(_) => {
println!("Didn't get any input");
}
}
}
if body_complete {
println!("You were unsuccessful at guessing {}", &answer.answer)
} else {
println!("Yes! The word was {}", &answer.answer);
}
}
OUR DATA
让我们先看一下数据结构,解释一下必要的各种数据元素。我们谈论的是我们需要的数据,而不是谈论如何获取这些数据元素。让我们从讨论程序设计开始,而不仅仅是语言的工作机制。当您使用带有大量moving
片段的程序时,从分解单个元素开始就很有用了,包括您需要的所有数据,甚至可能是一个粗略的操作流程。虽然有些程序可以在运行中编写,但另一些则需要预先考虑。你可能想在手边放一个本子,这样你就不会试图把所有的东西都记在你的脑子里
意思就是在真正编写程序之前,需要在脑海中,或者在纸上,预演一下程序的逻辑流程,以及所需要的数据结构是什么样子的,数据会有什么行为,把这些罗列一下,这样到了编写时,会让程序更有条理,写起来思路更清晰,其实写程序就像是写文章一样,需要一个大纲
struct Word {
answer: String,
length: usize,
correct_count: usize,
representation: String
}
开始逐个解析数据结构
- 第一个
answer
是用户需要猜的那个单词 - 第二个
length
是这个单词的长度,虽然可以每次想要的时候通过计算得到,但是没必要 - 第三个
correct_count
是用户猜对了几个字符,相比起对比string,这个只需要追踪猜对几个字符的方式,要更加高效,这就要求我们正确地识别单词中的所有字符,然后准确地跟踪所找到的正确字母的数量。我们稍后再讲一下。 - 第四个
representation
,我们需要能够给用户在正确的位置用猜测的字母进行单词的表示。如果我们不这样做,玩家就很难得到正确的单词。即使知道正确的字母也没有帮助。如果你知道单词r,e,a和g在这个单词中,并且这个单词包含四个字母,你认为正确的单词是什么?你可以猜猜gear,这可能是正确的。你也可以猜到rage,这可能是正确的。在不知道字母的顺序的情况下,你可能不知道作为一个玩家,当你有正确的单词,以及根据适当的字母来猜测什么字母。这个字符串将在所有位置上用_字符进行初始化。当我们找到正确的字符时,我们用正确的字母替换_字符,这样我们就可以打印部分单词,在字母的地方加下划线。这给了玩家一个单词的视觉表示。
我们已经有了目前所需要的数据。除此之外,我们需要的是一组函数,它们将用于直接与数据结构中的数据交互。这些函数被称为trait,它们需要与struct本身分开来定义。
The Traits
我们必须记住,这不是您可能从其他语言中使用的面向对象的意义。我们没有对象,所以我们没有可以使用的初始化器来将所有数据放到正确的位置,包括执行一些计算。例如,如果我们在Java中这样做,我们可以创建类并添加构造函数,在创建word类的构造函数时,这个构造函数允许我们通过将选定的单词传递给构造函数来自动设置所有内容。
我们没有对象,所以我们没有自动生成的构造函数,尽管您可以通过创建一个new() trait 来创建一个对象。稍后,在创建数据结构的实例时,我们将讨论如何初始化数据结构。现在,我们可以放弃构造函数或初始化器。我们将继续讨论我们需要访问的其他功能。首先,我们必须确定我们是否有一个正确的字符
我们必须做的下一件事是确定用户是否已经猜到了这个词。我们需要这个trait,因为所有必要的数据都存储在数据结构中。您可以在这里看到所定义的这两个trait。请记住,一个trait只是一个接口的定义。trait定义中除了需要实现的函数签名之外,没有其他东西。我们有两个trait,需要根据我们的数据结构来实现。没有什么花哨的
trait CheckLetter {
fn check_for_letter(&mut self, c: char) -> bool;
}
trait CheckComplete {
fn check_complete(&self) -> bool;
}
在Rust中,可以很多个trait具有同样的方法,然后还可以为同一个struct实现这些trait,但在使用这些方法的时候,需要指定使用,不然会有歧义
fn main() {
let w = Word;
Double::increment(&w);
Triple::increment(&w);
}
Implementations
impl CheckComplete for Word {
fn check_complete(&self) -> bool {
self.correct_count == self.length
}
}
从之前的一些行为可以看出来,在Rust中,数据和行为是分开的,没有关联的,struct代表数据,trait代表行为,所以实现的位置,也是独立开的,也就是trait只有定义,但是没有实现,实现被隐藏了,调用这个函数,并不需要了解关于实现的任何信息,只需要知道这个函数的入参和出参就可以了
impl CheckLetter for Word {
fn check_for_letter(&mut self, c: char) -> bool {
let mut count: usize = 0;
let mut found: bool = false;
let mut response = String::with_capacity(self.length);
let mut index = 0;
for letter in self.answer.chars() {
if letter == c {
found = true;
count += 1;
response.push(c);
} else {
if self.representation.chars().nth(index) != Some('_') {
response.push(self.representation.chars().nth(index).unwrap());
} else {
response.push('_');
}
}
index += 1;
}
if found {
println!("Found a ")
}
self.representation = response;
self.correct_count += count;
count > 0
}
}
第二个实现,检查字符是不是在单词中,可以看到关于self的入参有些不一样,前面解释过self的含义,它是对调用该函数的实例的标识符,一般用的&
引用,因为只查看这个实例的内容,而不需要这个实例的所有权,然后这个函数中,多了个mut
关键字,它代表可变的意思,也就是可修改内容
然后可以看到书作者的一些编码习惯,他会将所有的变量都在函数的开头声明,然后在后面的逻辑中使用,这样分门别类的方式,强迫症看起来就很舒服
还有个需要注意的是,在Rust中,不可以像其他语言一样直接通过index访问string里面的每一个字符,因为在Rust中,string不仅仅是一个数组,在string中,字符是UTF-8的,就相当于一个字符是一个Unicode码点。
Unicode是一种文本编码标准,开发是因为认识到并非所有字母都可以轻松地用ASCII最初支持的7位表示。扩展的ASCII是8位,但即使这样对于某些字符集来说也是不够的。
由于用于表示字符的位/字节数可能不一致,因此人们认为索引到字符数组会导致潜在的不一致。
所以上面的代码中,使用了chars()方法,它返回了一个关于字符的迭代器。该解决方法的一部分是索引变量,它跟踪我们在字符串中的位置,这样我们就可以从当前表示值中检索正确的字符,并将其放到我们正在创建的新字符串中。同样,由于Rust处理内存管理的方式,我们正在做一个创建和替换,而不是对现有的一个进行调整。
READING FILES AND SELECTING WORDS
在构建完数据结构和相应的行为之后,我们需要随机从一个单词表中抽取一个单词,然后让用户猜
Handling Errors Concisely
异常是指程序中发生了无法处理或无法显式处理的不良情况。例如,库函数运行于一个没有设计用来处理的情况,它没有提供大量额外的代码来考虑任何潜在的响应或情况,函数只是抛出一个异常,并期望调用函数正确处理情况。
书中举了一个Java代码打开文件的例子,演示其他语言如何处理错误:
import java.io.*;
public class openfile {
public static void main(String[] args)
{
try {
File file1 = new File("thisfile.txt");
}
catch (FileNotFoundException e) {
System.out.println("Couldn't find the file");
}
}
}
Java使用了try/catch来处理异常,它会将可能发生错误的代码,放入try块里面,由于与文件系统的交互可能会有很多问题,因此我们可以采取预防措施,并告诉Java注意可能需要捕获一个异常。这是catch块完成的功能。
处理异常的目的是允许程序清除错误并继续使用替代计划,或者优雅地退出
在Rust中,使用的是另外一种方式去处理异常,还是打开文件:
let file = File::open(filename);
let mut file = match file {
Ok(file) => file,
Err(e) => return Err(e),
};
这是Rust中一种比较模板的代码,还有一种更加简洁的写法:
fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
where P: AsRef<Path>, {
let file = File::open(filename)?;
Ok(io::BufReader::new(file).lines())
}
先不关注那一大串的函数签名,首先看到打开文件的语句的结尾多了一个?
,它的效果与上面的match模板是一样的,最终的代码由编译器来完成,这个符号的含义是,有错误就此返回,没错就赋值
所以关于函数的返回值肯定是个Result
,错误的部分有了,那最后的Ok分支就由我们手动封装,我们会返回一个迭代器,里面是文件的每行内容,这个由BufReader来完成,所以新建了一个BufReader,并包装在Ok里面
Generics and Bounds
接着继续看这个函数的签名,会发现以前没见过的部分,首先它是一个泛型函数,所以它入参的类型也是一种参数
然后是函数的返回值,这种时候就和洋葱一样从外层看到内层
最后,看到有个where
关键字,这是用于给类型参数添加限制的,或者说,用来更加具体的描述,这个函数所需要的类型参数的范围,在这个例子中,我们需要的是一个文件的地址,所以不能任由外部传进来一个数字,这不合理,所以我们需要加以限制,以保证入参的合理性。
然后我们调用的时候是传递的String,但是这个函数需要的是一个Path,这不匹配,但是AsRef
这个trait帮助我们将string转换为Path,AsRef是为字符串类型实现的一个trait,它返回一个Path值。
这样可能不好理解,这样解释,现在函数的类型参数的要求是 AsRef<Path>
,然后我们提供的是 String
,又因为String实现了AsRef这个trait,所以,String可以转型为AsRef,这在面向对象里面就是多态
A Vector of Lines
if let Ok(lines) = read_lines(filename) {
...
}
出现一个新的语法,上一小节中,可以看到read_lines之后的结果是一个Result,这个枚举就有两个结果,然后现在是我们只关心它的正确的结果,对于错误忽略,当然我们可以用match语法去处理,但是往往我们对err的结果都是忽略,所以就有了这个语法糖,可以简化模板代码,直接获取有用的结果,这个语法看起来就很语义化,可以直接读出来,如果结果是Ok的那就执行代码
THE REST OF THE STORY
fn select_word() -> String {
let mut rng = rand::thread_rng();
let filename:String = "words.txt".to_string();
let words:Vec<String> = read_list(filename);
let word_count = words.len();
let selection = rng.gen_range(1, word_count);
let select: String = words[selection].clone();
select
}
这里面值得注意的是 words[selection].clone()
,为什么从Vector中拿到这个String需要还需要使用clone()方法,还是因为所有权的问题,首先,String它是Move
语义,不是Copy
语义,因为它在编译时不知道大小,那么当你想要从集合中获取这个String的时候,如果是直接通过赋值的方式,那么集合内部的那个对应的String不就失效了吗,但是Rust中,是不允许访问失效的变量的,所以我们不能通过直接赋值的方式将所有权转移,要么完整复制一份,要么替换一个,这里选择的是复制