F# 组件设计指南

本文档是 F# 编程的一组组件设计准则,基于 F# 组件设计指南、v14、Microsoft Research 以及最初由 F# Software Foundation 策划和维护的版本。

本文档假定你熟悉 F# 编程。 许多人感谢 F# 社区对本指南的各个版本的贡献和有用的反馈。

概述

本文档介绍与 F# 组件设计和编码相关的一些问题。 组件可能意味着以下任一项:

  • F# 项目中在该项目内具有外部使用者的层。
  • 旨在供 F# 代码跨程序集边界使用的库。
  • 旨在供任何 .NET 语言跨程序集边界使用的库。
  • 一个库,用于通过包存储库进行分发,例如 NuGet

本文中介绍的技术遵循 良好的 F# 代码的五个原则,从而适当地利用功能和对象编程。

无论采用哪种方法,组件和库设计器在尝试创建一个最容易供开发人员使用的 API 时,都会面临许多实用且不合理的问题。 认真应用 .NET 库设计指南 将引导你创建一组一致的 API,这些 API 是令人愉快使用的。

一般准则

无论库的目标受众如何,都有一些适用于 F# 库的通用准则。

了解 .NET 库设计指南

无论你从事哪种类型的 F# 编码,了解 .NET 库设计指南都具有实用价值。 大多数其他 F# 和 .NET 程序员将熟悉这些准则,并期望 .NET 代码符合这些准则。

.NET 库设计指南提供有关命名、设计类和接口、成员设计(属性、方法、事件等)等的一般指导,是各种设计指南的有用第一个参考点。

向代码添加 XML 文档注释

有关公共 API 的 XML 文档可确保用户在使用这些类型和成员时可以获得良好的 Intellisense 和 Quickinfo,并支持为库生成文档文件。 请参阅有关在 xmldoc 注释中可用于其他标记的各种 xml 标记的 XML 文档

/// A class for representing (x,y) coordinates
type Point =

    /// Computes the distance between this point and another
    member DistanceTo: otherPoint:Point -> float

可以使用短格式 XML 注释(/// comment)或标准 XML 注释(///<summary>comment</summary>)。

考虑对稳定库和组件 API 使用显式签名文件 (.fsi)

在 F# 库中使用显式签名文件提供了公共 API 的简洁摘要,这有助于确保你了解库的完整公共图面,并提供公共文档和内部实现详细信息之间的干净分离。 签名文件通过要求同时对实现文件和签名文件进行更改,从而增加了更改公共 API 的难度。 因此,签名文件通常仅在 API 已巩固且不再预期会显著更改时引入。

遵循在 .NET 中使用字符串的最佳做法

遵循 有关在 .NET 中使用字符串的最佳做法 指南,当项目范围符合此要求时。 具体而言,在字符串转换和比较中(如果适用)明确说明 文化意图

面向 F# 的库的准则

本部分介绍开发面向 F# 的公共库的建议;也就是说,公开供 F# 开发人员使用的公共 API 的库。 有许多特定于 F# 的库设计建议。 如果没有后续的特定建议,.NET 库设计指南是默认指南。

命名约定

使用 .NET 命名和大小写约定

下表遵循 .NET 命名和大写约定。 有少量的新增内容,以便也包含 F# 构造。 这些建议特别适用于跨越 F# 到 F# 边界的 API,适合 .NET BCL 和大多数库中的习惯用法。

构造 情况 组成部分 例子 笔记
具体类型 PascalCase 名词/形容词 List、Double、Complex 具体类型是结构、类、枚举、委托、记录和联合。 尽管类型名称在 OCaml 中是小写的,但 F# 已采用类型的 .NET 命名方案。
DLL PascalCase Fabrikam.Core.dll
联合标记 PascalCase 名词 Some、Add、Success 不要在公共 API 中使用前缀。 在内部时还可以使用前缀,例如“type Teams = TAlpha | TBeta | TDelta”。
活动 PascalCase 动词 ValueChanged/ValueChanging
异常 PascalCase WebException 名称应以“Exception”结尾。
字段 PascalCase 名词 CurrentName
接口类型 PascalCase 名词/形容词 IDisposable 名称应以“I”开头。
方法 PascalCase 动词 ToString
Namespace PascalCase Microsoft.FSharp.Core 通常使用 <Organization>.<Technology>[.<Subnamespace>],但如果技术与某个组织无关,则去掉组织的名称。
参数 camelCase 名词 typeName、transform、range
let 值(内部) camelCase 或 PascalCase 名词/谓词 getValue、myTable
let 值(外部) camelCase 或 PascalCase 名词/谓词 List.map、Dates.Today 遵循传统函数设计模式时,let 绑定值通常是公共的。 通常情况下,当标识符可以从其他 .NET 语言中使用时,应使用 PascalCase。
财产 PascalCase 名词/形容词 IsEndOfFile、BackColor 布尔属性通常使用 Is 和 Can,应是肯定的,如 IsEndOfFile 中所示,而不是 IsNotEndOfFile。

避免缩写

.NET 准则禁止使用缩写(例如,“使用 OnButtonClick 而不是 OnBtnClick”。 允许使用常见缩写(例如表示“异步”的 Async)。 对于函数编程,有时忽略此准则;例如,List.iter 使用“迭代”的缩写。 因此,在 F#到 F# 编程中,使用缩写的容忍度较高,但在公共组件设计中通常仍应避免。

避免大小写名称冲突

.NET 准则指出,不能单独使用大小写来消除名称冲突的歧义,因为某些客户端语言(例如,Visual Basic)不区分大小写。

在适当情况下使用首字母缩略词

XML 等首字母缩略词不是缩写,在非资本化形式的 .NET 库中广泛使用(Xml)。 仅应使用广为人知的首字母缩略词。

使用 PascalCase 命名泛型参数名称

在公共 API(包括面向 F# 的库)中,将 PascalCase 用于泛型参数名称。 具体而言,对任意泛型参数使用 TUT1T2 等名称,如果特定名称有意义,则对于面向 F# 的库,请使用名称,如 KeyValueArg(但不例如,TKey)。

对 F# 模块中的公共函数和值使用 PascalCase 或 camelCase

camelCase 用于旨在进行非限定使用的公共函数(例如 invalidArg)以及“标准集合函数”(例如 List.map)。 在这两种情况下,函数名称的行为非常类似于语言中的关键字。

对象、类型和模块设计

使用命名空间或模块来组织和包含您的类型和模块

组件中的每个 F# 文件都应以命名空间声明或模块声明开头。

namespace Fabrikam.BasicOperationsAndTypes

type ObjectType1() =
    ...

type ObjectType2() =
     ...

module CommonOperations =
    ...

module Fabrikam.BasicOperationsAndTypes

type ObjectType1() =
    ...

type ObjectType2() =
    ...

module CommonOperations =
    ...

使用模块和命名空间在顶层组织代码之间的区别如下:

  • 命名空间可以跨越多个文件
  • 命名空间不能包含 F# 函数,除非它们位于内部模块中
  • 任何给定模块的代码都必须包含在单个文件中
  • 顶级模块可以包含 F# 函数,而无需内部模块

选择顶级命名空间或模块将影响代码的编译形式,因此,如果 API 最终在 F# 代码之外被使用,则会影响从其他 .NET 语言的角度对其的看法。

使用方法和属性进行对象类型固有的操作

使用对象时,最好确保易耗型功能作为该类型的方法和属性实现。

type HardwareDevice() =

    member this.ID = ...

    member this.SupportedProtocols = ...

type HashTable<'Key,'Value>(comparer: IEqualityComparer<'Key>) =

    member this.Add(key, value) = ...

    member this.ContainsKey(key) = ...

    member this.ContainsValue(value) = ...

给定成员的大部分功能不需要在该成员中实现,但该功能中需使用的部分应实现。

使用类来封装可变状态

在 F# 中,仅当其他语言构造尚未封装该状态(例如关闭、序列表达式或异步计算)时,才需要执行此操作。

type Counter() =
    // let-bound values are private in classes.
    let mutable count = 0

    member this.Next() =
        count <- count + 1
        count

使用接口类型表示一组操作。 这比其他选项更受欢迎,例如函数元组或函数记录。

type Serializer =
    abstract Serialize<'T> : preserveRefEq: bool -> value: 'T -> string
    abstract Deserialize<'T> : preserveRefEq: bool -> pickle: string -> 'T

优先于:

type Serializer<'T> = {
    Serialize: bool -> 'T -> string
    Deserialize: bool -> string -> 'T
}

接口是 .NET 中的一等概念,可用于实现 Functor 通常能实现的功能。 此外,它们可用于将现有类型编码到程序中,而函数记录则无法实现这一点。

使用模块对对集合执行操作的函数进行分组

定义集合类型时,请考虑为新的集合类型提供一组标准操作,例如 CollectionType.mapCollectionType.iter)。

module CollectionType =
    let map f c =
        ...
    let iter f c =
        ...

如果包含此类模块,请遵循 FSharp.Core 中找到的函数的标准命名约定。

使用模块对常见规范函数(特别是在数学和 DSL 库中)的函数进行分组

例如,Microsoft.FSharp.Core.Operators 是由 FSharp.Core.dll提供的顶级函数(如 abssin)自动打开的集合。

同样,统计信息库可能包含具有函数 erferfc的模块,其中此模块旨在显式或自动打开。

请考虑使用 RequireQualifiedAccess 并仔细应用 AutoOpen 属性

[<RequireQualifiedAccess>] 属性添加到模块表示模块可能未打开,并且对模块元素的引用需要显式限定的访问权限。 例如,Microsoft.FSharp.Collections.List 模块具有此属性。

当模块中的函数和值具有可能与其他模块中的名称冲突的名称时,这非常有用。 要求限定的访问权限可以极大地提高库的长期可维护性和可演变性。

强烈建议为扩展 FSharp.Core 提供的模块(如 SeqListArray)的自定义模块具有 [<RequireQualifiedAccess>] 属性,因为这些模块在 F# 代码中普遍使用,并对其定义了 [<RequireQualifiedAccess>];更通常,当此类模块阴影或扩展具有该属性的其他模块时,不建议定义缺少属性的自定义模块。

[<AutoOpen>] 属性添加到模块意味着在打开包含命名空间时将打开该模块。 还可以将 [<AutoOpen>] 属性应用于程序集,以指示引用程序集时自动打开的模块。

例如,统计信息库 MathsHeaven.Statistics 可能包含 module MathsHeaven.Statistics.Operators,而后者包含函数 erferfc。 将此模块标记为 [<AutoOpen>]是合理的。 这意味着 open MathsHeaven.Statistics 还将打开此模块,并将名称 erferfc 引入范围。 [<AutoOpen>] 的另一个良好用途是包含扩展方法的模块。

过度使用 [<AutoOpen>] 会导致污染的命名空间,并且该属性应谨慎使用。 对于特定域中的特定库,明智地使用 [<AutoOpen>] 可能会导致更好的可用性。

请考虑对适合使用已知运算符的类定义运算符成员

有时,类用于对数学构造(如 Vectors)进行建模。 当所建模的域具有已知的运算符时,将它们定义为类固有的成员非常有用。

type Vector(x: float) =

    member v.X = x

    static member (*) (vector: Vector, scalar: float) = Vector(vector.X * scalar)

    static member (+) (vector1: Vector, vector2: Vector) = Vector(vector1.X + vector2.X)

let v = Vector(5.0)

let u = v * 10.0

本指南对应于这些类型的常规 .NET 指南。 但是,在 F# 编码中,它同样重要,因为这允许将这些类型与 F# 函数和成员约束的方法(如 List.sumBy)结合使用。

请考虑使用 CompiledName 为其他 .NET 语言使用者提供一个 .NET 友好名称。

有时,你可能希望为 F# 用户以一种样式命名某物(例如用小写来命名静态成员,使其看起来像一个模块绑定的函数),但在将其编译为程序集时,名称会采用不同的样式。 可以使用 [<CompiledName>] 属性为使用程序集的非 F# 代码提供不同的样式。

type Vector(x:float, y:float) =

    member v.X = x
    member v.Y = y

    [<CompiledName("Create")>]
    static member create x y = Vector (x, y)

let v = Vector.create 5.0 3.0

通过使用 [<CompiledName>],可以将 .NET 命名约定用于程序集的非 F# 使用者。

如果这样做可提供更简单的 API,请对成员函数进行方法重载

方法重载是一种功能强大的工具,用于简化可能需要执行类似功能的 API,但具有不同的选项或参数。

type Logger() =

    member this.Log(message) =
        ...
    member this.Log(message, retryPolicy) =
        ...

在 F# 中,重载参数数而不是参数类型更为常见。

如果记录和联合类型的设计可能会演变,则隐藏这些类型的表示形式

避免显示对象的具体表示形式。 例如,.NET 库设计的外部公共 API 不显示 DateTime 值的具体表示形式。 在运行时,公共语言运行时知道将在整个执行过程中使用的已提交实现。 但是,编译的代码本身不会选取对具体表示形式的依赖项。

避免使用实现继承以实现扩展性

在 F# 中,很少使用实现继承。 此外,当新要求到来时,继承层次结构通常很复杂且难以更改。 在 F# 中仍存在继承实现,以确保兼容性和极少数情况下,这是解决问题的最佳解决方案,但在设计多态性(如接口实现)时,应在 F# 程序中寻求替代技术。

函数和成员签名

返回少量的多个不相关值时,使用元组作为返回值

下面是在返回类型中使用元组的良好示例:

val divrem: BigInteger -> BigInteger -> BigInteger * BigInteger

对于包含许多组件的返回类型,或者组件与单个可识别实体相关的位置,请考虑使用命名类型而不是元组。

在 F# API 边界处使用 Async<T> 进行异步编程

如果有一个名为 Operation 的同步操作返回 T,则如果异步操作返回 Async<T>OperationAsync 返回 Task<T>,则应将其命名为 AsyncOperation。 对于公开 Begin/End 方法的常用 .NET 类型,请考虑使用 Async.FromBeginEnd 编写扩展方法作为外观,以向这些 .NET API 提供 F# 异步编程模型。

type SomeType =
    member this.Compute(x:int): int =
        ...
    member this.AsyncCompute(x:int): Async<int> =
        ...

type System.ServiceModel.Channels.IInputChannel with
    member this.AsyncReceive() =
        ...

异常

请参阅 错误管理,了解如何适当地使用异常、结果和选项。

扩展成员

在 F# 到 F# 组件中谨慎应用 F# 扩展成员

通常,F# 扩展成员只应用于在大多数使用模式中与类型关联的内部操作闭包中的操作。 一种常见的用途是为各种 .NET 类型提供更符合 F# 习惯的 API:

type System.ServiceModel.Channels.IInputChannel with
    member this.AsyncReceive() =
        Async.FromBeginEnd(this.BeginReceive, this.EndReceive)

type System.Collections.Generic.IDictionary<'Key,'Value> with
    member this.TryGet key =
        let ok, v = this.TryGetValue key
        if ok then Some v else None

联合类型

对树结构数据使用可区分联合而不是类层次结构

树状结构以递归方式定义。 这对于继承而言十分尴尬,但对于可区分联合而言却很简洁。

type BST<'T> =
    | Empty
    | Node of 'T * BST<'T> * BST<'T>

使用可区分联合表示类似于树的数据还可让你受益于模式匹配的穷尽性。

对用例名称不足以唯一的联合类型使用 [<RequireQualifiedAccess>]

你可能会发现自己所处域中的相同名称是不同内容的最佳名称,如可区分联合用例。 可以使用 [<RequireQualifiedAccess>] 消除用例名称的歧义,以便避免由于隐藏对 open 语句顺序的依赖而导致的混乱错误

如果这些类型的设计可能会演变,则为二进制兼容 API 隐藏可区分联合的表示形式

联合类型依赖于 F# 模式匹配形式来实现简洁的编程模型。 如前所述,如果这些类型的设计可能会演变,则应避免显示具体的数据表示形式。

例如,可使用专用或内部声明或是使用签名文件隐藏可区分联合的表示形式。

type Union =
    private
    | CaseA of int
    | CaseB of string

如果不加区别地透露可区分联合,你可能会发现难以在不中断用户代码的情况下对库进行版本控制。 相反,您可以考虑开放一个或多个活动模式,从而允许对您类型的值进行模式匹配。

活动模式提供了一种替代方法,用于为 F# 使用者提供模式匹配,同时避免直接公开 F# 联合类型。

内联函数和成员约束

使用内联函数和隐式成员约束和静态解析泛型类型定义泛型数值算法

算术成员约束和 F# 比较约束是 F# 编程的标准。 例如,请考虑以下代码:

let inline highestCommonFactor a b =
    let rec loop a b =
        if a = LanguagePrimitives.GenericZero<_> then b
        elif a < b then loop a (b - a)
        else loop (a - b) b
    loop a b

此函数的类型如下所示:

val inline highestCommonFactor : ^T -> ^T -> ^T
                when ^T : (static member Zero : ^T)
                and ^T : (static member ( - ) : ^T * ^T -> ^T)
                and ^T : equality
                and ^T : comparison

这是一个适用于数学库中的公共 API 的函数。

避免使用成员约束模拟类型类和鸭子类型化

可以使用 F# 成员约束模拟“鸭子类型化”。 但是,使用此功能的成员通常不应在 F# 到 F# 库设计中使用。 这是因为基于不熟悉或非标准隐式约束的库设计往往会导致用户代码变得不灵活,并绑定到一种特定的框架模式。

此外,很有可能以这种方式大量使用成员约束可能会导致编译时间很长。

运算符定义

避免定义自定义符号运算符

在某些情况下,自定义运算符是必不可少的,并且在大量实现代码中是非常有用的表示工具。 对于库的新用户,命名函数通常更易于使用。 此外,由于 IDE 和搜索引擎中存在现有限制,自定义符号运算符可能难以记录,用户发现在运算符上查找帮助会更加困难。

因此,最好将功能作为命名函数和成员进行发布,此外,仅当符号化好处超出了使用它们的文档和认知成本时,才公开此功能的运算符。

度量单位

在 F# 代码中谨慎使用测量单位以增加类型安全性。

通过其他 .NET 语言进行查看时,会擦除度量单位的其他类型化信息。 请注意,.NET 组件、工具和反射会看到不带单位的类型。 例如,C# 使用者会看到 float 而不是 float<kg>

类型缩写

仔细使用类型缩写来简化 F# 代码

.NET 组件、工具和反射不会看到类型的缩写名称。 类型缩写的显著用法也可能使域看起来比实际更为复杂,这可能会使使用者感到困惑。

避免对公共类型使用缩写,因为这些类型的成员和属性应本质上不同于被缩写的类型中的可用类型。

在这种情况下,进行缩写的类型会透露过多有关所定义实际类型的表示形式的信息。 相反,请考虑在类类型或单用例可区分联合中包装缩写(或是在性能非常重要时,请考虑使用结构类型包装缩写)。

例如,将多重映射定义为 F# 映射的特殊用例会十分有吸引力:

type MultiMap<'Key,'Value> = Map<'Key,'Value list>

但是,此类型上的逻辑点表示法运算与映射上的运算不同 – 例如,如果键不在字典中,则查找运算符 map[key] 返回空列表(而不是引发异常)是合理的。

有关从其他 .NET 语言使用的库的准则

设计用于其他 .NET 语言的库时,请务必遵循 .NET 库设计准则。 在本文档中,这些库标记为普通 .NET 库,而不是在无限制情况下使用 F# 构造的面向 F# 的库。 设计 vanilla .NET 库意味着通过尽量减少在公共 API 中使用特定于 F# 的构造,提供与 .NET Framework 其余部分一致的熟悉和惯用 API。 以下各节将介绍这些规则。

命名空间和类型设计(适用于从其他 .NET 语言使用的库)

将 .NET 命名约定应用于组件的公共 API

特别注意缩写名称的使用以及 .NET 的大写指南。

type pCoord = ...
    member this.theta = ...

type PolarCoordinate = ...
    member this.Theta = ...

使用命名空间、类型和成员作为组件的主要组织结构

包含公共功能的所有文件都应以 namespace 声明开头,命名空间中唯一面向公众的实体应该是类型。 请勿使用 F# 模块。

使用非公共模块保存实现代码、实用工具类型和实用工具函数。

静态类型应优先于模块,因为它们允许 API 的未来演变使用重载和其他可能不在 F# 模块中使用的 .NET API 设计概念。

例如,代替以下公共 API:

module Fabrikam

module Utilities =
    let Name = "Bob"
    let Add2 x y = x + y
    let Add3 x y z = x + y + z

而是考虑使用:

namespace Fabrikam

[<AbstractClass; Sealed>]
type Utilities =
    static member Name = "Bob"
    static member Add(x,y) = x + y
    static member Add(x,y,z) = x + y + z

如果类型的设计不会演变,请在 vanilla .NET API 中使用 F# 记录类型

F# 记录类型编译为简单的 .NET 类。 这些类型适用于 API 中的一些简单稳定类型。 请考虑使用 [<NoEquality>][<NoComparison>] 属性来禁止自动生成接口。 此外,避免在 vanilla .NET API 中使用可变记录字段,因为这些字段公开了公共字段。 始终考虑类是否会为 API 的未来演变提供更灵活的选项。

例如,以下 F# 代码向 C# 使用者公开公共 API:

F#:

[<NoEquality; NoComparison>]
type MyRecord =
    { FirstThing: int
        SecondThing: string }

C#:

public sealed class MyRecord
{
    public MyRecord(int firstThing, string secondThing);
    public int FirstThing { get; }
    public string SecondThing { get; }
}

在普通 .NET API 中隐藏 F# 联合类型的表示形式

F# 联合类型通常不会跨组件边界使用,甚至在 F# 到 F# 的编码中也是如此。 在组件和库中内部使用时,它们是出色的实现设备。

设计 vanilla .NET API 时,请考虑使用私有声明或签名文件隐藏联合类型的表示形式。

type PropLogic =
    private
    | And of PropLogic * PropLogic
    | Not of PropLogic
    | True

还可以使用成员增强在内部使用联合表示形式的类型,以提供面向 .NET 的所需 API。

type PropLogic =
    private
    | And of PropLogic * PropLogic
    | Not of PropLogic
    | True

    /// A public member for use from C#
    member x.Evaluate =
        match x with
        | And(a,b) -> a.Evaluate && b.Evaluate
        | Not a -> not a.Evaluate
        | True -> true

    /// A public member for use from C#
    static member CreateAnd(a,b) = And(a,b)

使用框架的设计模式来设计 GUI 和其他组件

.NET 中提供了许多不同的框架,例如 WinForms、WPF 和 ASP.NET。 如果要设计要在这些框架中使用的组件,则应使用每个组件的命名和设计约定。 例如,对于 WPF 编程,请为要设计的类采用 WPF 设计模式。 对于用户界面编程中的模型,请使用设计模式,例如事件和基于通知的集合,例如在 System.Collections.ObjectModel中找到的集合。

对象和成员设计(适用于从其他 .NET 语言使用的库)

使用 CLIEvent 属性公开 .NET 事件

构造一个具有特定 .NET 委托类型的 DelegateEvent,该委托类型接受一个对象和 EventArgs(而不是默认情况下仅使用 EventFSharpHandler 类型),以便事件能够以 .NET 语言中惯用的方式发布。

type MyBadType() =
    let myEv = new Event<int>()

    [<CLIEvent>]
    member this.MyEvent = myEv.Publish

type MyEventArgs(x: int) =
    inherit System.EventArgs()
    member this.X = x

    /// A type in a component designed for use from other .NET languages
type MyGoodType() =
    let myEv = new DelegateEvent<EventHandler<MyEventArgs>>()

    [<CLIEvent>]
    member this.MyEvent = myEv.Publish

将异步操作作为返回 .NET 任务的方法进行公开

.NET 中使用任务来表示活动的异步计算。 任务总体上不如 F# Async<T> 对象那样具有组合性,因为它们表示“正在执行”的任务,并且无法像并行组合那样组合在一起,也无法隐藏取消信号和其他上下文参数的传播。

但是,尽管如此,返回 Tasks 的方法是 .NET 上异步编程的标准表示形式。

/// A type in a component designed for use from other .NET languages
type MyType() =

    let compute (x: int): Async<int> = async { ... }

    member this.ComputeAsync(x) = compute x |> Async.StartAsTask

你还经常要接受显式取消标记:

/// A type in a component designed for use from other .NET languages
type MyType() =
    let compute(x: int): Async<int> = async { ... }
    member this.ComputeAsTask(x, cancellationToken) = Async.StartAsTask(compute x, cancellationToken)

使用 .NET 委托类型而不是 F# 函数类型

此处的“F# 函数类型”表示 int -> int等“箭头”类型。

不要执行此操作:

member this.Transform(f: int->int) =
    ...

执行此操作:

member this.Transform(f: Func<int,int>) =
    ...

在其他 .NET 语言中,F# 函数类型显示为 class FSharpFunc<T,U>,对理解委托类型的语言功能和工具不太合适。 在创作以 .NET Framework 3.5 或更高版本为目标的高阶方法时,System.FuncSystem.Action 委托是要发布的正确 API,使 .NET 开发人员能够以低摩擦的方式使用这些 API。 (面向 .NET Framework 2.0 时,系统定义的委托类型更加有限;请考虑使用预定义的委托类型,如 System.Converter<T,U> 或定义特定的委托类型。

另一方面,.NET 委托并不天然适用于面向 F# 的库(请参阅有关面向 F# 的库的下一部分)。 因此,开发 vanilla .NET 库的更高顺序方法时,常见的实现策略是使用 F# 函数类型创作所有实现,然后使用委托作为实际 F# 实现上的精简外观创建公共 API。

建议使用 TryGetValue 模式,而不是返回 F# 选项值,并且优先使用方法重载而不是将 F# 选项值作为参数。

API 中 F# 选项类型的常见用法模式在 vanilla .NET API 中使用标准 .NET 设计技术得到更好的实现。 请考虑使用布尔返回类型和 out 参数(如“TryGetValue”模式中所示)而不是返回 F# 选项值。 可以考虑使用方法重载或可选参数,而不是将 F# 的选项值作为参数。

member this.ReturnOption() = Some 3

member this.ReturnBoolAndOut(outVal: byref<int>) =
    outVal <- 3
    true

member this.ParamOption(x: int, y: int option) =
    match y with
    | Some y2 -> x + y2
    | None -> x

member this.ParamOverload(x: int) = x

member this.ParamOverload(x: int, y: int) = x + y

将 .NET 集合接口类型 IEnumerable<T> 和 IDictionary<Key,Value> 用于参数和返回值

避免使用具体集合类型,如 .NET 数组 T[]、F# 类型 list<T>Map<Key,Value>Set<T>,以及 .NET 具体集合类型(如 Dictionary<Key,Value>)。 .NET 库设计指南对何时使用各种集合类型(如 IEnumerable<T>)有很好的建议。 在某些情况下,出于性能原因,可以接受对数组(T[])的某些特定用法。 请注意,seq<T> 只是 IEnumerable<T>的 F# 别名,因此 seq 通常是适用于 vanilla .NET API 的合适类型。

不要使用 F# 列表:

member this.PrintNames(names: string list) =
    ...

而是使用 F# 序列:

member this.PrintNames(names: seq<string>) =
    ...

使用单元类型作为方法的唯一输入类型来定义零自变量方法,或用作定义 void 返回方法的唯一返回类型

避免将单位类型用于其他用途。 以下是良好做法:

✔ member this.NoArguments() = 3

✔ member this.ReturnVoid(x: int) = ()

这很糟糕:

member this.WrongUnit( x: unit, z: int) = ((), ())

在普通 .NET API 边界上检查 null 值

由于不可变设计模式以及对 F# 类型的 null 文本使用的限制,F# 实现代码的 null 值往往较少。 其他 .NET 语言通常更频繁地使用 null 作为值。 因此,公开 vanilla .NET API 的 F# 代码应在 API 边界检查参数是否为 null,并防止这些值更深入地流入 F# 实现代码。 可以使用 isNull 函数或在 null 模式上进行模式匹配。

let checkNonNull argName (arg: obj) =
    match arg with
    | null -> nullArg argName
    | _ -> ()

let checkNonNull' argName (arg: obj) =
    if isNull arg then nullArg argName
    else ()

从 F# 9 开始,可以利用新的 | null语法 使编译器指示可能的 null 值,以及需要处理的位置:

let checkNonNull argName (arg: obj | null) =
    match arg with
    | null -> nullArg argName
    | _ -> ()

let checkNonNull' argName (arg: obj | null) =
    if isNull arg then nullArg argName 
    else ()

在 F# 9 中,编译器在检测到未处理可能的 null 值时发出警告:

let printLineLength (s: string) =
    printfn "%i" s.Length

let readLineFromStream (sr: System.IO.StreamReader) =
    // `ReadLine` may return null here - when the stream is finished
    let line = sr.ReadLine()
    // nullness warning: The types 'string' and 'string | null'
    // do not have equivalent nullability
    printLineLength line

在匹配时,应使用 F# null 模式来处理这些警告:

let printLineLength (s: string) =
    printfn "%i" s.Length

let readLineFromStream (sr: System.IO.StreamReader) =
    let line = sr.ReadLine()
    match line with
    | null -> ()
    | s -> printLineLength s

避免使用元组作为返回值

相反,首选返回包含聚合数据的命名类型,或使用 out 参数返回多个值。 尽管元组和结构元组存在于 .NET 中(包括结构元组的 C# 语言支持),但它们通常不会为 .NET 开发人员提供理想且预期的 API。

避免使用参数策展

而是使用 .NET 调用约定 Method(arg1,arg2,…,argN)

member this.TupledArguments(str, num) = String.replicate num str

提示:如果要设计从任何 .NET 语言使用的库,则没有方法可替代实际执行一些试验 C# 和 Visual Basic 编程来确保从这些语言“正确感知”库。 还可以使用 .NET 反射器和 Visual Studio 对象浏览器等工具来确保库及其文档按预期显示给开发人员。

附录

设计 F# 代码以供其他 .NET 语言使用的端到端示例

请考虑以下类:

open System

type Point1(angle,radius) =
    new() = Point1(angle=0.0, radius=0.0)
    member x.Angle = angle
    member x.Radius = radius
    member x.Stretch(l) = Point1(angle=x.Angle, radius=x.Radius * l)
    member x.Warp(f) = Point1(angle=f(x.Angle), radius=x.Radius)
    static member Circle(n) =
        [ for i in 1..n -> Point1(angle=2.0*Math.PI/float(n), radius=1.0) ]

此类的推断 F# 类型如下所示:

type Point1 =
    new : unit -> Point1
    new : angle:double * radius:double -> Point1
    static member Circle : n:int -> Point1 list
    member Stretch : l:double -> Point1
    member Warp : f:(double -> double) -> Point1
    member Angle : double
    member Radius : double

让我们来看看使用另一种 .NET 语言的程序员眼中,这个 F# 类型是如何呈现的。 例如,近似的 C# “签名” 如下所示:

// C# signature for the unadjusted Point1 class
public class Point1
{
    public Point1();

    public Point1(double angle, double radius);

    public static Microsoft.FSharp.Collections.List<Point1> Circle(int count);

    public Point1 Stretch(double factor);

    public Point1 Warp(Microsoft.FSharp.Core.FastFunc<double,double> transform);

    public double Angle { get; }

    public double Radius { get; }
}

对于 F# 如何在此处表示构造,有一些要点需要注意。 例如:

  • 已保留参数名称等元数据。

  • 采用两个参数的 F# 方法将成为采用两个参数的 C# 方法。

  • 函数和列表将成为对 F# 库中相应类型的引用。

以下代码演示如何调整此代码以考虑这些事项。

namespace SuperDuperFSharpLibrary.Types

type RadialPoint(angle:double, radius:double) =

    /// Return a point at the origin
    new() = RadialPoint(angle=0.0, radius=0.0)

    /// The angle to the point, from the x-axis
    member x.Angle = angle

    /// The distance to the point, from the origin
    member x.Radius = radius

    /// Return a new point, with radius multiplied by the given factor
    member x.Stretch(factor) =
        RadialPoint(angle=angle, radius=radius * factor)

    /// Return a new point, with angle transformed by the function
    member x.Warp(transform:Func<_,_>) =
        RadialPoint(angle=transform.Invoke angle, radius=radius)

    /// Return a sequence of points describing an approximate circle using
    /// the given count of points
    static member Circle(count) =
        seq { for i in 1..count ->
                RadialPoint(angle=2.0*Math.PI/float(count), radius=1.0) }

代码的推断 F# 类型如下所示:

type RadialPoint =
    new : unit -> RadialPoint
    new : angle:double * radius:double -> RadialPoint
    static member Circle : count:int -> seq<RadialPoint>
    member Stretch : factor:double -> RadialPoint
    member Warp : transform:System.Func<double,double> -> RadialPoint
    member Angle : double
    member Radius : double

C# 签名现在如下所示:

public class RadialPoint
{
    public RadialPoint();

    public RadialPoint(double angle, double radius);

    public static System.Collections.Generic.IEnumerable<RadialPoint> Circle(int count);

    public RadialPoint Stretch(double factor);

    public RadialPoint Warp(System.Func<double,double> transform);

    public double Angle { get; }

    public double Radius { get; }
}

准备此类型以供用作 Vanilla .NET 库的一部分的修补程序如下所示:

  • 调整了多个名称:Point1nlf 分别 RadialPointcountfactortransform

  • 通过将使用 [ ... ] 的列表构造更改为使用 IEnumerable<RadialPoint>的序列构造,使用了返回类型 seq<RadialPoint>,而不是 RadialPoint list

  • 使用 .NET 委托类型 System.Func 而不是 F# 函数类型。

这使它在 C# 代码中更易于使用。