Applied Active Pattern in F# (2)
原創:顧遠山
著作權歸作者所有,轉載請標明出處。
活動模式允許你通過定義命名分區對輸入數據進行分割 ,并在模式匹配表達式中如可區分聯合一般使用。命名分區可直觀地描述輸入數據是什么,而分割則決定輸入數據如何被利用。活動模式是F#語言的一個特性,它可用于按需把數據分割成不同模式繼而被后續模式匹配邏輯所利用,靈活且強大。
《活動模式小例(一)》
從官方定義及推論可知,活動模式的強項就是定義及分割數據,小例(一)僅對其進行簡單介紹,接下來本文將進一步闡述活動模式在數據處理中的應用。
問題描述
本例將利用活動模式實現一個自定義且可擴展的日期解析器。目標是接受一個諸如"2017/4/5"
的字符串,按照短日期的模式把其中的年份、月份和日提取出來,并轉換成數據模型中預定義的日期格式。
高階設計
假設我們的數據模型里已有定義好的日期格式,包括代表月份的枚舉類型Mon
和代表日期類型的記錄類型Date
,如下:
type Mon = Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec
type Date = {Year:int; Month:Mon; Day:int}
于是解決方案不妨這樣設計:
測試用例可以有如下:
輸入數據 | 匹配模式 | 年 | 月 | 日 | 輸出數據 |
---|---|---|---|---|---|
2017/4/5 | 年/月/日 | 2017 | 4 | 5 | {Year=2017; Month=Apr; Day=5} |
2018-5-6 | 年-月-日 | 2018 | 5 | 6 | {Year=2018; Month=May; Day=6} |
2019.6.7 | 年.月.日 | 2019 | 6 | 7 | {Year=2019; Month=Jun; Day=7} |
2020/07/08 | 年/月/日 | 2020 | 7 | 8 | {Year=2020; Month=Jul; Day=8} |
2021-08-09 | 年-月-日 | 2021 | 8 | 9 | {Year=2021; Month=Aug; Day=9} |
2022.09.10 | 年.月.日 | 2022 | 9 | 10 | {Year=2022; Month=Sep; Day=10} |
23/10/11 | 年/月/日 | 2023 | 10 | 11 | {Year=2023; Month=Oct; Day=11} |
24-11-12 | 年-月-日 | 2024 | 11 | 12 | {Year=2024; Month=Nov; Day=12} |
25.12.13 | 年.月.日 | 2025 | 12 | 13 | {Year=2025; Month=Dec; Day=13} |
28-DEC-2020 | 日-月-年 | 2020 | DEC | 28 | {Year=2020; Month=Dec; Day=28} |
December 29, 2020 | 月 日, 年 | 2020 | December | 29 | {Year=2020; Month=Dec; Day=29} |
Dec 30, 2020 | 月 日, 年 | 2020 | Dec | 30 | {Year=2020; Month=Dec; Day=30} |
Dec 31st, 2020 | 月 日+, 年 | 2020 | Dec | 31 | {Year=2020; Month=Dec; Day=31} |
2021年1月1日 | 年月日 | 2021 | 1 | 1 | {Year=2021; Month=Jan; Day=1} |
例子僅為演示活動模式的使用,并非用于實際生產,所以我們約定所有的日期均屬于21世紀。
利用活動模式實現
本質上來說,這個日期解析器的核心功能,就是把年、月、日從字符串中提取出來。而三者又各自有規則,這種情況非常契合活動模式的應用場景——定義輸入數據是什么,以及分割輸入數據再利用。
于是我們可以分別對年、月、日進行活動模式定義,如下:
let (|Year|) (s:string) =
match int s with
| y when y < 99 -> y + 2000 //兩位數字的年份補全為四位
| y when y > 1999 && y < 2100 -> y //只支持21世紀的年份
| _ -> -1 //補充無效值
let (|Month|) (s:string) =
let s' = match s.StartsWith('0') with
| true -> s |> int |> string //去掉月份的前置0
| _ -> s
match s'.ToUpper() with
| "JAN" | "JANUARY" | "1" -> Jan
| "FEB" | "FEBRUARY" | "2" -> Feb
| "MAR" | "MARCH" | "3" -> Mar
| "APR" | "APRIL" | "4" -> Apr
| "MAY" | "5" -> May
| "JUN" | "JUNE" | "6" -> Jun
| "JUL" | "JULY" | "7" -> Jul
| "AUG" | "AUGUST" | "8" -> Aug
| "SEP" | "SEPTEMBER"| "9" -> Sep
| "OCT" | "OCTOBER" | "10" -> Oct
| "NOV" | "NOVEMBER" | "11" -> Nov
| "DEC" | "DECEMBER" | _ -> Dec
let (|Day|) (s:string) =
match int s with
| d when d > 0 && d < 32 -> d //只支持1日到31日
| _ -> -1 //補充無效值
因為不同的輸入數據對應的模式各異,我們希望通過正則表達式進行匹配,所以把匹配過程也演變成活動模式,如下:
let (|RegexMatch|_|) pattern input = //用指定模式把輸入字符串分割為若干組
match input with
| null -> None //補充無效輸入
| _ ->
let m = Regex.Match(input, pattern) //用正則表達式匹配模式
match m.Success with
| false -> None //補充匹配失敗的無效值
| _ ->
Some [for x in m.Groups -> x.Value] //返回所有捕獲組的值列表
從(|RegexMatch|)
這個活動模式我們可以看出,活動模式可以帶參數,而輸入數據,參數和輸出數據的類型可以不同。在本例中:input
為輸入數據,字符串型;pattern
為活動模式的參數,字符串型,在后續的模式匹配中必須以“活動模式”+“參數”的形式 出現,Some (...)
和None
則為輸出數據,選項類型。
基于這四個活動模式,我們就可以把它們按照高階設計耦合起來完成實現,如下:
let parseDate dstr =
let p1 = @"^(\d{4}|\d{2})([/\-\.])(\d{1,2})\2(\d{1,2})$"
let p2 = @"^(\d{1,2})([/\-\.])(.+?)\2(\d{4}|\d{2})$"
let p3 = @"^(.+?)\s(\d{1,2})\,\s*(\d{4}|\d{2})$"
let p4 = @"^(.+?)\s(\d{1,2}).{2}\,\s*(\d{4}|\d{2})$"
let p5 = @"^(\d{4}|\d{2})年(\d{1,2})月(\d{1,2})日$"
match dstr with
| RegexMatch p1 [_;Year y;_;Month m;Day d]
| RegexMatch p2 [_;Day d;_;Month m;Year y]
| RegexMatch p3 [_;Month m;Day d;Year y]
| RegexMatch p4 [_;Month m;Day d;Year y]
| RegexMatch p5 [_;Year y;Month m;Day d]
-> Some {Year=y; Month=m; Day=d}
| _ -> None
這個日期解析器的實現,乍一眼看去好像什么都沒干,再一眼看去又好像全部干完了。為了更清楚地闡明活動模式的使用,我們進行細節剖析,如下:
- 淡黃色兩部分等價,其中
dstr
為輸入數據,而|
之后的字符串則是針對輸入數據進行活動模式匹配的其中一種情況,說明dstr
可以被匹配為這種活動模式 -
(|RegexMatch|)
是用于進行正則表達式匹配的活動模式(標注2),它接受一個模式作為參數(標注3),對輸入數據(標注1)進行匹配,并把返回輸出數據(標注4) -
(|Year|)
是用于進行年份匹配的活動模式(標注5),它不接受參數,匹配后直接返回輸出數據(標注6)
所以,上面這段代碼實際上應用了活動模式嵌套,即對原始輸入數據進行帶參數的活動模式匹配后得到的結果再進行不帶參數的活動模式匹配,相當于輸入的dstr
,在p
模式的匹配下,直接被分割成年份y
、月份m
和日d
,繼而被灌入數據模型中。
觀察一下測試結果,所有新用例都被解析成預期的結果,驗證通過。
有意思的是Dec 31st, 2020
不是常規的短日期格式,人類理解無礙,但對于計算機來說,表示順序的后綴st
其實是噪音,大多數編程語言內置的日期解析器并不能處理這種格式。正因為缺省不能涵蓋所有情況,自定義的世界才顯得更豐富多彩。
對于業務一時一變的格式要求,基于活動模式實現的日期解釋器可以很靈活地進行擴展,大多數時候,通過增加一個匹配模式的少量代碼,就能應付這種變化。
結語
本例通過一個簡單日期解析器的例子演示了利用活動模式進行數據定義和數據分割的靈活性和可擴展性。
活動模式可以只是對數據分類(沒有返回值),也可以有不限類型的返回值,更可以接受參數對數據進行分割,還可以嵌套使用。
在實際項目中,活動模式的應用場景很多,包括各類解析器,比如日志解析器、網頁解析器、XML解析器、JSON解析器等,這些工具能助力后續的數據分析任務。
另外,活動模式也廣泛用于領域特定語言編譯器的實現,其本質也是解析器,不過它在此場景解析的是操作指令。
活動模式是函數式編程語言(如F#)的內建特性,雖然很多現代編程語言(包括C#,Swift等)正逐漸引入模式匹配,但它們大多對抽象表示的支持不及活動模式。