【译】用 Rust 实现 Advent of Code 2020 第4天

107 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

到了 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);

最后是 pidcid,我想它们都可以以 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(眼睛颜色)。这些值中的一个:ambblubrngrygrnhzloth
  • 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:我们一向如此 😎

下次见,保重!