BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

F# 4.1全面概览

| 作者 Jonathan Allen 关注 493 他的粉丝 ,译者 Rays 关注 3 他的粉丝 发布于 2017年5月4日. 估计阅读时间: 28 分钟 | GMTC大前端的下一站,PWA、Web框架、Node等最新最热的大前端话题邀你一起共同探讨。

亲爱的读者:我们最近添加了一些个人消息定制功能,您只需选择感兴趣的技术主题,即可获取重要资讯的邮件和网页通知

本文要点

  • 结构体元组(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>]属性。你可能会疑惑为什么不能使用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<int>对象中存储的一个拷贝。如果不是因为“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<T>中。因而,如果一个方法有五个可选参数,而你对其中的三个提供了值,那么就需要做三次内存分配。

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

另一个考虑是互操作性。VB和C#等语言并不能理解F#风格的可选参数。因此这些语言需要在FSharpOption<T>对象中做手工参数包装,或是对缺失值传递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:没有特殊原因要保留它,[<Measure>]属性足矣。
  • method:F#社区更愿意使用“member”引入方法。
  • object:没有保留的必要。
  • recursive:F#更愿意使用“rec”。
  • volatile:当前没有保留的必要,[<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<T>为什么没有接受IEnumerable<T>的构建函数?如果它有这样的构建函数,就能自动与JSON.NET协同工作。

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

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

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

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)关注我们。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT