1. 程式人生 > 程式設計 >[譯] 通過 Rust 學習解析器組合器 — 第四部分

[譯] 通過 Rust 學習解析器組合器 — 第四部分

如果你沒看過本系列的其他幾篇文章,建議你按照順序進行閱讀:

一個展現自己的機會

但是,稍等一下,它給我們提供了一個修復另一件可能有點麻煩的問題的機會。

還記得我們最後寫的解析器嗎?由於我們的組合器是獨立的函式,當我們巢狀一些“組合器”時,我們的程式碼開始變得有些難以理解。回想一下我們的 quoted_string 解析器:

fn quoted_string<'a>() -> impl Parser<'a,String> {
    map(
        right(
            match_literal("\""),left(
                zero_or_more(pred(any_char,|c| *c != '"')),match_literal("\""),),|chars| chars.into_iter().collect(),)
}
複製程式碼

如果我們可以在解析器而不是獨立函式上建立那些組合器方法,那麼可讀性會更好。假如我們將組合器宣告為 Parser trait 方法會怎麼樣呢?

問題在於,如果我們這樣做,我們的返回值就會失去依賴 impl Trait 的能力,因為 trait 宣告中不允許使用 impl Trait

但現在我們有了 BoxedParser。 我們不能宣告一個返回 impl Parser<'a,A> 的 trait 方法,但我們肯定可以宣告一個返回 BoxedParser<'a,A> 的方法。

最好的情況是我們甚至可以使用預設實現宣告這些方法,這樣我們就不必為每個實現 Parser 的型別重新實現每個組合器。

讓我們用 map 函式來嘗試,通過如下方式擴充套件我們的 Parser trait 解析器:

trait Parser<'a,Output> {
    fn parse(&self,input: &'a str) -> ParseResult<'a,Output>;

    fn map<F,NewOutput>(self,map_fn: F) -> BoxedParser<'a,NewOutput>
    where
        Self: Sized + 'a,Output: 'a,NewOutput: 'a,F: Fn(Output) -> NewOutput + 'a,{
        BoxedParser::new(map(self,map_fn))
    }
}
複製程式碼

這裡有很多 'a,但是它們都是必要的。幸運的是,我們仍然可以重新使用舊的組合函式 —— 並且,它有著額外的優勢,我們不僅可以獲得更好的語法來應用它們,還可以通過自動包裝擺脫易引發爭議的 impl Trait 方式。

現在我們可以稍微改進一下 quoted_string 解析器:

fn quoted_string<'a>() -> impl Parser<'a,String> {
    right(
        match_literal("\""),left(
            zero_or_more(pred(any_char,)
    .map(|chars| chars.into_iter().collect())
}
複製程式碼

乍一看,很明顯 .map() 會被 right() 的結果呼叫 。

我們也可以給 pairleftright 做同樣的處理,但是在有這三個的情況下,我認為當它們是函式時會有更好的可讀性,因為它們反映了 pair 輸出的結構型別。如果你不同意的話,完全可以將它們新增到 trait 中,就像我們使用 map 一樣,並且非常歡迎你繼續將其作為練習去嘗試它。

然而,還有一個等待處理的函式是 pred。 讓我們為它的 Parser trait 新增一個定義:

fn pred<F>(self,pred_fn: F) -> BoxedParser<'a,Output>
where
    Self: Sized + 'a,F: Fn(&Output) -> bool + 'a,{
    BoxedParser::new(pred(self,pred_fn))
}
複製程式碼

讓我們用 pred 呼叫重寫 quoted_string 中的那一行,如下所示:

zero_or_more(any_char.pred(|c| *c != '"')),複製程式碼

這樣閱讀起來更好一些,並且我認為應該保留 zero_or_more —— 它讀起來像是“零或更多的 any_char 並且應用了下面的判斷”,對我來說這聽起來是正確的。如果你願意全力以赴的話,你也可以繼續將 zero_or_moreone_or_more 移動到 trait 中。

除了重寫 quoted_string 之外,還要修復 single_element 中的 map

fn single_element<'a>() -> impl Parser<'a,Element> {
    left(element_start(),match_literal("/>")).map(|(name,attributes)| Element {
        name,attributes,children: vec![],})
}
複製程式碼

讓我們嘗試取消註釋 element_start 並用之前註釋過的測試程式碼測試一下,看看結果是否變得更好。讓我們恢復遊戲中的程式碼並嘗試執行測試……

……嗯,是的,編譯時間現在恢復正常了。你甚至可以移除檔案頂部設定的型別大小,你完全不需要它了。

我們只是裝箱了兩個 map 和一個 pred —— 並且我們得到了更好的語規則!

有子元素的情況

現在讓我們為父元素的開始標籤編寫解析器。它除了以 > 而不是 /> 結尾之外,其他幾乎與 single_element 相同。它後面跟著零個或多個子項以及結束標籤。首先我們需要解析實際的開始標籤,讓我們完成它。

fn open_element<'a>() -> impl Parser<'a,match_literal(">")).map(|(name,})
}
複製程式碼

現在,我們如何得到那些子元素?它們不是單個元素就是父元素本身,它們中也可能有零個或多個子元素,而我們擁有可靠的 zero_or_more 組合器,那我們該怎樣輸入呢?我們還有一個東西尚未處理,那就是多選解析器:可以解析單個元素可以解析父元素。

為了達到目的,我們需要組合器按順序嘗試兩個解析器:如果第一個解析器成功,任務就完成了,並返回它的結果。如果它失敗了,我們會用相同的輸入嘗試第二個解析器,而不是返回錯誤。如果成功,那很好,如果沒有,我們就會返回錯誤,因為這意味著我們的解析器都失敗了,這是一個徹底的失敗。

fn either<'a,P1,P2,A>(parser1: P1,parser2: P2) -> impl Parser<'a,A>
where
    P1: Parser<'a,A>,P2: Parser<'a,{
    move |input| match parser1.parse(input) {
        ok @ Ok(_) => ok,Err(_) => parser2.parse(input),}
}
複製程式碼

這允許我們宣告一個解析器 element,它匹配單個元素或父元素(現在,我們僅使用 open_element 來代表它,一旦我們有 element 我們就會處理子元素)。

fn element<'a>() -> impl Parser<'a,Element> {
    either(single_element(),open_element())
}
複製程式碼

現在讓我們為結束標籤新增一個解析器。它有個有趣的屬性,必須以開始標籤匹配,這意味著解析器必須知道開始標籤的名稱是什麼。但這就是函式引數的用途,是吧?

fn close_element<'a>(expected_name: String) -> impl Parser<'a,String> {
    right(match_literal("</"),left(identifier,match_literal(">")))
        .pred(move |name| name == &expected_name)
}
複製程式碼

那個 pred 組合器證明非常有用,不是嗎?

現在,讓我們把它放在一起,用於實現完整的父元素解析器,子元素解析器和所有其他的解析器:

fn parent_element<'a>() -> impl Parser<'a,Element> {
    pair(
        open_element(),left(zero_or_more(element()),close_element(…oops)),)
}
複製程式碼

哎呀,我們現在該如何將該引數傳遞給 close_element 呢?我想這是我們要實現的最後一個組合器。

我們現在離完成非常接近了。一旦我們解決了最後一個讓 parent_element 工作的問題,我們可以用實現的新的 parent_element 替換 element 解析器中的 open_element 佔位符,就這樣,我們實現了一個完全可用的 XML 解析器。

還記得我說我們之後會回到 and_then 嗎?就是現在回到了 and_then。實際上,我們需要的組合器是 and_then:我們需要一些帶有解析器的東西,和一個獲取解析器結果並返回解析器的函式,之後我們將執行它。它有點像 pair,但它只是在元組中收集兩個結果,我們通過函式將它們串聯起來。這也是 and_thenResultOption 一起使用的方法,但它更容易理解,因為 ResultOption 不是真的任何事情,它們只是持有一些資料的東西(或不是,視情況而定)。

所以讓我們嘗試編寫一個它的實現。

fn and_then<'a,P,F,A,B,NextP>(parser: P,f: F) -> impl Parser<'a,B>
where
    P: Parser<'a,NextP: Parser<'a,B>,F: Fn(A) -> NextP,{
    move |input| match parser.parse(input) {
        Ok((next_input,result)) => f(result).parse(next_input),Err(err) => Err(err),}
}
複製程式碼

檢視型別會有很多型別變數,但我們知道輸入解析器 P 的結果型別為 A。然而我們的函式 F,其中的 map 有一個從 AB 的函式,此兩者之間關鍵的區別是 and_then 會從 A 獲取一個函式到一個新的解析器 NextP,其結果型別為 B。最終的結果型別是B,因此我們可以假設從 NextP 輸出的任何東西都是最終的結果。

程式碼有點複雜:我們從執行輸入解析器開始,如果失敗,它就會失敗並且代表我們已經完成了。但如果成功,我們先在結果上呼叫函式 f(型別為A),f(result) 的返回是一個新的解析器,並帶有一個型別為 B 的結果。我們在下一位輸入上執行這個解析器,並直接返回結果。如果失敗,那就失敗了,如果成功,我們就會得到型別為 B 的值。

再一次:我們首先執行 P 型別的解析器,如果成功,我們以解析器 P 的結果作為引數呼叫函式 f 來得到我們的下一個型別為 NextP 的解析器,接著我們繼續執行,並得到最後的結果。

讓我們直接將它新增到 Parser trait中,因為這個像 map 一樣,以這種方式肯定會更容易閱讀。

fn and_then<F,NextParser,f: F) -> BoxedParser<'a,NewOutput>
where
    Self: Sized + 'a,NextParser: Parser<'a,NewOutput> + 'a,F: Fn(Output) -> NextParser + 'a,{
    BoxedParser::new(and_then(self,f))
}
複製程式碼

好的,現在這麼做都有什麼好處?

首先,我們幾乎可以使用它來實現 pair

fn pair<'a,R1,R2>(parser1: P1,(R1,R2)>
where
    P1: Parser<'a,R1> + 'a,R2> + 'a,R1: 'a + Clone,R2: 'a,{
    parser1.and_then(move |result1| parser2.map(move |result2| (result1.clone(),result2)))
}
複製程式碼

它看起來非常簡潔,但是有一個問題:parser2.map() 使用 parser2 來建立封裝好的解析器,包裝函式是 Fn,而不是 FnOnce,因此它不允許使用 parser2 解析器,我們只能參考它。換句話說,這是 Rust 的問題。在更高階別的語言中,這些事情不是問題,它們可能會用更優雅的方式定義 pair

但是,即使在 Rust 中我們也可以使用該函式來延遲生成 close_element 解析器的正確版本,或者換句話說,我們可以通過傳遞引數獲取解析器。

回顧我們之前失敗的嘗試:

fn parent_element<'a>() -> impl Parser<'a,)
}
複製程式碼

使用 and_then,我們現在可以通過使用這個函式構造正確版本的 close_element 來實現這一點。

fn parent_element<'a>() -> impl Parser<'a,Element> {
    open_element().and_then(|el| {
        left(zero_or_more(element()),close_element(el.name.clone())).map(move |children| {
            let mut el = el.clone();
            el.children = children;
            el
        })
    })
}
複製程式碼

它現在看起來有點複雜,因為 and_then 必須繼續通過 open_element() 呼叫,我們會在那裡找到跳轉到 close_element 的名字。這意味著 open_element 之後的其它解析器都必須在 and_then 閉包內構造。此外,由於閉包現在是 open_elementElement 結果的唯一接收者,我們返回的解析器也必須向前傳遞該資訊。

我們在生成的解析器上 map 的內部閉包能從外部閉包中引用 Element(el)。由於我們在 Fn 中只能引用它一次,因此我們必須 clone() 它。我們取內部解析器的結果(子元素的 Vec<Element> )並將它新增到我們克隆的 Element 中,並將其作為最終結果返回。

我們現在需要做的就是回到 element 解析器並確保將 open_element 改為 parent_element,這樣它會解析整個元素結構而不僅僅是它的開頭,我相信我們已經完成解析器組合器了!

你會問那個 M 開頭的單詞我是否應該完成嗎?

還記得我們談到過如何將 map 模式稱為 Haskell 星球上的“函子(functor)”嗎?

and_then 模式是另一個你會在 Rust 中時常看到的東西,它通常與 map 出現在相同的位置。它在 迭代器(Iterator)上被稱為 flat_map,但它與 Rest 的模式相同。

這個奇特的單詞是“單子(monad)”。如果你有一個 Thing<A>,並且你有一個 and_then 函式可以將一個函式從 A 傳遞給 Thing<B>,那麼相反,現在你有了一個新的 Thing<B> ,這就是一個 monad。

就像當你有 Option<A> 的時候,函式可能會被立即呼叫,我們已經知道它是 Some(A) 還是 None,所以如果是 Some(A) 我們直接應用函式,並輸出 Some(B)

它也可能被稱為延遲呼叫。 例如,如果你有一個仍然等待解決的 Future<A>,它不會通過 and_then 立即呼叫函式建立一個 Future<B>,而是建立一個新的Future<B>,既包含 Future<A> 又包含函式,然後等待 Future<A> 完成。 當它發生的時候,它會呼叫帶有 Future<A> 結果的函式,而鮑勃是你的叔叔 [1],你會得到 Future<B>。 換句話說,在 Future 的情況下,你可以將傳遞給 and_then 的函式視為回撥函式,因為它在完成時會被原始的未來的結果呼叫。它也比這更有意思,因為它返回了一個新的 Future,它可能已經或可能沒有被解析,所以它是一種連線未來狀態的方法。

然而,與函子一樣,Rust 的型別系統目前還不能表達 monad,所以我們只需記住這種模式被叫做 monad,而且相當令人失望的是,與在網際網路上所描述的相反,它與 burrito 沒什麼關係。讓我們繼續前進。

空格,最終版

最後一件事了。

我們現在應該有了一個能夠解析一些 XML 的解析器,但它不太支援空格。標籤之間應該允許任意數量的空格,這樣我們就可以自由地在我們的標籤之間插入換行符(原則上,在識別符號和文字之間應該允許空格,比如 <div />,但讓我們跳過它)。

此時我們應該能夠毫不費力地組裝一個快速組合器。

fn whitespace_wrap<'a,A>(parser: P) -> impl Parser<'a,A>
where
    P: Parser<'a,{
    right(space0(),left(parser,space0()))
}
複製程式碼

如果我們將 element 包裝在其中,它將忽略 element 周圍的所有前導和尾隨的空格,這意味著我們可以自由地使用我們希望的任意數量的換行符和縮排。

fn element<'a>() -> impl Parser<'a,Element> {
    whitespace_wrap(either(single_element(),parent_element()))
}
複製程式碼

我們終於完成了!

我想我們做到了!讓我們寫一個整合測試來慶祝一下。

#[test]
fn xml_parser() {
    let doc = r#"
        <top label="Top">
            <semi-bottom label="Bottom"/>
            <middle>
                <bottom label="Another bottom"/>
            </middle>
        </top>"#;
    let parsed_doc = Element {
        name: "top".to_string(),attributes: vec![("label".to_string(),"Top".to_string())],children: vec![
            Element {
                name: "semi-bottom".to_string(),"Bottom".to_string())],},Element {
                name: "middle".to_string(),attributes: vec![],children: vec![Element {
                    name: "bottom".to_string(),"Another bottom".to_string())],}],],};
    assert_eq!(Ok(("",parsed_doc)),element().parse(doc));
}
複製程式碼

它會由於缺少閉合標籤而導致失敗,只是為了確保我們能夠做到這一點:

#[test]
fn mismatched_closing_tag() {
    let doc = r#"
        <top>
            <bottom/>
        </middle>"#;
    assert_eq!(Err("</middle>"),element().parse(doc));
}
複製程式碼

好訊息是當返回值缺少閉合標籤時會丟擲錯誤。壞訊息是它實際上並沒有指明問題是由於缺少閉合標籤導致的,只是標記了錯誤在哪裡。不過它總比沒有好,但老實說,隨著錯誤資訊的發生,它看起來會非常糟糕。 但是實際上讓它產生正確的錯誤資訊是另一個主題,也許至少是一篇一樣長的文章。

讓我們專注於好訊息上吧:我們從頭開始用解析器組合器來編寫一個編譯器!我們知道解析器形成了一個函子(functor)和一個單子(monad),所以你現在可以在派對中用你知道的令人生畏的範疇理論知識給人們留下深刻的印象 [2]

最重要的是,我們現在知道解析器組合器是如何從頭開始工作的了。已經沒人能阻止我們了!

勝利小狗

更多資源

首先,我很嚴謹地用嚴格的 Rusty 術語向你解釋 monad,我知道如果我沒有把你指向他的開創性論文,那麼 Phil Wadler 會對我非常不滿,因為這篇論文更加令人興奮 —— 它包含了他們是如何關聯解析器組合器的。

本文的想法與 pom 解析器組合器背後的想法極為相似,如果你希望用相同的風格使用解析器組合器的話,我極力推薦它。

Rust 解析器組合器中的最先進的依然是 nom,在某種程度上之前提到的 pom 明顯是它衍生的命名(沒有比它更高的讚美了)。但它採取的方法與我們今天在這裡的設計截然不同。

Rust 的另一個流行的解析器組合器庫是 combine,它也值得一看。

Haskell 的開創性解析器組合器庫是 Parsec

最後,我在 Graham Hutton 的書 Haskell 程式設計中第一次認識到解析器組合器,這本書非常值得一讀,並且可以教你有關 Haskell 的知識。

協議

本文版權歸 Bodil Stokke 所有,並受知識共享署名 - 非商業性使用 - 相同方式共享 4.0 國際許可。要檢視此許可證的副本,請訪問 creativecommons.org/licenses/by…

腳註

  1. 他並不真是你的叔叔。
  2. 請不要在派對上做那樣的人。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄