【转】F# 基础
2011-11-05 15:41 AnyKoro 阅读(352) 评论(0) 编辑 收藏 举报http://msdn.microsoft.com/zh-cn/magazine/ff714588.aspx
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 库时或用于优化特定的代码路径时,也常常可以选择应用可变性,mutable只能用在标示符的前面,不能用在type前面:
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 项目。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)