Recap Active Pattern in F#
原创:顾远山
著作权归作者所有,转载请标明出处。
笔者早前针对F#中活动模式的应用列举了五个小例,分别为活动模式的简介(小例一)、以及把活动模式应用到各类数据的解析场景,包括日期(小例二)、指令(小例三)、网页(小例四)和结构化数据(小例五)等。然而很多时候知其然是不够的,为了知其所以然,笔者对活动模式进行了简单的小结,分为三个部分:活动模式的由来,活动模式的分类,以及活动模式的本质,以便大家能深刻理解活动模式并灵活自如地把它应用到项目实践中。
活动模式的由来
模式匹配对于函数式编程语言来说是标配,参考F#语言指南:模式匹配我们可以发现,F#中的模式匹配只支持以下十七种模式:
模式匹配众所周知,以上有限的模式匹配与抽象的交互很差,所以F#的设计者们决定引入活动模式,以支持对通用异构数据的抽象表示进行模式匹配。活动模式在设计上结合了完全分解数据的视图功能和部分分解数据的其他匹配功能,在实现上则是基于F#语言一个简单轻巧的语法扩展,其官方基本语法如下:
// Active pattern of one choice.
let (|identifier|) [arguments] valueToMatch = expression
// Active Pattern with multiple choices.
// Uses a FSharp.Core.Choice<_,...,_> based on the number of case names.
// In F#, the limitation n <= 7 applies.
let (|identifer1|identifier2|...|) valueToMatch = expression
// Partial active pattern definition.
// Uses a FSharp.Core.option<_> to represent if the type is satisfied at the call site.
let (|identifier|_|) [arguments ] valueToMatch = expression
活动模式的分类
上文提到活动模式适用三种语法,但从实践角度我们习惯把它分为四类:
- 单例完全模式
- 多例完全模式
- 单例部分模式
- 参数部分模式
现针对其语法及适用场景逐个分述。
单例完全模式 (Single-case Total Pattern)
单例完全模式语法为:let (|ActivePatternName|) input = ...
。
活动模式需要被(|
和|)
进行定义,(|
和|)
又俗称"香蕉夹",其中(|
和|)
之间的字面量则是活动模式的名称。因为是单例完全,所以香蕉夹之间只有一个字面量。如下例:
type Account = {Phone:string; Password:string; Name:string; Address:string; Email:string}
let (|LoginCredentials|) user = (user.Phone,user.Password)
LoginCredentials
就是活动模式的名字,其目的是从Account类型的记录中抽取Phone
和Password
字段组成二元组并返回。
单例完全模式适合“视图”功能的场景,当需要从已有类型中抽取特定的字段进行组合及计算时,可使用单例完全模式。再如下段代码中的Area
活动模式:
type Shape =
| Circle of Radius:float
| Rectangle of Width:float * Height:float
let (|Area|) shape =
match shape with
| Circle radius -> radius * radius * 3.14
| Rectangle (width,height) -> width * height
多例完全模式 (Multiple-case Total Pattern)
多例完全模式的语法为:let (|APN1|APN2|APN3|...|APNm|) input = ...
,其中字面量APN1
...APNm
是所有活动模式的名称。由于是多例完全,所以香蕉夹里有m个字面量。如以前举过的例子:
let (|IDNumber|PassportNumber|UnknownNumber|) input =
match Regex(@"\d{18}").Match(input).Success with
| true -> IDNumber
| _ ->
match Regex(@"G|E\d{8}").Match(input).Success with
| true -> PassportNumber
| _ -> UnknownNumber
上述活动模式对数据进行判断及分类:
- 若输入字符串是18位数字,理解为身份证号码,返回
IDNumber
模式 - 若输入字符串以
G
或E
开头,后接8位数字,理解为护照号码,返回PassportNumber
模式 - 若输入字符串不满足上述条件,理解为未知号码,返回
UnknownNumber
模式
多例完全模式适合条件判断的场景,当某个输入要么是甲要么是乙要么是丙要么是丁时,可使用多例完全模式。再如以下代码中的``活动模式:
let (|ADD|SUB|MUL|DIV|REM|) input =
let elements = Regex(@"(\d+)\s*([\+\-\*\/\%])\s*(\d+)").Match(input).Groups
let operand1 = elements.[1].Value
let operator = elements.[2].Value
let operand2 = elements.[3].Value
match operator with
| "+" -> ADD(operand1,operand2)
| "-" -> SUB(operand1,operand2)
| "*" -> MUL(operand1,operand2)
| "/" -> DIV(operand1,operand2)
| "%" -> REM(operand1,operand2)
上述活动模式把诸如"12 + 5"
或"100 % 3"
之类的字符串通过正则表达式转换成抽象语法树片段。
单例部分模式 (Single-case Partial Pattern)
单例部分模式的语法为:let (|ActivePatternName|_|) input = ...
。
正如香蕉夹中的内容所示,单例部分模式由两种(且只有两种)模式组合而成,其中第二种模式总是为通配符,实际上单例部分模式可以理解为多例完全模式在二维上的一个特例。
比如常见的整型解析,如下:
let (|ParseInt|_|) input =
match Int32.TryParse(input.ToString()) with
| true, i -> Some (i)
| _ -> None
单例部分模式非常适合处理Option
类型值的场景,可提高函数式代码的纯粹性,是替代传统try
...with
异常处理的良好实践。
从活动模式设计者发表的论文易知,他们没有发掘到多例部分模式的好处,所以F#并不支持多例部分模式。
参数部分模式 (Parameterized Partial Pattern)
参数部分模式的语法为:let (|ActivePatternName|_|) param1 ... paramN input = ...
。
参数部分模式是单例部分模式的扩展,在活动模式名称和输入之间,可定义一到多个参数,其中param1
... paramN
是参数的名称。如在小例中应用多次的正则表达式匹配活动模式:
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] |> List.tail)
上述活动模式通过pattern
这个参数对输入字符串input
进行正则表达式匹配:
- 若输入字符串为空,返回
Option
类型值None
- 若输入字符串不为空,且不能被正则表达式通过
pattern
进行匹配,返回Option
类型值None
- 若输入字符串不为空,且能被正则表达式通过
pattern
进行匹配,返回所有匹配组的值列表
参数部分模式为实际的项目应用提供了极大的灵活性,但活动模式的参数化会导致模式匹配同一性的损失,因此编译器无法对其执行冗余或完整性分析,所以在同一个match
块内,就算每个模式在语法上都有相同的参数,它们出现的时候仍需要被重新求值。比如之前演示过的日期解析器代码:
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
虽然match
块内只有五个形式一致的RegexMatch p [...]
模式,但由于参数化的缘故,每一个模式都会被求值,另外,最后也需要手动添加通配符模式以保证匹配的完整性。
活动模式的本质
无论是单例完全模式、多例完全模式、单例部分模式还是参数部分模式,严格意义上来说,都是针对F#模式匹配的语言扩展,也就是语法糖,究其本质,所有的活动模式都是函数,只是类型有异,如下:
活动模式在函数式编程语言中,函数是头等公民。活动模式在F#中本质就是函数,所以自然也属于头等公民。既然活动模式是头等公民,那它就可以被当成“值”用于“值”可用的地方。略举二例如下:
-
活动模式作为函数的参数
复用单例完全模式中的
LoginCredentials
活动模式,易得login
函数:let login (LoginCredentials(phone,password)) = ...
该函数的签名为
Account -> unit
,它接受一个Account
类型的参数,但此参数在函数定义时就被活动模式LoginCredentials
分解为phone
和password
两个值,函数体内可直接使用这两个值,而Account类型的参数中其他字段值则被函数忽略。此例再次演示了单例完全模式常被用作数据筛选的“视图”。 -
活动模式作为值参与运算
当活动模式在模式匹配中被用于条件判断时,可以带上输出作为值进行组合运算,如下例:
let (|DividedBy|) d y = y % d = 0 let isLeapYear year = match year with | DividedBy 400 true -> true | DividedBy 4 true & DividedBy 100 false -> true | _ -> false
其中
DividedBy 4 true
和DividedBy 100 false
两个模式进行了与运算,而模式匹配本身就是或运算。
结语
-
活动模式是F#中针对通用异构数据的抽象表示基于模式匹配的语言扩展;
-
活动模式可分为单例完全模式、多例完全模式、单例部分模式和参数部分模式四种类型;
-
活动模式是F#中的头等公民,其本质是函数,可作为参数传入高阶函数,亦可带输出作为值进行组合运算。
参考资料
Extensible Pattern Matching Via a Lightweight Language Extension
(Nearly) Everything You Ever Wanted to Know About F# Active Patterns
F#语言指南:活动模式
活动模式小例(一)
活动模式小例(二)
活动模式小例(三)
活动模式小例(四)
活动模式小例(五)
网友评论