跳轉到內容

F# 程式設計/計算表示式

來自 Wikibooks,開放書籍,開放世界
前一節:引用 索引 下一節:非同步工作流
F#:計算表示式

計算表示式是 F# 中最難理解,但也是功能最強大的語言結構。

單子入門

[編輯 | 編輯原始碼]

計算表示式靈感來自 Haskell 單子,而 Haskell 單子又源於範疇論中的數學單子概念。為了避免所有關於單子的抽象技術和數學理論,簡單來說,"單子"是一個聽起來很嚇人的詞,意思是執行這個函式並將它的返回值傳遞給另一個函式

注意: F# 的設計者使用術語 "計算表示式" 和 "工作流",因為它們比 "單子" 聽起來不那麼晦澀難懂,並且因為單子計算表示式雖然相似,但並不是完全相同的東西。本書作者更喜歡 "單子",以強調 F# 和 Haskell 之間的平行關係(嚴格來說,只是一個有趣的五美元詞)。

Haskell 中的單子

Haskell 很有趣,因為它是一種函數語言程式設計語言,所有語句都是延遲執行的,這意味著 Haskell 不會計算值,直到它們真正需要時才會計算。雖然這賦予了 Haskell 一些獨特的功能,例如定義"無限" 資料結構的能力,但也使得難以推斷程式的執行過程,因為你無法保證程式碼行會按照任何特定的順序執行(甚至是否執行)。

因此,用 Haskell 來完成需要按順序執行的操作是一項相當大的挑戰,這包括任何形式的 I/O、在多執行緒程式碼中獲取鎖物件、讀寫套接字,以及任何可能對應用程式中其他記憶體位置產生副作用的操作。Haskell 使用名為單子的東西來管理順序操作,它可以用來模擬不可變環境中的狀態。

用 F# 視覺化單子

為了視覺化單子,讓我們看一些用命令式風格編寫的日常 F# 程式碼

let read_line() = System.Console.ReadLine()
let print_string(s) = printf "%s" s

print_string "What's your name? "
let name = read_line()
print_string ("Hello, " + name)

我們可以重新編寫 read_lineprint_string 函式,使它們接受一個額外的引數,即我們的計算完成後要執行的函式。最後我們會得到類似下面的東西

let read_line(f) = f(System.Console.ReadLine())
let print_string(s, f) = f(printf "%s" s)

print_string("What's your name? ", fun () ->
    read_line(fun name ->
        print_string("Hello, " + name, fun () -> () ) ) )

如果你能理解這一點,那麼你就能理解任何單子。

當然,說為什麼要以這種受虐狂的方式編寫程式碼呢?它所做的只是將 "Hello, Steve" 列印到控制檯! 也是完全合理的。畢竟,我們所知和喜愛的 C#、Java、C++ 或其他語言會嚴格按照指定順序執行程式碼——換句話說,單子解決了 Haskell 中在命令式語言中根本不存在的問題。因此,單子設計模式在命令式語言中幾乎是未知的。

然而,單子偶爾對模擬難以用命令式風格捕獲的計算很有用。

Maybe 單子

一個眾所周知的單子,Maybe 單子,代表一個短路計算,如果計算的任何部分失敗,它應該 "退出"。用一個簡單的例子來說,假設我們要寫一個函式,要求使用者輸入 3 個介於 0 到 100(含)之間的整數——如果使用者在任何時候輸入了一個非數字或超出我們範圍的輸入,整個計算都應該被中止。傳統上,我們可能會用以下程式碼來表示這種程式

let addThreeNumbers() =
    let getNum msg =
        printf "%s" msg
        // NOTE: return values from .Net methods that accept 'out' parameters are exposed to F# as tuples. 
        match System.Int32.TryParse(System.Console.ReadLine()) with
        | (true, n) when n >= 0 && n <= 100 -> Some(n)
        | _ -> None
    
    match getNum "#1: " with
    | Some(x) ->
        match getNum "#2: " with
        | Some(y) ->
            match getNum "#3: " with
            | Some(z) -> Some(x + y + z)
            | None -> None
        | None -> None
    | None -> None
注意: 誠然,這個程式的簡單性——獲取幾個整數——是荒謬的,還有很多更簡潔的方法可以編寫這個程式碼,只需預先獲取所有值。然而,你可以想象 getNum 是一個相對昂貴的操作(也許它會對資料庫執行查詢、透過網路傳送和接收資料、初始化一個複雜的資料結構),而編寫這個程式最有效的方式要求我們在遇到第一個無效值時就退出。

這段程式碼很醜陋,而且冗餘。但是,我們可以透過將其轉換為單子風格來簡化這段程式碼

let addThreeNumbers() =
    let bind(input, rest) =
        match System.Int32.TryParse(input()) with
        | (true, n) when n >= 0 && n <= 100 -> rest(n)
        | _ -> None
        
    let createMsg msg = fun () -> printf "%s" msg; System.Console.ReadLine()
    
    bind(createMsg "#1: ", fun x ->
        bind(createMsg "#2: ", fun y ->
            bind(createMsg "#3: ", fun z -> Some(x + y + z) ) ) )

神奇之處在於 bind 方法。我們從函式 input 中提取返回值,並將它(或繫結它)作為第一個引數傳遞給 rest

為什麼要使用單子?

上面的程式碼對於實際使用來說仍然過於繁瑣和冗長,但是單子在模擬難以按順序捕獲的計算方面特別有用。例如,多執行緒程式碼在命令式風格下 notoriously 難以編寫;但是用單子風格編寫起來卻異常簡潔和容易。讓我們修改上面的 bind 方法,如下所示

open System.Threading
let bind(input, rest) =
    ThreadPool.QueueUserWorkItem(new WaitCallback( fun _ -> rest(input()) )) |> ignore

現在我們的 bind 方法將在自己的執行緒中執行函式。使用單子,我們可以在安全、命令式風格下編寫多執行緒程式碼。以下是在 fsi 中演示這種技術的示例

> open System.Threading
open System.Text.RegularExpressions

let bind(input, rest) =
    ThreadPool.QueueUserWorkItem(new WaitCallback( fun _ -> rest(input()) )) |> ignore
    
let downloadAsync (url : string) =
    let printMsg msg = printfn "ThreadID = %i, Url = %s, %s" (Thread.CurrentThread.ManagedThreadId) url msg
    bind( (fun () -> printMsg "Creating webclient..."; new System.Net.WebClient()), fun webclient ->
        bind( (fun () -> printMsg "Downloading url..."; webclient.DownloadString(url)), fun html ->
            bind( (fun () -> printMsg "Extracting urls..."; Regex.Matches(html, @"http://\S+") ), fun matches ->
                    printMsg ("Found " + matches.Count.ToString() + " links")
                )
            )
        )
        
["http://www.google.com/"; "http://microsoft.com/"; "http://www.wordpress.com/"; "http://www.peta.org"] |> Seq.iter downloadAsync;;

val bind : (unit -> 'a) * ('a -> unit) -> unit
val downloadAsync : string -> unit

>
ThreadID = 5, Url = http://www.google.com/, Creating webclient...
ThreadID = 11, Url = http://microsoft.com/, Creating webclient...
ThreadID = 5, Url = http://www.peta.org, Creating webclient...
ThreadID = 11, Url = http://www.wordpress.com/, Creating webclient...
ThreadID = 5, Url = http://microsoft.com/, Downloading url...
ThreadID = 11, Url = http://www.google.com/, Downloading url...
ThreadID = 11, Url = http://www.peta.org, Downloading url...
ThreadID = 13, Url = http://www.wordpress.com/, Downloading url...
ThreadID = 11, Url = http://www.google.com/, Extracting urls...
ThreadID = 11, Url = http://www.google.com/, Found 21 links
ThreadID = 11, Url = http://www.peta.org, Extracting urls...
ThreadID = 11, Url = http://www.peta.org, Found 111 links
ThreadID = 5, Url = http://microsoft.com/, Extracting urls...
ThreadID = 5, Url = http://microsoft.com/, Found 1 links
ThreadID = 13, Url = http://www.wordpress.com/, Extracting urls...
ThreadID = 13, Url = http://www.wordpress.com/, Found 132 links

有趣的是,Google 開始在第 5 個執行緒上下載,並在第 11 個執行緒上完成。此外,第 11 個執行緒在某個時候被 Microsoft、Peta 和 Google 共享。每次呼叫 bind 時,我們都會從 .NET 的執行緒池中取出一個執行緒,當函式返回時,該執行緒會被釋放回執行緒池,另一個執行緒可能會再次拾取它——非同步函式在其生命週期中可能會在任意數量的執行緒之間跳轉,這是完全有可能的。

這種技術非常強大,以至於它以非同步工作流的形式烘焙到了 F# 庫中。

定義計算表示式

[編輯 | 編輯原始碼]

計算表示式從根本上來說與上面看到的概念相同,儘管它們將單子語法的複雜性隱藏在厚厚的一層語法糖後面。單子是一種特殊的類,它必須具有以下方法:BindDelayReturn

我們可以像下面這樣重新編寫我們之前描述的 Maybe 單子

type MaybeBuilder() =
    member this.Bind(x, f) =
        match x with
        | Some(x) when x >= 0 && x <= 100 -> f(x)
        | _ -> None
    member this.Delay(f) = f()
    member this.Return(x) = Some x

我們可以在 fsi 中測試這個類

> type MaybeBuilder() =
    member this.Bind(x, f) =
        printfn "this.Bind: %A" x
        match x with
        | Some(x) when x >= 0 && x <= 100 -> f(x)
        | _ -> None
    member this.Delay(f) = f()
    member this.Return(x) = Some x

let maybe = MaybeBuilder();;

type MaybeBuilder =
  class
    new : unit -> MaybeBuilder
    member Bind : x:int option * f:(int -> 'a0 option) -> 'a0 option
    member Delay : f:(unit -> 'a0) -> 'a0
    member Return : x:'a0 -> 'a0 option
  end
val maybe : MaybeBuilder

> maybe.Delay(fun () ->
    let x = 12
    maybe.Bind(Some 11, fun y ->
        maybe.Bind(Some 30, fun z ->
            maybe.Return(x + y + z)
            )
        )
    );;
this.Bind: Some 11
this.Bind: Some 30
val it : int option = Some 53

> maybe.Delay(fun () ->
    let x = 12
    maybe.Bind(Some -50, fun y ->
        maybe.Bind(Some 30, fun z ->
            maybe.Return(x + y + z)
            )
        )
    );;
this.Bind: Some -50
val it : int option = None

語法糖

[編輯 | 編輯原始碼]

單子很強大,但是超過兩個或三個變數,巢狀函式的數量就會變得難以管理。F# 提供了語法糖,允許我們以更可讀的方式編寫相同的程式碼。工作流使用 builder { comp-expr } 的形式進行評估。例如,以下程式碼片段是等效的

帶糖語法 去糖語法
let maybe = new MaybeBuilder()
let sugared =
    maybe {
        let x = 12
        let! y = Some 11
        let! z = Some 30
        return x + y + z
    }
let maybe = new MaybeBuilder()
let desugared = 
    maybe.Delay(fun () ->
        let x = 12
         maybe.Bind(Some 11, fun y ->
            maybe.Bind(Some 30, fun z ->
                maybe.Return(x + y + z)
                )
            )
        )
注意:你可能已經注意到,帶糖語法與用於宣告序列表達式 seq { expr } 的語法驚人地相似。這不是巧合。在 F# 庫中,序列被定義為計算表示式,並被用作計算表示式。非同步工作流 是你在學習 F# 時會遇到的另一個計算表示式。

帶糖形式讀起來就像正常的 F#。程式碼 let x = 12 按預期工作,但是 let! 在做什麼?注意我們說 let! y = Some 11,但是值 y 的型別是 int option 而不是 int。構造 let! y = ... 呼叫一個名為 maybe.Bind(x, f) 的函式,其中值 y 被繫結到傳遞到 f 函式中的引數。

類似地,return ... 呼叫一個名為 maybe.Return(x) 的函式。幾個新關鍵字會去糖化成其他一些構造,包括你在序列表達式中已經見過的 yieldyield!,以及一些新的關鍵字,比如 useuse!

這個 fsi 示例展示了使用計算表示式語法,我們的 maybe 單子是多麼容易使用

> type MaybeBuilder() =
    member this.Bind(x, f) =
        printfn "this.Bind: %A" x
        match x with
        | Some(x) when x >= 0 && x <= 100 -> f(x)
        | _ -> None
    member this.Delay(f) = f()
    member this.Return(x) = Some x

let maybe = MaybeBuilder();;

type MaybeBuilder =
  class
    new : unit -> MaybeBuilder
    member Bind : x:int option * f:(int -> 'a0 option) -> 'a0 option
    member Delay : f:(unit -> 'a0) -> 'a0
    member Return : x:'a0 -> 'a0 option
  end
val maybe : MaybeBuilder

> maybe {
    let x = 12
    let! y = Some 11
    let! z = Some 30
    return x + y + z
};;
this.Bind: Some 11
this.Bind: Some 30
val it : int option = Some 53

> maybe {
    let x = 12
    let! y = Some -50
    let! z = Some 30
    return x + y + z
};;
this.Bind: Some -50
val it : int option = None

這段程式碼與去糖化的程式碼做的事情相同,只是它更容易閱讀得多。

解剖語法糖

[編輯 | 編輯原始碼]

根據F# 規範,工作流可以用以下成員定義

成員 描述
member Bind : M<'a> * ('a -> M<'b>) -> M<'b> 必需成員。用於在計算表示式中去糖化 let!do!
member Return : 'a -> M<'a> 必需成員。用於在計算表示式中去糖化 return
member Delay : (unit -> M<'a>) -> M<'a> 必需成員。用於確保計算表示式中的副作用在預期的時間內執行。
member Yield : 'a -> M<'a> 可選成員。用於在計算表示式中去糖化 yield
member For : seq<'a> * ('a -> M<'b>) -> M<'b> 可選成員。用於在計算表示式中去糖化 for ... do ...M<'b> 可以選擇為 M<unit>
member While : (unit -> bool) * M<'a> -> M<'a> 可選成員。用於在計算表示式中去糖化 while ... do ...M<'b> 可以選擇為 M<unit>
member Using : 'a * ('a -> M<'b>) -> M<'b> when 'a :> IDisposable 可選成員。用於在計算表示式中去糖化 use 繫結。
member Combine : M<'a> -> M<'a> -> M<'a> 可選成員。用於在計算表示式中去糖化順序。第一個 M<'a> 可以選擇為 M<unit>
member Zero : unit -> M<'a> 可選成員。用於在計算表示式中去糖化 if/then 的空 else 分支。
member TryWith : M<'a> -> M<'a> -> M<'a> 可選成員。用於在計算表示式中去糖化空 try/with 繫結。
成員 TryFinally : M<'a> -> M<'a> -> M<'a> 可選成員。用於在計算表示式中對 try/finally 繫結進行反糖化。

這些糖化結構的反糖化如下

結構 反糖化形式
let pat = expr in cexpr let pat = expr in cexpr
let! pat = expr in cexpr b.Bind(expr, (fun pat -> cexpr))
return expr b.Return(expr)
return! expr b.ReturnFrom(expr)
yield expr b.Yield(expr)
yield! expr b.YieldFrom(expr)
use pat = expr in cexpr b.Using(expr, (fun pat -> cexpr))
use! pat = expr in cexpr b.Bind(expr, (fun x -> b.Using(x, fun pat -> cexpr))
do! expr in cexpr b.Bind(expr, (fun () -> cexpr))
for pat in expr do cexpr b.For(expr, (fun pat -> cexpr))
while expr do cexpr b.While((fun () -> expr), b.Delay( fun () -> cexpr))
if expr then cexpr1 else cexpr2 if expr then cexpr1 else cexpr2
if expr then cexpr if expr then cexpr else b.Zero()
cexpr1
cexpr2
b.Combine(cexpr1, b.Delay(fun () -> cexpr2))
try cexpr with patn -> cexprn b.TryWith(expr, fun v -> match v with (patn:ext) -> cexprn | _ raise exn)
try cexpr finally expr b.TryFinally(cexpr, (fun () -> expr))

計算表示式有什麼用?

[edit | edit source]

F# 鼓勵一種稱為面向語言程式設計的程式設計風格來解決問題。與通用程式設計風格相比,面向語言程式設計的核心是程式設計師識別他們想要解決的問題,然後編寫特定於領域的迷你語言來解決問題,最後在新的迷你語言中解決問題。

計算表示式是 F# 程式設計師用來設計迷你語言的幾種工具之一。

令人驚訝的是,計算表示式和類似單子的結構在實踐中經常出現。例如,Haskell 使用者組 收集了常見和不常見的單子,包括那些計算整數分佈和解析文字的單子。另一個重要的例子,軟體事務記憶體的 F# 實現,在 hubFS 上介紹。

其他資源

[edit | edit source]
前一節:引用 索引 下一節:非同步工作流
華夏公益教科書