开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情
- Advent of Code 2020 Day4 译文(用 Rust 实现 Advent of Code 2020 第4天)
- 原文链接:fasterthanli.me/series/adve…
- 原文作者:Amos
- 译文来自:github.com/suhanyujie/…
- 译者:suhanyujie
- ps:水平有限,如有不当之处,欢迎指正
- 标签:Rust,advent of code, algo
到了 Advent of Code 2020 第 4 天啦。
我已经看了第一部分的问题描述,感觉一般,没有特别亮的点。
但它强化了我最近提出的“类型的正确性”的观点。
酷熊:是的,这个看起来像小说一样长的文章。
今天的题目,主要内容是解析护照信息,有如下一些字段:
- byr (出生年份)
- iyr (发行年份)
- eyr (到期年份)
- hgt (身高)
- hcl (发色)
- ecl (眼睛颜色)
- pid (护照编号)
- cid (国家 ID)
换行可被忽略,除非有两个换行,两个换行表示一个护照“记录”与下一个“记录”隔开,如下所示:
ecl:gry pid:860033327 eyr:2020 hcl:#fffffd
byr:1937 iyr:2017 cid:147 hgt:183cm
iyr:2013 ecl:amb cid:350 eyr:2023 pid:028048884
hcl:#cfa07d byr:1929
hcl:#ae17e1 iyr:2013
eyr:2024
ecl:brn pid:760753108 byr:1931
hgt:179cm
hcl:#cfa07d eyr:2025 pid:166559648
iyr:2011 ecl:brn hgt:59in
哦,还有,有些护照信息的部分字段缺失,并且只有 cid 可能缺失,其他的不会。
我们再将这个场景与 Rust 类型联系起来思考!
byr, iyr, 和 eyr 都表示年份 —— 一个 u64 足够了。
酷熊:是的,这样可以防止他们出生在遥远的未来。
#[derive(Clone, Copy, PartialEq, Debug)]
struct Year(u64);
hgt 的单位可能是 cm(厘米),也可能是 in(英尺),很明显,可以用枚举表示:
#[derive(Clone, Copy, PartialEq, Debug)]
enum Length {
/// Centimeters (the correct unit)
Cm(u64),
/// Inches (the incorrect unit)
In(u64),
}
然后是“发色” —— 它的格式似乎总是 #rrggbb 形式,所以我们会比较容易识别,将其保存在一个 String 中。我怀疑下一步会涉及到色彩处理。
#[derive(Clone, Copy, PartialEq, Debug)]
struct Color<'a>(&'a str);
最后是 pid 和 cid,我想它们都可以以 0 开头,我们可以把它们保存在动态的字符串变量中。
/// An identifier
#[derive(Clone, Copy, PartialEq, Debug)]
struct ID<'a>(&'a str);
在所有这些字段中,只有 cid 是可选(Option)的:
#[derive(PartialEq, Debug)]
struct Passport<'a> {
birth_year: Year,
issue_year: Year,
expiration_year: Year,
height: Length,
hair_color: Color<'a>,
eye_color: Color<'a>,
passport_id: ID<'a>,
country_id: Option<ID<'a>>,
}
跟“第 2 天的冒险”一样,使用 peg crate 来完成:
$ cargo add peg
Adding peg v0.6.3 to dependencies
但在此之前... 为了使语法更简单,我们将创建另一个类型 PassportBuilder,其中所有字段都是可选的。
#[derive(PartialEq, Debug, Default)]
struct PassportBuilder<'a> {
birth_year: Option<Year>,
issue_year: Option<Year>,
expiration_year: Option<Year>,
height: Option<Length>,
hair_color: Option<Color<'a>>,
eye_color: Option<Color<'a>>,
passport_id: Option<ID<'a>>,
country_id: Option<ID<'a>>,
}
$ cargo add thiserror
Adding thiserror v1.0.22 to dependencies
添加返回 Passport 和错误类型,以及生成错误类型的方法:
#[derive(thiserror::Error, Debug)]
enum Error {
#[error("missing field: {0}")]
MissingField(&'static str),
}
impl<'a> PassportBuilder<'a> {
fn build(self) -> Result<Passport<'a>, Error> {
Ok(Passport {
birth_year: self.birth_year.ok_or(Error::MissingField("birth_year"))?,
issue_year: self.issue_year.ok_or(Error::MissingField("issue year"))?,
expiration_year: self
.expiration_year
.ok_or(Error::MissingField("expiration_year"))?,
height: self.height.ok_or(Error::MissingField("height"))?,
hair_color: self.hair_color.ok_or(Error::MissingField("hair color"))?,
eye_color: self.eye_color.ok_or(Error::MissingField("eye_color"))?,
passport_id: self.passport_id.ok_or(Error::MissingField("passport id"))?,
country_id: self.country_id,
})
}
}
现在,这个语法也太。。。:
酷熊:等等。。
Amos:什么?
酷熊:没感觉重复代码太多了?
Amos:可能在 Vim 模式下,看起来没那么明显吧
酷熊:我们不能用宏吗?
Amos:大熊,太晚了,我有点累
酷熊:拜托!我们总是在重复这些
好吧!
impl<'a> PassportBuilder<'a> {
fn build(self) -> Result<Passport<'a>, Error> {
macro_rules! build {
(
required => {
$($req: ident),* $(,)*
}$(,)*
optional => {
$($opt: ident),* $(,)*
}$(,)*
) => {
Ok(Passport {
$($req: self.$req.ok_or(Error::MissingField(stringify!($req)))?),*,
$($opt: self.$opt),*
})
}
}
build! {
required => {
birth_year,
issue_year,
expiration_year,
height,
hair_color,
eye_color,
passport_id,
},
optional => {
country_id,
},
}
}
}
酷熊:太棒啦!我们有了简单的 DSL。对了,怎么测试呢?
#[test]
fn test_builder() {
assert!(PassportBuilder {
..Default::default()
}
.build()
.is_err());
assert!(PassportBuilder {
birth_year: Some(Year(2014)),
issue_year: Some(Year(2017)),
expiration_year: Some(Year(2023)),
height: Some(Length::Cm(195)),
hair_color: Some(Color("#ffffff")),
eye_color: Some(Color("#ee7812")),
passport_id: Some(ID("00023437")),
country_id: None,
}
.build()
.is_ok());
}
$ cargo test --quiet
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Amos:这下可以了吧?
酷熊:真棒!
现在,语法有些不同。
因为字段可以以任意顺序出现。
我们将编写一个只解析 一条记录 的解析器,一般情况下解析器会持有 PassportBuilder 的可变引用:
impl<'a> PassportBuilder<'a> {
fn parse(input: &'a str) -> Self {
let mut b: Self = Default::default();
peg::parser! {
grammar parser() for str {
pub(crate) rule root(b: &mut PassportBuilder<'input>)
= (field(b) separator()*)* ![_]
rule separator()
= ['\n' | ' ']
rule field(b: &mut PassportBuilder<'input>)
// years
= byr(b) / iyr(b) / eyr(b)
// height
/ hgt(b)
// colors
/ hcl(b) / ecl(b)
// IDs
/ pid(b) / cid(b)
rule byr(b: &mut PassportBuilder<'input>)
= "byr:" year:year() { b.birth_year = Some(year) }
rule iyr(b: &mut PassportBuilder<'input>)
= "iyr:" year:year() { b.issue_year = Some(year) }
rule eyr(b: &mut PassportBuilder<'input>)
= "eyr:" year:year() { b.expiration_year = Some(year) }
rule hgt(b: &mut PassportBuilder<'input>)
= "hgt:" height:length() { b.height = Some(height) }
rule pid(b: &mut PassportBuilder<'input>)
= "pid:" id:id() { b.passport_id = Some(id) }
rule cid(b: &mut PassportBuilder<'input>)
= "cid:" id:id() { b.country_id = Some(id) }
rule hcl(b: &mut PassportBuilder<'input>)
= "hcl:" color:color() { b.hair_color = Some(color) }
rule ecl(b: &mut PassportBuilder<'input>)
= "ecl:" color:color() { b.eye_color = Some(color) }
rule year() -> Year
= num:num() { Year(num) }
rule color() -> Color<'input>
= s:$((!separator()[_])*) { Color(s) }
rule length() -> Length
= num:num() "cm" { Length::Cm(num) }
/ num:num() "in" { Length::In(num) }
rule num() -> u64
= s:$(['0'..='9']+) { s.parse().unwrap() }
rule id() -> ID<'input>
= s:$(['0'..='9']+) { ID(s) }
}
}
parser::root(input, &mut b).unwrap_or_else(|e| panic!("Could not parse {}: {}", input, e));
b
}
}
Amos:希望代码看起来很清晰。
酷熊:我来看看,[_] 是什么?
Amos:可以匹配任意内容,rustdocs 中是这么解释的。
酷熊:好吧。那 ![_] 呢?
Amos:仅仅匹配 EOF(文件结束符!)。
酷熊:这样我们就能确保从输入中解析所有内容了吗?
Amos:是的!
是时候用代码解答第一部分题目了:
fn main() {
let results = include_str!("input.txt")
.split("\n\n")
.map(PassportBuilder::parse)
.map(PassportBuilder::build);
let num_valid = results.filter(Result::is_ok).count();
println!("{} passport records were valid", num_valid);
}
在测试了我的输入之后,我不得不做两个调整。
首先,护照 ID 有时包含一个“英镑”/“井号”/“哈希”/“八索普”字符和一些字母:
// in the grammar:
rule id() -> ID<'input>
= s:$(['0'..='9' | 'a'..='z' | '#']+) { ID(s) }
其次,有时候“身高”没有跟单位(无奈!)。幸运的是,这里也很容易调整:
#[derive(Clone, Copy, PartialEq, Debug)]
enum Length {
/// Centimeters (the correct unit)
Cm(u64),
/// Inches (the incorrect unit)
In(u64),
/// No unit
Unspecified(u64), // new!
}
// in the grammar:
rule length() -> Length
= num:num() "cm" { Length::Cm(num) }
/ num:num() "in" { Length::In(num) }
/ num:num() { Length::Unspecified(num) }
$ cargo run --quiet
235 passport records were valid
第二部分
问题的第二部分是关于验证,我们现在有:
- byr (出生年份)-四位数字; 大于 1920,小于等于 2002 年
- iyr (发行年份)-四位数字; 大于 2010,小于等于 2020 年
- eyr (过期年份)-四位数字; 2020 ~ 2030 年
- hgt (身高) - 后跟 cm(厘米)或 in(英尺)单位的数字
- 如果是厘米,则数字必须至少为 150,最多为 193
- 如果是英尺,数字必须是 59 ~ 76
- hcl (头发颜色) - 一个
#后面正好跟着 6 个 0-9/a-f 字符组成的字符串 - ecl(眼睛颜色)。这些值中的一个:
amb,blu,brn,gry,grn,hzl,oth - pid (护照 ID) - 一个 9 位数字,可能以
0开头。 - cid (国家 ID) - 可能有,也可能无
是时候检验我们的解析器了!
为此,我们将使用 Rust 代码块 {? } (而不是 {})。这样我们就可以返回 Err(携带 &'static str)或 Ok。
我们可以通过在 year 解析器中添加一个参数来修复所有的年份:
rule byr(b: &mut PassportBuilder<'input>) -> ()
= "byr:" year:year((1920..=2002)) { b.birth_year = Some(year); }
rule iyr(b: &mut PassportBuilder<'input>) -> ()
= "iyr:" year:year((2010..=2020)) { b.issue_year = Some(year); }
rule eyr(b: &mut PassportBuilder<'input>) -> ()
= "eyr:" year:year((2020..=2030)) { b.expiration_year = Some(year); }
rule year(range: RangeInclusive<u64>) -> Year
= num:num() {?
if range.contains(&num) {
Ok(Year(num))
} else {
Err("year out of range")
}
}
对于 hgt,我们首先需要删除 Unspecified 的变量,并检查范围:
rule hgt(b: &mut PassportBuilder<'input>)
= "hgt:" height:length() {?
match &height {
Length::Cm(v) if !(150..=193).contains(v) => {
Err("bad height (cm)")
},
Length::In(v) if !(59..=76).contains(v) => {
Err("bad height (in)")
},
_ => {
b.height = Some(height);
Ok(())
},
}
}
酷熊:我就知道有问题! 长度无限,太扯了!
剩下的我们也可以很容易地解决:
rule pid(b: &mut PassportBuilder<'input>)
= "pid:" id:$(['0'..='9']*<9,9>) { b.passport_id = Some(ID(id)) }
rule cid(b: &mut PassportBuilder<'input>)
= "cid:" id:$((!separator()[_])+) { b.country_id = Some(ID(id)) }
rule hcl(b: &mut PassportBuilder<'input>)
= "hcl:" color:hcl0() { b.hair_color = Some(color) }
rule hcl0() -> Color<'input>
= s:$("#" ['0'..='9' | 'a'..='f']*<6,6>) { Color(s) }
rule ecl(b: &mut PassportBuilder<'input>)
= "ecl:" color:ecl0() { b.eye_color = Some(color) }
rule ecl0() -> Color<'input>
= s:$("amb" / "blu" / "brn" / "gry" / "grn" / "hzl" / "oth") { Color(s) }
现在,我们只需要抛出解析错误,而不是直接 panic!。
酷熊:哈哈! 这就是你懒地处理错误的后果。
Amos:早期 Rust 的处理方式... 那时候说得通!
#[derive(thiserror::Error, Debug)]
enum Error {
#[error("missing field: {0}")]
MissingField(&'static str),
// new!
#[error("could not parse {0}: {1}")]
ParseError(String, String),
}
fn parse(input: &'a str) -> Result<Self, Error> {
let mut b: Self = Default::default();
// omitted: grammar
parser::root(input, &mut b).map_err(|e| Error::ParseError(input.into(), e.to_string()))?;
Ok(b)
}
最后,让我们看看第二部分问题的答案:
fn main() {
let results = include_str!("input.txt")
.split("\n\n")
.map(|input| PassportBuilder::parse(input).and_then(|b| b.build()));
let num_valid = results.filter(Result::is_ok).count();
println!("{} passport records were valid", num_valid);
}
$ cargo run --quiet
194 passport records were valid
酷熊:哇,一次就对了!
Amos:我们一向如此 😎
下次见,保重!