代码改变世界

F#探险之旅(二):函数式编程(下)

2008-09-06 11:29 Anders Cui 阅读(...) 评论(...) 编辑 收藏

模式匹配(Pattern Matching)
 
模式匹配允许你根据标识符值的不同进行不同的运算
。有点像一连串的if...else结构,也像C++和C#中的switch,但是它更为强大和灵活。
看下面Lucas序列的例子,Lucas序列定义跟Fibonacci序列一样,只不过起始值不同:

Code
let rec luc x =
match x with
| x when x <= 0 -> failwith "value must be greater than zero"
| 1 -> 1
| 2 -> 3
| x -> luc(x - 1) + luc(x - 2)

printfn
"(luc 2) = %i" (luc 2)
printfn
"(luc 6) = %i" (luc 6)


这里可以看到模式匹配的简单应用,使用关键字match和with,不同的模式规则间用“|”隔开,而“->”表示如果该模式匹配,运算结果是什么。
这个例子的打印结果为:

Output
(luc 2) = 3
(luc
6) = 18

 

匹配规则时按照它们定义的顺序,而且模式匹配必须完整定义,也就是说,对于任意一个可能的输入值,都有至少一个模式能够满足它(即能处理它);否则编译器会报告一个错误信息。另外,排在前面的规则不应比后面的更为“一般”,否则后面的规则永远不会得到匹配,编译器会报告一个警告信息,这种方式很像C#中的异常处理方式,在捕获异常时,我们不能先不会“一般”的Exception异常,然后再捕获“更具体”的NullReferenceException异常。

可以为某个模式规则添加一个when卫语句(guard),可将when语句理解为对当前规则的更强的约束,只有when语句的值为true时,该模式规则才算匹配。在上例的第一个规则中,如果没有when语句,那么任意整数都能够匹配模式,加了when语句后,就只能匹配非正整数了。对于最简单的情况,我们可以省略第一个“|”:

Code
let boolToString x =
match x with false -> "False" | _ -> "True"


这个例子中包含两个模式规则,“_”可以匹配任意值,因此当x值为false时匹配第一个规则,否则就匹配第二个规则。

另一个有用的特性是,我们可以合并两个模式规则,对它们采取相同的处理方式,这个就像C#中的switch…case结构中可以合并两个case一样。

Code
let stringToBool x =
match x with
| "T" | "True" | "true" -> true
| "F" | "False" | "false" -> false
| _ -> failwith "Invalid input."


在本例中,我们把三种模式规则合并在了一起,将字符串值转换为相应的布尔值。
可以对大多数F#中定义的类型进行模式匹配,下面的例子就展示了对元组进行匹配。

Code
let myOr b1 b2 =
match b1, b2 with
| true, _ -> true
| _, true -> true
| _ -> false

let myAnd p =
match p with
| true, true -> true
| _ -> false


这两个函数说明了如何对元组应用模式匹配,它们的功能是求两个布尔值“或”和“且”运算的结果。在myOr中,从第一、二两个模式规则可以知道b1、b2只要有一个为true,计算结果就是true,否则为false。myOr true false的结果为true,myAnd(true, false)结果为false。

模式匹配的常见用法是对列表进行匹配,事实上,对列表来说,较之if…then…else结构,模式匹配的方式更好。看下面的例子:

Code
let listOfList = [[2; 3; 5]; [7; 11; 13]; [17; 19; 23; 29]]

let rec concatenateList list =
match list with
| head :: tail -> head @ (concatenateList tail)
| [] -> []

let rec concatenateList2 list =
if List.nonempty list then
let head = List.hd list in
let tail = List.tl list in
head @ (concatenateList2 tail)
else
[]

let primes = concatenateList listOfList
print_any primes


listOfList是一个列表的列表,两个函数concatenateList和concatenateList2的功能都是将listOfList的元素连接为一个大的列表,只不过一个用模式匹配方式实现,一个使用if…then…else结构实现。可以看到使用模式匹配的代码更为简洁明了。观察concatenateList函数,它处理列表的方式是先取出列表的头元素(head),处理它,然后递归地处理剩余元素,这其实是通过模式匹配方式处理列表的最常见的方式(但不是唯一的方式)。

在F#中,模式匹配还可用在其它地方,在后面的文章中将陆续介绍。

定义类型(Defining Types)

F#的类型系统提供了若干特性,可用来创建自定义类型。所有的类型可分为两类,一是元组(Tuple)或记录(Record),它们类似于C#中的类;二是Union类型,有时称为Sum类型。下面分别来看一下它们的特点。


元组是任意对象的有序集合,通过它我们可以快速、方便地将一组值组合在一起。创建之后,就可以引用元组中的值。

Code
let pair = true, false
let b1, b2 = pair
let _, b3 = pair
let b4, _ = pair


第一行代码创建了一个元组,其类型为bool * bool, 说明pair元组包含两个值,它们的类型都是bool。通过第二、三、四行这样的代码,可以访问元组的值,“_”告诉编译器,我们对该值不感兴趣,将其忽略。这里b1的值为true,b3的值为false。

进一步分析,元组是一种类型,但是我们并没有显式地使用type关键字来声明类型,pair本质上是F#中Tuple类的一个实例,而不是自定义类型。如果需要声明自定义类型,就要使用type关键字了,最简单的情况是给已有类型起个别名:

Code
type Name = string
// FirstName, LastName
type FullName = string * string


对于Name类型来说,它仅仅是string类型的别名,FullName则是元组类型的别名。

记录(Record)类型与元组有相似之处,它也是将多个类型的值组合为同一类型,不同之处在于记录类型中的字段都是有名称的。看下面的例子:

Code
type Organization = { Boss : string; Lackeys : string list }

let family =
{ Boss =
"Children";
Lackeys = [
"Wife"; "Husband"] }


第一行是创建Organization类型,第二行则是创建它的实例,令人惊奇的是不需要声明实例的类型,F#编译器能够根据字段名推导出它的类型。这个功能是很强大,但是F#不强求每个类型的字段都是不同的,如果两个类型的各个字段名都一样怎么办呢?这时可以显式地声明类型:

Code
type Company = { Boss : string; Lackeys : string list }

let myCom =
{
new Company
with Boss = "Bill"
and Lackeys = ["Emp1"; "Emp2"] }


一般情况下,类型的作用域从声明处至所在源文件的结束。如果一个类型要使用在它后面声明的类型,可将这两个类型声明在同一个代码块中。类型间用and分隔,看下面食谱的例子:

Code
type recipe =
{ recipeName :
string;
ingredients : ingredient list;
instructions :
string }
and ingredient =
{ ingredientName :
string;
quantity :
int }

let greenBeansPineNuts =
{ recipeName =
"Green Beans & Pine Nuts";
ingredients =
[{ingredientName =
"Green beans"; quantity = 200};
{ingredientName =
"Pine nuts"; quantity = 200}];
instructions =
"Parboil the green beans for about 7 minutes." }

let name = greenBeansPineNuts.recipeName
let toBuy =
List.fold_left
(
fun acc x ->
acc + (Printf.sprintf
"\t%s - %i\r\n" x.ingredientName x.quantity))
"" greenBeansPineNuts.ingredients
let instructions = greenBeansPineNuts.instructions

printf
"%s\r\n%s\r\n\r\n\t%s" name toBuy instructions


本例不仅展示了如何将两个类型声明在“一块”,还显示了如何访问记录的字段值。可以看到访问记录的字段要比访问元组的值更为方便

也可以对记录类型应用模式匹配:

Code
type couple = { him : string; her : string }
let couples =
[ { him =
"Brad"; her = "Angelina" };
{ him =
"Becks"; her = "Posh" };
{ him =
"Chris"; her = "Gwyneth" } ]

let rec findDavid list =
match list with
| { him = x; her = "Posh" } :: tail -> x
| _ :: tail -> findDavid tail
| [] -> failwith "Couldn't find David"

print_string(findDavid couples)


首先创建了couple类型的列表,findDavid函数将对该列表进行模式匹配,可以将字段与常量值比较,如her = “Posh”;将字段值赋给标识符,如him = x;还可以使用“_”忽略某个字段的值。最后上面例子的打印结果:Becks。

字段值也可以是函数,这种技术将在本系列文章的第三部分介绍。

Union类型,有时称为sum类型或discriminated union,可将一组具有不同含义或结构的数据组合在一起。可与C语言中的联合或C#中的枚举类比。先来看个例子:

Code
type Volume =
| Liter of float
| UsPint of float
| ImperialPint of float


Volume类型属于Union类型,包含3个数据构造器(Data Constructor),每个构造器都包含单一的float值。声明其实例非常简单:

Code
let vol1 = Liter 2.5
let vol2 = UsPint 2.5
let vol3 = ImperialPint 2.5


事实上,通过Reflector可以看到,Liter、UsPint和ImperialPint是Volume类型的派生类。在将Union类型解析为其基本类型时,我们需要模式匹配。

Code
let convertVolumeToLiter x =
match x with
| Liter x -> x
| UsPint x -> x * 0.473
| ImperialPint x -> x * 0.568


记录类型和Union类型都可以被参数化(Parameterized)。参数化的意思是在一个类型的定义中,它使用了一个或多个其它类型,这些类型不是在定义中确定的,而是在该代码的客户代码中确定。这与前面提及的可变类型是类似的概念。

对于类型参数化,F#中有两种语法予以支持。来看第一种:

OCaml-Style
type 'a BinaryTree =
| BinaryNode of 'a BinaryTree * 'a BinaryTree
| BinaryValue of 'a

let tree1 =
BinaryNode(
BinaryNode(BinaryValue
1, BinaryValue 2),
BinaryNode(BinaryValue
3, BinaryValue 4))


在type关键字和类型名称BinaryTree之家添加了’a,而’a就是可变的类型,它的确切类型将在使用它的代码中确定,这是OCaml风格的语法。在标识符tree1中,定义BinaryValue时用的值是1,编译器将’a解析为int类型。再看看第二种语法:

.NET-Style
type Tree<'a> =
| Node of Tree<'a> list
| Value of 'a

let tree2 =
Node( [Node([Value
"One"; Value "Two"]);
Node([Value
"Three"; Value "Four"])])


这种语法更接近于C#中的泛型定义,是.NET风格的语法。在tree2中,’a被解析为string类型。不管哪种语法,都是单引号后跟着字母,我们一般只使用单个字母。

创建和使用参数化类型的实例跟非参数化类型的过程是一样的,因为编译器会自动推导参数化的类型。

在本节中,我们逐一讨论了元组、记录和Union类型。通过Reflector可以看到,元组值是Tuple类型的实例,而Tuple实现了Microsoft.FSharp.Core.IStructuralHash和System.IComparable接口;记录和Union则直接实现了这两个接口。要了解IStructualHash接口的更多内容,请参考Jome Fisher的文章

到这里,我们讨论完了如何定义类型、创建和使用它们的实例,却未提及对它们的修改。那是因为我们没法修改这些类型的值,这是函数式编程的特性之一。但F#提供了多种编程范式,对某些类型来说,它们是可修改的,这将在下一部分(命令式编程)进行介绍。

异常处理(Exception Handling)

在F#中,异常的定义类似于Union类型的定义,而异常处理的语法则类似于模式匹配。使用exception关键字来定义异常,可选地,如果异常包含了数据我们应当声明数据的类型,注意是可以包含多种类型数据的:

Code
exception SimpleException
exception WrongSecond of int
// Hour, MInute, Second
exception WrongTime of int * int * int


要抛出一个异常,可使用raise关键字。F#还提供了另一种方法,如果仅仅想抛出一个包含文本信息的异常,可以使用failwith函数,该函数抛出一个FailureException类型的异常。

Code
let testTime() =
try
let now = System.DateTime.Now in
if now.Second < 10 then
raise SimpleException
elif now.Second <
30 then
raise (WrongSecond now.Second)
elif now.Second <
50 then
raise (WrongTime (now.Hour, now.Minute, now.Second))
else
failwith
"Invalid Second"
with
| SimpleException ->
printf
"Simple exception"
| WrongSecond s ->
printf
"Wrong second: %i" s
| WrongTime(h, m, s) ->
printf
"Wrong time: %i:%i:%i" h m s
| Failure str ->
printf
"Error msg: %s" str

testTime()


这个例子展示了如何抛出和捕获各种异常,如果你熟悉C#中的异常处理,对此应该不会感到陌生。

与C#类似,F#也支持finally关键字,它当然要与try关键字一起使用。不管是否有异常抛出,finally块中的代码都会执行,在下面的例子中使用finally块来保证文件得以正确地关闭和释放:

Code
let writeToFile() =
let file = System.IO.File.CreateText("test.txt") in
try
file.WriteLine(
"Hello F# Fans")
finally
file.Dispose()

writeToFile()


需要注意的是,由于CLR架构的原因,抛出异常的代价是很昂贵的,因此要谨慎使用

延迟求值(或惰性求值,Lazy Evaluation)

第一次接触Lazy的东西是iBATIS中的LazyLoad,也就是延迟加载,它并不是在开始时加载所有数据,而是在必要时才进行读取。延迟求值与此类似,除了性能的提升外,还可用于创建无限的数据结构。

延迟求值与函数式编程语言关系密切,其原理是如果一种语言没有副作用,编译器或运行时可随意选择表达式的求值顺序。F#允许函数具有副作用,因此编译器或运行时不能够按随意地顺序对函数求值,可以说F#具有严格的求值顺序或 F#是一种严格的语言

如果要利用延迟求值的特性,必须要显式地声明哪些表达式的求值需要延迟,这个要使用lazy关键字。如果需要对该表达式求值,则要调用Lazy模块的force函数。在调用force函数的时候,它会计算表达式的值,而所求得的值会被缓存起来,再次对表达式应用force函数时,所得的值其实是缓存中的值

Code
let sixtyWithSideEffect = lazy(printfn "Hello, sixty!"; 30 + 30)

print_endline
"Force value the first time:"
let actualValue1 = Lazy.force sixtyWithSideEffect

print_endline
"Force value the second time:"
let actualValue2 = Lazy.force sixtyWithSideEffect


打印结果为:

Code
Force value the first time:
Hello, sixty!
Force value the second time:


小节

本文继续讨论F#函数式编程范式的核心内容,主要是模式匹配、自定义类型、异常处理和延迟求值等内容,至此,F#的函数式编程的相关内容就介绍完了。模式匹配可以很大程度上简化我们的程序;自定义类型则可以帮助我们更好地组织程序;延迟求值不仅能够提升性能,还可用于创建无限的数据结构,比如自然数序列。另外,在开发F#程序时,建议常用Reflector来看看编译后代码的样子,来了解它优雅的函数式编程背后到底是什么。在下一站,我们将看看命令式编程的风景。

参考:
《Foundations of F#》 by Robert Pickering
《Expert F#》 by Don Syme , Adam Granicz , Antonio Cisternino
F# Specs