免费下载案例集|20+数字化领先企业人才培养实践经验 了解详情
写点什么

F# 4.1 全面概览

  • 2017-05-03
  • 本文字数:7736 字

    阅读完需:约 25 分钟

本文要点

  • 结构体元组(Struct Tuple)、结构体记录(Struct Record)和结构体差别联合(Struct Discriminated Union)是 F#性能问题的关注点。
  • 需要更快的性能时,使用 ByRef 返回(ByRef Returns)。
  • Caller 信息属性简化了日志的实现。
  • F#不再保留一些从未使用的关键字。
  • 可选参数(Optional Parameter)现在工作正常。

语义化版本(Semantic Versioning)有时颇具误导性。虽然 F# 4.1 向后兼容 4.0 版,但是它完全不是一个小的版本。 F# 4.1 预览版自发布以来,得到了来自 Microsoft 以及更大程度上来自于社区的贡献,因此 F# 4.1 在性能、互操作性和便利性等方面上新增了一些特性。

性能

F# 4.1 发布的重头是使用结构体(structs)的能力。结构体也称为值类型(value type),它并非引用类型(reference type)。结构体能从堆栈上分配值并将嵌入到其它对象中,使用正确时可对性能产生巨大影响。

结构体元组(Struct Tuples)

结构体中首先要介绍的是结构体元组。对于 F#和其它函数式编程语言而言,在惯用代码中元组是非常重要的。一个对 F#实现的主要批评是“System.Tuple”元组是引用类型的,这意味着每次创建一个元组时,可能需要进行代价昂贵的内存分配。作为不可变对象,这是时常发生的。

通过在.NET 中引入 ValueTuple 类型,这一问题得到了解决。VB 和 C#也使用这一值类型,当内存具有压力和垃圾回收周期成为问题时,它会改进性能。但在使用中应该慎重,因为重复拷贝 16 个字节以上的 ValueTuples 可能会带来其它的性能损失。

在 F#中,使用 struct 标注可以将一个元组定义结构体元组,而非标准元组。该定义所生成的类型与标准元组的工作机制类似,但是两者并不兼容,两者间的转换是一种破坏性更改。例如:

复制代码
let origin = struct (0,0)
let f (struct (x,y)) = x+y

如果出于性能的原因而采用了结构体元组,进行测试是十分重要的。由于元组在 F#中广为使用,因此编译器对元组有特殊的优化机制,有时会完全地清除元组。这样的优化机制可能不必用于结构体元组。正如 Arbil 在原始提案中所写的:“据我们的测试,如果考虑上垃圾回收的代价,短结构体元组的性能可达标准元组的25 倍。”

该特性可扩展为一种称为“结构推演”的特性。想想下面的代码:

let (x0,y0) = origin在 F#中,该代码可能会产生编译器错误。这是因为 origin 是一个结构体元组,表达式 (x0,y0) 表示一个引用元组。如果能实现结构推演,那么此代码中会隐含地使用 struct 关键字。

鉴于这是一个编译器错误,为避免对编译器做破坏性更改,该特性可能会在今后的版本中实现。由于它会对语义和编译器产生大量影响,因此并不保证该特性将一定会出现。

结构体记录(Struct Records)

另一个 F#编程中的重要概念是使用记录类型。记录类型在很多方面上类似于元组,例如都是不可变的,都具有固定的大小。但是两者间的最大差别在于,记录中的每个域都具有不同的名字,而元组则依赖于实际位置区分各个域。

一般说来,软件库开发人员更愿意在公开 API 中使用记录,而非元组,因为命名的域更易于应用开发人员的理解。

不幸的是,记录面对着和元组同样的问题,即它们通常都是值类型,或者曾经作为值类型使用。F#的贡献者 Will Smith (网名 TIHan)在创建了结构体记录时,部分参考了结构体元组的工作。

要将一个类型标识为结构体记录,而不是一般情况下的引用类型记录,必须使用[] 属性。你可能会疑惑为什么不能使用struct 关键字。对此网友 Dsyme 是这样解释的:

@TIHan 是正确的,的确需要的是属性,这是属性一直存在的原因之一。如果要表示的是标称类型(nominal type)定义的结构体特性,首选使用属性。

另一个基本原则是 F#只使用“let”、“module”和“type”作为顶层声明(其实还有“exception”和“extern”,但是它们很少使用)。对各种标称类型,我们均不使用“new”关键字引出声明。

警告:F# 4.0 并不兼容结构体记录。这是编译器的一个瑕疵,该瑕疵导致编译器将结构体记录看成是一种引用类型,而非值类型。如果你的库有可能被使用旧版本编译器的人调用,就不要使用这个特性。

结构体差别联合

继续 F#结构体这一话题,现在我们看一下结构体差别联合(Struct Discriminated Unions)。差别联合在本质上等价于C++ 等语言中的联合类型,只是额外具有一些句法上的小技巧。例如,可以使用类似于“case 标识符”的形式在差别联合中有效地定义新类型,例如:

复制代码
type Shape =
| Rectangle of width : float * length : float
| Circle of radius : float
| Prism of width : float * float * height : float

在上面的例子中,Shape 联合具有三个子类型,即 Rectangle、Circle 和 Prism,它们只存在于 Shaple 的上下文中。一个指定的 Shape 实例中,只能包含三个子类型中的一类。

可能你并不熟悉 F#的语法,在类型定义中,各个域是通过星号“*”分隔的。因此子类型 Rectangle 具有两个域,Circle 具有一个域,而 Prism 具有三个域(其中有一个域未命名)。

如果某个“case 标识符”具有一个以上的域,就实现为一个元组。这会使我们回想起这一特性的初衷所在。差别联合允许实现为值类型,而不是引用类型。

警告:正如对结构体记录一样,F# 4.0 编译器将不能正确地解释结构体差异联合。

支持 ByRef 返回

C# 7 中添加了一个称为“ref locals”的新特性,允许指向值的安全指针。值可以是一个对象内部由 ref 关键字所指定的参数,在一些情况下也可以指向堆栈上的值。想想如下的简单例子:

复制代码
var a = new int[ ] {1, 2, 3};
ref int x = ref a[0];
x = 10; // 数组 a 现在是{10, 2, 3}
int x_value = x // 去除对值的引用

实现同样功能的 F#代码类似于:

复制代码
let a = [| 1; 2; 3; |]
let x = & a.[0]
x <- 10
let x_value : int = x // 去除对值的引用

在该特性的公告和 RFC 中,均称 F#已通过“引用单元”(Reference Cells)支持 ref locals。虽然这种说法并不正确,但是也可以理解,因为该特性的语法的确类似于 C#的 ref locals。例如:

复制代码
let y = ref a.[0]
y := 20
let y_value : int = !y // 去除对值的引用

但是在查看引用单元的源代码后,事情就变得十分清楚了,该特性实际上只是包装了一个可变值。相关的源代码如下:

复制代码
public sealed class FSharpRef<T> : IEquatable<FSharpRef<T>>, IStructuralEquatable, IComparable<FSharpRef<T>>, IComparable, IStructuralComparable
{
public T contents@;
public FSharpRef(T contents)
{
this.contents@ = contents;
}
// 此处省略了接口的具体实现
public T Value
{
get { return this.contents@; }
set { this.contents = value; }
}
}

因此在上面的例子中,命名为 y 的变量并未真正地引用了数组 a 中的元素。y 仅是在 FSharpRef对象中存储的一个拷贝。如果不是因为“ref locals”的语法与“引用单元”差别不大,则会引发混淆。

互操作性

F# 4.1 突出强调的另一个方面,就是确保 F#代码能与其它语言所编写的库进行良好交互。因为.NET 已深入挂接到 C、COM 及一些动态编程语言中,这意味着仅使用 C#软件库是不够的。

使用 fixed 关键字实现内存钉住

该特性只对那些需要从 F#调用 C 库的开发人员有用。如果要将一个数据结构传递给 C 库,并且该 C 库需要保持该结构,这时你会碰到一些严重的问题。不同于.NET 语言,C 并不希望背后有垃圾回收器移动内存中的对象。

解决方案是将对象“钉”在内存中,以防止垃圾回收器移动对象。开发人员必须谨慎,不要滥用这一特性,因为它会对内存使用产生消极影响。

在 F#中,该功能是使用 use 关键字 fixed 关键字联合实现的。这可能会对一些编程人员造成困惑,因为 use 关键字非常类似于 C#的 using 关键字,通常用于 IDisposable 对象上。在这种情况下,use 关键字仅提供关联变量的范围,并确保了在该范围之外会解除内存的钉住状态。

Caller 信息

在.NET 中, Caller 信息是使用由 CallerFilePath、CallerLineNumber 或 CallerMemberName 属性装饰的可选参数实现的,主要用于日志,也可在其他的场景中看到,例如支持WPF/XAML 应用中的属性更改通知

在F#中,无需特别介绍该特性。根据 RFC ,F#需要该特性以符合.NET 标准,因此必须要实现该特性。

可选参数已正确工作

如果简单地将.NET 风格的可选参数放入 F#中,它并不会正确的工作。理论上,你可以将 [<Optional;DefaultParameterValue<(…)>] 置于参数上,并获得与 VB 和 C#中同样的可选参数行为。但是 F# 4.0 及更早的版本并不能正确地编译 DefaultParameterValue 属性。这意味着该属性在所有语言中被忽略了。

与此相关的问题是,虽然 F#可以使用其它库编译后的可选参数和默认参数,但是它不能在同一组装中的代码中使用它们。这一问题只会影响到.NET 风格的可选参数,F#风格的可选参数仍按预期工作。

在“ RFC FS-1027 Optional 和 DefaultParameterValue 属性的完全实现”中,解决了这两个软件缺陷。

虽然这主要是一个互操作问题,但是同样潜在存在着对性能的显著影响。.NET 风格的可选参数在本质上是自由的。编译器只是传入了一个由 DefaultParameterValue 属性指定的常量。

如果你使用 F#风格的可选参数,所提供的每个可选参数需要包装在 FSharpOption中。因而,如果一个方法有五个可选参数,而你对其中的三个提供了值,那么就需要做三次内存分配。

虽然这样做会使代码显著的冗长了,但是相比于 F#风格的可选参数,.NET 风格的可选参数将会为你提供更好的性能。

另一个考虑是互操作性。VB 和 C#等语言并不能理解 F#风格的可选参数。因此这些语言需要在 FSharpOption对象中做手工参数包装,或是对缺失值传递 Null 值。

继续说一点,该特性的文档也存在着问题。在公开 API 中并不暴露 F#风格的可选参数的默认值。事实上,F#并不真正地具有默认值这一概念(这是有些奇特,考虑到它的设计灵感源自 OCaml,而 OCaml 则是),而是提供了一个应被遵循的可选设计模式的惯用代码,但绝不强制如此。

.NET Core、.NET Standard 1.5 和可移植类库中的反射(Reflection)

该特性以前被称为“在可移植类库的 Profile 78 和 259、.NET Standard 1.5 中允许 FSharp.Reflection 功能”,解决了在有限的平台上使用 F#时一些长期存在且没有必要的限制。在 RFC 中是这样介绍的:

在 FSharp.Core 反射对 Profile 78 和 259、.NET Core(即.NET Standard 1.5)的支持中,缺失了 FSharpValue.MakeRecord 及其他类似的方法。这是因为它们的签名中使用了 BindingFlags 类型,这一类型在这些 Profile 中不可用。

这一功能的确是基础 F#编程模型的组成部分。这是个令人沮丧的问题,因为 BindingFlags 的确仅用于支持 BindingFlags.NonPublic,总是一个布尔型标识。

该 RFC 是为了使该功能可用,不仅在.NET Core 上,而且在可移植 Profile 上。

不同于其它 RFC,由于该 RFC 十分简单,因此并没有提供讨论期。

便利性

虽然并非必须要提供能简化开发人员工作的特性,但是所有的主版本在发布时都会提供一些这样的特性。

数值常量中的下划线

当面对数值 1000000 时,你是否只有数一下其中零的个数才能明白它所指代的数值的大小?如果是这样,你会发现这个特性非常有用。在 F# 4.1 中,现在你可以将该数值编写为 1_000_000。

无独有偶,今年在 C#中也将添加对数值常量添加下划线的功能。

同一文件中类型和模块的互引用

F#在设计上存在一个的问题,即不能从一个类型或模块中引用另一个。对于那些主要使用 VB、C#、Java 等语言的开发人员而言,该可能问题从来就不算是问题。

在 F#中,项目是逐个文件进行编译的,而非一次编译所有的文件。这意味着在正常情况下,除非一个给定的类型或模块在编译顺序中比另一个出现得更早,否则前者是不能引用后者的。这在实践中意味着两个类型间不能相互引用。

例如,假定有一个 LinkedList 类和一个 Node 类,如果在编译顺序中 Node 出现在 LinkedList 之前,那么 LinkedList 中允许存在指向 Node 对象集合的代码。但是由于 Node 出现在先,它不能有属性回指到拥有该属性的 LinkedList。

在 F#的早期版本中,可以通过使用 rec 关键字创建一个“递归范围”提升该限制。但是这种方法的作用非常有限,只能用于同一文件中的一组函数或一组类型。例如,类型或模块不能相互指向,异常也不能包含对能抛出异常的类型的引用。

通过使用“在同一文件中的互引用类型和模块特性”,该限制在一定程度上被放宽了。在命名空间或模块层面使用 rec 关键字,就可以在限定于单一命名空间中的文件间相互整体引用。

使用 rec 关键字是出于哲学上的考虑,而非技术上的原因。在 F#的惯用代码中,存在着“循环依赖会导致面条代码”这一理念。为了缓解该问题,引入循环依赖的难度被有意地增大了。公平地说,通常这会使依赖链易于理解,但是缺点是在必须使用循环依赖时会令文件非常大。

需要指出的是,F#不允许互引用类型跨越多个文件并非只是出于哲学上的考虑。在 RFC 中是这样描述的:

对于一个类型推断(type-inferred)的、Hindley-Milner 类型的语言,一个程序包中的所有文件都是相互引用的,在技术上基本不可能为其中的单个文件提供增量检查。这意味着当使用 VisualIDE 工具编辑大型程序包时,打开此功能会使性能变差。

命名相同的模块和类型

在 VB 和 C#中,有时具有命名相同的类型或模块(C#静态类)会令你烦恼。这通常出现在为一个特定类设计扩展方法或其他功能库时。

F#解决了这个问题,当存在命名相同的模块和类型时,F#自动为模块添加前缀“Module”。以前需要使用显示使用 [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] 属性,但是现在模块重命名是隐含使用的

你可能会质疑这种设计逻辑。假定你发布的库中有一个命名为Foo 的模块,然后你又添加了一个命名为Foo 的类型。这将导致编译器自动重命名模块,Foo 模块会改为FooModule,这是一个破坏性更改。鉴于该特性现在无需提供属性就可实现,因此并不会给出告警。

Gauthier Segay 是这样回应的:

你是否认为“F#开发新手”会积极地设计他们的代码,给出一个类型 A 和一个模块 A?

如果他们具有相似的需求,恕我直言,我认为他们会对自身的业务代码稍做重构,并不会过多地影响到前端代码。

我认为这一特性主要用于那些具有丰富的 ML 经验的开发人员,以及那些想要对 C#暴露 API 的开发人员,使用这个很好的特性使 F#与 C#惯用代码工作良好。

不再保留的关键字

很多关键字在创建 F#时被保留起来以供将来使用,尤其是在其它基于 ML 的语言中出现的关键字。F#在历经 12 年的发展后所得出结论是,一些保留字将永远不会被用到,包括:

  • atomic:该保留字是与事务内存相关的,这一个概念曾在 2006 年前后热及一时。在 F#中这将是一个由库所界定的计算表达式。
  • constructor:F#社区更愿意使用“new”引入构造函数。
  • eager:不再需要,它最初设计用于“去全部抓取(eager)”,相对与可能的“去延迟抓取(lazy)”。
  • functor:如果 F#添加了参数化模块,将使用“module M(args) =……” 。
  • measure:没有特殊原因要保留它,[] 属性足矣。
  • method:F#社区更愿意使用“member”引入方法。
  • object:没有保留的必要。
  • recursive:F#更愿意使用“rec”。
  • volatile:当前没有保留的必要,[] 属性足矣。

如果你需要使用一个依然被保留为关键字的标识符,可以用双反引号括起该标识符,例如private。这类似于 VB 中的方括号(例如 [Public]),或是 C#中的“@”符号(例如 @private)。

API 的更改

F# 4.1 也在 API 上做了一些更改。下面介绍其中一些值得关注的更改。

值类型

在一些函数式编程语言中,存在问题的函数并非抛出一个异常,而返回了一个错误的值。在 F#中,使用 Option 类型时有时会出现这一问题,这会导致严重的后果。Option 并不能指出操作失败原因。只能指出一个值是否存在。

要解决这个问题,F#开发人员可以创建自己的差别联合,它要么返回一个结果值,要么返回详细的错误值。但是,这种做法会导致在不同库间的不一致性。在 F#4.1 中,我们看到引入了正式的结果类型。例如:

复制代码
/// <summary>Helper 类型用于错误处理,无需异常 </summary>
[<StructuralEquality; StructuralComparison>]
[<CompiledName("FSharpResult`2")>]
[<Struct>]
type Result<'T,'TError> =
/// 表示一切正常,或是成功地返回了结果。代码后跟随了'T 值。
| Ok of 'T
/// 表示存在一个错误或是故障。代码失败,并给出了表示出错之处的'TError 值。
| Error of 'Terror

正如在该例中所看到的,这里使用了新的结构体差别联合的特性。

在 list<'T> 中实现 IReadOnlyCollection<'T>

正常情况下,我们无需介绍这些细枝末节的 API 更改,但是这个更改具有一些有意思的影响。在核心.NET 语言中,编译器和框架类之间存在着一定的差距。这意味着通常可以对旧版本的.NET 运行时使用最新的 C#或 VB 编译器。

在 F#中,这一规则有稍许不同。这是由于 F#意在向后兼容 ML 和 OCaml,至少提供的兼容性足以简化移植,因此 F#具有自己的一套同编译器一并交付的框架类,或是有自己的一套框架库。

考虑到该特性,仅添加缺失的接口实现是不够的。F#也必须要使用条件编译指令为开关,使得可以继续构建并不存在接口的.NET可移植类库

添加 IReadOnlyCollection<'T> 接口的副作用是破坏了 JSON.NET 。虽然该问题很快就修复了,但是已使 JSON.NET 的创建者 James Newton-King 提出质疑:

FSharpList为什么没有接受 IEnumerable的构建函数?如果它有这样的构建函数,就能自动与 JSON.NET 协同工作。

问题在于如何定义 FSharpList,也称为 list<'T>。它并不是一个正常的类,而是一个差别联合(参见上文)。其中可能不包含内容,也可能是由一个值和另一个 FSharpList组成。因此它本质上是一个没有包装类的链接列表,与 F#的模式匹配语法兼容。

因为这样的设计,FSharpList不允许拥有自己的构造函数。此外,链接列表中的每个节点是一个独立的 IReadOnlyCollection<'T>,具有自己的计数,计数中忽略了列表中出现在当前节点之前的条目。该操作复杂度为 O(n),复杂度有时是实现接口的开发人员所关心的问题。

相比较而言,.NET 中 LinkedList类是对 LinkedListNode的包装。LinkedList中几乎所有操作都是通过包装完成的,因此它可以具有构造函数,并可维护当前计数等元数据。

F# 4.2

F# 4.2 的特性正在开发中,例如,覆写差别联合和差别记录的 ToString 方法。在 GitHub 上的 fslang-design 代码库中的 F# 4.2 文件夹内,可看到具体的进展情况。

关于本文作者

Jonathan Allen的首份工作是在上世纪九十年代末,实现的是一个诊所的 MIS 项目,Allen 将该项目逐步由基于 Access 和 Excel 升级成一个企业级解决方案。在从事为财政部门编写自动交易系统代码的工作五年之后,他成为了一名项目顾问,参与了多个行业的项目,包括机器人仓库 UI、癌症研究软件中间层、主要房地产保险企业的大数据需求等。在闲暇时,他喜欢研究并撰文介绍 16 世纪的格斗术。

查看英文原文: A Comprehensive Look at F# 4.1


感谢冬雨对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

2017-05-03 18:262097
用户头像

发布了 227 篇内容, 共 73.9 次阅读, 收获喜欢 28 次。

关注

评论

发布
暂无评论
发现更多内容
F# 4.1全面概览_.NET_Jonathan Allen_InfoQ精选文章