第三章函数编程
第一章,我们讲过,纯函数编程把一切都当成值对待,包括函数。虽然 F# 不是纯函数语言,但是,同样鼓励你以函数风格编程,即使用只有一个返回结果的表达式和计算,而不用有副作用的语句。在这一章,我们将介绍 F# 支持的、函数编程模式的主要语言结构,学习如何使函数风格编程更容易。
文字
文字(Literals),表示常量值,用于计算的构建块。F# 的文字很丰富,见如表3-1。
表3-1 F# 文字
示例 | F# 类型 | .NET 类型 | 描述 |
"Hello\t", "World\n” | string | System.String | 字符串,其中的反斜杠(\) 是转义字符 |
@"c:\dir\fs", @"""" | string | System.String | 字符串,其中的反斜杠(\) 是正常字符 |
"bytesbytesbytes"B | byte array | System.Byte[] | 字符串作为字节数组存储 |
'c' | char | System.Char | 字符 |
true, false | bool | System.Boolean | 布尔 |
0x22 | int/int32 | System.Int32 | 十六进制整数 |
0o42 | int/int32 | System.Int32 | 八进制整数 |
0b10010 | int/int32 | System.Int32 | 二进制整数 |
34y | sbyte | System.SByte | 有符号字节 |
34uy | byte | System.Byte | 无符号字节 |
34s | int16 | System.Int16 | 16 位整数 |
34us | uint16 | System.UInt16 | 无符号 16 位整数 |
34l | int/int32 | System.Int32 | 32 位整数 |
34ul | uint32 | System.UInt32 | 无符号 32 位整数 |
34n | nativeint | System.IntPtr | 本机大小(native-sized) 整数 |
34un | unativeint | System.UIntPtr | 无符号本机大小整数 |
34L | int64 | System.Int64 | 64 位整数 |
34UL | uint64 | System.Int64 | 无符号 64 位整数 |
3.0F, 3.0f | float32 | System.Single | 32 位 IEEE 浮点数 |
3.0 | float | System.Double | 64 位 IEEE 浮点数 |
3474262622571I | bigint | Microsoft.FSharp.Math.BigInt | 大整数 |
474262612536171N | bignum | Microsoft.FSharp.Math.BigNum | 大数字 |
在F# 中,字符串可以包含换行符,正常的字符可以包含标准转义符,Verbatim字符串使用反斜杠(\)作为正常字符,两个双引号("")转义为一个双引号。用适当的前缀和后缀,整型可以定义为八进制、十六进制。下面示例展示了这些文字的部分应用,学习一下F# 的 printf 函数如何用%A 输出到控制台。Printf 函数解释 %A 格式模式,通过使用 F# 的反射(reflection,第七章中讨论)和 .NET的 ToString 方法进行组合,适用于所有类型,以可读的方式输出值。
// 字符串
let message = "Hello
World\r\n\t!"
let dir = @"c:\projects"
// 字节数组
let bytes = "bytesbytesbytes"B
// 数值类型
let xA = 0xFFy
let xB = 0o7777un
let xC = 0b10010UL
// 输出结果
let main() =
printfn “%A” message
printfn “%A” dir
printfn “%A” bytes
printfn “%A” xA
printfn “%A” xB
printfn “%A” xC
// 调用 main 函数
main()
示例编译并执行以后的结果如下:
"Hello
World
!"
"c:\projects"
[|98uy; 121uy; 116uy; 101uy; 115uy; 98uy;121uy; 116uy; 101uy; 115uy; 98uy;
121uy; 116uy; 101uy; 115uy|]
-1y
4095un
18UL
函数
在F# 中,字义函数使用 fun 关键字,函数参数之间用空格分隔,参数与函数体之间用(->)隔开。
下面是一个函数的示例,它取两个值,并把它们加起来。
fun x y -> x + y
注意,这个函数没有名字,这是一类函数文字。以这种方式定义的函数称为匿名函数(anonymous functions)、lambda 函数,或者干脆就叫lambdas。
函数不需要名字,这种想法看起来有点怪。然而,如果一个函数被当作参数传递给另一个函数,就可能不需要名字,特别是,它所承担的任务还可能相当简单。
如果需要给函数一个名字,你可以把它绑定给一个标识符,下一节再讨论。
标识符
F# 中,标识符是对值的命名,因此,在后面的程序中可以引用它们。标识符定义,用关键字 let,后面跟标识符名,等于号,表达式,它指定该标识符所引用的值。表达式可以任意的一段代码,表示将要返回值的计算。下列表达式显示一个值赋给标识符:
let x = 42
对于大多数有命令编程背景的人来说,这就像变量赋值。是有许多相似,但有一个关键的不同,
在纯函数编程中,一旦值赋给标识符,就不再改变。这也就是为什么我在全书中都称它们为标识符,而不是变量。
注意,在某些情况下,可以重新定义标识符,这看起来有点像是标识符的值改变了,但是,还是有些不同。另外,在 F# 中的命令编程,在某些情况下,标识符的值可以改变。在这一章,我们只关注函数编程,在这里,标识符的值不能改变。
一个标识符既可以指向值,也可以指向函数,因为F# 的函数本质上就是值,这毫不奇怪。这就是说,F# 没有真正的函数名或参数名的概念,它们统称为标识符。可以把一个匿名函数到标识符,用同样的方法,也可以把一个字符串或整数绑定到标识符:
let myAdd = fun x y -> x + y
然而,由于定义有名字的函数太常用了,因此,F# 为此提供了一个快捷语法。写函数定义的方法与值标识符相同,除非函数在 let 关键字和等号之间有两个或更多的标识符,就像下面的代码:
let raisePowerTwo x = x ** 2.0
第一个标识符是函数名,raisePowerTwo,后面的标识符是函数的参数名,x。如果函数有名字,强烈建议你用这种快捷语法来定义。
在 F# 中,声明值和函数非常简单,因为函数就是值,F# 语法认为它们相同。例如,考虑下面的代码:
let n = 10
let add a b = a + b
let result = add n 4
printfn "result = %i" result
第一行,把10 指定给标识符n;
第二行,定义函数add,完成两个参数相加。注意语法多么相似,唯一不同的是,函数有参数,列在函数名的后面。由于在F# 中一切都是值,第一行中的文字10 是值,第二行中的表达式 a+b 的结果也是值,自动成为函数 add 的结果。
注意,与命令语言不同,函数不需要显式返回值。
这段代码编译并执行以后,结果如下:
result = 14
标识符的命名
标识符的命名要符合一定的规则。标识符必须以下划线(_)或字母开头,可以包含字母、数字、下划线,或单引号(’);关键字不能作标识符。由于 F# 可以把单引号作为标识符名字的一部分,这样,能够用它来表示“重要的部分”,使创建的标识符名字相似而不同,比如:
let x = 42
let x' = 43
F# 支持 Unicode,因此,可以用非拉丁字母文字作为标识符的名字:
let 标识符 = 42
如果你觉得这些规则过于严苛,可以使用双反撇号(``,[即键盘左上角 1 旁边的那个字符])把标识符名字括起来,这样,就可以使用任意字符序列作标识符的名字,只要不包括tabs、回车,或双反撇号。就是说,标识符的名字能够以问号结尾,例如(有些程序员认为,表示布尔值的名字能以问号结尾会更有意义):
let ``more? `` = true
这样,还可把关键字作为标识符或类型的名字:
let ``class`` = "style"
例如,你可能需要使用库函数中的成员,而这个库函数[由于中文的原因,library 有时翻译成库,有时翻译成库函数,并无区别。]不是用 F# 写的,并且正好使用了 F# 中的关键字作名字(我们将在第四章介绍非 F# 库函数)。不过,通常情况下,最好还是要避免过度使用这个功能,因为这会导致从其他 .NET 语言中使用这些库函数的困难。
作用域(Scope)
标识符的作用域定义了程序中哪些地方可以使用标识符(或者类型,看本章后面的“定义类型”一节)。理解得好是相当重要的,因为如果使用的标识符不在作用域中,会产生编译错误。
所有的标识符,不管是与函数相关,还是与值相关,范围是从它的定义开始,直到在它出现的这一段的结束。因此,对于顶级的标识符(相对于其他的函数、值来说,这些标识符不是本地的),这个标识符的作用域,是从它定义开始,直到源文件结束。顶级标识符一旦指定给值(或函数),这个值就不能改变或重新定义。标识符只能在它定义完成后才可以使用,就是说,通常情况下,不能根据自身定义标识符。
你将会注意到,在 F# 中,从来不需要显式返回值,计算结果会自动绑定到它所指定的标识符。那么,如何在函数中计算中间值呢?F# 通过空格进行控制。一个标识就创建了一个新的作用域,这个作用的结束由这一段的缩进的结尾所决定。缩进表示这个let 绑定在这一个计算中是中间值,在这个作用域之外是不可用的。当作用域结束(即这一段缩进的结束),标识符就不再可用,就是说,已经超出作用域,或者不在作用域内。
为了说明作用域,看一下这个例子,这个函数计算两个整数的中间数,第三、四行显示的是如何计算中间值。
//计算中点的函数
let halfWay a b =
let dif = b - a
let mid = dif / 2
mid + a
// 计算函数并打印结果
printfn "(halfWay 5 11) = %i"(halfWay 5 11)
printfn "(halfWay 11 5) = %i"(halfWay 11 5)
首先,这两个被计算的数字是不一样的。标识符dif 是用关键字 let 指定的,它是函数的中间值,有四个空格的缩进。具体缩进几个空格,由程序员决定,但是,通常是四个;然后,计算中点,指定给标识符 mid,使用同样的缩进;最后,函数的期望结果是把这个中点再加上 a,代码直接写成 mid + a,它就成为函数的结果。
注意,不能使用 tabs 代替缩进,因为,它在不同的文本编辑器中看起来可能不同,当空白有意义时,它会引发问题。[因此,书中提到的空白,全部译成空格。]
程序的结果:
(halfWay 5 11) = 8
(halfWay 11 5) = 8
F# 的轻量语法
默认情况下,F# 中的空格是有意义的,通常缩进控制标识符的作用域。F# 是基于Objective Caml (OCaml)语言的,而它的空白是无意义的,在 OCaml 中,作用域是通过关键字in 控制的。例如,前面的 halfWay 函数就可能变成下面这样(在中间两加上关键字 in):
let halfWay a b =
let dif = b - a in
let mid = dif / 2 in
mid + a
F# 空格有意义的语法被称为轻量语法,因为有些关键字和符号可以省略了,比如:in、;、begin、end。就是说,前面的函数定义,即使有关键字 in,F# 的编译器也是可以接受的。如果打算强制使用这些关键字,需要在每个源文件的开始加上#light "off" 的声明。
我认为,空格有意义的编程方法更加直观,因为,它可以很容易帮助程序员决定如何布局代码,因此,本书只讨论 F# 的轻量语法。
函数中的标识符被限定在表达式的结束,通常,被限定为所在函数定义的结束。因此,定义在函数内的标识符,在函数外边不能使用。看一下这个例子:
let printMessage() =
let message = "Help me"
printfn “%s” message
printfn “%s” message
企图在函数 printMessage的外边使用的标识符 message,超出了作用域,编译这段代码,会出错:
Prog.fs(34,17): error: FS0039: The value orconstructor 'message' is not defined.
函数内部的标识符与顶级标识符有一些不同,它们可以用let关键字重新定义。就是说,不需要一直保留为了处理中间值而定义的标识符名字。下面的例子演示了用F# 函数实现的一个数学谜题。这里,需要计算大量的中间值,但我们并不特别关心这些值,如果为每一个中间值都命名,实在不必要,那样,对程序员实在是一个负担。
open System
let readInt() = int (Console.ReadLine())
let mathsPuzzle() =
printfn "Enter day of the month on which you were born: "
let input = readInt ()
let x = input * 4 // Multiply it by 4
letx = x + 13 // Add 13
let x = x * 25 // Multiply the result by 25
let x = x - 200 // Subtract 200
printfn "Enter number of the month you were born: "
let input = readInt ()
let x = x + input
let x = x * 2 // Multiply by 2
let x = x - 40 // Subtract 40
let x = x * 50 // Multiply the result by 50
printfn "Enter last two digits of the year of your birth:"
let input = readInt ()
let x = x + input
let x = x - 10500 // Finally, subtract 10,500
printf "Date of birth (ddmmyy): %i" x
mathsPuzzle()
运行的结果如下:
Enter day of the month on which you wereborn: 23
Enter number of the month you were born: 5
Enter last two digits of the year of yourbirth: 78
Date of birth (ddmmyy): 230578
注意:这不同于改变标识符的值。因为重新定义标识符,可能会改变标识符的类型,但仍然是类型安全的,比如下面的示例。
注意
类型安全(Type safety),有时也叫强类型(strong typing),主要是说,F# 会阻止你对值进行不适当操作。例如,不能把浮点数当成整数。在本章后面的“类型和类型推断”一节会讨论类型和如何保证类型安全。
let changeType () =
let x = 1 // bind x to an integer
let x = "change me" // rebind x to a string
let x = x + 1 // attempt to rebind to itself plus an integer
printfn x
这个例子不能编译,因为第三行把值 x 从整数改成了字符串"change me",而第四行尝试对字符串和整型进行加法运算,在 F# 中是非法的,因此,编译器会出错:
prog.fs(55,13): error: FS0001: Thisexpression has type
int
but is here used with type
string
stopped due to error
如果重新定义标识符,它的原来值在这个标识符定义的过程中仍然是可用的,但定义完成之后,即这个表达式的结束,就不可用了,原来值就被隐藏了。如果这个标识符在一个新的作用域内重新定义,那么,当这个新作用域结束以后,这个标识符还是原来的值。
下一个例子定义了标识符 message ,并把它输出到控制台,然后,在内部函数 innerFun 中重新定义message,它也打印这个 message。接着,调用函数innerFun,最后,第三次打印message。
let printMessages() =
// define message and print it
let message = "Important"
printfn "%s" message;
// define an inner function that redefines value of message
let innerFun () =
let message = "Very Important"
printfn "%s" message
// call the inner function
innerFun ()
// finally print message again
printfn "%s" message
printMessages()
下面是执行的结果:
Important
Very Important
Important
命令编程的程序员可能认为最后输出的message应该是Very Important,而不是Important。因为标识符message 保存的值Important,在函数innerFun内部,是被重新绑定到值Very Important,而不是赋值,这个绑定只有在函数innerFun 的作用域内才有效,因此,函数一旦结束,标识符message 的值重新回到原始值。
注意
使用内部函数是常见而有效的办法,可以把一些大的功能拆分成易于管理小块,你会看到本书中大量使用。它们有时也被称为闭包(closures)或 lambdas,虽然这是两个术语有更特殊的意义。闭包意思是这个函数使用了一个在顶层没有定义的值;而lambdas 即匿名函数。