F# 编码约定

以下约定是根据使用大型 F# 代码库的经验来制定的。 理想的 F# 代码的五个原则是每个建议的基础。 它们与 F# 组件设计准则相关,但适用于任何 F# 代码,而不仅仅适用于组件(例如库)。

组织代码

F# 采用两种主要方式来组织代码:模块和命名空间。 这两种方式是类似的,但具有以下差异:

  • 命名空间编译为 .NET 命名空间。 模块编译为静态类。
  • 命名空间始终为顶层。 模块可以为顶层,也可以嵌套在其他模块中。
  • 命名空间可跨多个文件。 模块不能。
  • 模块可以使用 [<RequireQualifiedAccess>][<AutoOpen>] 进行修饰。

以下准则将帮助你使用这些方法来组织代码。

首选顶层命名空间

对于任何可公开使用的代码,命名空间优先于顶层的模块。 因为它们编译为 .NET 命名空间,所有可在 C# 中使用它们,无需使用using static

// Recommended.
namespace MyCode

type MyClass() =
    ...

仅在 F# 中进行调用时,使用顶层模块可能不会表现出差别,但对于 C# 使用者而言,当不知道特定using static C# 构造时,调用方可能会惊讶于必须使用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 绑定值初始化中出现任何错误,则它会表现为 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,它在引发异常时会将重要信息包含在异常元数据中。 在活动模式中展开所捕获异常主体中的有用值并返回该值在某些情况下可能会有十分有用。

请勿使用单一错误处理替换异常

异常通常被视为纯函数范例中的禁忌。 实际上,异常违反了纯度,因此可安全地将它们视为功能不完全。 但是,这忽略了代码必须运行且可能会发生运行时错误这一现实。 一般情况下,编写代码假设大多数内容不是纯或完全内容,以最大程度地减少令人不快的意外(类似于 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# 值在默认情况下不可变,这样可避免某些类别的 bug(尤其是涉及并发和并行的 bug)。 但是在某些情况下,若要实现执行时间或内存分配的最佳(甚至是合理的)效率,最好使用就地状态变化来实现工作范围。 可以通过 mutable 关键字,在 F# 中选择实现这一点。

在 F# 中使用 mutable 可能会感觉与函数纯度不一致。 这是可以理解的,但每个位置的函数纯度都可能与性能目标不一致。 一种折衷方法是封装变化,使调用方无需担心调用函数时会发生什么情况。 这样,你便可在基于变化的实现上为性能关键型代码编写函数接口。

此外,F# let绑定构造允许将绑定嵌套到另一个绑定中,这可以用于将mutable变量的范围保持接近或在其理论最小值。

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

任何代码都无法访问仅用于初始化data允许绑定值的可变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 值。 但 null 值在 F# 编程中很少出现,使用 F# 的好处之一是能在大多数情况下避免 null 引用(空引用)错误。

避免使用 AllowNullLiteral 特性

默认情况下,F# 声明的类型不支持使用 null 文本。 可以手动批注带有 AllowNullLiteral 的 F# 类型以允许此操作。 但最好还是不要这样做。

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

可以使用 Unchecked.defaultof<_> 为 F# 类型生成 null 或初始化为零的值。 此操作在为部分数据结构初始化存储时、在部分高性能编码模式中,或者在互操作性方面非常有用。 但应避免使用此构造。

避免使用 DefaultValue 特性

默认情况下,必须在构造时正确地对 F# 记录和对象进行初始化。 DefaultValue 特性可用于使用 null 或初始化为零的值填充对象的某些字段。 极少需要此构造,应避免使用它。

如果检查 null 输入,请抓住机会引发异常

编写新的 F# 代码时,实际上无需检查 null 输入,除非要通过 C# 或其他 .NET 语言使用该代码。

如果决定添加对 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 不是抽象;它只是整数的另一个名称。
  • 如果 BufferSize 在公共 API 中进行公开,则很容易被误解为不仅仅是 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 类型来包装值。