F# 編碼慣例

下列慣例是透過使用大型 F# 程式碼基底的體驗所制撰寫。 良好 F# 程式碼的五個準則是每個建議的基礎。 其與 F# 元件設計指導方針相關,但適用於任何 F# 程式碼,而不只是程式庫等元件。

組織程式碼

F# 具有兩種主要方式可組織程式碼:模組和命名空間。 這些方式類似,但有下列差異:

  • 命名空間會編譯為 .NET 命名空間。 模組會編譯為靜態類別。
  • 命名空間一律為最上層。 模組可為最上層,並巢狀於其他模組內。
  • 命名空間可以跨越多個檔案。 模組則無法。
  • 您可以使用 [<RequireQualifiedAccess>][<AutoOpen>] 來裝飾模組。

下列指導方針將協助您使用這些模組來組織程式碼。

偏好最上層的命名空間

對於任何公開消費性程式碼,最上層的模組會優先使用命名空間。 由於它們會編譯為 .NET 命名空間,因此可從 C# 取用它們,並不需採用 using static

// Recommended.
namespace MyCode

type MyClass() =
    ...

僅從 F# 呼叫時,使用最上層模組可能不會出現差異,但對於 C# 取用者而言,呼叫端可能會因為在不了解特定 C# using static 建構的情況下必須以 MyCode 模組限定 MyClass 而感到意外。

// Will be seen as a static class outside F#
module MyCode

type MyClass() =
    ...

小心套用 [<AutoOpen>]

[<AutoOpen>] 建構可能會干擾呼叫端可用的範圍,而項目來源的答案往往會「不可思議」。 這不是好事。 此規則的例外狀況是 F# 核心程式庫本身 (不過,這項事實也備受爭議)。

不過,如果您有公用 API (要與該公用 API 分開組織) 的協助程式功能,那麼這就很便利。

module MyAPI =
    [<AutoOpen>]
    module private Helpers =
        let helper1 x y z =
            ...

    let myFunction1 x =
        let y = ...
        let z = ...

        helper1 x y z

這可讓您在每次呼叫協助程式時,從函式的公用 API 清楚分隔實作詳細資料,而無須完整限定協助程式。

此外,在命名空間層級公開擴充方法和運算式建立器可以整齊地利用 [<AutoOpen>] 來表示。

每當名稱可能有所衝突,或您認為有助於可讀性時,請使用 [<RequireQualifiedAccess>]

[<RequireQualifiedAccess>] 屬性新增至模組表示模組可能未開啟,且對模組元素的參考需要明確的限定存取權。 例如,Microsoft.FSharp.Collections.List 模組具有這個屬性。

當模組中的函式和值所使用的名稱可能與其他模組中的名稱衝突時,這會非常有用。 要求限定的存取權可大幅提升長期維護性,以及發展程式庫的能力。

[<RequireQualifiedAccess>]
module StringTokenization =
    let parse s = ...

...

let s = getAString()
let parsed = StringTokenization.parse s // Must qualify to use 'parse'

以拓撲方式排序 open 陳述式

在 F# 中,宣告的順序至關重要,包括使用 open 陳述式 (以及 open type,稍後只稱為 open)。 這與 C# 不同,其中 usingusing static 的效果與檔案中這些陳述式的順序無關。

在 F# 中,通向範圍的元素可能會遮蔽其他已存在的項目。 這表示重新排序 open 陳述式可能會改變程式碼的意義。 因此,不建議將所有 open 陳述式任意排序 (例如,以英數位元方式),讓您產生可能預期到的不同行為。

相反地,建議您以拓撲方式來排序這些陳述式;也就是說,依您系統所定義的open圖層順序來排序 陳述式。 您也可以考慮在不同的拓撲圖層內執行英數字元排序。

例如,以下為 F# 編譯器服務公用 API 檔案的拓撲排序:

namespace Microsoft.FSharp.Compiler.SourceCodeServices

open System
open System.Collections.Generic
open System.Collections.Concurrent
open System.Diagnostics
open System.IO
open System.Reflection
open System.Text

open FSharp.Compiler
open FSharp.Compiler.AbstractIL
open FSharp.Compiler.AbstractIL.Diagnostics
open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.AbstractIL.ILBinaryReader
open FSharp.Compiler.AbstractIL.Internal
open FSharp.Compiler.AbstractIL.Internal.Library

open FSharp.Compiler.AccessibilityLogic
open FSharp.Compiler.Ast
open FSharp.Compiler.CompileOps
open FSharp.Compiler.CompileOptions
open FSharp.Compiler.Driver

open Internal.Utilities
open Internal.Utilities.Collections

分行符號會分隔拓撲圖層,後續會以英數字元方式排序每個圖層。 這會完全地組織程式碼,而不會意外遮蔽值。

使用類別來包含會有副作用的值

將值初始化時經常可能會有副作用,例如將內容具現化至資料庫或其他遠端資源。 很容易在模組中初始化這類項目,並在後續函式中加以使用:

// Not recommended, side-effect at static initialization
module MyApi =
    let dep1 = File.ReadAllText "/Users/<name>/connectionstring.txt"
    let dep2 = Environment.GetEnvironmentVariable "DEP_2"

    let private r = Random()
    let dep3() = r.Next() // Problematic if multiple threads use this

    let function1 arg = doStuffWith dep1 dep2 dep3 arg
    let function2 arg = doStuffWith dep1 dep2 dep3 arg

這會因以下幾個原因而經常有問題:

首先,會透過 dep1dep2 將應用程式組態推送至程式碼基底。 這在較大的程式碼基底中則難以維護。

其次,如果您的元件本身會使用多個執行緒,靜態初始化的資料就不應包含非執行緒安全的值。 dep3 顯然違反了這個原則。

最後,模組初始化會編譯為整個編譯單位的靜態建構函式。 如果在該模組的 let-bound 值初始化中發生任何錯誤,則會將其顯示為 TypeInitializationException,隨後會快取為應用程式的整個存留期。 這可能難以診斷。 通常會出現內部例外狀況且您可能要嘗試推斷,但如果沒有,則不會告知根本原因。

相反地,僅使用簡單的類別來保存相依性:

type MyParametricApi(dep1, dep2, dep3) =
    member _.Function1 arg1 = doStuffWith dep1 dep2 dep3 arg1
    member _.Function2 arg2 = doStuffWith dep1 dep2 dep3 arg2

這可處理下列項目:

  1. 推送 API 本身之外的任何相依狀態。
  2. 現在可在 API 外部完成設定。
  3. 初始化相依值的錯誤可能不會顯示為 TypeInitializationException
  4. API 現在更易於測試。

錯誤管理

大型系統中的錯誤管理是一項複雜而細微的工作,且沒有任何銀色項目符號以確保系統容錯且行為良好。 下列指導方針應能提供指引,巡覽此困難之處。

以網域內建類型代表錯誤案例及不合法的狀態

使用已區分的聯集時,F# 可讓您在類型系統中代表錯誤的程式狀態。 例如:

type MoneyWithdrawalResult =
    | Success of amount:decimal
    | InsufficientFunds of balance:decimal
    | CardExpired of DateTime
    | UndisclosedFailure

在此情況下,從銀行帳戶提領金錢時,有三種已知方式可能會失敗。 每個錯誤案例都會以類型表示,因此可以在整個程式中安全地進行處理。

let handleWithdrawal amount =
    let w = withdrawMoney amount
    match w with
    | Success am -> printfn $"Successfully withdrew %f{am}"
    | InsufficientFunds balance -> printfn $"Failed: balance is %f{balance}"
    | CardExpired expiredDate -> printfn $"Failed: card expired on {expiredDate}"
    | UndisclosedFailure -> printfn "Failed: unknown"

一般而言,如果您可以將某個項目在網域中可能會失敗的不同方式模型化,則除了一般程式流程之外,錯誤處理程式碼不會再被視為您必須處理的項目。 這只是一般程式流程的一部分,而不會視為例外狀況。 這有兩個主要的好處:

  1. 當您的網域隨著時間變更時,會更容易維護。
  2. 錯誤案例更易於進行單元測試。

無法以類型表示錯誤時,請使用例外狀況

並非所有錯誤都可在問題網域中表示。 這類的錯誤在本質上是例外狀況,因此能夠在 F# 中引發及攔截例外狀況。

首先,建議您閱讀例外狀況設計指導方針。 這些也適用於 F#。

為了引發例外狀況的目的,應該以下列喜好設定順序來考慮 F# 中可用的主要建構:

函數 語法 目的
nullArg nullArg "argumentName" 使用指定的引數名稱引發 System.ArgumentNullException
invalidArg invalidArg "argumentName" "message" 使用指定的引數名稱和訊息引發 System.ArgumentException
invalidOp invalidOp "message" 使用指定的訊息引發 System.InvalidOperationException
raise raise (ExceptionType("message")) 擲回例外狀況的一般用途機制。
failwith failwith "message" 使用指定的訊息引發 System.Exception
failwithf failwithf "format string" argForFormatString 使用格式字串及其輸入所決定的訊息引發 System.Exception

使用 nullArginvalidArginvalidOp 做為視需要擲回 ArgumentNullExceptionArgumentExceptionInvalidOperationException 的機制。

通常應該避免 failwithfailwithf 函式,因為這些函式會引發基底 Exception 類型,而不是特定的例外狀況。 根據例外狀況設計指導方針,您需要在可行時引發更具體的例外狀況。

使用例外狀況處理語法

F# 會透過 try...with 語法支援例外狀況模式:

try
    tryGetFileContents()
with
| :? System.IO.FileNotFoundException as e -> // Do something with it here
| :? System.Security.SecurityException as e -> // Do something with it here

如果您想要使程式碼保持乾淨,在遇到模式比對的例外狀況時所要執行的協調功能可能會有點棘手。 其中一種處理方式是使用現用模式做為一種方式,將錯誤案例周圍的功能分組,並包含例外狀況本身。 例如,您可能會取用 API,當 API 擲回例外狀況時,會將有價值的資訊封入例外狀況中繼資料中。 在某些情況下,將現用模式內所擷取到的例外狀況主體中有用的值解除包裝並傳回該值可能會很有用。

請勿使用單一錯誤處理來取代例外狀況

在單純功能性範例中,通常會將例外狀況視為禁忌。 事實上,例外狀況有違單純性,因此可以放心地將例外狀況視為在功能性上不完全單純。 不過,這會忽略程式碼必須執行的位置,而且可能會發生執行時間錯誤。 一般而言,請在大部分項目並非單純或總計的假設下撰寫程式碼,以盡可能避免令人不快的意外狀況 (類似於 C# 中的空白 catch,或錯誤管理堆疊追蹤、捨棄資訊)。

請務必就總體 .NET 執行階段和跨語言生態系統的相關性和適當性,考慮下列例外狀況的核心優勢/層面:

  • 這些項目包含詳細的診斷資訊,這在偵錯問題時很有用。
  • 執行階段和其他 .NET 語言都能充分了解這些項目。
  • 相較於藉由以臨機操作方式實作其語意的一些子集來特地避免例外狀況的程式碼,這些項目可以減少大量重複使用。

上述的第三點非常重要。 針對非複雜作業,無法使用例外狀況可能會導致處理如下的結構:

Result<Result<MyType, string>, string list>

這可能很容易導致程式碼變弱,例如「字串類型」錯誤的模式比對:

let result = doStuff()
match result with
| Ok r -> ...
| Error e ->
    if e.Contains "Error string 1" then ...
    elif e.Contains "Error string 2" then ...
    else ... // Who knows?

此外,在傳回「較好」類型的「簡單」函式中,可能會傾向抑制任何例外狀況:

// Can be problematic due to discarding the cause of error.
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with _ -> None

可惜的是,tryReadAllText 可能會根據檔案系統上可能發生的大量事項擲回許多例外狀況,而此程式碼會捨棄您環境中可能實際發生錯誤的任何資訊。 如果您將此程式碼取代為結果類型,則會回到「字串類型」錯誤訊息剖析:

// Problematic, callers only have a string to figure the cause of error.
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Ok
    with e -> Error e.Message

let r = tryReadAllText "path-to-file"
match r with
| Ok text -> ...
| Error e ->
    if e.Contains "uh oh, here we go again..." then ...
    else ...

而將例外狀況物件本身放在 Error 建構函式中,只會強制您在呼叫位置 (而不是在函式中) 正確處理例外狀況類型。 這麼做會有效地建立已檢查的例外狀況,API 的呼叫端在處理這種情況時極為不容易。

上述範例的一個絕佳替代方案是攔截特定例外狀況,並在該例外狀況的內容中傳回有意義的值。 如果您如下修改 tryReadAllText 函式,則 None 具有更多意義:

let tryReadAllTextIfPresent (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with :? FileNotFoundException -> None

此函式現在會正確處理找不到檔案,而將該意義指派給傳回的案例,而不是以全部擷取方式運作。 此傳回值可以對應至該錯誤案例,同時不會捨棄任何內容資訊,或強制呼叫端處理程式碼中在該點可能不相關的案例。

諸如 Result<'Success, 'Error> 的類型適用於屬非巢狀的基本作業,而 F# 選擇性類型非常適合用來表示當某些內容可傳回某些內容沒有內容的情況。 不過,這些並不是例外狀況的取代項目,但不應該在嘗試取代例外狀況的情況下使用。 相反地,應該謹慎地套用這些內容,以目標方式解決例外狀況和錯誤管理原則的特定層面。

部分應用程式和無點程式設計

F# 支援部分應用程式,因此,會以各種方式進行無點樣式程式設計。 這對於模組內的程式碼重複使用或實作程式碼很有用,但並非對外公開的內容。 一般而言,無點程式設計本身並非優點,且可能對未沈浸於樣式的人員增加重要的認知障礙。

請勿在公用 API 中使用部分應用程式和局部調用

除了一些例外狀況,在公用 API 中使用部分應用程式可能會使取用者造成混淆。 F# 程式碼中的 let 繫結值通常為,而不是函式值。 將值和函式值混合在一起,可能會省下幾行程式碼來換取少量認知額外負荷,特別是在與 >> 等運算子結合以撰寫函式的情況。

考量無點程式設計的工具隱含

局部調用函式不會標記其引數。 這具有工具隱含。 請考量下列兩個函式:

let func name age =
    printfn $"My name is {name} and I am %d{age} years old!"

let funcWithApplication =
    printfn "My name is %s and I am %d years old!"

這兩個都是有效的函式,但 funcWithApplication 是局部調用函式。 當您將滑鼠停留在編輯器中的類型上方時,您會看到:

val func : name:string -> age:int -> unit

val funcWithApplication : (string -> int -> unit)

在呼叫位置中,Visual Studio 之類的工具提示會為您提供類型簽章,但因為沒有定義名稱,所以不會顯示名稱。 名稱對於良好的 API 設計至關重要,因為其可協助呼叫端進一步了解 API 背後的意義。 在公用 API 中使用無點程式碼可能會使呼叫端更難以了解。

如果您遇到類似 funcWithApplication 的可公開取用無點程式碼,建議您執行完整的 η 擴充,讓工具可以為引數挑選有意義的名稱。

此外,若非不可能,偵錯無點程式碼可能很困難。 偵錯工具仰賴繫結至名稱的值 (例如 let 繫結),以便您可在執行期間檢查中繼值。 當您的程式碼沒有可檢查的值時,就不會進行偵錯。 未來,偵錯工具可能會發展成根據先前執行的路徑來合成這些值,但不建議對潛在的偵錯功能做出您的避險措施。

考慮使用部分應用程式做為減少內部重複使用的技巧

相較於上一點,部分應用程式可減少應用程式內部或 API 更深入內部的重複使用,因此是一個絕佳的工具。 這對於更複雜 API 的實作進行單元測試很有用,其中重複使用通常很麻煩。 例如,下列程式碼示範如何完成大部分模擬架構所提供的功能,而無須對這類架構採取外部相依性,且無須了解相關的訂用 API。

例如,請考量下列解決方案地形:

MySolution.sln
|_/ImplementationLogic.fsproj
|_/ImplementationLogic.Tests.fsproj
|_/API.fsproj

ImplementationLogic.fsproj 可能會公開程式碼,例如:

module Transactions =
    let doTransaction txnContext txnType balance =
        ...

type Transactor(ctx, currentBalance) =
    member _.ExecuteTransaction(txnType) =
        Transactions.doTransaction ctx txnType currentBalance
        ...

單元測試 ImplementationLogic.Tests.fsproj 中的 Transactions.doTransaction 很簡單:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

部分套用 doTransaction 與模擬內容物件可讓您呼叫所有單元測試中的函式,而不需要每次都建構模擬的內容:

module TransactionTests

open Xunit
open TransactionTypes
open TransactionsTestingUtil
open TransactionsTestingUtil.TransactionsTestable

let testableContext =
    { new ITransactionContext with
        member _.TheFirstMember() = ...
        member _.TheSecondMember() = ... }

let transactionRoutine = getTestableTransactionRoutine testableContext

[<Fact>]
let ``Test withdrawal transaction with 0.0 for balance``() =
    let expected = ...
    let actual = transactionRoutine TransactionType.Withdraw 0.0
    Assert.Equal(expected, actual)

請勿將此技巧泛用至整個程式碼基底,但這是減少複雜內部項目重複使用和單元測試這些內部項目的好方法。

存取控制

F# 有多個存取控制選項,都是繼承自 .NET 執行階段中可用的選項。 這些不只可用於類型,您也可將其用於函式。

廣泛取用程式庫內容中的良好做法:

  • 偏好非 public 類型和成員,直到您需要加以公開取用為止。 這也可大幅降低取用者結合的項目。
  • 致力於保留所有協助程式功能 private
  • 如果協助程式函式數量太多,請考慮在私人模組上使用 [<AutoOpen>]

型別推斷和泛型

型別推斷可以讓您省去輸入許多重複使用項目。 此外,F# 編譯器中的自動一般化可協助您撰寫更多泛型程式碼,您幾乎不需要額外耗費精力。 不過,這些功能並非泛用。

  • 請考慮在公用 API 中使用明確型別來標記引數名稱,而不仰賴此型別推斷。

    這是因為應該控制 API 的形狀,而非編譯器。 雖然編譯器可為您妥善推斷型別,但如果其仰賴的內部項目已變更類型,就可以變更 API 的圖形。 這可能符合您所需要,但這幾乎勢必會導致下游取用者所必須處理的重大 API 變更。 相反地,如果您明確控制公用 API 的圖形,則可以控制這些重大變更。 在 DDD 詞彙中,這可視為反損毀圖層。

  • 請考慮為泛型引數提供有意義的名稱。

    除非您撰寫並非特定網域所特有的真正泛型程式碼,否則有意義的名稱可協助其他程式設計人員了解他們正在使用的網域。 例如,在與文件資料庫互動的環境中,名為 'Document 的型別參數可讓您更清楚,您正在使用的函式或成員可以接受泛型文件類型。

  • 請考慮使用 PascalCase 來命名泛型型別參數。

    這是在 .NET 中執行作業的一般方式,因此建議您使用 PascalCase,而不是使用 snake_case或 camelCase。

最後,自動一般化對於不熟悉 F# 或大型程式碼基底的人員並非一向都很有用。 使用泛型元件時,會有認知額外負荷。 此外,如果自動一般化函式未與不同的輸入類型搭配使用 (若用來做為這類輸入類型,則單獨使用),這些函式在泛型上就沒有有實際的好處。 請務必考慮您所撰寫的程式碼是否實際受益於泛型。

效能

針對具有高配置率的小型類型,請考量結構

使用結構 (也稱為實值型別) 通常可能會導致某些程式碼的效能提高,因為其通常會避免設置物件。 不過,結構不一定是「加速」按鈕:如果結構中的資料大小超過 16 個位元組,則複製資料通常可能會造成較使用參考型別更多的 CPU 時間。

若要判斷您是否應該使用結構,請考慮下列條件:

  • 如果資料的大小為 16 個位元組或更小。
  • 如果您可能有許多這類型的執行個體位於執行中程式的記憶體中。

如果適用第一個條件,則您通常應該使用結構。 如果兩者都適用,您應該幾乎一律使用結構。 在某些情況下,可能適用先前的條件,但使用結構並不優於或劣於使用參考型別,但可能很罕見。 不過,請務必在進行類似這樣的變更時一律進行測量,而非根據假設或直覺進行操作。

將具有高配置率的小型實值型別分組時,請考慮結構元組

請考量下列兩個函式:

let rec runWithTuple t offset times =
    let offsetValues x y z offset =
        (x + offset, y + offset, z + offset)

    if times <= 0 then
        t
    else
        let (x, y, z) = t
        let r = offsetValues x y z offset
        runWithTuple r offset (times - 1)

let rec runWithStructTuple t offset times =
    let offsetValues x y z offset =
        struct(x + offset, y + offset, z + offset)

    if times <= 0 then
        t
    else
        let struct(x, y, z) = t
        let r = offsetValues x y z offset
        runWithStructTuple r offset (times - 1)

當您使用 BenchmarkDotNet 等統計基準測試工具來基準測試這些函式時,您會發現使用結構元組的 runWithStructTuple 函式執行速度快上 40%,而且不會配置任何記憶體。

不過,這些結果不一定是您自己程式碼中的情況。 如果您將函式標示為 inline,則使用參考元組的程式碼可能會取得一些額外的最佳化,或配置的程式碼可能會直接最佳化。 每當考量到效能時,您應該一律測量結果,且絕不根據假設或直覺進行操作。

當類型很小且具有高配置率時,請考量結構記錄

稍早所述的經驗法則也適用於 F# 記錄類型。 請考慮下列處理這些類型的資料類型和函式:

type Point = { X: float; Y: float; Z: float }

[<Struct>]
type SPoint = { X: float; Y: float; Z: float }

let rec processPoint (p: Point) offset times =
    let inline offsetValues (p: Point) offset =
        { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

    if times <= 0 then
        p
    else
        let r = offsetValues p offset
        processPoint r offset (times - 1)

let rec processStructPoint (p: SPoint) offset times =
    let inline offsetValues (p: SPoint) offset =
        { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

    if times <= 0 then
        p
    else
        let r = offsetValues p offset
        processStructPoint r offset (times - 1)

這類似於先前的元組程式碼,但範例這次會使用記錄和內嵌內部函式。

當您使用 BenchmarkDotNet 等統計基準測試工具來基準測試這些函式時,您會發現 processStructPoint 執行速度幾乎快上 60%,且在受控堆積上沒有配置任何內容。

當資料類型很小且配置率偏高時,請考慮結構區分聯集

先前有關結構元組和記錄效能的觀察也適用於 F# 已區分的聯集效能。 請考慮下列程式碼:

    type Name = Name of string

    [<Struct>]
    type SName = SName of string

    let reverseName (Name s) =
        s.ToCharArray()
        |> Array.rev
        |> System.String
        |> Name

    let structReverseName (SName s) =
        s.ToCharArray()
        |> Array.rev
        |> System.String
        |> SName

針對網域模型定義這類型單一案例區分聯集很常見。 當您使用 BenchmarkDotNet 等統計基準測試工具來對這些函式進行基準測試時,您會發現小型字串的 structReverseName 執行速度較 reverseName 快上約 25%。 對於大型字串,兩者的執行速度幾乎相同。 因此,在此情況下,最好是使用結構。 如先前所述,請一律進行測量,並且請勿根據假設或直覺進行操作。

雖然上一個範例中顯示結構區分聯集會產生較佳的效能,但在模型化網域時,通常會有較大的區分聯集。 這類較大型的資料類型,若視類型上的作業而定為結構時,可能也無法執行,因為可能會涉及更多複製。

不變性和變動

F# 值預設是不可變的,可讓您避免某些類別 (特別是涉及並行和平行處理原則) 的錯誤。 不過,在某些情況下,為了達到最佳 (或甚至合理) 的執行時間或記憶體配置效率,最好使用就地變動狀態來實作工作範圍。 這就加入 F# 與 mutable 關鍵字是可行的。

在 F# 中使用 mutable 時,可能會感到與功能單純性大相徑庭。 這是可理解的,但所有地方的功能單純性都可能與效能目標大相徑庭。 入侵是封裝變動,如此會使呼叫端在呼叫函式時,不需要關心會發生什麼事。 這可讓您針對效能關鍵性程式碼,透過變動型實作來撰寫功能介面。

此外,F# let 繫結建構可讓您將繫結巢狀至另一個繫結中,這可用來將 mutable 變數的範圍保持接近或達到理論上的最小範圍。

let data =
    [
        let mutable completed = false
        while not completed do
            logic ()
            // ...
            if someCondition then
                completed <- true   
    ]

任何程式碼都無法存取只用來初始化 data let bound 值的可變 completed

將可變程式碼包裝在不可變的介面中

若以引用透明度做為目標,撰寫不會公開效能關鍵性函式的可變動弱點程式碼至關重要。 例如,下列程式碼會在 F# 核心程式庫中實作 Array.contains 函式:

[<CompiledName("Contains")>]
let inline contains value (array:'T[]) =
    checkNonNull "array" array
    let mutable state = false
    let mutable i = 0
    while not state && i < array.Length do
        state <- value = array[i]
        i <- i + 1
    state

多次呼叫此函式並不會變更基礎陣列,也不需要您維護任何可變動的狀態來加以取用。 雖然其中幾乎每一行程式碼都會使用變動,但其引用方式是透明的。

請考慮將可變動的資料封裝在類別中

上一個範例使用單一函式來封裝使用可變動資料的作業。 這不一定足夠用於更複雜的資料集。 請考慮下列函式集:

open System.Collections.Generic

let addToClosureTable (key, value) (t: Dictionary<_,_>) =
    if t.ContainsKey(key) then
        t[key] <- value
    else
        t.Add(key, value)

let closureTableCount (t: Dictionary<_,_>) = t.Count

let closureTableContains (key, value) (t: Dictionary<_, HashSet<_>>) =
    match t.TryGetValue(key) with
    | (true, v) -> v.Equals(value)
    | (false, _) -> false

此程式碼是高效能的,但其會公開呼叫端所負責維護的變動型資料結構。 您可將此包裝在類別內,沒有任何可變更的基礎成員:

open System.Collections.Generic

/// The results of computing the LALR(1) closure of an LR(0) kernel
type Closure1Table() =
    let t = Dictionary<Item0, HashSet<TerminalIndex>>()

    member _.Add(key, value) =
        if t.ContainsKey(key) then
            t[key] <- value
        else
            t.Add(key, value)

    member _.Count = t.Count

    member _.Contains(key, value) =
        match t.TryGetValue(key) with
        | (true, v) -> v.Equals(value)
        | (false, _) -> false

Closure1Table 會封裝基礎變動型資料結構,因此不會強制呼叫端維護基礎資料結構。 類別是封裝資料與常式的強大方式,這些資料與常式是變動型,因此不會向呼叫端公開詳細資料。

偏好 let mutable 而非 ref

參考資料格是表示參考值的方式,而不是值本身。 雖然其可用於效能關鍵性程式碼,但並不建議使用。 請考慮下列範例:

let kernels =
    let acc = ref Set.empty

    processWorkList startKernels (fun kernel ->
        if not ((!acc).Contains(kernel)) then
            acc := (!acc).Add(kernel)
        ...)

    !acc |> Seq.toList

使用參考資料格現在會「干擾」所有後續的程式碼,且必須取值並重新參考基礎資料。 相反地,請考慮 let mutable

let kernels =
    let mutable acc = Set.empty

    processWorkList startKernels (fun kernel ->
        if not (acc.Contains(kernel)) then
            acc <- acc.Add(kernel)
        ...)

    acc |> Seq.toList

除了 Lambda 運算式中的單一變動點之外,其他所有觸及 acc 的程式碼都可以透過與一般 let 繫結不可變值用法不同的方式來執行。 這可讓您更輕鬆地隨著時間進行變更。

Null 和預設值

通常應該在 F# 中避免使用 Null。 根據預設,F# 宣告的類型不支援使用 null 常值,且會初始化所有值和物件。 不過,某些常見的 .NET API 會傳回或接受 Null,而一些常見的 .NET 宣告型別 (例如陣列和字串) 會允許 Null。 不過,F# 程式設計中出現 null 值的情況非常罕見,而使用 F# 的優點之一,是可在大部分情況下避免發生 Null 參考錯誤。

避免使用 AllowNullLiteral 屬性

根據預設,F# 宣告的類型不支援使用 null 常值。 您可以使用手動以 AllowNullLiteral 標註 F# 類型,以允許此項目。 不過,大部分情況下最好避免這麼做。

避免使用 Unchecked.defaultof<_> 屬性

您可以使用 Unchecked.defaultof<_> 來產生 null 或零初始化的 F# 型別值。 在初始化某些資料結構的儲存體,或在某些高效能編碼模式中或互通性中,這會很有用。 不過,應避免使用此建構。

避免使用 DefaultValue 屬性

根據預設,必須在建構時正確初始化 F# 記錄和物件。 DefaultValue 屬性可用來以 null 或零初始化的值填入物件的某些欄位。 幾乎不需要此建構,因此應避免使用。

如果您檢查 Null 輸入,則一有機會就引發例外狀況

撰寫新的 F# 程式碼時,除非您預期從 C# 或其他 .NET 語言使用該程式碼,否則實際上並不需要檢查 Null 輸入。

如果您決定要新增 Null 輸入的檢查,請一有機會就執行檢查並引發例外狀況。 例如:

let inline checkNonNull argName arg =
    if isNull arg then
        nullArg argName

module Array =
    let contains value (array:'T[]) =
        checkNonNull "array" array
        let mutable result = false
        let mutable i = 0
        while not state && i < array.Length do
            result <- value = array[i]
            i <- i + 1
        result

基於舊有原因,FSharp.Core 中的某些字串函式仍會將 Null 視為空字串,且在 Null 引數上不會失敗。 不過,請勿將此視為指引,也請勿採用將任何語意意義設為「null」的編碼模式。

物件程式設計

F# 完整支援物件和物件導向 (OO) 概念。 雖然許多 OO 概念都功能強大且實用,但並非所有概念都適合使用。 下列清單提供高階 OO 功能的類別指引。

在許多情況下,請考慮使用這些功能:

  • 點標記法 (x.Length)
  • 執行個體成員
  • 明確建構函式
  • 靜態成員
  • 定義 Item 屬性來編製標記法索引 (arr[x])
  • 定義 GetSlice 成員來分割標記法 (arr[x..y]arr[x..]arr[..y])
  • 具名和選擇性引數
  • 介面和介面實作

請勿先觸達這些功能,但在解決問題時,請謹慎套用這些功能:

  • 方法多載
  • 封裝可變動資料
  • 類型上的運算子
  • 自動屬性
  • 實作 IDisposableIEnumerable
  • 型別延伸模組
  • 事件
  • 結構
  • 委派
  • 列舉

除非您必須使用這些功能,否則通常請避免這些功能:

  • 繼承型類型階層和實作繼承
  • Null 和 Unchecked.defaultof<_>

偏好組合而非繼承

透過繼承組合是一種長期慣用語,可遵守良好的 F# 程式碼。 基本準則是您不應該公開基底類別,並強制呼叫端繼承自該基底類別來取得功能。

如果您不需要類別,請使用物件運算式來實作介面

物件運算式可讓您即時實作介面,將實作的介面繫結至值,而不需要在類別內執行此動作。 這很方便,特別是當您需要實作介面,而不需要完整類別時。

例如,以下是在 Ionide 中執行的程式碼,如果您已新增沒有 open 陳述式的符號,請提供程式碼修正動作:

    let private createProvider () =
        { new CodeActionProvider with
            member this.provideCodeActions(doc, range, context, ct) =
                let diagnostics = context.diagnostics
                let diagnostic = diagnostics |> Seq.tryFind (fun d -> d.message.Contains "Unused open statement")
                let res =
                    match diagnostic with
                    | None -> [||]
                    | Some d ->
                        let line = doc.lineAt d.range.start.line
                        let cmd = createEmpty<Command>
                        cmd.title <- "Remove unused open"
                        cmd.command <- "fsharp.unusedOpenFix"
                        cmd.arguments <- Some ([| doc |> unbox; line.range |> unbox; |] |> ResizeArray)
                        [|cmd |]
                res
                |> ResizeArray
                |> U2.Case1
        }

由於與 Visual Studio Code API 互動時不需要類別,因此物件運算式是適合此動作的理想工具。 當您需要以現成的方式虛設具有測試常式的介面時,其對於單元測試也很重要。

請考慮使用類型縮寫來縮短簽章

類型縮寫是將標籤指派給另一種類型 (例如函式簽章或更複雜的類型) 的便利方式。 例如,下列別名會將標籤指派給使用 CNTK (深度學習程式庫) 定義計算所需的標籤:

open CNTK

// DeviceDescriptor, Variable, and Function all come from CNTK
type Computation = DeviceDescriptor -> Variable -> Function

Computation 名稱是表示任何符合其別名簽章函式的便利方式。 使用這種類型縮寫很方便,且可使用更簡潔的程式碼。

請避免使用類型縮寫來代表您的網域

雖然類型縮寫方便提供函式簽章的名稱,但在縮寫其他類型時,可能會造成混淆。 請考慮此縮寫:

// Does not actually abstract integers.
type BufferSize = int

有多種方式可能會使這造成混淆:

  • BufferSize 不是抽象概念;這只是整數的另一個名稱。
  • 如果在公用 API 中公開 BufferSize,很容易就會誤譯為不只是表示 int。 一般而言,網域類型有多個屬性,且不是基本類型,例如 int。 這個縮寫違反該假設。
  • BufferSize 大小寫 (PascalCase) 表示此類型會保留更多資料。
  • 相較於提供函式的具名引數,此別名並不會提供更高的明確性。
  • 縮寫不會顯示在編譯的 IL 中;這只是整數,而這個別名是編譯時間建構。
module Networking =
    ...
    let send data (bufferSize: int) = ...

總而言之,類型縮寫的陷阱是類型縮寫並非其縮寫類型的抽象概念。 在上一個範例中,BufferSize 只是藉 int 之名,除了 int 已擁有的資料之外,並沒有額外的資料,也沒有類型系統中的任何好處。

使用類型縮寫來代表網域的替代方法是使用單一大小寫區分聯集。 先前的範例可加以模型化,如下所示:

type BufferSize = BufferSize of int

如果您撰寫以 BufferSize 及其基礎值運作的程式碼,則您必須建構一個程式碼,而不是傳入任何任意整數:

module Networking =
    ...
    let send data (BufferSize size) =
    ...

這可減少誤將任意整數傳遞至 send 函式的可能性,因為呼叫端在呼叫函式之前,必須建構 BufferSize 類型來包裝值。