简化 F# 类型提供器的开发

阅读数:457 2015 年 3 月 26 日

话题:.NET语言 & 开发

类型提供器(Type Provider)是 F# 3.0 版本中最令人感兴趣,也是最强大的特性之一。编写良好的类型提供器能够使在 F# 应用程序中进行数据访问的方式更平滑易用,因为这些类型提供器的存在,就无需手动开发及维护任何与底层数据结构相关的类型了。对于数据爆炸型的任务来说,这一点显得尤为重要,因为各种属于互相竞争关系的数据访问技术都需要预先进行大量的配置,才能够发挥作用。

尽管类型提供器存在着这些优势,但它本身的存在却类似于一个黑匣子,只要简单地进行引用,就能够直接发挥作用。我本人并不是那种对魔法咒语般的特性能够感到满意的开发者,因此最近花费了一些时间,对它的功能进行了深入的研究。

我首先创建了一个新的类型提供器,它倒并不像我预计的那样困难。我找到了FSharp.TypeProviders.StarterPack这个 NuGet 包,它对 F# 3.0 示例包中所提供的类型进行了封装,以简化类型提供器的创建工作。通过使用这个 NuGet 包,我就能够快速地编写一个类型提供器。

虽然 F# 3.0 中提供的诸多类型确实为我提供了极大的便利性,但令我感到困扰的是,虽然这些类型是作为 F# 源代码文件(.fs 文件)编写的,但它们的设计方式却是高度命令式,并且面向对象的,而没有真正地按照 F# 的惯例进行设计。这就导致这些代码看上去与 F# 原本的代码显得格格不入。让我们看一下以下这个示例,该示例来自于某个 ID3 类型提供器,其中定义了一个新的 ProvidedProperty 实例,首先在其中加入了某些 XML 文档,随后将该属性附加给某个名为 ty 的所提供类型:

let prop =
  ProvidedProperty(
    "AlbumTitle",
    typeof<string>,
    GetterCode =
      fun [tags] ->
        <@@ (((%%tags:obj) :?> Dictionary<string, ID3Frame>).["TALB"]).GetContent() |> unbox @@>)
prop.AddXmlDocDelayed (fun () -> "Gets the album title. Corresponds to the TALB tag.")
ty.AddMember prop

在创建提供者方法时,所使用的模式也大致相同,请看以下示例:

let method =
  ProvidedMethod(
    "GetTag",
    [ ProvidedParameter("tag", typeof<string>) ],
    typeof<ID3Frame option>,
    InvokeCode =
      (fun [ tags; tag ] -> <@@ let tagDict = ((%%tags:obj) :?> Dictionary<string, ID3Frame>)
      if tagDict.ContainsKey(%%tag:string) then Some tagDict.[(%%tag:string)]
      else None @@>))
method.AddXmlDocDelayed (fun () -> "Returns an ID3Frame object representing the specific tag")
ty.AddMember method

这两个示例中的语法都很容易理解 —— 尤其是对于具有面向对象开发背景的使用者来说更是如此。但对于 F# 编程者而言,这两段代码就显得有些啰嗦了。因为它们使用了中间绑定,这就与管道化或函数组合的方式不太合拍,并且也不符合这门语言本身的精神。如果能够编写一些简单的函数,在其中充分利用 F# 的静态解析类型参数的特性,我们就能够极大地改善开发类型提供器的经验。

虽然每个提供器类型类看起来在整个类型提供器设计中都能够占有一席之地,但其中最常用的似乎就是 ProvidedConstructor、ProvidedProperty、ProvidedMethod,和 ProvidedParameter 这么几个类,因此本文余下的部分将特别专注于这几个类型。

我们首先定义一些简单的工厂函数,以封装对应的函数函数,如下所示:(这些函数理应被安置在独立的模块中,出于便利性的考虑,可以将这个模块用 AutoOpen 属性进行装饰。)

let inline makeProvidedConstructor parameters invokeCode =
  ProvidedConstructor(parameters, InvokeCode = invokeCode)

let inline makeReadOnlyProvidedProperty< ^T> getterCode propName =
  ProvidedProperty(propName, typeof< ^T>, GetterCode = getterCode)

let inline makeProvidedMethod< 'T> parameters invokeCode methodName =
  ProvidedMethod(methodName, parameters, typeof< 'T>, InvokeCode = invokeCode)

let inline makeProvidedParameter< ^T> paramName =
  ProvidedParameter(paramName, typeof< ^T>)

这些函数都比较简单,但还是有一些值得注意的地方。最重要的一点在于:通过将这些函数编写为柯里化(curried)函数,就能够方便地组合某些专门的函数,通过部分应用的方式更好地表达出提供器成员的实际意图。举例来说,在我们假想的 ID3 标签类型提供器示例中,我们可以为每个 ID3 标签暴露出个别的属性。我们无需为每个标签编写重复的代码,其中只有属性和标签的名称不同,而是可以构建一个新的 makeTagProperty 函数,让它将属性类型设为字符串,接受标签和属性的名称,并自动地编译 getter 方法的代码表达式。

其次,每个函数都包括一个内联的修改器。这就给编译器发出了一条指令,在调用端准备调用时,自动插入方法体,以取代函数调用,以此消除函数调用所造成的内存开销。内联修改器通常是与操作符一同使用的,但在这种类型的函数中也能够起到作用。

这些函数中最有趣的一点(除了 makeProvidedConstructor 这个函数),当属它们对于泛型的应用了。在每个函数中都使用了泛型,对提供器成员的返回类型进行了部分抽象。我个人倾向于使用这种方式,因为它提供了与.NET Framework 中的其它部分的一致性,并且将对这些函数的 typeof 的调用独立出来了。除了抽象以外,泛型化所提供的更重要的作用在于:泛型定义中使用了静态解析的类型参数,而不是常规的类型参数。这里的参数是由 ^ 前缀所指定的,而不是常规类型参数中所使用的标准的单引号前缀。

这一区别在此处显得尤为重要,因为它影响着类型提供器的编译行为。在常规的泛型使用中,类型参数是在运行时被解析的,正如 C# 和 Visual Basic 中的常见行为一样。而在 F# 中使用静态解析的类型参数的情况下,参数的类型则是在编译时进行解析的,通常来说会产生效率更高的代码。此外,静态解析的类型参数也允许使用一些额外的限制类型,这些限制类型通常来说在常规的类型参数中是不允许使用的。这种限制类型的一个例子是成员限制类型,稍后将会对此进行详细说明。

至此,我们从这些帮助函数身上所获得的真正好处只是对某些构造函数的封装。虽然这种方式的确提供了某些额外的便利性,但它们对于帮助我们实现最终的目标:编写更地道的 F# 代码来说并没有赶到多少作用。为了达到这一目标,让我们将视线转到原始代码示例中的 AddXmlDocDelayed 方法看看。

如果能够将附加 XML 文档至成员这一步骤实现为管道或组织链中的其中一环,那就再好不过了。虽然在我们至今所讨论过的每个提供器类型中都存在着 AddXmlDocDelayed 方法(除了 ProvidedParameter 方法之外),但这些方法是互相独立的 —— 这意味着并不存在某个单一的、统一的接口可供我们在创建新的函数时进行引用。实际上,每个提供器成员类型只是简单地继承于一个相应的 MemberInfo 实现,并且未引用任何其它的接口。因此我们只剩下这几个选择:

  • 为每个提供器成员类型创建个别的函数,
  • 使用反射,
  • 使用动态的类型 - 测试模式,以获得正确的类型以及相应的方法,或者……
  • 从 F# 中的静态解析类型参数中借用某些黑魔法的力量,编写一个函数限制,让它只作用于那些包括了 AddXmlDocDelayed 方法的类型。

前三个选择都没有什么吸引力。为每个支持的提供器类型编写独立的函数很可能会造成将来的各种维护问题。反射是一种可行的方案,但需要编写额外的错误处理逻辑,并且无法提供任何编译期的支持。动态类型 - 测试模式更符合 F# 的语法风格,当然也能够提供编译期检查的功能,但我们必须为每个允许的类型提供相应的 match 时的代码。只有通过使用一个成员受限的静态解析类型参数,我们才能够在获得编译期检查功能的同时,不需要显式地指定我们所操作的类型。

以下是该函数的完整形式:

let inline addDelayedXmlComment comment providedMember =
  (^a : (member AddXmlDocDelayed : (unit -> string) -> unit) providedMember, (fun () -> comment))
  providedMember

除去函数签名之外,addDelayedXmlComment 函数只包含了两行代码而已。与之前所示的工厂函数不同,由于从函数的签名中去除了显式的类型参数,而是将这些细节安置在函数体内,因此编译器能够对函数进行更好的推断。

该函数体的第一行的作用,可以比喻为使用反射获得对某个 MethodInfo 实例的引用,该实例代表了该类型的 AddXmlDocDelayed 方法,并调用该实例的 Invoke 方法。区别在于,解析过程是发生在编译期的。在这里,我们所“反射”的类型由 ^a 所表示,这暗示着它是一个静态解析的类型参数。随后,成员限制指明了该 ^a 对象必须具有一个名为 AddXmlDocDelayed 的成员函数,它接受一个函数(unit -> string),并返回 unit 类型。最后,我们以元组形式提供方法的首个形参 providedMember 的实参,就如同我们为 MethodInfo.Invoke 方法所提供的对象参数一样,而第二个实参的内容则是在 AddXmlDocDelayed 方法中用于生成注释的函数。

该函数的第二行只是简单地返回了提供器成员,这就使该提供器成员能够继续在函数调用链中进行传递。

通过这些新的帮助函数,我们就能够改写原始的、命令式的提供器属性代码,如下所示:

"AlbumTitle"
|> makeReadOnlyProvidedProperty<string>
    (fun [tags] ->
      <@@ (((%%tags:obj) :?> Dictionary<string, ID3Frame>).["TALB"]).GetContent() |> unbox @@>))
|> addDelayedXmlComment "Gets the album title. Corresponds to the TALB tag."
|> ty.AddMember

而提供器方法也能够按照如下方式进行重写:

"GetTag"
|> makeProvidedMethod<ID3Frame option>
     [ makeProvidedParameter<string> "tag" ]
     (fun [ tags; tag ] -> <@@ let tagDict = ((%%tags:obj) :?> Dictionary<string, ID3Frame>)
                               if tagDict.ContainsKey(%%tag:string) then Some tagDict.[(%%tag:string)]
                               else None @@>)
|> addDelayedXmlComment "Returns an ID3Frame object representing the specific tag" |> ty.AddMember

此外,通过管道,我们能够很清晰地看到:我们定义了一个只读字符串属性或方法,附加了某些 XML 文档,并为提供器类型加入了成员。

由于大多数 MP3 文件都包含多个字符串标签,因此每个标签的提供器属性代码很可能是重复的。我们在此不会重复这些代码,让它们仅仅存在标签名和注释文本的不同。我们将进一步使用的帮助函数中的某些应用,以组成一个特定的工厂函数:

let inline makeTagPropertyWithComment tag comment =
  let expr =
    fun [tags] ->
      <@@ (((%%tags:obj) :?> Dictionary<string, ID3Frame>).[tag]).GetContent() |> unbox @@>
  (makeReadOnlyProvidedProperty<string> expr) >> (addDelayedXmlComment comment)

makeTagPropertyWithComment 函数中使用了向前组合操作符,以组合出一个新函数,它首先创建了提供器的属性,随后添加了延迟的 XML 注释信息。该函数的返回值正是提供器的属性,正如其签名所显示的一样:

string -> string -> (string -> ProvidedProperty)

作为结果,我们能够将提供器属性返回结果随意传递给其它函数。通过使用这一函数,我们的标签属性能够被进一步简化,如下所示:

"AlbumTitle"
|> makeTagPropertyWithComment tag "Gets the album title. Corresponds to the TALB tag."
|> ty.AddMember

这一最终版本代码与原始代码之间的区别可谓令人惊叹。通过使用某些简单的内联封装函数,加上静态解析的类型参数,以及成员限制,我们最终成功地将创建一个提供器属性、添加延迟 XML 文档、并将该属性附加至某一类型这一系列任务的代码量减少了约 60%。在此过程中,由于我们消除了中间变量(prop),并将大多数直接方法调用用管道函数进行取代,最终代码变得更为符合 F# 的风格。接下来,我们要做的是就是遵循以上所述的相同模式,将这些示例进行扩展,为各种提供器类型提供额外的功能。

如果这些技术能有部分出现在将来的 F# 版本中,或是出现在类型提供器初学者包中,那就再好不过了。也许现在正是时候提交一个 pull 请求了!

关于作者

Dave Fancher基于.NET Framework 进行软件开发已经超过十年了。在印第安纳州开发者社区中,他已经是一张熟悉的老面孔了,不仅作为一名演讲者,同时也作为在全美的各种用户组中的积极参与者。在 2013 年七月,Dave 被评选为 Visual F# 方向的一名 Microsoft MVP(最有价值专家)。如果他不在编码,或者不在davefancher.com上撰写关于代码方面的文章,那么他通常会选择看电影,或是在 Xbox One 上打游戏。

查看英文原文:Simplifying F# Type Provider Development