F# 程式設計/基本概念
| F#:基本概念 |
現在我們已經擁有了 F# 的工作安裝環境,我們可以探索 F# 的語法和函數語言程式設計的基礎知識。我們將從 F# 互動式環境開始,因為它提供了一些非常有價值的型別資訊,這有助於我們理解 F# 中實際發生了什麼。從開始選單開啟 F# 互動式環境,或開啟命令列提示符並鍵入 fsi。
在計算機程式設計中,每一段資料都有一個型別,它描述了程式設計師正在使用的資料的型別。在 F# 中,基本資料型別是
| F# 型別 | .NET 型別 | 大小 | 範圍 | 示例 | 表示 |
|---|---|---|---|---|---|
| 整數型別 | |||||
sbyte
|
System.SByte
|
1 位元組 | -128 到 127 | 42y-11y
|
8 位有符號整數 |
byte
|
System.Byte
|
1 位元組 | 0 到 255 | 42uy200uy
|
8 位無符號整數 |
int16
|
System.Int16
|
2 位元組 | -32768 到 32767 | 42s-11s
|
16 位有符號整數 |
uint16
|
System.UInt16
|
2 位元組 | 0 到 65,535 | 42us200us
|
16 位無符號整數 |
int/int32 |
System.Int32
|
4 位元組 | -2,147,483,648 到 2,147,483,647 | 42-11
|
32 位有符號整數 |
uint32
|
System.UInt32
|
4 位元組 | 0 到 4,294,967,295 | 42u200u
|
32 位無符號整數 |
int64
|
System.Int64
|
8 位元組 | -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 | 42L-11L
|
64 位有符號整數 |
uint64
|
System.UInt64
|
8 位元組 | 0 到 18,446,744,073,709,551,615 | 42UL200UL
|
64 位無符號整數 |
bigint
|
System.Numerics.BigInteger
|
至少 4 位元組 | 任何整數 | 42I14999999999999999999999999999999I
|
任意精度整數 |
| 浮點型別 | |||||
float32
|
System.Single
|
4 位元組 | ±1.5e-45 到 ±3.4e38 | 42.0F-11.0F
|
32 位有符號浮點數(7 位有效數字) |
float
|
System.Double
|
8 位元組 | ±5.0e-324 到 ±1.7e308 | 42.0-11.0
|
64 位有符號浮點數(15-16 位有效數字) |
decimal
|
System.Decimal
|
16 位元組 | ±1.0e-28 到 ±7.9e28 | 42.0M-11.0M
|
128 位有符號浮點數(28-29 位有效數字) |
BigRational
|
Microsoft.FSharp.Math.BigRational
|
至少 4 位元組 | 任何有理數。 | 42N-11N
|
任意精度有理數。使用此型別需要引用 FSharp.PowerPack.dll。 |
| 文字型別 | |||||
char
|
System.Char
|
2 位元組 | U+0000 到 U+ffff | 'x''\t'
|
單個 Unicode 字元 |
string
|
System.String
|
20 + (2 * 字串長度) 位元組 | 0 到大約 20 億個字元 | "Hello""World"
|
Unicode 文字 |
| 其他型別 | |||||
bool
|
System.Boolean
|
1 位元組 | 只有兩個可能的值,true 或 false |
truefalse
|
儲存布林值 |
F# 是一種完全面向物件的語言,使用基於 .NET 通用語言基礎結構 (CLI) 的物件模型。因此,它具有單繼承、多介面物件模型,並允許程式設計師宣告類、介面和抽象類。值得注意的是,它完全支援泛型類、介面和函式定義;但是,它缺少其他語言中發現的一些 OO 特性,例如 mixin 和多重繼承。
F# 還提供了一系列獨特的資料結構,這些資料結構直接構建到語言的語法中,包括
- Unit,只包含一個值的型別,等同於 C 語言中
void。 - 元組型別,這是程式設計師可以用來將相關值組合到單個物件中的臨時資料結構。
- 記錄型別,它們類似於元組,但提供命名欄位來訪問記錄物件儲存的資料。
- 區分聯合,用於建立定義明確的型別層次結構和層次資料結構。
- 列表、對映 和 集合,它們分別表示堆疊、雜湊表和集合資料結構的不可變版本。
- 序列,它們表示按需計算的專案的延遲列表。
- 計算表示式,它們在 Haskell 中與 monad 有相同的用途,允許程式設計師以命令式風格編寫延續式程式碼。
所有這些特性將在本書的後續章節中進一步列舉和解釋。
F# 是一種靜態型別語言,這意味著編譯器在編譯時知道變數和函式的資料型別。F# 也是強型別語言,這意味著繫結到 int 的變數不能在以後的某個時刻重新繫結到 string;一個 int 變數永遠與 int 資料繫結。
與 C# 和 VB.Net 不同,F# 不執行隱式轉換,即使是安全轉換(例如將 int 轉換為 int64)。F# 需要顯式轉換才能在資料型別之間進行轉換,例如
> let x = 5;;
val x : int = 5
> let y = 6L;;
val y : int64 = 6L
> let z = x + y;;
let z = x + y;;
------------^
stdin(5,13): error FS0001: The type 'int64' does not match the type 'int'
> let z = (int64 x) + y;;
val z : int64 = 11L
數學運算子 +, -, /, *, 和 % 被過載以處理不同的資料型別,但它們要求運算子兩側的引數具有相同的資料型別。我們嘗試將 int 新增到 int64 時會遇到錯誤,因此我們必須在程式成功編譯之前將上面一個變數中的一個強制轉換為另一個變數的資料型別。
與許多其他強型別語言不同,F# 通常不需要程式設計師在宣告函式和變數時使用型別標註。相反,F# 會嘗試根據變數在程式碼中的使用方式自行推斷出型別。
例如,讓我們來看這個函式
let average a b = (a + b) / 2.0
我們沒有使用任何型別標註:也就是說,我們沒有明確地告訴編譯器 a 和 b 的資料型別,也沒有指示函式返回值的型別。如果 F# 是一種強型別、靜態型別語言,編譯器如何在事先不知道任何資料型別的情況下知道任何事物的資料型別?這很簡單,它使用簡單的推斷
+和/運算子被過載以處理不同的資料型別,但在沒有額外資訊的情況下,它預設為整數加法和整數除法。(a + b) / 2.0,粗體值型別為float。由於 F# 不執行隱式轉換,並且它要求運算子兩側的引數具有相同的資料型別,因此值(a + b)也必須返回float。+運算子只有在運算子兩側的引數都是float時才會返回float,因此a和b也必須是float。- 最後,由於
float / float的返回值是float,因此average函式必須返回一個float。
這個過程稱為型別推斷。在大多數情況下,F# 能夠自行推斷出資料型別,而無需程式設計師明確寫出型別標註。這對小型程式和大型程式一樣有效,並且可以節省大量時間。
在 F# 無法正確推斷出型別的情況下,程式設計師可以提供顯式標註來指導 F# 朝正確的方向前進。例如,如上所述,數學運算子預設為對整數的運算
> let add x y = x + y;;
val add : int -> int -> int
在沒有其他資訊的情況下,F# 確定 add 接受兩個整數並返回另一個整數。如果我們想使用 float,我們會寫
> let add (x : float) (y : float) = x + y;;
val add : float -> float -> float
F# 的模式匹配類似於其他語言中的 if... then 或 switch 結構,但功能更加強大。模式匹配允許程式設計師將資料結構分解為其組成部分。它根據資料結構的形狀匹配值,例如
type Proposition = // type with possible expressions ... note recursion for all expressions except True
| True // essentially this is defining boolean logic
| Not of Proposition
| And of Proposition * Proposition
| Or of Proposition * Proposition
let rec eval x =
match x with
| True -> true // syntax: Pattern-to-match -> Result
| Not(prop) -> not (eval prop)
| And(prop1, prop2) -> (eval prop1) && (eval prop2)
| Or(prop1, prop2) -> (eval prop1) || (eval prop2)
let shouldBeFalse = And(Not True, Not True)
let shouldBeTrue = Or(True, Not True)
let complexLogic =
And(And(True,Or(Not(True),True)),
Or(And(True, Not(True)), Not(True)) )
printfn "shouldBeFalse: %b" (eval shouldBeFalse) // prints False
printfn "shouldBeTrue: %b" (eval shouldBeTrue) // prints True
printfn "complexLogic: %b" (eval complexLogic) // prints False
eval 方法使用模式匹配遞迴地遍歷和評估抽象語法樹。rec 關鍵字將函式標記為遞迴函式。模式匹配將在本書的後續章節中詳細解釋。
函數語言程式設計與指令式程式設計對比
[edit | edit source]F# 是一種混合正規化語言:它支援命令式、面向物件和函數語言程式設計風格,其中對函數語言程式設計風格的重視程度最高。
不可變值與變數
[edit | edit source]初學者接觸函數語言程式設計時常犯的第一個錯誤,就是認為 let 語句等同於賦值。請考慮以下程式碼
let a = 1
(* a is now 1 *)
let a = a + 1
(* in F# this throws an error: Duplicate definition of value 'a' *)
從表面上看,這與我們熟悉的命令式虛擬碼完全一樣
a = 1 // a is 1 a = a + 1 // a is 2
然而,F# 程式碼的本質卻截然不同。每個 let 語句都會引入一個新的作用域,並在該作用域內將符號繫結到值。如果執行超出此引入的作用域,則符號將恢復其原始含義。這顯然與使用賦值進行變數狀態修改不同。
為了澄清,讓我們將 F# 程式碼進行反糖化
let a = 1 in
((* a stands for 1 here *);
(let a = (* a still stands for 1 here *) a + 1 in (* a stands for 2 here *));
(* a stands for 1 here, again *))
實際上,程式碼
let a = 1 in
(printfn "%i" a;
(let a = a + 1 in printfn "%i" a);
printfn "%i" a)
會輸出
1 2 1
一旦符號繫結到值,就不能再為其分配新值。改變繫結符號含義的唯一方法是透過引入該符號的新繫結(例如,使用 let 語句,如 let a = a + 1)來對其進行 遮蔽,但這隻會產生區域性效果:它只會影響新引入的作用域。F# 使用所謂的 "詞法作用域",這意味著只需檢視程式碼即可確定繫結的作用域。因此,(let a = a + 1 in ..) 中 let a = a + 1 繫結的作用域僅限於括號。使用詞法作用域,程式碼片段無法更改其外部繫結的符號的值,例如在呼叫它的程式碼中。
不可變性是一個很棒的概念。不可變性允許程式設計師將值傳遞給函式,而不必擔心函式會以不可預知的方式改變值的狀態。此外,由於值不能被修改,程式設計師可以處理多個執行緒共享的資料,而不必擔心資料會被其他程序修改;因此,程式設計師可以編寫沒有鎖的多執行緒程式碼,可以消除與競態條件和死鎖相關的整類錯誤。
函式式程式設計師通常透過向函式傳遞額外的引數來模擬狀態;物件透過建立具有所需更改的完全新例項來“修改”,並讓垃圾收集器丟棄不再需要的舊例項。這種風格帶來的資源開銷可以透過結構共享來處理。例如,更改包含 1000 個整數的單鏈表的頭部,只需分配一個新的整數,並重復使用原始連結串列的尾部(長度為 999)。
對於真正需要修改的罕見情況(例如,在效能瓶頸的數字運算程式碼中),F# 提供了引用型別和 .NET 可變集合(如陣列)。
遞迴還是迴圈?
[edit | edit source]指令式程式設計語言傾向於使用迴圈來迭代集合
void ProcessItems(Item[] items)
{
for(int i = 0; i < items.Length; i++)
{
Item myItem = items[i];
proc(myItem); // process myItem
}
}
這可以直接轉換為 F#(由於 F# 可以推斷出 i 和 item 的型別註釋,因此省略了這些註釋)
let processItems (items : Item []) =
for i in 0 .. items.Length - 1 do
let item = items.[i] in
proc item
然而,上面的程式碼顯然不是用函式式風格編寫的。它的一個問題是它遍歷了一個專案陣列。對於包括列舉在內的許多目的,函式式程式設計師會使用不同的資料結構,即單鏈表。以下是用模式匹配迭代此資料結構的示例
let rec processItems = function
| [] -> () // empty list: end recursion
| head :: tail -> // split list in first item (head) and rest (tail)
proc head;
processItems tail // recursively enumerate list
需要注意的是,由於對 processItems 的遞迴呼叫作為函式中的最後一個表示式出現,這是一個所謂的 尾遞迴 示例。F# 編譯器會識別出這種模式,並將 processItems 編譯為一個迴圈。因此,processItems 函式以恆定空間執行,不會導致棧溢位。
F# 程式設計師依賴尾遞迴來構建他們的程式,只要這種技術有助於程式碼清晰度。
細心的讀者會注意到,在上面的例子中,proc 函式來自環境。透過將此函式引數化(使 proc 成為一個引數),可以改進程式碼並使其更通用
let rec processItems proc = function
| [] -> ()
| hd :: tl ->
proc hd;
processItems proc tl // recursively enumerate list
這個 processItems 函式確實非常有用,它以 List.iter 的名稱被納入標準庫。
為了完整起見,必須提到 F# 包含 List.iter 的通用版本,名為 Seq.iter(其他 List.* 函式通常也有 Seq.* 對應項),它適用於列表、陣列和所有其他集合。F# 還包含一個適用於所有實現 System.Collections.Generic.IEnumerable 的集合的迴圈構造
for item in collection do
process item
函式組合而不是繼承
[edit | edit source]傳統的 OO 廣泛使用 實現繼承;換句話說,程式設計師建立具有部分實現的基類,然後從基類構建物件層次結構,並在需要時覆蓋成員。這種風格從 1990 年代初開始就證明非常有效,但這種風格與函數語言程式設計並不一致。
函數語言程式設計旨在構建簡單、可組合的抽象。由於傳統的 OO 只能使物件的介面變得更復雜,而不能使其更簡單,因此繼承在 F# 中很少使用。因此,F# 庫往往類更少,物件層次結構更“扁平”,與在等效的 Java 或 C# 應用程式中發現的非常深且複雜的層次結構形成對比。
F# 傾向於更多地依賴物件組合和委託,而不是繼承來跨模組共享實現片段。
函式作為一等型別
[edit | edit source]F# 是一種函數語言程式設計語言,這意味著函式是一等資料型別:它們可以像任何其他變數一樣宣告和使用。
在像 Visual Basic 這樣的命令式語言中,變數和函式之間傳統上存在根本區別。
Function MyFunc(param As Integer)
MyFunc = (param * 2) + 7
End Function
' The program entry point; all statements must exist in a Sub or Function block.
Sub Main()
Dim myVal As Integer
' Also statically typed as Integer, as the compiler (for newer versions of VB.NET) performs local type inference.
Dim myParam = 2
myVal = MyFunc(myParam)
End Sub
請注意定義和評估函式與定義和賦值變數之間的語法差異。在前面的 Visual Basic 程式碼中,我們可以對變數執行許多不同的操作,我們可以
- 建立一個令牌(變數名)並將其與一個型別相關聯
- 為它分配一個值
- 查詢它的值
- 將其傳遞給函式或子例程(不返回值的函式)
- 從函式中返回它
函數語言程式設計不區分值和函式,因此我們可以認為函式與所有其他資料型別相同。這意味著我們可以
- 建立一個令牌(函式變數名)並將其與一個型別相關聯
- 為它分配一個值(實際計算)
- 查詢它的值(執行計算)
- 將函式作為另一個函式或子例程的引數傳遞
- 返回函式作為另一個函式的結果
F# 程式的結構
[edit | edit source]一個簡單的、非平凡的 F# 程式包含以下部分
open System
(* This is a
multi-line comment *)
// This is a single-line comment
let rec fib = function
| 0 -> 0
| 1 -> 1
| n -> fib (n - 1) + fib (n - 2)
[<EntryPoint>]
let main argv =
printfn "fib 5: %i" (fib 5)
0
大多數 F# 程式碼檔案以一些 open 語句開頭,這些語句用於匯入名稱空間,使程式設計師能夠引用名稱空間中的類,而無需編寫完全限定的型別宣告。這個關鍵字在功能上等同於 C# 中的 using 指令和 VB.Net 中的 Imports 指令。例如,Console 類位於 System 名稱空間下;如果不匯入名稱空間,程式設計師將需要透過其完全限定名 System.Console 來訪問 Console 類。
F# 檔案的主體通常包含用於實現應用程式中業務邏輯的函式。
最後,許多 F# 應用程式表現出這種模式
[<EntryPoint>]
let main argv =
// Code to be executed
0
F# 程式的入口點由 [<EntryPoint>] 屬性標記,其後必須是一個函式,該函式接受字串陣列作為輸入,並返回一個整數(預設情況下為 0)。