CLR 全面透彻解析

F# 基础

Luke Hoban

F# 是一种面向对象的新型函数编程语言,用于 Microsoft .NET Framework,已集成到本年度发行的 Microsoft Visual Studio 2010 中。F# 集简单、简洁的语法与高度的静态类型化于一身。这种语言能够胜任的任务从 F# Interactive 中的轻量探索性编程直到使用 Visual Studio 进行的基于 .NET Framework 的大型组件开发。

F# 设计为完全在 CLR 上运行。作为一种基于 .NET Framework 的语言,F# 充分利用了 .NET Framework 平台上丰富的库资源,可以用于构建 .NET 库或实现 .NET 接口。F# 还利用了大部分 CLR 核心功能,包括泛型、垃圾收集、尾调用指令和基本的公共语言基础结构 (CLI) 类型系统。

本文介绍 F# 语言的一些核心概念及其在 CLR 上的实现。

F# 简要概览

首先,我们来简单了解一下 F# 语言中的一些核心功能。要更多了解 F# 语言中的这些功能以及您感兴趣的其他概念,请参阅 F# 开发人员中心上的相关文档,网址为 fsharp.net

F# 最基本的功能是 let 关键字,可将值与名称绑定。let 可用于绑定数据和函数值,可实现顶层绑定和本地绑定:

let data = 12
 
let f x = 
    let sum = x + 1 
    let g y = sum + y*y 
    g x

F# 提供了几种核心数据类型,以及一种使用结构化数据(包括列表、类型化可选值和元组)的语法:

let list1 = ["Bob"; "Jom"]

let option1 = Some("Bob")
let option2 = None

let tuple1 = (1, "one", '1')

可以通过使用 F# 模式匹配表达式来匹配这些结构化数据类型与其他类型。模式匹配类似于在 C 语言之类的编程语言中使用 switch 语句,但提供了更多的方式从已匹配的表达式中匹配和提取部件,这有些类似于将正则表达式用于模式匹配字符串的方式:

let person = Some ("Bob", 32)

match person with
| Some(name,age) -> printfn "We got %s, age %d" name age
| None -> printfn "Nope, got nobody"

F# 充分利用 .NET Framework 库来执行许多任务,例如从各种各样的数据源访问数据。.NET 库在 F# 中的使用方式与在其他 .NET 语言中的使用方式相同:

let http url = 
    let req = WebRequest.Create(new Uri(url))
    let resp = req.GetResponse()
    let stream = resp.GetResponseStream()
    let reader = new StreamReader(stream)
    reader.ReadToEnd()

与 C# 或 Visual Basic 类似,F# 也是一种面向对象的语言,可以定义任何 .NET 类或结构:

type Point2D(x,y) = 
    member this.X = x
    member this.Y = y
    member this.Magnitude = 
        x*x + y*y
    member this.Translate(dx, dy) = 
        new Point2D(x + dx, y + dy)

另外,F# 支持两种特殊的类型:记录和可辨识联合。记录为具有命名字段的数据值提供了一种简单的表示形式,可辨识联合则是一种类型表达方式,其中包含多种不同的值,且每一种值中的关联数据各不相同:

type Person = 
    { Name : string;
      HomeTown : string;
      BirthDate : System.DateTime }

type Tree = 
    | Branch of Tree * Tree
    | Leaf of int

在 CLR 上使用 F# 语言

F# 是一种比 C# 更高级的语言,这体现在许多方面,其类型系统、语法和语言结构进一步远离了 CLR 的元数据和中间语言 (IL)。这说明几个很有意思的问题。最重要的是,这意味着 F# 开发人员常常可以站在更高角度上、在距离问题更近的范围内来解决问题和考虑编程工作。但也意味着,F# 编译器在将 F# 代码映射到 CLR 上时要做更多工作,映射过程也更加曲折。

C# 1.0 编译器和 CLR 是同时开发的,两者的功能密切配合。几乎所有的 C# 1.0 语言结构都在 CLR 类型系统中和 CIL 中有非常直接的表示形式。而在后来的 C# 发行版本中就不是这样了,因为 C# 语言的进化速度快于 CLR 本身。迭代器和匿名方法是 C# 2.0 的基本语言功能,但没有直接等效的 CLR 表示形式。在 C# 3.0 中,查询表达式和匿名类型也存在此问题。

F# 在这个方面前进了一步。其中的大部分语言结构由于没有直接等效的 IL 表示形式,因此,模式匹配表达式之类的功能被编译到一组丰富的 IL 指令中,用于有效地完成模式匹配。记录和联合之类的 F# 类型可自动生成所需的大部分成员。

但需要注意的是,此处探讨的是当前 F# 编译器所用的编译技术。大部分的实现细节都是 F# 开发人员无法直接看到的,而这些细节在将来的 F# 编译器版本中可能会修改,以优化性能或者引入新功能。

默认情况下不可变

F# 中基本的 let 绑定类似于 C# 中的 var,除了一个非常重要的区别:以后不能更改 let 绑定名称的值。也就是说,F# 中的值在默认情况下是固定不变的:

let x = 5
x <- 6 // error: This value is not mutable

不可变性对于并行非常有利,因为无需担忧使用不可变状态时的锁定问题:可以从多个线程安全地访问该状态。不可变性往往还会减少组件之间的耦合,因此组件之间相互影响的唯一方式是对组件进行显式调用。

在 F# 中,当调用其他 .NET 库时或用于优化特定的代码路径时,也常常可以选择应用可变性:

let mutable y = 5
y <- 6

与此相似,F# 中的类型在默认情况下也是固定不变的:

let bob = { Name = "Bob"; 
            HomeTown = "Seattle" }
// error: This field is not mutable
bob.HomeTown <- "New York" 

let bobJr = { bob with HomeTown = "Seattle" }

在本示例中,如果无法进行转变,则一般在更改一个或多个字段时,转而通过复制与更新将旧版本转变为新版本。尽管创建了新对象,但它与原来的对象共用许多部件。在本示例中,只需要一个字符串:“Bob”。这种共用是不可变性的一个重要部分。

F# 集合中也存在共用现象。例如,F# 列表类型是一种链接列表数据结构,可以与其他列表共用尾部:

let list1 = [1;2;3]
let list2 = 0 :: list1
let list3 = List.tail list1

由于复制与更新和共用是不可变对象编程中固有的,因此这种编程的性能特点常常与一般的命令式编程大不相同。

CLR 在此中起着重要作用。由于对数据进行转换而不是就地更改,因此不可变编程往往会创建生存期更短的对象。CLR 垃圾收集器 (GC) 可以处理这些对象。由于 CLR GC 采用分代标记与清除功能,因此生存期短的小型对象相对来说非常“便宜”。

函数

F# 是一种函数语言,很自然地,函数在整个语言中占有重要地位。函数是 F# 类型系统的一级部件。例如,类型“char -> int”表示接收 char 并返回 int 的 F# 函数。

尽管 F# 函数与 .NET 委托有相似之处,但存在两个重要的区别。首先,函数与名称并非一一对应。任何接收 char 并返回 int 的函数都是“char -> int”类型,但是可能需要使用多个不同名称的委托来表示此签名的多个函数,并且不可互换。

其次,F# 函数可以有效支持部分应用或完整应用。部分应用是指具有多个参数的函数只给定了部分参数,从而产生一个新函数来接收其余的参数。

let add x y = x + y

let add3a = add 3
let add3b y = add 3 y
let add3c = fun y -> add 3 y

按照 F# 运行时库 FSharp.Core.dll 中的定义,所有的一级 F# 函数值都是类型 FSharpFunc<, > 的实例。从 C# 中使用 F# 库时,作为参数接收的或从方法返回的所有 F# 函数值都将具有此类型。这个类大体上如下所示(如果已在 C# 中定义):

public abstract class FSharpFunc<T, TResult> {
    public abstract TResult Invoke(T arg);
}

需要特别注意的是,所有的 F# 函数基本上接收单一参数并产生单一结果。这就体现了部分应用的概念,即具有多个参数的 F# 函数实际上是如下类型的实例:

FSharpFunc<int, FSharpFunc<char, bool>>

也就是说,一个接收 int 的函数返回另一个函数,而返回的函数接收 char 并返回 bool。完整应用一般是通过使用 F# 核心库中的一组帮助程序类型来快速实现的。

使用 lambda 表达式(fun 关键字)创建或者由于另一个函数的部分应用而创建 F# 函数值(如前面所示的 add3a 例子)后,F# 编译器将生成一个闭包类:

internal class Add3Closure : FSharpFunc<int, int> {
    public override int Invoke(int arg) {
        return arg + 3;
    }
}

这些闭包类似于由 C# 和 Visual Basic 编译器为其 lambda 表达式结构创建的闭包。闭包是 .NET Framework 平台上最常见的由编译器生成的结构之一,没有直接的 CLR 级支持。闭包几乎在所有的 .NET 编程语言中都存在,在 F# 中的应用尤其广泛。

由于函数对象在 F# 中很常用,因此 F# 编译器采用了许多优化技术,从而不需要分配这些闭包。在可能的情况下,使用内联、lambda 提升和直接表示形式作为 .NET 方法,由 F# 编译器生成的内部代码常常与此处所描述的有所不同。

类型推断和泛型

迄今为止,所有代码示例的一个显著特点是缺少类型注释。尽管 F# 是一种静态类型化编程语言,但通常不需要明确的类型注释,这是因为 F# 广泛应用了类型推断。

C# 和 Visual Basic 开发人员会很熟悉类型推断并将其用于本地变量,如以下 C# 3.0 代码中的用法:

var name = "John";

F# 中的 let 关键字与此相似,但 F# 中的类型推断实质上更进一步,还适用于字段、参数和返回类型。在下面的示例中,x 和 y 两个字段被推断具有 int 类型,该类型是类型定义主体内的这些值上所用的 + 和 * 运算符的默认设置。Translate 方法被推断具有“Translate : int * int -> Point2D”类型:

type Point2D(x,y) = 
    member this.X = x
    member this.Y = y
    member this.Magnitude = 
        x*x + y*y
    member this.Translate(dx, dy) = 
        new Point2D(x + dx, y + dy)

当然如果需要,可以使用类型注释来告诉 F# 编译器特定的值、字段或参数真正所需要的类型。注释信息随后将用于类型推断。例如,您可以更改 Point2D 的定义以使用 float 而不是 int,这只需添加几个类型注释即可:

type Point2D(x : float,y : float) = 
    member this.X = x
    member this.Y = y
    member this.Magnitude = 
        x*x + y*y
    member this.Translate(dx, dy) = 
        new Point2D(x + dx, y + dy)

类型推断的一个重要结果是,未与特定类型关联的函数将自动泛化为泛型函数。因此,您的代码会变得尽可能泛化,而不需要您明确指定所有的泛化类型。这就导致泛型在 F# 中具有基础性的作用。F# 函数编程的这种组合式风格还可实现小的功能重用,这主要受益于最大程度的范化。可以编写泛型函数而不需要复杂的类型注释是 F# 的一个重要特点。

例如,下面的 map 函数将其参数函数 f 应用于每个元素,从而遍历值列表并生成一个新列表:

let rec map f values = 
    match values with
    | [] -> []
    | x :: rest -> (f x) :: (map f rest)

请注意,尽管不需要类型注释,但 map 的类型推断为“map :(‘a -> ‘b) -> list<’a>  -> list<’b>”。F# 能够使用模式匹配并使用参数 f 作为函数来推断两个参数的类型具有特定的形状,但并不完全是固定的。因此 F# 会尽可能将函数泛化,同时仍将类型作为实现所需的要素。需要注意的是,F# 中的泛型参数开头以 ‘ 字符表示,以便在语法上区别于其他名称。

Don Syme 是 F# 的设计者,他以前是 .NET Framework 2.0 中泛型实现方面的主要研发人员。F# 等语言的理念主要是在运行时使用泛型,Syme 研发 F# 的兴趣部分来自于希望真正利用这种 CLR 特性。F# 广泛利用了 .NET 泛型,例如,实现 F# 编译器本身就使用了超过 9,000 个通用类型参数。

但类型推断终究还是一种编译时特性,每个 F# 代码片段均会获取一种推断类型,该类型是针对 F# 程序集在 CLR 元数据中进行编码的。

尾调用

由于不可变性以及函数编程,因此 F# 中常常使用递归作为计算工具。例如,可以对 F# 列表进行遍历,并使用一段简单的递归 F# 代码来收集列表中各值的平方和:

let rec sumOfSquares nums =
    match nums with
    | [] -> 0
    | n :: rest -> (n*n) + sumOfSquares rest

尽管使用递归通常很方便,但可能会占用调用堆栈中的大量空间,因为每次迭代都会增加一个新的堆栈帧。如果输入足够大,甚至会导致堆栈溢出异常。为避免堆栈增长,可以用尾递归方式编写递归代码,这意味着递归调用始终是函数返回结果之前的最后一个步骤:

let rec sumOfSquaresAcc nums acc = 
    match nums with 
    | [] -> acc
    | n :: rest -> sumOfSquaresAcc rest (acc + n*n)

F# 编译器使用两项技术来实现尾递归函数,以确保堆栈不会增长。直接对正在定义的函数进行尾调用(例如调用 sumOfSquaresAcc)时,F# 编译器会自动将递归调用转换到 while 循环中,从而避免进行任何调用,并生成与同一函数的命令式实现非常相似的代码。

但是尾递归并不总是如此简单,相反,可能是多个相互递归的函数的结果。在这种情况下,F# 编译器要依赖 CLR 本身对尾调用的支持。

CLR 具有一个专用的 IL 指令用来帮助进行尾递归,即 IL 前缀“tail.”。tail. 指令告诉 CLR,它可以在进行相关调用之前舍弃调用方的方法说明。这意味着在接收该调用时堆栈不会增长。也说明至少在理论上,JIT 有可能只使用一个跳转指令有效地进行调用。这一点对于 F# 很有用,可确保尾递归几乎在所有情况 下都是安全的:

IL_0009:  tail.
IL_000b:  call    bool Program/SixThirtyEight::odd(int32)
IL_0010:  ret

在 CLR 4.0 中,对尾调用的处理做出了几个重大改进。x64 JIT 以前可以非常高效地实现尾调用,但是所用的技术不能应用于出现 tail. 指令的所有情形。也就是说,在 x86 平台上运行成功的 F# 代码在 x64 平台上会运行失败,发生堆栈溢出。在 CLR 4.0 中,x64 JIT 将其对尾调用的有效实现扩展到更多情形,并且还实现了开销更高的机制以便确保随时都可以像在 x86 JIT 上一样接收尾调用。

“CLR 代码生成”博客中对 CLR 4.0 中尾调用方面的改进进行了详细介绍 (blogs.msdn.com/clrcodegeneration/archive/2009/05/11/tail-call-improvements-in-net-framework-4.aspx)。

F# Interactive

F# Interactive 是一个命令行工具和 Visual Studio 工具窗口,用于以交互方式执行 F# 代码(参见图 1)。利用此工具可以轻松地使用 F# 来试验数据、探索 API 和测试应用程序逻辑。F# Interactive 可以通过 CLR Reflection.Emit API 获得。该 API 允许程序在运行时生成新的类型和成员,并动态调用新代码。F# Interactive 使用 F# 编译器来编译用户在提示处输入的代码,然后使用 Reflection.Emit 来生成类型、函数和成员,而不是向磁盘中写入程序集。

图 1 在 F# Interactive 中执行代码

此方法的一个重要结果是,正在执行的用户代码将完全编译和全面 JIT 化(包括这两种步骤中有用的优化措施),而不是成为注释版本的 F# 程序代码。这使得 F# Interactive 成为一种卓越的高性能环境,用于试验新的问题解决方法以及交互式探索大型数据集。

元组

F# 中的元组提供了一种简单的方式来打包数据并将其作为整体传送,而不需要自定义新的类型,也不需要使用复杂的参数系统(例如 out 参数)以返回多个值。

let printPersonData (name, age) = 
    printfn "%s is %d years old" name age

let bob = ("Bob", 34)

printPersonData bob

    
let divMod n m = 
    n / m, n % m

let d,m = divMod 10 3

元组是简单类型,但在 F# 中具有一些重要属性。最重要的是,它们是固定不变的。一旦完成构造,元组的各项元素便无法修改。因此,可以放心地将元组作为其各项元素的组合来处理。也因为如此,元组还具备了另一项重要的特性:结构等同性。元组与其他 F# 类型(例如列表、选项和用户定义的记录与联合)通过比较其元素来比较等同性。

在 .NET Framework 4 中,元组现在已是一种核心数据类型,在基类库中定义。在 .NET Framework 4 中使用时,F# 用 System.Tuple 类型来表示元组值。在 mscorlib 中支持这种核心类型意味着 F# 用户可以轻松地与 C# API 共享元组,反之亦然。

尽管元组在概念上是简单类型,但在构建 System.Tuple 类型时会涉及到许多设计决策。Matt Ellis 在最近的一期“CLR 全面透彻解析”专栏中详细介绍了元组的设计过程 (msdn.microsoft.com/magazine/dd942829)。

优化

由于 F# 无法直接转换成 CLR 指令,因此 F# 编译器有更大的优化余地,而不是仅仅依赖于 CLR JIT 编译器。F# 编译器利用这一点,在 Release 模式中实现了比 C# 和 Visual Basic 编译器更重要的优化。

一个简单的例子是中间元组的消除。元组经常用于处理中的结构数据。在一个函数主体中创建元组并随后将其解构这种情况很常见。发生这种情况时,会对元组对象进行不必要的分配。由于 F# 编译器知道创建和解构元组不会有任何重大副作用,因此将尝试避免分配中间元组。

在以下示例中,无需分配任何元组对象,因为元组对象只能通过在模式匹配表达式中解构来使用:

let getValueIfBothAreSame x y = 
    match (x,y) with
    | (Some a, Some b) when a = b -> Some a
    |_ -> None

度量单位

诸如米和秒之类的度量单位常用于科学、工程和模拟,从根本上说,是适用于各种数量的类型系统。在 F# 中,将度量单位直接引入到了该语言的类型系统中,从而可以用相应的单位来注释数量。这些单位将被带入计算,如果出现不匹配就会报告错误。在下面的示例中,试图将千米和秒相加是错误的,但要注意的是,用千米除以秒是正确的。

[<Measure>] type kg
/// Seconds
[<Measure>] type s
    
let x = 3.0<kg>
//val x : float<kg>

let y = 2.5<s>
// val y : float<s>

let z = x / y
//val z : float<kg/s>

let w = x + y
// Error: "The unit of measure 's' 
// does not match the unit of measure 'kg'"

度量单位能够非常轻松地相加要归功于 F# 类型推断。借助类型推断,用户提供的单位注释只需在接受来自外部源的数据时直白地显示。然后,类型推断可在程序中传播这些注释,并检查是否已根据所用的单位正确执行所有计算。

尽管度量单位是 F# 类型系统的一部分,但在编译时会将其去除。这意味着,生成的 .NET 程序集将不包含单位的相关信息,CLR 只把组合值作为其基础类型来处理,这样就不会对性能产生影响。这与 .NET 泛型形成对比,后者在运行时完全可用。

如果将来的核心 CLR 类型系统中会集成度量单位,F# 将能够公开单位信息,以便从其他 .NET 编程语言可以看到这些信息。

与 F# 交互

如您所看到的,F# 为 .NET Framework 提供了一种富有表现力和探索性的、面向对象的函数编程语言。它已集成到 Visual Studio 2010 中(包括 F# Interactive 工具,用于直接进入该语言进行试用)。

F# 语言和工具全面利用 CLR 并引入了一些更高级的概念,这些概念对应到 CLR 的元数据和 IL。当然,F# 终究还是另一种 .NET 语言,可以借助常用的类型系统和运行时,作为一个组件轻松融入新的或现有的 .NET 项目。    

Luke Hoban* 是 Microsoft F# 团队的一名项目经理。在调入 F# 团队之前,他是 C# 编译器团队的项目经理,从事 C# 3.0 和 LINQ 方面的工作。*