Applied Active Pattern in F# (5)
原创:顾远山
著作权归作者所有,转载请标明出处。
Active Patterns: Views for Structured Data. Pattern matching is a key technique provided in F# for decomposing domain models and other representations.
Expert F# 4.0 by Don Syme, Adam Granicz, Antonio Cisternino
在此之前,本系列已经有四篇文章,小例(一)介绍了F#中的活动模式,小例(二)把活动模式应用于日期解析器,小例(三)通过活动模式演示了如何把前端输入和后端逻辑解耦,小例(四)借力活动模式实现了蝇量级网络爬虫。但这四个例子中,被分割的输入数据全部都是文本(字符串)类型,本例将展示使用活动模式分解结构化数据,简化模式匹配的条件,并揭示活动模式的本质,例子虽然简单,但涉及的要点算是活动模式的进阶应用了。
问题描述
特斯拉官网上有四个型号的电动汽车,数据如下:
型号 | 长度 (mm) | 宽度 (mm) | 高度 (mm) | 重量 (kg) | 续航里程 (km) |
---|---|---|---|---|---|
Model S | 4970 | 1964 | 1445 | 2327 | 712 |
... | ... | ... | ... | ... | ... |
笔者想选出续航里程在500km到600km之间的型号。
高阶设计
正如大神(们)所言,活动模式可用作结构化数据的视图,于是实现的逻辑简单直白,如下:
- 初始化电动汽车数据列表;
- 读取列表中的电动汽车数据;
- 若数据为空则程序结束,数据非空则通过视图(活动模式)从电动汽车数据中提取续航里程;
- 通过条件(活动模式)对续航里程进行区间范围比对,符合条件则输出结果,不符合条件则略过;
- 重复步骤2至步骤4。
易得高阶设计,如下:
其中完全活动模式(|View|)
用作从电动汽车数据中获取续航里程的视图,而分部活动模式(|Between|_|)
则用于对续航里程进行区间范围比对的匹配条件。
动手之前
在这里有必要先科普几个代码实现中即将用到的F#
语言特性,也许在其他编程语言没有类似的特性,由泛及专如下:
- 记录类型
- 函数的柯里化
- 度量单位
笔者尽量引用官方和权威的说法,并辅以例子解释,以期不至于晦涩。
记录类型
根据MSDN,记录类型表示命名值的简单聚合,其中成员可选。它们可以是结构体也可以是引用类型,但默认是引用类型。语法如下:
[ attributes ]
type [accessibility-modifier] typename =
{ [ mutable ] label1 : type1;
[ mutable ] label2 : type2;
... }
[ member-list ]
记录类型非常适合用于表示含有特定字段的结构化数据。比如外国人的姓名可以是一个记录类型type FullName = {FirstName:string; LastName:string}
,比如大神Don Syme的名字就可以初始化为此类型的一条记录let name = {FirstName="Syme"; LastName="Don"}
。多条记录放在一起,就跟二维表相当,其中每一条记录对应一行,每一个字段对应一列。
记录类型有字段也可以有成员(方法),但它和面向对象编程范式里的类主要区别在于以下两点:
记录类型的字段自动暴露为属性并用于创建和复制记录,而类的属性、字段和参数等与类的构建无直接关系;
记录类型是结构相等性语义,而类是引用相等性语义。
柯里化和偏函数应用
在数学和计算机科学里,柯里化是一种函数转换方法,它把一个接受多个入参的函数转换成一系列只接受单个参数的函数。而偏函数应用也被称为部分函数应用。两者有关联,但不一样,最显著的区别在于:偏函数应用的调用直接返回结果,而柯里化的调用则返回柯里化链的下游函数。举例如下:
-
柯里化:
函数 被柯里化之后为,当我们传入之后得到的是另一个函数,而这个函数依然是只接受一个参数并返回一个函数的函数。
-
偏函数应用:
函数 被偏函数应用之后得到的是,它返回的是另一个函数,这个函数接受的参数为,返回值结果,只是这个函数的入参个数相比原函数减少了一个。
所以偏函数应用可以被看成是柯里化的函数在某个固定点被求值,记作:
具体到F#语言,我们用一个简单的函数let Add a b c v = v + a + b + c
(函数类型:int -> int -> int -> int -> int
)来举例说明,如下:
类别 | 第一步 | 第二步 | 第三步 | 总结 |
---|---|---|---|---|
函数柯里化 | let AddA a = Add a; int -> (int -> int -> int -> int); 接受一个入参,返回一个函数 | Let AddAB a b = Add a b; int -> int -> (int -> int -> int); 接受两个入参,返回一个函数 | let AddABC a b c = Add a b c; int -> int -> int -> (int -> int); 接受三个入参,返回一个函数 | 入参个数逐一递增,并始终返回一个函数 |
偏函数应用 | let Add10 = AddABCOnV 10; int -> int -> int -> int; 接受三个入参,返回值 | let Add30 = Add10 20; int -> int -> int; 接受两个入参,返回值 | let Add60 = Add30 30; int -> int; 接受一个参数,返回值 | 入参个数逐一递减,并始终返回值 |
度量单位
在现实的工程实践中,数字本身是没有意义的,只有加上了度量单位才有实际意义。举个简单的例子,假设我们需要用表达式v
来表示行驶速度,有let v = 200
,但它到底是200
呢,还是200
?不同人也许有不同的缺省理解。在一个多人团队的合作中,缺乏度量单位的数字,往往会诱发不必要的(沟通/返工)成本。
还是引用那个经典到不能再经典的例子,以下是谷歌的塑料翻译:度量单位对从事科学研究的程序员来说是无价的,它们增加了一层保护,以防止转换相关的错误。 举一个著名的案例研究为例,NASA耗资1.25亿美元的“火星气候轨道器”项目以失败而告终,原因是该轨道器比原先的预期距离火星倾斜了90公里,导致其破裂并在火星大气层中破裂。 事后分析将问题的根本原因缩小到用于降低航天器进入轨道的轨道器推进系统中的转换错误:NASA将数据以公制单位传递给系统,但软件以英制单位传递了预期数据。 尽管有许多导致项目执行失败的项目管理错误,但是如果软件工程师使用了功能强大的类型系统来检测与单元相关的错误,则可以特别防止该软件错误。
非常庆幸的是,F#支持度量单位,而且这是F#专有的语言特性,它的设计者是大神Andrew Kennedy。具体语法如下:
[] type unit-name [ = measure ]
比如[
,简洁明了。
代码实现
电动汽车型号数据需要度量单位的支持,我们先把所需的度量单位定义好,如下:
[] type mm
[] type kg
[] type km
详细数据非常结构化,不妨用记录类型进行初始化,于是定义Vehicle
类型,如下:
type Vehicle = {Model:string; Length:int; Height:int; Width:int; Weight:int;Range:int}
数据有很多列,本例我们只取续航里程
这一列,易得活动模式,如下:
let (|View|) vehicle = vehicle.Range
得益于F#的类型推断,活动模式(|View|)
可被自动推断出来类型Vehicle -> int
。它看起来和一般函数很像,但它是活动模式,被用作视图从Vehicle类型中抽取了Range字段。
续航里程
被抽取出来之后,我们需要借助一个辅助函数between进行区间比对,如下:
let between min max value = if value >= min && value <= max then Some() else None
正如字面所示,若目标值大于等于区间最小值且小于等于区间最大值,返回某物,反之返回无物。在此Some()
和None
都是Option类型,与其他语言中的Maybe
,Just
,Nothing
相当。
然而这个between函数非常空泛,从它的函数类型'a -> 'a -> 'a -> unit option'
就能看出来。毕竟它只是个辅助函数,通过柯里化调用它我们可以得到活动模式(|Between|_|)
,从而实现模式匹配条件的简化,如下:
let (|Between|_|) minValue maxValue = between minValue maxValue
值得注意的是,函数between
期待接受三个入参,返回一个unit option
类型的值,而活动模式(|Between|_|)
只传给它两个入参,如果编程语言不支持柯里化,这样的使用方式会报错(实参个数于函数签名的入参个数不一致),但F#是函数式编程语言,天生就支持函数的柯里化,所以活动模式(|Between|_|)
不但不会报错,而且会返回一个函数,其类型为'a -> unit option'
。这个函数的目的非常明显,就是接受一个续航里程
,返回是否在区间范围内的比对结果。从这个例子我们可以看出来,活动模式本质上还是函数,而且是调用方式倒置的函数。
基于这两个活动模式,我们便可把整个高阶设计的逻辑实现如下:
let getMyPreference vehicles =
vehicles |> List.iter (fun vehicle ->
let (View range) = vehicle
match range with
| Between 500 600 -> printfn "%s is my preference." vehicle.Model
| _ -> ignore vehicle)
这段代码有两个关键点,如下:
-
续航里程range
的提取,很多从非函数式编程语言转过来的同学或许会误用惯性思维写成let range = View vehicle
,这种写法是普通的函数调用,而刚才我们揭示了活动模式的本质是调用方式倒置的函数,所以按普通函数调用的写法是不能达到目的的。调用方式倒置是结果,其原因是我们通过这个活动模式对输入数据进行匹配抽取结果,=
右边的vehicle
是输入数据,而range
是在(|View|)
这个活动模式下被匹配到的结果。这也是大神们把活动模式成为结构化数据视图的原因,就算对于同一份输入数据,在不同的活动模式下,它可以被匹配到不同的结果,可谓是真·千人千面。在现实工程实践中,对于上游系统传入的同一份数据,下游系统不同的使用场景关注的重点各有差异,活动模式作为视图用作数据提取,比写一堆独立/关联的提取函数或者在同一个提取函数中并列/嵌套一堆if-else,业务逻辑的清晰度和可扩展性,代码可读性和可维护性,强了不是一点半点。 - 比对过程通过调用匹配活动模式
(|Between|)
达到目的,这里我们传入两个实参,500
是比对区间最小值,600
是比对区间最大值,其中应用到度量单位km。正如前文提到,这个活动模式并不会计算结果,它其实返回的是一个类型为'a -> unit option
的函数,而活动模式本质是调用方式倒置的函数,其输入即待匹配的数据,在此例中正是待比对的续航里程range
,当它作为入参以倒置的方式被传入活动模式后,便可返回Some()
或None
的结果作为模式匹配的条件了。若比对过程返回了某物
,说明候选值落在比对区间范围内,我们就得到了笔者在问题描述中期待的结果。
至此,实现完成,初始化汽车模型数据(详见附录)后执行函数即可获取结果,如下:
teslaModels |> getMyPreference
结果符合预期,Model Y
的续航里程
在500
和600
之间,是笔者的选择,如下:
结语
- 活动模式可用作视图,从结构化数据中抽取信息;
- 活动模式也可用作柯里化链的下游函数,简化模式匹配的条件;
- 活动模式本质上还是函数,而且是调用方式倒置的函数
附录
汽车类型数据的初始化代码如下:
let modelS = {Model= "Model S"; Length = 4970; Height = 1445; Width = 1964; Weight = 2327; Range = 712}
let model3 = {Model= "Model 3"; Length = 4694; Height = 1443; Width = 1850; Weight = 1844; Range = 605}
let modelX = {Model= "Model X"; Length = 5036; Height = 1684; Width = 1999; Weight = 2577; Range = 605}
let modelY = {Model= "Model Y"; Length = 4750; Height = 1626; Width = 1920; Weight = 2003; Range = 594}
let teslaModels = [modelS; model3; modelX; modelY]