F# 组件设计准则

本文档是针对 F# 编程的一组组件设计准则(基于 F# 组件设计准则 v14、Microsoft Research 以及最初由 F# 软件基金会策展和维护的一个版本)。

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

概述

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

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

本文中所述的方法遵循理想的 F# 代码的五个原则,因而可根据需要利用函数和对象编程。

无论采用何种方法,组件和库设计人员在尝试创建最容易由开发人员使用的 API 时,都会面临许多实际而乏味的问题。 .NET 库设计准则的引导式应用程序将引导你创建一组一致且易于使用的 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>)。

请考虑将显式签名文件 (.fsi) 用于稳定库和组件 API

在 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
命名空间 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 对“iterate”使用了缩写。 因此在 F# 到 F# 编程中,往往可在更大程度上容忍使用缩写,但在公共组件设计中通常仍应避免缩写。

避免大小写名称冲突

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

在合适的位置使用首字母缩写词

首字母缩写词(如 XML)不是缩写,以非大写形式 (Xml) 在 .NET 库中得到广泛使用。 只应使用众所周知、广泛认可的首字母缩写词。

将 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 中的一流概念,可用于实现函子通常为你提供的功能。 此外,它们可用于将现有类型编码到程序中,而函数记录则无法实现这一点。

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

定义集合类型时,请考虑为新集合类型提供一组标准操作(如 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>] 可以提高可用性。

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

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

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

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

Async<T> 用于 F# API 边界处的异步编程

如果有一个名为 Operation 且返回 T 的对应同步操作,则异步操作应命名为 AsyncOperation(如果返回 Async<T>)或 OperationAsync(如果返回 Task<T>)。 对于公开 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# 的库。 设计普通 .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

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

F# 记录类型会编译为简单 .NET 类。 这些类型适用于 API 中的一些简单的稳定类型。 请考虑使用 [<NoEquality>][<NoComparison>] 属性禁止自动生成接口。 还应避免在普通 .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# 编码也是如此。 在组件和库内部使用时,它们是优秀的实现工具。

设计普通 .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 事件

构造一个 DelegateEvent,它具有采用一个对象和 EventArgs(而不是 Event,后者仅在默认情况下使用 FSharpHandler 类型)的特定 .NET 委托类型,以便将这些事件以熟悉的方式发布到其他 .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> 对象,因为它们表示“已在执行”的任务,无法以执行并行组合或是隐藏取消信号和其他上下文参数的传播的方式组合在一起。

不过尽管如此,返回任务的方法仍是 .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>) =
    ...

F# 函数类型会向其他 .NET 语言显示为 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# 的库的下一部分)。 因此,为普通 .NET 库开发高阶方法时的一种常见实现策略是使用 F# 函数类型创作所有实现,然后通过使用委托作为实际 F# 实现上的窄外观来创建公共 API。

使用 TryGetValue 模式而不是返回 F# 选项值,并首选方法重载而不是将 F# 选项值作为参数

使用标准 .NET 设计方法可在普通 .NET API 中更好地实现 API 中 F# 选项类型的常见使用模式。 请考虑使用 bool 返回类型和 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# 别名,因此序列通常是适合于普通 .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 作为值。 因此,公开普通 .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 ()

避免使用元组作为返回值

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

避免使用参数策展

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

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

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

附录

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

请考虑以下类:

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

我们来看看此 F# 类型如何呈现给使用另一种 .NET 语言的程序员。 例如,近似 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; }
}

为准备此类型以用作普通 .NET 库的一部分而进行的修复如下所示:

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

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

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

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