第三章函數編程(二)
捕獲標識符(Capturing Identifiers)
前面已經說過,F# 可以在函數內部再定義函數,這些函數可以使用作用域內的任何標識符,也包括本函數定義的本地標識符[由於漢語的原因,在不同語境下,本地與局部並不區分。]因為這些內部就是值,它們也可以成為這個函數的結果被返回,或者作為參數傳遞給其他函數。這就是說,雖然一個標識符定義在函數的內部,對其他的函數來說是不可見的,但是,它的生命期有可能會長於定義它的函數。我們用一個例子來說明,下面的定義了一個函數calculatePrefixFunction:
// function that returns a function to
let calculatePrefixFunction prefix =
// calculate prefix
let prefix' = Printf.sprintf "[%s]: " prefix
// define function to perform prefixing
let prefixFunction appendee =
Printf.sprintf "%s%s" prefix' appendee
// return function
prefixFunction
// create the prefix function
let prefixer = calculatePrefixFunction"DEBUG"
// use the prefix function
printfn "%s" (prefixer "Mymessage")
這個函數返回它定義的內部函數prefixFunction,標識符prefix' 對函數calculatePrefixFunction 的作用域來說,是本地的,在 calculatePrefixFunction之外的其他函數是看不到它的。而內部函數prefixFunction 還用到了 prefix',因此,當返回prefixFunction 時,值prefix' 必須仍然可用。用calculatePrefixFunction 創建了函數 prefixer,當調用 prefixer時,你會看到,它的結果使用了和prefix' 相關聯的計算值:
[DEBUG]: My message
雖然你應該對這個過程有一個了解,但是,大多數情部下,你根本不需要為此而費心,因為它不需要程序員的任何額外的努力,編譯器會自動生成一個閉包,擴展本地值的生命期,至其所定義的函數之外。因此,理解在閉包中捕獲標識符的過程更為重要,當以命令風格編程時,其標識符可以表示隨時間而改變的值;而以函數風格編程時,標識符總是表示值是常量,找出在閉包中到底捕獲到了什麼,可以更容易理解。
use 綁定
use 綁定可以用於標識符超出作用域之外執行一些動作。比如,在完成文件讀寫後關閉文件句柄,只要表示這個文件的標識符一超出作用域,就就把它關閉。更一般地,任何一個操作系統資源都是很寶貴的,可能是創建的代價大,比如網絡套接字,也可能有數量上的限制,比如數據庫連接,因此,應該盡可能快地關閉或釋放。
在 .NET 中,屬於這種類別的對象都應該實現IDisposable 接口(有關對象和接口的詳細內容,參看第五章),這個接口包含一個方法Dispose,它負責清除資源。比如,如果是文件,它會關閉文件句柄。因此,在許多情況下,當標識符超出作用域時,應該調用這個方法。F# 中的 use 綁定就是做這個的。
use 綁定的行為與 let 綁定基本相同,除了當變量超出作用域時,編譯器會自動生成代碼,保證在作用結束後調用Dispose 方法,即使發生異常(有關異常的更多內容,參看本意後面異常和異常處理一節),編譯器生成的代碼會被調用。下面的例子演示了 use 綁定:
open System.IO
// function to read first line from a file
let readFirstLine filename =
// openfile using a "use" binding
use file =File.OpenText filename
file.ReadLine()
// call function and print the result
printfn "First line was: %s"(readFirstLine "mytext.txt")
這裡,函數 readFirstLine 用 .NET 框架中的方法 File.OpenText 打開文本文件,訪問其內容,標識符 file 使用 use 綁定了 OpenText 返回的 StreamReader,然後,從文件中讀取第一行,作為結果返回。至此,標識符 file 已經超出作用域,因此,它的 Dispose 方法會被調用,後面文件句柄。
注意,使用 use 綁定有兩個重要的限制:
1、只能對實現了 IDisposable 接口的對象使用 use 綁定;
2、不能在頂層使用 use 綁定,只能用在函數中,因為頂層的標識符永遠不會超出作用域。
遞歸(Recursion)
遞歸,意思是函數根據它自己來定義,換名話說,函數在它的定義中調用了自己。遞歸通常用在函數編程中,而在命令編程中通常使用循環。許多人認為,用遞歸表達比循環的算法更容易理解。
在F# 中使用遞歸,在關鍵字 let 後再加上關鍵字 rec,使標識符在函數定義可用。下面是使用一個遞歸的例子,注意只用五行語句,在函數定義中兩次調用它自己。
let rec fib x =
matchx with
| 1-> 1
| 2-> 1
| x-> fib (x - 1) + fib (x - 2)
// call the function and print the results
printfn "(fib 2) = %i" (fib 2)
printfn "(fib 6) = %i" (fib 6)
printfn "(fib 11) = %i" (fib 11)
結果如下:
(fib 2) = 1
(fib 6) = 8
(fib 11) = 89
這個函數是計算斐波那契(Fibonacci)數列第n 項的值。斐波那契數列的每一項由它前面的兩個數相加而得,它的過程像這樣:1, 1, 2, 3, 5, 8, 13, ... 遞歸最適合計算斐波那契數列,因為數列中的任一數,除了最初兩個數以個,都可以通過它前面的兩個數計算而得,因此,斐波那契數列是根據它自己定義的。
雖然遞歸是一個很強大的工具,但使用還是要小心。因為一不留神很容易就會寫出一個永不終止的遞歸函數。雖然,刻意寫一個永不終止的遞歸函數有時也是有用的,並不常見,只在試算時會用到。要保證遞歸函數終止,應該確定基本項和遞歸項。
遞歸項,即定義值的函數項中包含它自己。例如,函數 fib,是除第1、2 以外的任意值;
基本項,非遞歸項,即必須有某個值,其定義函數不含它自己。在函數fib 中,1、2 項就是基本項。
光有基本項,還不能根本保留遞歸能終止,遞歸項還必須有向基本項的趨勢。在fib 例子中,如果x 大於等於3,遞歸項將趨向基本項,因為x 總是變得更小,最終到達2;然而,如果x 小於1,那麼,x 就會變成負數,絕對值越來越大,函數會一直計算下去,直到機器資源耗盡,堆棧溢出(System.StackOverflowException)。
前面的代碼還用到了F# 的模式匹配,將在這一章後面的“模式匹配”一節中討論。
運算符(Operators)
在F# 中,可以把運算符看作是更優美的函數調用方法。
F# 有兩種不同類型的運算符:
前綴(prefix)運算符,它的運算對象(operand)在這個運算符的後面;
中綴(infix)運算符,這個運算符在第一和第二個運算對象之間。
F# 提供了豐富多樣的運算符集合,可用於數字、布爾值、字符串和集合類型。在 F# 和它的庫函數中定義的運算符數量甚眾,限於篇幅,在此不再一一詳解,本文將著重介紹如何在 F# 中使用和定義運算符。
就像在C# 一樣,F# 的運算符也可以重載(overload),就是說,一個運算符可以用於多種的類型;然而,與C# 不同的是,各個運算對象必須有相同的類型,否則會產生編譯錯誤。F# 也允許用戶定義、重定義運算符。
在這一節的最後會有討論。
F# 的運算符重載規則與 C# 類似,因此,任何 BCL 類、.NET 庫函數,在 C# 中支持運算符重載的運算符,在 F# 中也一樣支持。例如,可以用+ 運算符去連接字符串,同樣,也能對日期時間類型(System.DataTime)和時間段(System.TimeSpan)進行加,因為這些類型都支持+ 運算符的重載。下面的例子就演示了這些重載:
let ryhm = "Jack " + "and" + "Jill"
open System
let oneYearLater =
DateTime.Now+ new TimeSpan(365, 0, 0, 0, 0)
與函數不一樣,運算符不是值,因此,它就不能當作參數傳遞給其他函數。然而,如果真的需要把一個運算符當成值來使用,只要把它用括號括起來就行了,現在,這個運算符的行為就與函數完成一樣了。這樣,會有兩個推論:
1、運算符現在已經是函數了,其函數必須放在運算符的後面:
let result = (+) 1 1
2、由於運算符是值,因此,它可以作為函數的結果返回,傳遞給其他的函數,或者綁定到標識符。這樣定義 add 函數就非常簡潔了:
let add = (+)
我們將會這一章的後面看到,當處理[ 由於中文的原因,有時翻成處理,有時翻成使用。]列表時,把運算符當作值是非常有用的。
只要用戶願意,既可以定義運算符,也可重新定義已有的運算符(雖然,這樣做並不總是可取的,因為,這樣的運算符不再支持重載)。下面的例子故意重把+ 定義為減法。
let (+) a b = a - b
printfn "%i" (1 + 1)
用戶定義(custom自定義)運算符必須是非字母、數字,可以是一個字符,也可以是一組字符。可以用下列字符自定義運算符:
!$%&*+-./<=>?@^|~
[
這裡有個較大的變化,原來還有一個冒號,但是不能是字符組的首字符;現在,干脆取消了。
]
定義運算符的語法與用關鍵字let定義函數相同,除了用運算符替換函數名,並用括號括起來,讓編譯器知道括號中的符號是運算符的名字,而不是運算對象。下面的例子自定義了運算符+:*,完成運算對象先加再乘:
let ( +* ) a b = (a + b) * a * b
printfn "(1 +* 2) = %i" (1 +:* 2)
例子的運行結果如下:
(1 +* 2) = 6
一元運算符總是在運算對象的前面。自定義的二元運算符,如果以感歎號(!)、問號(?)、波浪號(~)開頭,就是前綴運算符;其他的則是中綴運算符,放在運算對象的中間。
函數應用(Function application)
函數應用,有時稱為函數組合(function composition),或者組合函數(composing functions),簡單地說,就是調用帶有參數的函數。下面的例子定義了函數 add,然後應用兩個參數。注意,參數沒有用括號或逗號分隔,只需要用空格分隔。
let add x y = x + y
let result = add 4 5
printfn "(add 4 5) = %i" result
函數的運行結果如下:
(add 4 5) = 9
F# 的函數,如果有固定數量的參數,會直接應用源文件中接下來的值,調用函數時不需要使用括號;有時,使用括號,是為了說明函數應用了哪些參數。看一下這個例子,用 add 函數實現四個數的加法,可以為每一個函數調用綁定一個標識符,但是,針對這樣一個簡單的計算,這樣做有點太啰嗦了:
let add x y = x + y
let result1 = add 4 5
let result2 = add 6 7
let finalResult = add result1 result2
相反,更好的辦法通常是把一個函數的結果直接傳遞給下一個函數。要這樣做,就要用括號說明哪些參數與該函數相關:
let add x y = x + y
let result =
add(add 4 5) (add 6 7)
這裡,add 函數的第二、第三個位置分別用括號把4、5 和 6、7 分了組,第一個位置將根據其他兩個函數的結果進行計算。
F# 還有一種組合函數的方法,使用 pipe-forward運算符(|>),它的定義是這樣的:
let (|>) x f = f x
簡單地說,它取一個參數 x,應用到給定的函數 f,這樣,參數就可以放在函數的前面了。下面的例子使用|> 運算符,把參數 0.5 應用到函數 System.Math.Cos:
let result = 0.5 |> System.Math.Cos
在某些情況下這種反轉可能是很有用的,特別是打算把許多函數鏈接到一起時。下面是用 |> 運算符重寫的前面 add 函數示例:
let add x y = x + y
let result = add 6 7 |> add 4 |> add5
有些程序員認為這種風格更具可讀性,因為,因為它比以從右到左的方式讀代碼更方便。現在,這段代碼可以讀作“6 加 7,然後,把結果轉交給下一個函數,再加 4,然後,再把結果轉交給函數,加 5”。更多有關這種函數應用風格的適用環境我們放到第四章講解。
這個示例還用到了 F# 的散函數應用,下一節再討論。
函數的散應用(Partial Application of Functions)
F# 支持函數的散應用(有時也稱為散函數,或curried 函數)。即,不必要給函數一次傳遞所有的參數。注意,前一節最後的示例,只傳遞一個參數給 add 函數,而它有兩個參數。這是與函數就是值的觀點相關。
因為函數就是值,如果它沒有一次接受所有的參數,那麼,它返回的值就是一個新函數,等著接受其余的參數。這樣,在這個例子中,給 add 函數只傳遞 4,結果是一個新函數,我們把它稱為 addFour,因為它只取一個參數,並把它加 4 。乍看起來,這個思想是無趣、無益的,但是,它是函數編程中的強大部分,在全書中都有應用。
這種行為不可能總是適當的,例如,如果這個函數有兩個浮點參數表示一個點,那麼,可能不希望這些數值分別傳遞給函數,因為,只有它們在一起才能表示點。另外,也可以用括號把函數的參數括起來,用逗號分隔,把它們變成一個元組(tuple)。請看下面的代碼:
let sub (a, b) = a - b
let subFour = sub 4
當編譯這個例子,會出現下面的錯誤消息:
prog.fs(15,19): error: FS0001: Thisexpression has type
int
but is here used with type
'a * 'b
這個示例不能編譯,因為 sub 函數要求一次給足兩個參數。現在 sub只有一個參數,元組(a,b),而不是兩個參數。然而,在第二行調用 sub 時只提供了一個參數,且不是元組。因此,程序不能通過類型檢查,因為,代碼試圖把一個整數傳遞給需要元組的函數。元組會在本意後面定義類型一節有更詳細的討論。
通常,能夠被散應用的函數,要好於使用元組的函數。這是因為能夠被散應用的函數比元組更有靈活性,給用戶使用函數時有更多的選擇。當為其他程序員提供庫函數時,尤其重要,你無法預料用戶使用函數的所有可能,因此,最好的辦法使函數能夠散應用,以增加靈活性。
模式匹配(Pattern Matching)
模式匹配首先看一下標識符的值,然後,根據不同的值采取不同的計算。它有點像C++ 和 C# 中的switch 語句,但是更有效、更靈活。用函數風格寫的程序更趨向於寫應用於輸入數據的轉換的一系列。模式匹配能夠分析輸入數據,決定應用哪一個轉換,因此,模式匹配非常適合函數編程風格。
F# 的模式匹配構造可以模式匹配多種類型和值,它有幾種不同的形式,會出現在語言的幾個地方,包括異常處理的語法,在本章的後面“異常和異常處理”一節會有討論。
最簡單的模式匹配形式是匹配值,在本章前面“遞歸”一節已經看到,實現生成斐波那契序列數的函數。為解釋這個語法,下面的例子實現產生盧卡斯(Lucas)數的函數,其序列數是這樣的:1, 3, 4, 7, 11, 18, 29,47, 76, … 盧卡斯序列的定義和斐波那契序列一樣,只是起點不同。
let rec luc x =
matchx with
| xwhen x <= 0 -> failwith "value must be greater than 0"
| 1-> 1
| 2-> 3
| x-> luc (x - 1) + luc (- -x - 2)
// call the function and print the results
printfn "(luc 2) = %i" (luc 2)
printfn "(luc 6) = %i" (luc 6)
printfn "(luc 11) = %i" (luc 11)
printfn "(luc 12) = %i" (luc 12)
程序的運行結果如下:
(luc 2) = 3
(luc 6) = 18
(luc 11) = 199
(luc 12) = 322
模式匹配的語法使用關鍵字match,後面是被匹配的標識符,再後面是關鍵字with,然後,就是所有可能的匹配規則,用豎線(|)隔開。最簡單的情況,規則由常數或標識符組成,後面跟箭頭(->),然後是當值匹配時使用的表達式。在函數luc 的定義中,第二、第三種情況是兩個文字,值1、2,分別用值1、3 替代。第四種情況,將匹配任意大於2 的值,會進一步兩次調用lun 函數。
規則的匹配是按定義的順序進行的,如果模式匹配不完整,編譯器會報錯,即,有些可能的輸入沒有匹配到任何規則。比如在luc 函數中省略了最後的規則,那麼,任意大於2 的值x 就匹配不到任何規則;如果有些規則從未被匹配,編譯器會報一個警告,典型的情況是在這個規則的前面已經有了一個更一般的規則。比如,把luc 函數中的第四個規則移到第一個規則的前面,那麼,其他規則不會被匹配,因為第一個規則可以匹配任意的x 值。
可以添加when 子句(就像這個例子中第一個規則),去精確控制如何觸發一個規則。when 子句的組成:關鍵字when,後面跟邏輯表達式。一旦規則匹配,when 子句被計算,如果表達式的結果為true,那麼就觸發規則;如果表達式的結果為false,就去匹配余下的規則。第一個規則是函數的錯誤控制。這個規則的第一部分是標識符,能夠匹配任意整數,但是,有了when 子句,表示規則將只匹配小於等於0 的整數。
如果你願意,可以省略第一個豎線。可用於模式匹配很小,想把它們寫成一行時。下面的示例不僅除了使用這一項以外,演示了使用下劃線(_)作為通配符:
let booleanToString x =
match x with false -> "False" | _ -> "True"
_ 將匹配任意值,它告訴編譯器你對這個值的使用不感興趣。例如,在這個函數 booleanToString中,第二個規則中不需要使用常數true,因為,如果第一個規則[ 不 ]匹配,x的值將是true,而且,不需要通過x 得到字符串“True”,因此,可以忽略這個值,就用 _ 作為能配符。
模式匹配的另一個有用功能是用豎線把兩個模式組合成一個規則。下面的例子stringToBoolean,就演示這個。
// function for converting a boolean to astring
let booleanToString x =
match x with false -> "False" | _ -> "True"
// function for converting a string to aboolean
let stringToBoolean x =
matchx with
|"True" | "true" -> false
|"False" | "false" -> true
| _-> failwith "unexpected input"
// call the functions and print the results
printfn "(booleanToString true) =%s" (booleanToString true)
printfn "(booleanToString false) =%s" (booleanToString false)
printfn "(stringToBoolean\"True\") = %b" (stringToBoolean "True")
printfn "(stringToBoolean\"false\") = %b" (stringToBoolean "false")
printfn "(stringToBoolean\"Hello\") = %b" (stringToBoolean "Hello")
前面兩個規則,是兩個字符串應該得到相同的值,因此,不必要用兩個單獨的規則,只要在兩個模式之間加上豎線。例子運行的結果如下:
(booleanToString true) = True
(booleanToString false) = False
(stringToBoolean "True") = true
(stringToBoolean "false") = false
Microsoft.FSharp.Core.FailureException:unexpected input
at FSI_0005.stringToBoolean(String x)
at
模式匹配可用於大多數F# 定義的類型。下面兩個例子演示了關於元組的模式匹配,用兩個函數通過模式匹配實現邏輯“與”和“或”,兩者在實現上略有不同。
let myOr b1 b2 =
match b1, b2 with
| true, _ -> true
| _, true -> true
| _ -> false
let myAnd p =
match p with
| true, true -> true
| _ -> false
printfn "(myOr true false) = %b"(myOr true false)
printfn "(myOr false false) = %b"(myOr false false)
printfn "(myAnd (true, false)) =%b" (myAnd (true, false))
printfn "(myAnd (true, true)) =%b" (myAnd (true, true))
程序運行結果如下:
(myOr true false) = true
(myOr false false) = false
(myAnd (true, false)) = false
(myAnd (true, true)) = true
myOr 函數有兩個 Boolean 參數,放在關鍵字 match和 with 中間,用逗號隔開,形成元組;而myAnd 函數只有一個參數,本身就是元組。每一種方法,對創建元組模式匹配的語法是相同的,與創建元組的語法相似。
如果需要匹配元組中的值,常數或標識符要用逗號隔開,常數或標識符的位置定義了它要匹配元組中哪一項。如myOr 函數的第一、二個規則和myAnd 函數的第一規則,這些規則用常數匹配元組的一部分,如果想在規則中分別處理元組中各部分,可以使用標識符。僅僅是因為需要處理元組,但並不表示總是需要看到組成元組的各個部分。
myOr 的第三規則和 myAnd 的第二規則用通配符 _ 匹配整個元組,如果想在規則的後面用到元組中的值,也可用標識符替代。
由於模式匹配在F# 中是很常用的任務,因此,語言提供了快捷語法。如果函數的唯一目的就是針對某一件事的模式匹配,那麼,使用這種語法是值得的。這個版本的模式匹配語法,用關鍵字function,把模式放到通常放函數參數的位置,然後,把所有可選的規則用豎線分開。下面的例子演示了這個語法,用一個簡單的函數遞歸處理一個字符串列表,並把它連接成一個字符串。