Skip to content

Commit 9b661b9

Browse files
committed
組合子剖析器
1 parent 0c05c60 commit 9b661b9

File tree

2 files changed

+205
-0
lines changed

2 files changed

+205
-0
lines changed

book/.vitepress/config.mts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export default defineConfig({
7979
text: "再遇剖析(一)決定算子優先級",
8080
link: "/零.二版/再遇剖析(一)決定算子優先級.md",
8181
},
82+
{
83+
text: "再遇剖析(二)組合子剖析器",
84+
link: "/零.二版/再遇剖析(二)組合子剖析器.md",
85+
},
8286
],
8387
},
8488
{
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
回憶零.一版[實作遞迴下降](../零.一版/剖析(語法分析)#實作手寫遞迴下降)時,吾人曾歸納,遞迴下降函數中主要是兩種短路結構,一是「或」結構,一是「且」結構。
2+
3+
「或」結構對應語法的 `|` ,「且」結構對應語法的 ``。重新看一次全是「且」結構的`變數宣告式`,其語法規則寫為:
4+
5+
```語法
6+
變數宣告式 = "元"・"・"・變數・"="・算式
7+
```
8+
9+
其對應的遞迴下降函式:
10+
11+
```rust
12+
fn 剖析變數宣告(&self, 游標: usize) -> Option<(O變數宣告, usize)> {
13+
let 游標 = self.消耗(游標, O詞::元)?; // 若匹配不了 "元" ,短路返回 None
14+
let 游標 = self.消耗(游標, O詞::音界)?; // 若匹配不了 "・" ,短路返回 None
15+
let (變數名, 游標) = self.剖析變數(游標)?; // 若匹配不了 變數 ,短路返回 None
16+
let 游標 = self.消耗(游標, O詞::等號)?; // 若匹配不了 "=" ,短路返回 None
17+
let (算式, 游標) = self.剖析算式(游標)?; // 若匹配不了 算式 ,短路返回 None
18+
19+
Some((O變數宣告 { 算式, 變數名 }, 游標))
20+
}
21+
```
22+
23+
可以看到遞迴下降函式相較語法規則要長上許多,有沒有辦法做簡化呢?
24+
25+
前 5 行很相似,但在參數與回傳值中仍存在一些差異,如果改寫 `self.消耗(游標, O詞)` ,全改寫為 `self.消耗某詞(游標) -> ((), usize)`,前 5 行的形式就會完全相同:
26+
27+
```rust
28+
fn 剖析變數宣告(&self, 游標: usize) -> Option<(O變數宣告, usize)> {
29+
let (_, 游標) = self.消耗元(游標)?; // 若匹配不了 "元" ,短路返回 None
30+
let (_, 游標) = self.消耗音界(游標)?; // 若匹配不了 "・" ,短路返回 None
31+
let (變數名, 游標) = self.剖析變數(游標)?; // 若匹配不了 變數 ,短路返回 None
32+
let (_, 游標) = self.消耗等號(游標)?; // 若匹配不了 "=" ,短路返回 None
33+
let (算式, 游標) = self.剖析算式(游標)?; // 若匹配不了 算式 ,短路返回 None
34+
35+
Some((O變數宣告 { 算式, 變數名 }, 游標))
36+
}
37+
```
38+
39+
前 5 行已經如此相似,寫出一個巨集`且!`(宏/macro)來生成類似代碼也許是個好主意:
40+
```rust
41+
fn 剖析變數宣告(&self, 游標: usize) -> Option<(O變數宣告, usize)> {
42+
let (_, _ , 變數名, _, _ 算式) =
43+
!(self.消耗元, self.消耗音界, self.消耗, self.消耗等號, self.剖析算式)
44+
Some((O變數宣告 { 算式, 變數名 }, 游標))
45+
}
46+
```
47+
48+
至於這個`且!`具體要怎麽寫,就交由道友自行練習。除了採用宏來簡化,還有另一種函數式語言的思路。
49+
50+
## 組合式剖析器
51+
52+
如果仔細觀察遞迴下降函式的前 5 行,它們的共性是什麼?皆返回 `Option` ,且一遇失敗便回傳 `None` 結束函式,那能否透過 `Option` 內建的 `and_then` 函式來簡化程式呢?
53+
54+
55+
## `and_then` 簡化「且」結構
56+
57+
```rust
58+
fn 剖析變數宣告(&self, 游標: usize) -> Option<(O變數宣告, usize)> {
59+
let (算式, 游標) = self
60+
.消耗元(游標)
61+
.and_then(|(_, 游標)| self.消耗音界(游標))
62+
.and_then(|(_, 游標)| self.剖析變數(游標))
63+
.and_then(|(變數名, 游標)| self.消耗賦值(游標))
64+
.and_then(|(_, 游標)| self.剖析算式(游標))?;
65+
66+
Some((O變數宣告 { 算式, 變數名 }, 游標))
67+
}
68+
```
69+
70+
這寫法有一個大問題一個小問題。
71+
72+
- 大問題:`變數名`卡在閉包裡,外部截取不到,導致編譯失敗
73+
- 小問題:`游標`還是反覆出現,改寫後程式碼簡潔不了多少
74+
75+
### 封裝 and_then
76+
先來解決小問題,在 `and_then` 方法上包裝一層就能消除掉重複使用 `游標` 了:
77+
78+
```rust
79+
// Rust 得先宣告 trait 才能給內建型別加方法
80+
trait 組合子<U> {
81+
fn 且<F>(self, f: F) -> Option<(U, usize)>
82+
where
83+
F: FnOnce(usize) -> Option<(U, usize)>;
84+
}
85+
86+
impl<T, U> 組合子<U> for Option<(T, usize)> {
87+
fn 且<F>(self, f: F) -> Option<(U, usize)>
88+
where
89+
F: FnOnce(usize) -> Option<(U, usize)>,
90+
{
91+
// 其他行都在寫類型
92+
// 可以先看這行就好:封裝掉游標傳遞
93+
self.and_then(|(_語法, 游標)| f(游標))
94+
}
95+
}
96+
```
97+
98+
在給 `Option<(T, usize)>` 加上``方法後就能將`剖析變數宣告`寫成
99+
```rust
100+
fn 剖析變數宣告(&self, 游標: usize) -> Option<(O變數宣告, usize)> {
101+
let (算式, 游標) = self
102+
.消耗元(游標)
103+
.且(self.消耗音界)
104+
.且(self.剖析變數)
105+
.且(self.消耗賦值)
106+
.且(self.剖析算式)?;
107+
108+
Some((O變數宣告 { 算式, 變數名 }, 游標))
109+
}
110+
```
111+
已經十分簡潔了。
112+
113+
實際上,上面這個函式編譯過不了,因為`消耗音界`的類型是`fn(&self: Self, 游標: usize)`,是一個接受兩個參數的函式,而 rust 不支援柯里化,編譯器接收到了一個 self 參數,仍會抱怨缺一參數。再進一步把每個剖析函數的回傳類型都改成 `(T, 游標, Self)` 或許能解決這問題。現在,暫時就先把參數加上去以過編譯。
114+
115+
傳遞 Self 也只是為了獲取 `VecDequeue<O詞>` 類型的 `詞流`,直接開個新結構把 `詞流` 以及 `游標` 的資訊一起傳遞還比較容易點,用切片又更簡單。
116+
117+
TODO: 剖析器零.一版範例程式一開始就用切片而非游標來當剖析函式的參數...
118+
119+
### 傳遞剖析結果
120+
再來解決剖析結果卡在閉包的大問題,解法一樣是透過包裝``來完成,讓每個剖析函數的的回傳值都能一路傳遞下來:
121+
122+
```rust
123+
trait 組合子<T, U> {
124+
fn 且<F>(self, f: F) -> Option<((T, U), usize)>
125+
where
126+
F: FnOnce(usize) -> Option<(U, usize)>;
127+
}
128+
129+
impl<T, U> 組合子<T, U> for Option<(T, usize)> {
130+
fn 且<F>(self, f: F) -> Option<((T, U), usize)>
131+
where
132+
F: FnOnce(usize) -> Option<(U, usize)>,
133+
{
134+
// 將之前的剖析結果與新剖析結果以 tuple 包裝
135+
self.and_then(|(語法, 游標)| f(游標).map(|(新語法, 游標)| ((語法, 新語法), 游標)))
136+
}
137+
}
138+
```
139+
最後回傳結構是巢狀的元組:
140+
```rust
141+
fn 剖析變數宣告(&self, 游標: usize) -> Option<(O變數宣告, usize)> {
142+
let ((((_, 變數名), _), 算式), 游標) = self
143+
.消耗元(游標)
144+
.且(self.消耗音界)
145+
.且(self.剖析變數)
146+
.且(self.消耗賦值)
147+
.且(self.剖析算式)?;
148+
149+
Some((O變數宣告 { 算式, 變數名 }, 游標))
150+
}
151+
```
152+
153+
這種寫法就被稱為組合子剖析器(也稱 parser combinator、剖析器組合子)。組合子函式泛指 `and_then`, `or_else`, `map`, `filter` 這類型高階函式,所以鏈式應用高階函式來寫剖析器就被叫做組合子剖析器了。
154+
155+
組合子剖析器能接近宏的簡潔程度,又避免掉宏不易除錯的問題。近年似乎漸露頭角,有好些新興的剖析庫都以此風格寫就。
156+
157+
## `or_else` 如法炮製「或」結構
158+
類似地,「或」結構也能用高階函數來簡化,回憶``的定義與遞迴下降函式:
159+
160+
```語法
161+
句 = 變數宣告式
162+
| 算式
163+
```
164+
165+
```rust
166+
fn 剖析句(&self, 游標: usize) -> Option<(O句, usize)> {
167+
if let Some((變數宣告, 游標)) = self.剖析變數宣告(游標) {
168+
return Some((O句::變數宣告(變數宣告), 游標));
169+
}
170+
if let Some((算式, 游標)) = self.剖析算式(游標) {
171+
return Some((O句::算式(算式), 游標));
172+
}
173+
None
174+
}
175+
```
176+
177+
「且」結構還能靠 Rust 的 `?` 來簡寫,「或」結構就得自己手寫 `if ... return` 來短路,顯得更加冗長。
178+
179+
先試著用 `or_else` 來簡化短路
180+
181+
```rust
182+
fn 剖析句(&self, 游標: usize) -> Option<(O句, usize)> {
183+
self.剖析變數宣告(游標)
184+
.map(|(變數宣告, 游標)| (O句::變數宣告(變數宣告), 游標))
185+
.or_else(|| {
186+
self.剖析算式(游標)
187+
.map(|(算式, 游標)| (O句::算式(算式), 游標))
188+
})
189+
}
190+
```
191+
好像沒什麼改善空間了,「或」語句不需要傳遞之前的剖析結果,而 `map` 中的類型轉換函數很難再省掉,就算省,也是在自動類型轉換上下功夫,與組合子關係不大了。
192+
193+
## 「重複」結構呢?
194+
195+
`音界咒 = 句.句*` 這種「重複」結構,也可以透過組合子來達成,在 `` 之外封上一層迴圈就能做到吧,同樣留作道友習題。
196+
197+
198+
## 總結
199+
組合子剖析器無非就是種風格,若嚴格地只使用組合子,那結構會很單純,風格會很一致。但想要混用原本遞迴下降平鋪直敘的寫法,或是加上一些宏也沒什麼問題。例如要結合應用前一章所說的優先級決定算法,也許遞迴下降的寫法更好寫一些喔。
200+
201+
零.二版的新語法脫不出這幾項結構,就不再將新的剖析代碼貼出來了。

0 commit comments

Comments
 (0)