F# 4.1全面概览

栏目: ASP.NET · 发布时间: 6年前

内容简介:F# 4.1全面概览

本文要点

  • 结构体元组(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 )关注我们。


以上所述就是小编给大家介绍的《F# 4.1全面概览》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Charlotte's Web

Charlotte's Web

E. B. White / Puffin Classics / 2010-6-3 / GBP 6.99

This is the story of a little girl named Fern who loved a little pig named Wilbur and of Wilbur's dear friend, Charlotte A. Cavatica, a beautiful large grey spider. With the unlikely help of Templeton......一起来看看 《Charlotte's Web》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

MD5 加密
MD5 加密

MD5 加密工具

SHA 加密
SHA 加密

SHA 加密工具