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等)正逐渐引入模式匹配,但它们大多对抽象表示的支持不及活动模式。