跳轉到內容

F# 程式設計/值與函式

來自華夏公益教科書,開放的書籍,為開放的世界
上一個:基本概念 索引 下一個:模式匹配基礎
F#:宣告值與函式

與其他 .NET 語言(如 C# 和 VB.Net)相比,F# 具有比較簡潔和極簡的語法。要跟隨本教程,請開啟 F# Interactive (fsi) 或 Visual Studio 並執行示例。

宣告變數

[編輯 | 編輯原始碼]

F# 中最普遍、最熟悉的關鍵字是 let 關鍵字,它允許程式設計師在應用程式中宣告函式和變數。

例如

let x = 5

這聲明瞭一個名為 x 的變數,並將其賦值為 5。自然,我們可以寫出以下內容

let x = 5
let y = 10
let z = x + y

z 現在持有值 15。

完整的程式如下所示

let x = 5
let y = 10
let z = x + y

printfn "x: %i" x
printfn "y: %i" y
printfn "z: %i" z

語句 printfn 將文字列印到控制檯視窗。正如你可能猜到的那樣,上面的程式碼打印出了 xyz 的值。這個程式將產生以下結果

x: 5
y: 10
z: 15

注意 F# Interactive 使用者:F# Interactive 中的所有語句都以 ;;(兩個分號)結尾。要在 fsi 中執行上面的程式,將上面的文字複製並貼上到 fsi 視窗中,輸入 ;;,然後按回車鍵。

值,而不是變數

[編輯 | 編輯原始碼]

在 F# 中,“變數”是一個誤稱。實際上,F# 中的所有“變數”都是不可變的;換句話說,一旦你將一個“變數”繫結到一個值,它將永遠保持該值。因此,大多數 F# 程式設計師更喜歡使用“值”而不是“變數”來描述上面的 xyz。在幕後,F# 實際上將上面的“變數”編譯為靜態只讀屬性。

宣告函式

[編輯 | 編輯原始碼]

在 F# 中,函式和值之間幾乎沒有區別。你使用與宣告值相同的語法來編寫函式

let add x y = x + y

add 是函式的名稱,它接受兩個引數,xy。注意,函式宣告中每個不同的引數都用空格隔開。同樣,當你執行此函式時,連續的引數也用空格隔開

let z = add 5 10

這將 z 賦值為該函式的返回值,在本例中恰好是 15

自然,我們可以將函式的返回值直接傳遞到其他函式中,例如

let add x y = x + y

let sub x y = x - y

let printThreeNumbers num1 num2 num3 =
    printfn "num1: %i" num1
    printfn "num2: %i" num2
    printfn "num3: %i" num3

printThreeNumbers 5 (add 10 7) (sub 20 8)

這個程式輸出

num1: 5
num2: 17
num3: 12

注意,我必須用括號將對 addsub 函式的呼叫括起來;這告訴 F# 將括號中的值視為單個引數。

否則,如果我們寫 printThreeNumbers 5 add 10 7 sub 20 8,不僅難以閱讀,而且實際上向函式傳遞了 7 個引數,這顯然是錯誤的。

函式返回值

[編輯 | 編輯原始碼]

與許多其他語言不同,F# 函式沒有明確的關鍵字來返回值。相反,函式的返回值只是函式中執行的最後一條語句的值。例如

let sign num =
    if num > 0 then "positive"
    elif num < 0 then "negative"
    else "zero"

該函式接受一個整數引數並返回一個字串。正如你可以想象的那樣,上面的 F# 函式等同於以下 C# 程式碼

string Sign(int num)
{
    if (num > 0) return "positive";
    else if (num < 0) return "negative";
    else return "zero";
}

就像 C# 一樣,F# 是一種強型別語言。一個函式只能返回一種資料型別;例如,以下 F# 程式碼將無法編譯

let sign num =
    if num > 0 then "positive"
    elif num < 0 then "negative"
    else 0

如果你在 fsi 中執行這段程式碼,你會收到以下錯誤訊息

> let sign num =
    if num > 0 then "positive"
    elif num < 0 then "negative"
    else 0;;

      else 0;;
  ---------^
stdin(7,10): error FS0001: This expression was expected to have type    string    but here has type    int   

錯誤訊息非常明確:F# 確定此函式返回一個 string,但函式的最後一行返回了一個 int,這是一個錯誤。

有趣的是,F# 中的每個函式都有一個返回值;當然,程式設計師並不總是編寫返回有意義值的函式。F# 有一個特殊的資料型別稱為 unit,它只有一個可能的值:()。當函式不需要向程式設計師返回值時,它們會返回 unit。例如,將字串列印到控制檯的函式顯然沒有返回值

let helloWorld () = printfn "hello world"

該函式接受 unit 引數並返回 ()。你可以將 unit 視為 C 樣式語言中 void 的等價物。

如何閱讀箭頭符號

[編輯 | 編輯原始碼]

F# 中的所有函式和值都有一個數據型別。開啟 F# Interactive 並輸入以下內容

> let addAndMakeString x y = (x + y).ToString();;

F# 使用鏈式箭頭符號報告資料型別,如下所示

val addAndMakeString : x:int -> y:int -> string

資料型別是從左到右讀取的。在用更準確的描述弄亂 F# 函式的構建方式之前,請考慮箭頭符號的基本概念:從左側開始,我們的函式接受兩個 int 輸入並返回一個 string。一個函式只有一個返回型別,由鏈式箭頭符號中最右邊的資料型別表示。

我們可以將以下資料型別讀作

int -> string

接受一個 int 輸入,返回一個 string

float -> float -> float

接受兩個 float 輸入,返回另一個 float

int -> string -> float

接受一個 int 和一個 string 輸入,返回一個 float

這個描述對於初學者來說是一個很好的入門方式來理解箭頭符號,如果你剛接觸 F#,可以在這裡停下來,直到你上手為止。對於那些覺得這個概念很舒服的人,F# 實際實現這些呼叫的方式是透過柯里化函式。

部分函式應用

[編輯 | 編輯原始碼]

雖然上面的箭頭符號描述直觀,但它並不完全準確,因為F# 隱式地柯里化 函式。這意味著一個函式永遠只有一個引數和一個返回型別,這與上面箭頭符號的描述大相徑庭,在第二和第三個示例中,兩個引數被傳遞給一個函式。實際上,F# 中的函式永遠只有一個引數和一個返回型別。這怎麼可能呢?考慮這個型別

float -> float -> float

由於這種型別的函式被 F# 隱式地柯里化,因此在用兩個引數呼叫函式時,有一個兩步過程來解析函式

  1. 一個函式被第一個引數呼叫,該引數返回一個函式,該函式接受一個 float 並返回一個 float。為了幫助澄清柯里化,讓我們將這個函式稱為funX(注意,這個命名只是為了說明目的,執行時建立的函式是匿名的)。
  2. 第二個函式(上面第 1 步中的'funX')用第二個引數呼叫,返回一個 float

因此,如果你提供兩個 float,結果看起來就像函式接受兩個引數一樣,但實際上執行時的行為並非如此。柯里化的概念可能會讓沒有深入研究函式概念的開發人員感到非常奇怪和不直觀,甚至不必要地冗餘和低效,因此,在嘗試進一步解釋之前,請考慮柯里化函式的優點,透過一個例子

let addTwoNumbers x y = x + y

這個型別有以下簽名

int -> int -> int

那麼這個函式

let add5ToNumber = addTwoNumbers 5

的型別簽名為 (int -> int)。注意 add5ToNumber 的主體只用一個引數呼叫 addTwoNumbers,而不是兩個。它返回一個函式,該函式接受一個 int 並返回一個 int。換句話說,add5toNumber部分應用addTwoNumbers 函式。

> let z = add5ToNumber 6;;

val z : int = 11

這個用多個引數對函式進行部分應用體現了柯里化函式的強大功能。它允許延遲應用函式,從而實現更模組化的開發和程式碼重用,我們可以透過部分應用來重用 addTwoNumbers 函式來建立一個新函式。由此,你可以體會到函式柯里化的強大功能:它總是將函式應用分解為最小的元素,為程式碼重用和模組化提供更多機會。

再舉一個例子,說明如何將部分應用的函式用作記賬技術。注意 holdOn 的型別簽名是一個函式 (int -> int),因為它是對 addTwoNumbers 的部分應用

> let holdOn = addTwoNumbers 7;;

val holdOn : (int -> int)

> let okDone = holdOn 8;;

val okDone : int = 15

這裡我們動態定義一個新的函式holdOn,僅僅為了跟蹤要新增的第一個值。然後,我們使用另一個值應用這個新的“臨時”函式holdOn,它返回一個int。由柯里化實現的部分應用函式是控制 F# 中複雜性的一個非常強大的手段。簡而言之,柯里化函式呼叫導致間接的原因是它允許部分函式應用,以及由此帶來的所有好處。換句話說,部分函式應用的目標是透過隱式柯里化實現的。

因此,雖然箭頭符號是理解函式型別簽名的良好簡寫,但它以過度簡化的代價實現了這一點,因為具有以下型別簽名的函式

f : int -> int -> int

實際上(當考慮隱式柯里化時)

// curried version pseudo-code
f: int -> (int -> int)

換句話說,f 是一個函式,它接受一個 int 並返回一個函式,該函式接受一個 int 並返回一個 int。此外,

f: int -> int -> int -> int

// curried version pseudo-code
f: int -> (int -> (int -> int))

的簡寫,或者,用非常難以解碼的英語來說:f 是一個函式,它接受一個 int 並返回一個函式,該函式接受一個 int 並返回一個函式,該函式接受一個 int 並返回一個 int。哎呀!

巢狀函式

[edit | edit source]

F# 允許程式設計師在其他函式內部巢狀函式。巢狀函式有很多應用,例如隱藏內部迴圈的複雜性

let sumOfDivisors n =
    let rec loop current max acc =
        if current > max then
            acc
        else
            if n % current = 0 then
                loop (current + 1) max (acc + current)
            else
                loop (current + 1) max acc
    let start = 2
    let max = n / 2     (* largest factor, apart from n, cannot be > n / 2 *)
    let minSum = 1 + n  (* 1 and n are already factors of n *)
    loop start max minSum

printfn "%d" (sumOfDivisors 10)
(* prints 18, because the sum of 10's divisors is 1 + 2 + 5 + 10 = 18 *)

外部函式sumOfDivisors呼叫內部函式loop。程式設計師可以根據需要設定任意級別的巢狀函式。

泛型函式

[edit | edit source]

在程式設計中,泛型函式是指不犧牲型別安全性的情況下返回不確定型別t的函式。泛型型別不同於具體型別,例如intstring;泛型型別表示稍後指定的型別。泛型函式很有用,因為它們可以推廣到許多不同的型別。

讓我們檢查以下函式

let giveMeAThree x = 3

F# 從變數在應用程式中的使用方式推匯出變數的型別資訊,但 F# 無法將值x限制為任何特定的具體型別,因此 F# 將x推廣到泛型型別'a

'a -> int

此函式接受一個泛型型別'a並返回一個int

當您呼叫泛型函式時,編譯器會將函式的泛型型別替換為傳遞給函式的值的資料型別。作為演示,讓我們使用以下函式

let throwAwayFirstInput x y = y

它的型別為'a -> 'b -> 'b,這意味著該函式接受一個泛型'a和一個泛型'b並返回一個'b

以下是一些 F# 互動式環境中的示例輸入和輸出

> let throwAwayFirstInput x y = y;;

val throwAwayFirstInput : 'a -> 'b -> 'b

> throwAwayFirstInput 5 "value";;
val it : string = "value"

> throwAwayFirstInput "thrownAway" 10.0;;
val it : float = 10.0

> throwAwayFirstInput 5 30;;
val it : int = 30

throwAwayFirstInput 5 "value" 用一個int和一個string呼叫該函式,它將int替換為'a,將string替換為'b。這將throwAwayFirstInput的資料型別更改為int -> string -> string

throwAwayFirstInput "thrownAway" 10.0 用一個string和一個float呼叫該函式,因此函式的資料型別更改為string -> float -> float

throwAwayFirstInput 5 30 恰好用兩個int呼叫該函式,因此函式的資料型別碰巧是int -> int -> int

泛型函式是強型別的。例如

let throwAwayFirstInput x y = y
let add x y = x + y

let z = add 10 (throwAwayFirstInput "this is a string" 5)

泛型函式throwAwayFirstInput被重新定義,然後add函式被定義,它的型別是int -> int -> int,這意味著此函式必須使用兩個int引數呼叫。

然後,throwAwayFirstInput被呼叫,作為add的引數,它自身帶有兩個引數,第一個是字串型別,第二個是整數型別。對throwAwayFirstInput的這次呼叫最終具有型別string -> int -> int。由於該函式的返回型別是int,因此程式碼按預期工作

> add 10 (throwAwayFirstInput "this is a string" 5);;
val it : int = 15

但是,當我們顛倒throwAwayFirstInput引數的順序時,會出現錯誤

> add 10 (throwAwayFirstInput 5 "this is a string");;

  add 10 (throwAwayFirstInput 5 "this is a string");;
  ------------------------------^^^^^^^^^^^^^^^^^^^

stdin(13,31): error FS0001: This expression has type
        string
but is here used with type
        int.

錯誤訊息非常明確:add函式需要兩個int引數,但throwAwayFirstInput 5 "this is a string"的返回型別是string,因此我們有一個型別不匹配。

後面的章節將演示如何在創造性和有趣的方式中使用泛型。

上一個:基本概念 索引 下一個:模式匹配基礎
華夏公益教科書