GMTC全球大前端技术大会(北京站)门票9折特惠截至本周五,点击立减¥480 了解详情
写点什么

F# 4.1 全面概览

2017 年 5 月 03 日

本文要点

  • 结构体元组(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 年 5 月 03 日 18:261291
用户头像

发布了 226 篇内容, 共 61.1 次阅读, 收获喜欢 17 次。

关注

评论

发布
暂无评论
发现更多内容

阿里P8熬了一个月肝出这份32W字Java面试手册,在Github标星31K+

神奇小汤圆

Java 程序员 架构 面试

智慧公安情报研判系统开发解决方案,情报信息平台建设

WX13823153201

🌏【架构师指南】带你分析认识缓存穿透/雪崩/击穿

李浩宇/Alex

缓存穿透 缓存击穿 缓存雪崩 6月日更 6 月日更

金三银四跳槽季,美团、字节、阿里、腾讯Java面经,终入字节

云流

Java 程序员 架构 面试

2021年最新阿里、腾讯、华为、京东300+道面试题,掌握80%进大厂

周老师

Java 编程 程序员 架构 面试

阿里三面居然被问到HTML?? 我:那就不好意思了...

Machine Gun

Linux 运维 网络安全 渗透测试

栈和队列没想象中那么难

北游学Java

Java 数据结构 队列

颠覆与创新,区块链将成音乐产业的下一个风口

CECBC区块链专委会

图解 SQL,这也太形象了吧!

xcbeyond

MySQL 6月日更

GitHub已霸榜!阿里技术官肝了3个月才完成的20万字Java面试手册

云流

Java 程序员 架构 面试

啃完这7套Java面试题,面试阿里P7稳了

程序员改bug

Java 编程 架构 面试

双非渣渣的上岸之路!备战60天,三战滴滴侥幸收获Offer

菜菜山

Java 程序员 架构 面试

三步教你编写一个Neumorphism风格的小时钟

空城机

JavaScript Vue 前端 6月日更

2021饮食品牌户外投放猛增?年入20亿!卫龙辣条:刷屏级户外地标广告

󠀛Ferry

6月日更

准备两个月,面试五分钟,Java中高级岗面试为何越来越难?

神奇小汤圆

Java 程序员 架构 面试

保姆级Spring Security笔记,Git点赞82K

菜菜山

Java 程序员 架构 微服务

备战金九银十:Java面试权威指南(泰山版)2021全新开源

程序员小毕

Java 程序员 架构 面试 分布式

小型电商微服务架构拆分

Simon

架构实战营

面试败给SpringBoot,二本渣渣闭关32天使劲啃,终归逆袭阿里涨薪15K!

不秃顶的Java程序员

Java spring 程序员 微服务 springboot

Java菜鸟神操:凭借“Java核心技能手册”,竟收割了21个大厂Offer

Crud的程序员

Java 架构 编程语言

全网最新最全面Java程序员面试清单(12专题5000道面试解析)

Crud的程序员

Java spring 架构 编程语言

太厉害!Redis+Nginx+设计模式+Spring全家桶+Dubbo技术精选合集

周老师

Java 编程 程序员 架构 面试

马丁策略量化交易系统搭建,网格量化策略系统

13823153121

深入SpringBoot的异常处理(一)

卢卡多多

异常 SpringBoot 2 全局异常 六月日更

2021年最新阿里巴巴Java面试权威指南(泰山版)震撼来袭

云流

Java 程序员 架构 面试 计算机

区块链行业的《高考志愿填报指南》

CECBC区块链专委会

Java Shutdown Hook 场景使用和源码分析

陈皮的JavaLib

Java 线程安全 Thread

系统设计系列之任务队列

看山

MQ

云算力系统开发,BZZ节点分币系统开发,节点矿池平台搭建

WX13823153201

过载保护 | 熔断和限流

Damon

6月日更

🌏【架构师指南】教你如何设计和规划系统架构(13条)

李浩宇/Alex

架构设计 架构设计原则 架构师技能 6月日更 6 月日更

F# 4.1全面概览-InfoQ