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

本文面向會使用 Rust 編程的人員,提供一些解析器的基礎知識。若是不具有其餘知識,咱們將會介紹和 Rust 無直接關係的全部內容,以及使用 Rust 實現這個會更加超出預期的一些方面。若是你還不瞭解 Rust 這個文章也不會講如何使用它,若是你已經瞭解了,那它也不能打包票能教會你解析器組合器的知識。若是你想學習 Rust,我推薦閱讀 Rust 編程語言前端

初學者的獨白

在不少程序員的職業生涯中,可能都會有這樣一個時刻,發現本身須要一個解析器。android

小白程序員可能會問,「解析器是什麼?」ios

中級程序員會說,「這很簡單,我會寫正則表達式。」git

高級程序員會說:閃開,我知道 lexyacc程序員

小白的心態是正確的。github

並不說正則很差。(但請不要嘗試將一個複雜的解析器寫成正則表達式。)也不是說使用像解析器和 lexer 生成器等這種功能強大的工具就沒有樂趣了,這些工具通過長久的迭代和改進,已經達到了很是好的程度。但從 0 開始學解析器是 頗有趣 的。而若是你直接走正則表達式或解析器生成器的方向,你將會錯過不少精彩的東西,由於它們只是對當前實際問題的抽象後造成的工具。正如某人所說,在初學者的腦殼中,是充滿可能性的。而在專家的頭腦中,可能就習慣於那一種想法。正則表達式

在本文中,咱們將學習怎樣從頭開始使用函數式編程語言中常見的技術構建一個解析器,這種技術被稱爲 解析器組合器。它們具備很好的優勢,一旦你掌握其中的基本思想,和基本原理,你將在基本組合器之上創建本身的抽象,這裏也將做爲惟一的抽象 —— 全部這些必須創建在你使用它們以前已經開始進行構思。編程

怎樣學習好這篇文章

強烈建議你新建一個新的 Rust 項目,並在閱讀時,將代碼片斷鍵入到文件 src/lib.rs 中(你能夠直接從頁面複製代碼片斷,但最好手敲,由於這樣會確保你完整的閱讀代碼)。本文會按順序介紹你須要的每一段代碼。請注意,它可能會引入你以前編寫的函數 已修改 版本,這種狀況下,你應該使用新版本的代碼替換舊版本的。後端

代碼是基於 2018 版次的 rustc 1.34.0 版本的編譯器。你應該可以使用最新版本的編譯器,只要確保你使用的是2018(檢查 Cargo.toml 是否包含了 edition = "2018")的版次。代碼無需外部依賴。bash

如你所料,要運行文章中介紹的測試,可使用 cargo test

XML 文本

咱們將爲簡化版的 XML 編寫一個解析器。它相似於這樣:

<parent-element>
  <single-element attribute="value" />
</parent-element>
複製代碼

XML 元素以符號 < 和一個標識符開始,標識符由若干字母、數字或 - 組成。其後是一些空格,或一些可選的屬性列表:前面定義的另外一個標識符,這個標識符後跟隨一個 = 和雙引號包含一些字符串。最後,可能有一個 /> 進行結束,表示沒有子元素的單個元素,也可能有一個 > 表示後面有一些子元素,最後使用一個以 </ 開頭的結束標記,後面跟一個標識符,該標識符必須在與開始標識符標記相匹配,最後使用 > 閉合。

這就是咱們要作的。沒有名稱空間,沒有文本節點,沒有其餘節點,並且 確定 沒有模式驗證。咱們甚至不須要爲這些字符串支持轉義引號 —— 它們從第一個雙引號開始,到下一個雙引號結束,就是這樣。若是你想要在實際的字符串中使用雙引號,你能夠將這種難處理的需求放到之後處理。

咱們將把這些元素解析成相似於這樣的結構:

#[derive(Clone, Debug, PartialEq, Eq)]
struct Element {
    name: String,
    attributes: Vec<(String, String)>,
    children: Vec<Element>,
}
複製代碼

沒有泛型,只有一個名爲 name 的字符串(即每一個標記開頭的標識符)、一些字符串的屬性(標識符和值),和一個看起來和父元素徹底同樣的子元素列表。

(若是你正在鍵入代碼,請確保包含這些 derives。稍後你會須要用到的。)

定義解析器

那麼,是時候開始編寫解析器了。

解析是從數據流派生出結構的過程。解析器就是用來將它們梳理出結構的東西。

在咱們將要探討的規程中,解析器最簡單的形式就是一個函數,它接收一些輸入並返回已解析的內容和輸入的剩餘部分,或者一個錯誤提示:「沒法解析」。

簡而言之,解析器在更復雜的場景中也是這個樣子。你可能會使輸入、輸出和錯誤複雜化,若是你有好的錯誤信息提示,這正是你須要的,可是解析器保持不變:處理輸入並將解析的結果和輸入的剩餘內容,或者提示出它沒法解析輸入,並顯示信息讓你知道。

咱們把它標記爲函數類型

Fn(Input) -> Result<(Input, Output), Error>
複製代碼

更詳細的說,在咱們的例子中,咱們要填充類型,就會獲得相似下面的結果,由於咱們要作的是將一個字符串轉換成一個 Element 結構體,這一點上,咱們不想將錯誤複雜地顯示出來,因此咱們只將咱們沒法解析的錯誤做爲字符串返回:

Fn(&str) -> Result<(&str, Element), &str>
複製代碼

咱們使用字符串 slice,由於它是指向一個字符串片斷的有效指針,咱們能夠經過 slice 的方式引用它,不管怎麼作,處理輸入的字符串 slice,並返回剩餘內容和處理結果。

使用 &[u8](一個字節的 slice,假設咱們限制本身只使用 ASCII 對應的字符) 做爲輸入的類型可能會更簡潔,特別是由於一個字符串 slice 的行爲不一樣於其餘大多數的 slice,尤爲是在不能用數字對字符串進行索引的狀況下,數字索引字符串如:input[0],你必須像這樣使用一個字符串 slice input[0..1]。另外一方面,對於解析字符串它們提供許多有用的方法,而字節 slice 沒有。

實際上,大多數狀況下,咱們將依賴這些方法,而不是對其進行索引,由於,Unicode。在 UTF-8 中,全部 Rust 字符串都是 UTF-8 的,這些索引並不能老是對應於單個字符,最好讓標準庫幫咱們處理與這個相關的問題。

咱們的第一個解析器

讓咱們嘗試編寫一個解析器,它只查看字符串中的第一個字符,並判斷它是不是字母 a

fn the_letter_a(input: &str) -> Result<(&str, ()), &str> {
  match input.chars().next() {
      Some('a') => Ok((&input['a'.len_utf8()..], ())),
      _ => Err(input),
  }
}
複製代碼

首先,咱們看下輸入和輸出的類型:咱們將一個字符串 slice 做爲輸入,正如咱們討論的,咱們返回一個包含 (&str, ())Result 或者 &str 類型的錯誤。有趣的是 (&str, ()) 這部分:正如咱們所討論的,咱們指望返回一個元組,它帶有下一個用於解析的輸入部分,以及解析結果。&str 是下一個輸入,處理的結果則是單個 () 類型,由於若是這個解析器成功運行,它將只能獲得一個結果(找到了字母 a),而且在這種狀況下,咱們不特別須要返回字母 a,咱們只須要指出已經成功的找到了它就行。

所以,咱們看看解析器自己的代碼。首先獲取輸入的第一個字符:input.chars().next()。咱們並無嘗試性的依賴標準庫來避免帶來 Unicode 的問題 —— 咱們調用它爲字符串的字符提供的一個 chars() 迭代器,而後從其中取出第一個單元。這就是一個 char 類型的項,而且經過 Option 包裝着,即 Option<char>,若是是 None 類型的 Option 則意味着咱們獲取到的是一個空字符串。

更糟糕的是,一個 char 類型甚至可能不是咱們想象的 Unicode 中的字符。這極可能就是 Unicode 中的 「字母集合」,它能夠由幾個 char 類型的字符組成,這些字符實際上表示 「標量值」,它比 "字母集合" 差很少還低 2 個層次。可是,這樣想未免有些激進了,就咱們的目的而言,咱們甚至不太可能看到 ASCII 字符集之外的字符,因此暫且忽略這個問題。

咱們對 Some('a') 進行模式匹配,它就是咱們正在尋找的特定結果,若是匹配成功,咱們將返回成功 Ok((&input['a'.len_utf8]()..], ()))。也就是說,咱們從字符串 slice 中移出的解析的項('a'),並返回剩餘的字符,以及解析後的值,也就是 () 類型。考慮到 Unicode 字符集問題,在對字符串 range 處理前,咱們用標準庫中的方法查詢一下字符 'a' 在 UTF-8 中的長度 —— 長度是1,這樣不會遇到以前認爲的 Unicode 字符問題。

若是咱們獲得其餘類型的結果 Some(char),或者 None,咱們將返回一個異常。正如以前提到的,咱們剛剛的異常類型就是解析失敗時的字符串 slice,也就是咱們咱們傳遞的輸入。它不是以 a 開頭,因此返回異常給咱們。這不是一個很嚴重的錯誤,但至少比「一些地方出了嚴重錯誤」要好一些。

實際上,儘管咱們不是要用這種解析器解析這個 XML,可是咱們須要作的第一件事是尋找開始的 <,因此咱們須要一些相似的東西。特別的,咱們還須要解析 >/=,因此,也許咱們能夠建立一個函數來構建一個解析器用於解析咱們想要解析的字符。

解析器構建器

咱們想象一下:若是要寫一個函數,它能夠爲 任意 長度而不只僅是單個字符的靜態字符串生成一個解析器。這樣作甚至更簡單一些,由於字符串 slice 是一個合法的 UTF-8 字符串 slice,而且暫且不考慮 Unicode 字符集問題。

fn match_literal(expected: &'static str) -> impl Fn(&str) -> Result<(&str, ()), &str> { move |input| match input.get(0..expected.len()) { Some(next) if next == expected => { Ok((&input[expected.len()..], ())) } _ => Err(input), } } 複製代碼

如今看起來有點不同了。

首先,咱們看看類型。咱們的函數看起來不像一個解析器,它如今使用 expected 字符串做爲參數,而且 返回 值是看起來像解析器同樣的東西。它是一個返回值是函數的函數 —— 換句話說,它是一個 高階 函數。基本上,咱們寫的是 生成 一個相似於以前咱們寫的 the_letter_a 同樣的函數。

所以,咱們不是在函數體中執行一些邏輯,而是返回一個閉包,這個閉包纔是執行邏輯的地方,而且與前面的解析器的函數簽名是匹配的。

匹配模式是同樣的,只是咱們不能直接匹配字符串文本,由於咱們不知道他具體是什麼,因此咱們使用條件 if next == expected 來判斷匹配。所以,它和以前徹底同樣,只是邏輯的執行是在閉包的內部。

測試解析器

咱們將編寫一個測試來確保咱們作的是對的。

#[test]
fn literal_parser() {
    let parse_joe = match_literal("Hello Joe!");
    assert_eq!(
        Ok(("", ())),
        parse_joe("Hello Joe!")
    );
    assert_eq!(
        Ok((" Hello Robert!", ())),
        parse_joe("Hello Joe! Hello Robert!")
    );
    assert_eq!(
        Err("Hello Mike!"),
        parse_joe("Hello Mike!")
    );
}
複製代碼

首先,咱們構建解析器:match_literal("Hello Joe!")。這應該使用字符串 Hello Joe! 做爲輸入,並返回字符串的其他部分,不然它應該提示失敗並返回整個字符串。

在第一種狀況下,咱們只是向他提供它指望的具體字符串做爲參數,而後,咱們看到它返回一個空字符串和 () 類型的值,這意味着:「咱們按照正常流程解析了字符串,實際上你並不須要它返回給你這個值」。

在第二種狀況下,咱們給它輸入字符串 Hello Joe! Hello Robert!,而且咱們確實看到它解析了字符串 Hello Joe! 並返回剩餘部分:Hello Robert!(空格開頭的剩餘全部字符串)。

在第三個例子中,咱們輸入了一些不正確的值:Hello Mike!,請注意,它確實根據輸入給出了錯誤並中斷執行。通常來講,Mike 並非正確的輸入部分,它不是這個解析器要尋找的對象。

用於不固定參數的解析器

這樣,咱們來解析 <,>,= 甚至 <//>。咱們實際上作的差很少了。

在開始 < 後的下一個元素是元素的名稱。雖然咱們不能用一個簡單的字符串比較來作到這一點,可是咱們 能夠 用正則表達式來作...

...可是咱們要剋制本身,它將是一個很容易在簡單代碼中複製的正則表達式,而且咱們不須要爲此而去依賴於 regex 的 crate 庫。咱們要試試是否能夠僅僅只使用 Rust 標準庫來編寫本身的解析器。

回顧元素名稱標識符的定義,它大概是這樣:一個字母的字符,而後是若干個字母數字中橫線 - 等多個字符。

fn identifier(input: &str) -> Result<(&str, String), &str> {
    let mut matched = String::new();
    let mut chars = input.chars();

    match chars.next() {
        Some(next) if next.is_alphabetic() => matched.push(next),
        _ => return Err(input),
    }

    while let Some(next) = chars.next() {
        if next.is_alphanumeric() || next == '-' {
            matched.push(next);
        } else {
            break;
        }
    }

    let next_index = matched.len();
    Ok((&input[next_index..], matched))
}
複製代碼

和往常同樣,咱們先查看一下類型。此次,咱們不是編寫函數來構建解析器,而是像最開始的那樣編寫解析器自己。這裏值得注意的不一樣點是,咱們沒有返回 () 的結果類型,而是返回一個元組,其中包含 String 以及輸入的未解析的剩餘部分。這個 String 將包含咱們剛剛解析過的標識符。

記住這一點,首先咱們建立一個空的 String,並將其命名爲 matched。它將做爲咱們的結果值。咱們還會經過輸入的字符串獲得一個迭代器,經過迭代器逐個遍歷分開這些字符。

第一步是看前綴是不是字母開始。咱們從迭代器中取出第一個字符,並檢查他是不是字母:next.is_alphabetic()。在這裏,Rust 標準庫固然會幫助咱們處理 Unicode —— 它將匹配任意字母,不只僅是 ASCII。若是它是一個字母,咱們將把它放入匹配完成的字符串中,若是不是,很明顯,咱們沒有找到元素標識符,咱們將直接返回一個錯誤。

第二步,咱們繼續從迭代器中提取字符,並把它放入構建的 String 中,直到咱們找到一個不符合 is_alphanumeric()(相似於 is_alphabetic()),也不匹配字母表中的任意字符,也不是 - 的字符。

當咱們第一次看到與這些條件不匹配的東西時,這意味着咱們已經完成了解析,所以咱們跳出循環,並返回咱們處理好的 String,記住咱們要從 input 中剝離出咱們已經處理的部分。一樣的,若是迭代器迭代完成,表示咱們到達了輸入的末尾。

值得注意的是,當咱們看到不是字母數字或 - 時,咱們沒有返回異常。一旦匹配了第一個字母,咱們就已經有足夠的內容來建立一個有效的標識符,解析標識符以後,在輸入字符串中解析更多的東西是徹底正常的,因此咱們只需中止解析並返回結果。只有當咱們連第一個字母都找不到時,咱們纔會返回一個異常,由於在這種狀況下,意味着輸入中確定沒有標識符。

還記得咱們要將 XML 文檔解析爲 Element 結構體嗎?

struct Element {
    name: String,
    attributes: Vec<(String, String)>,
    children: Vec<Element>,
}
複製代碼

實際上,咱們剛剛完成了第一部分的解析器,解析 name 字段。咱們解析器返回的 String 就是這樣,對於每一個 attribute 的前面部分來講,它也是適用的解析器。

讓咱們開始測試它。

#[test]
fn identifier_parser() {
    assert_eq!(
        Ok(("", "i-am-an-identifier".to_string())),
        identifier("i-am-an-identifier")
    );
    assert_eq!(
        Ok((" entirely an identifier", "not".to_string())),
        identifier("not entirely an identifier")
    );
    assert_eq!(
        Err("!not at all an identifier"),
        identifier("!not at all an identifier")
    );
}
複製代碼

咱們看到第一種狀況,字符串 i-am-an-identifier 被完整解析,只剩下空字符串。在第二種狀況下,解析器返回 "not" 做爲標識符,其他的字符串做爲剩餘的輸入返回。在第三種狀況下,解析器徹底失敗,由於它找到的首字符並非字母。

組合器

如今咱們能夠解析開頭的 <,而後解析接下來的標識符,可是咱們須要同時解析 這兩個,以便於可以向下運行。所以,下一步將編寫另外一個解析器構建器函數,該函數將兩個 解析器 做爲輸入,並返回一個新的解析器,它按順序解析這兩個解析器。換句話說,是另外一個解析器 組合器,由於它將兩個解析器組合成一個新的解析器。讓咱們看看能不能實現它。

fn pair<P1, P2, R1, R2>(parser1: P1, parser2: P2) -> impl Fn(&str) -> Result<(&str, (R1, R2)), &str>
where
    P1: Fn(&str) -> Result<(&str, R1), &str>,
    P2: Fn(&str) -> Result<(&str, R2), &str>,
{
    move |input| match parser1(input) {
        Ok((next_input, result1)) => match parser2(next_input) {
            Ok((final_input, result2)) => Ok((final_input, (result1, result2))),
            Err(err) => Err(err),
        },
        Err(err) => Err(err),
    }
}
複製代碼

這裏稍微有點複雜,但你應該知道接下來要作什麼:從查看類型開始。

首先,咱們有四個類型:P1P2R1R2。這是分析器 1,分析器 2,結果 1,結果 2。P1P2 是函數,你將注意到它們遵循已創建的解析器函數模式:就像返回值同樣,他們以 &str 做爲輸入,並返回剩餘輸入和解析結果,或者返回一個異常。

可是看看每一個函數的結果類型:P1 是一個解析器,若是成功,它將生成 R1P2 也將生成 R2。最終的解析器的結果是 —— 即函數的返回值 —— 是 (R1, R2)。所以,這個解析器的邏輯是首先在輸入上運行解析器 P1,保留它的結果,而後將 P1 返回的做爲輸入運行 P2,若是這2個方法都能正常運行,咱們將這2個結果合併爲一個元組 (R1, R2)

看看代碼,它也確實是這麼實現的。咱們首先在輸入上運行第一個解析器,而後運行第2個解析器,而後將兩個結果組合成一個元組並返回。若是其中一個解析器遇到異常,咱們當即返回它給出的錯誤。

這樣的話,咱們能夠結合以前的兩個解析器,match_literalidentifier,來實際的解析一下 XML 標籤一開始的字節。咱們寫個測試測一下它是否能起做用。

#[test]
fn pair_combinator() {
    let tag_opener = pair(match_literal("<"), identifier);
    assert_eq!(
        Ok(("/>", ((), "my-first-element".to_string()))),
        tag_opener("<my-first-element/>")
    );
    assert_eq!(Err("oops"), tag_opener("oops"));
    assert_eq!(Err("!oops"), tag_opener("<!oops"));
}
複製代碼

它彷佛能夠運行!但看結果類型:((), String)。很明顯,咱們只關心右邊的值,也就是 String。大部分狀況 —— 咱們的一些解析器只匹配輸入中的模式,而不產生值,所以能夠放心地忽略這種輸出。爲了適應這種場景,咱們要用咱們的 pair 組合器來寫另外兩個組合器:left,它丟棄第一個解析器的結果,並返回第二個解析器和對應的數字,right,這是咱們在咱們上面的測試中想要使用的而不是 pair —— 它丟棄左側的 (),只留下咱們的 String

許可證

本做品版權歸 Bodil Stokke 全部,在知識共享署名-非商業性-相同方式共享 4.0 協議之條款下提供受權許可。要查看此許可證,請訪問 creativecommons.org/licenses/by…

腳註

1: 他不是你真正的叔叔。 2: 請不要成爲聚會上的那我的。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


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

相關文章
相關標籤/搜索