从C# 3.0到F#
Written by Allen Lee
缘起
当你看到这篇文章的标题时,你有什么感觉?是不是很想脱口而出:"到底搞什么飞机啊,我C#还没来得及用好,现在又搞个F#,还让不让人活啊?"《程序员修炼之道》曾经建议我们"learn at least one new language every year",但Gustavo Duarte却对这种建议提出质疑,并宣称"learning new programming languages is often a waste of time for professional programmers"。面对这种争论,你可能会显示出某种理性:除非我有需要(学习新的语言),否则我认为够用就可以了。那么,你什么时候会有需要?回想一下你的项目经历,是否发现,有权提出这种需要的往往不是你,而是你的项目,只要项目有需要,即使是老掉牙的语言你也得学。根据马斯洛的需要层次理论,如果你的项目已经让你忙得一塌糊涂了,那么你根本不会有闲情和兴致学习新的语言,而在现今这个讲求快速见效的社会里,或许只有专门研究语言的人才支付得起学习新的语言的代价了,但我并非专门研究语言的人,至少现在不是,那么,我为何要学习新的语言呢?
我曾经在杰拉尔德·温伯格(Gerald M. Weinberg)的《咨询的奥秘——成功提出和获得建议的指南》里读到一个有趣的"锤子法则"(The Law of The Hammer):
在圣诞节收到锤子做礼物的孩子会发现每样东西都需要敲打。
读完上面这句话之后,你的脑子里想着什么?或许你已经猜到我想说什么了,工具在为使用者带来便利的同时也会约束使用者解决问题的思路和方法,编程语言直接体现了对问题的抽象和表达,不同范式的编程语言则协助程序员从不同的角度把握问题,这也正是我学习新的语言的主要原因。要有持久的学习行为,学习动机应该是指向内部的,其道理和《七个心理寓言》的"动机的寓言:孩子在为谁而玩"所说的是一样的。那么,我又为何选择F#呢?其实,这完全是因为C# 3.0,我们知道C# 3.0向函数式编程借鉴了不少,所以在学习C# 3.0的时候,我突然萌生了想了解函数式编程语言的念头,后来,在一次偶遇中,我邂逅了F#。在学习F#的过程中,我发现许多C# 3.0的新功能的"影子"(有人说C# 3.0的新功能是从F#那里借鉴过来的,是真的吗?),于是萌生了写下这篇文章的念头。
如何创建类型?
人们在接触新事物时通常不会抛开现有的积累,换句话说,你的知识和经验会影响你如何接受新事物。如果你是一个有使用面向对象编程语言经验的程序员,那么你第一个想问的问题很可能就是:"我如何创建类型?"
F#支持一种叫做Record Type的类型,它和C#里使用自动属性定义的类有点像:
代码 1
而Book的实例化也是非常直观的:
代码 2
F#会根据给出的属性名字以及值的类型推断出你要实例化的类型是Book。读到这里,你可能会问:"如果有两个不同的类型定义了相同的属性呢?"虽然出现这种情况的概率不大,但若真的让你碰上了,你可以使用显式语法来实例化它:
代码 3
面向对象编程的一个特征是封装,狭义的封装是指封装对象的内部状态(广义的封装则是指封装系统的变化因素),而对象的内部状态在对象的生命周期里发生改变是很常见的,但当我们试图在F# Interactive(类似于Python的交互式控制台)里修改Price属性时却报告错误("<-"用于赋值,相当于C#的"="):
图 1
为什么会这样呢?原来,在F#里,对象默认是不可变的(immutable),就像.NET的字符串那样。修改Price属性可以看作创建一个新对象,把原对象的Title、Authors和Tags属性的值复制到新对象对应的属性,并为新对象的Price属性设置新的值:
代码 4
读到这里,你可能在想:虽然现在内存很便宜了,但也不至于要用这种方法来耗啊?在面向对象编程里,拥有和维护可变的内部状态是对象的一个很重要的特征,正因为这样我们得以完成许多复杂的操作,但也正是可变的内部状态提高了并发操作的复杂程度和处理代价。泛泛而谈可变对象和不可变对象孰优孰劣是没有意义的,对于一个给定的系统,一些对象适合设计成可变的,另一些则应该考虑设计成不可变的,从而使两者达到一定的平衡。
在C#里,对象默认是可变的,但你可以通过readonly关键字使某个(些)数据"固定"下来;F#刚好相反,对象默认是不可变的,但你可以通过mutable关键字使某个(些)数据"活动"起来。如果我把Book重新定义为:
代码 5
那么修改Price属性就不会报错了。如果你决定使对象可变,那么你就应该做好并发处理的工作。什么?你的程序是单机单核单线程的?那你可以掷硬币决定对象是可变的还是不可变的。
除了Record Type,F#还支持Discriminated Union、Tuple和Constructed Class Type,有兴趣的话不妨到F# Home看看。
如何初始化对象?
C# 3.0引入了对象初始化器和集合初始化器,F#也提供了类似的功能。举个例子,假设我想初始化System.Windows.Forms.ListViewItem,并且设置它的Text、Selected和ToolTipText属性,我可以这样:
代码 6
F#把这个功能叫做初始属性设置(initial property settings)或者可选属性设置(optional property settings)。上面代码等效于:
代码 7
从这里可以看出,使用这个功能的前提条件是要初始化的属性必须具有set访问器。读到这里,你可能会问:"如果我要调用的构造函数是有参数的呢?"那也没问题,举个简单的例子,假设你要调用接受一个字符串作为参数的那个构造函数,你可以这样:
代码 8
或者这样:
代码 9
第一种方法就是简单地把你要初始化的属性追加到构造函数的参数后面;而第二种方法则使用了F#的命名参数(Named Argument)功能。命名参数可以放在任何位置,例如Selected和ToolTipText之间,但匿名参数就必须按顺序放在要初始化的属性前面。读到这里,你可能会问:"如果我要调用的构造函数的参数和我要初始化的属性重名了呢?"你在考验F#的忍耐力吗(笑)?当然,这种情况是有可能出现,首先,F# 不允许同一个名字出现两次,不管它是构造函数的参数的名字还是属性的名字,所以你不可能鱼与熊掌兼得(即构造函数的参数和属性同时初始化);其次,一旦出现这种情况了,F#会优先考虑构造函数的参数。
我们探讨了如何初始化一个对象,那么初始化一组对象又是怎样的呢?在F#里,说到集合类型就不得不提Microsoft.FSharp.Collections.List<'a>了("'a"是F#的类型参数表示法)。假设我要实例化一组Book对象(Book的定义参见代码1),并把它们储存在List<'a>里,我可以这样:
代码 10
列表里的每个元素通过";"分割。F#能够结合元素的类型推断出列表的完整类型,在这里是List<Book>(也可以表示为Book list)。F#的List<'a>通常只在F#里使用,如果要访问.NET的类库或者和其他语言交互,那么你通常会考虑使用数组(F#的List<'a>和数组的语法非常接近,能看出其中的区别吗?):
代码 11
而对于整数列表,F#还支持区间表达式,你可以指定起始值和终止值:
图 2
甚至指定递增值(步长):
图 3
如果你有兴趣进一步了解F#的List<'a>,可以阅读Dustin Campbell的《Why I Love F#: Lists - The Basics》和Chris Smith的《Mastering F# Lists》。
如何外包逻辑?
有一次,我和两个朋友到东方既白吃饭,选餐的时候,其中一个考虑了很久,终于发话了:"椰香咖喱牛肉饭可不可以不要椰香?"服务员看着我的朋友,非常不好意思地说:"这是不可以的。"看到服务员的表情,我猜她应该是苦于不知如何向一个12岁的小朋友解释"烧饭的工作遵循了一套标准化的流程,这个流程是不能随意更改的"。此时,我的朋友大概在想:同样的钱,不能加东西可以理解,为什么连减东西也不可以呢?虽然他最后还是选了椰香咖喱牛肉饭,但我猜他心里肯定觉得东方既白做得太呆板了。试想一下,如果你打算使用我提供的Sort方法排序books2数组(参见代码11),却发现这个方法只接受一个数组作为参数,你肯定会问:"我如何告诉这个方法我要根据价格进行排序?"接着,我告诉你:"不好意思,这是不可以的,这个方法会自行选择合适的排序依据。"此时,你会有什么感觉?
无可否认,我们已经进入了一个个性化的时代,用户不再像从前那样满足于你所提供的普遍适用的标准化软件,他们希望你的软件是可配置的,必要时还能够扩展,也就是可以满足他们的个性化需求。然而,把逻辑外包出去并不只是为了满足用户的个性化需求,为什么这样说?试想一下,你可不可以写出这样一个Sort方法,每次调用时都能"猜中"用户的排序依据?很明显,当我把books2数组传给Sort方法时,如果我不说,它不可能知道我想按书名排序还是按价格排序,是升序还是降序。换句话说,把逻辑外包出去其实就是把这种不稳定的因素封装起来,再转嫁给用户,然后美其名曰"用户参与",当然,由于用户认为你不是把麻烦抛给他,而是为他带来灵活性,于是造就了"双赢"。
考察System.Array.Sort方法的众多重载版本,不难发现.NET外包逻辑的两种主要方式是:委托和接口。在F#里,我们可以通过Lambda表达式向接受委托作为参数的重载版本注入逻辑(compare函数是F#提供的通用比较函数):
代码 12
当然,使用命名函数注入逻辑也是可以的:
代码 13
代码13除了向我们示范如何在F#里定义函数,还向我们展示了一个有趣的东西,留意comparePrice函数的定义,我并没有为x和y这两个参数指定类型,但F#却从函数体以及上下文推断出它们的类型是Book!
另外,F#并没有刻意区分命名函数和Lambda表达式,代码13的comparePrice函数也可以这样定义:
代码 14
代码13和代码14定义的两个comparePrice函数是等效的,使用上也没有区别,从代码14可以看出,在F#里,函数其实就是值,而我们在代码12里使用的Lambda表达式只不过是代码14定义的comparePrice函数的函数体。
Lambda表达式使你能够以一种紧凑的方式注入逻辑,但如果别人外包逻辑的方式是接口而不是委托呢?这个时候就轮到对象表达式(Object Expression)出场了:
代码 15
在这里,我通过"_"告诉F#我希望它帮我推断IComparer<'a>的类型变量,而F#也不负所托,成功推断出它的类型是Book。F#的对象表达式也算是一种匿名类型,但它和C# 3.0的匿名类型是不同的。在F#的对象表达式里,你可以实现接口的成员或者重写基类的成员,但不能添加任何新的成员;而C# 3.0的匿名类型则只允许属性的存在。
如何扩展类型?
假设我要把一组Book对象添加到System.Windows.Forms.ListView上,我应该怎样?对于习惯运用命令式编程方式思考问题的人,他可能会首先想到创建一个ToListViewItem函数:
代码 16
然后"foreach"那组Book对象,对每个Book对象应用ToListViewItem函数,并把函数返回的结果添加到ListView。由于ToListViewItem函数是一个和Book对象相关的操作,你也可能会考虑把它纳入Book类型的定义,使它变成Book类型的实例成员函数:
代码 17
需要说明的是,ToListViewItem成员函数前面的"b"指代当前的对象实例,相当于C#的"this"关键字和VB.NET的"Me"关键字,但因为F#没有强制使用特定的关键字,所以你可以根据具体的需要选择合适的符号,当然,你也可以通过制定命名规范强制使用特定的符号。现在,你可以"foreach"那组Book对象,对每个Book对象调用它的ToListViewItem成员函数,并把函数返回的结果添加到ListView。这个时候,可能会有人提出质疑:"Book类型本来是一个中立的数据载体,现在你在它的成员函数里使用了ListViewItem类型,无疑强化了Book类型和Windows Forms框架之间的关系,有损它本身的纯粹性。"面对这样的质疑,你有什么想法?
事实上,我们可能希望保留Book类型和ToListViewItem函数之间的"所属"关系,但这种关系会一直处于封印状态,直到我们通过某种仪式将它解封才可以使用。这个时候,我们可以考虑通过F# 的类型扩展(Type Extension)把ToListViewItem成员函数分离到一个单独的模块里(假设Book类型位于Lib命名空间里):
代码 18
这样,如果我们没有引用Ext模块,Book类型和ToListViewItem函数之间的关系就会继续保持封印状态:
图 4
而当我们在代码里引用Ext模块时,他们之间的关系就会解封:
图 5
有没有觉得F#的类型扩展和.NET Framework 3.5的扩展方法很像? 事实上,F#的类型扩展有两种形态:当类型扩展的代码和相关的类型处在相同的命名空间里时,它等效于分部类,F#把这种扩展称为固有扩展(Intrinsic Extension);而当两者处于不同的命名空间里时,它相当于扩展方法,F#把这种扩展称为可选扩展(Optional Extension)。但由于类型扩展和扩展方法在IL层面的实现方式是不同的,于是F#的类型扩展无法被C# 3.0/VB.NET 9.0识别,而C# 3.0/VB.NET 9.0的扩展方法也无法被F#识别。你可能会感到很奇怪:既然.NET 3.5已经提供了现成的实现方案,为什么F#还要另外弄一个出来呢?其实,现在的F#(1.9.4)是基于.NET Framework 2.0而不是3.5的,所以同时存在两种不同的实现方案并非有意的,不过Don Syme说将来F#会改用.NET Framework 3.5的实现方案,即代码18定义的类型扩展也能被C# 3.0/VB.NET 9.0识别。
那么,我现在是否可以在F#里定义能被C# 3.0/VB.NET 9.0识别的扩展方法?当然可以,虽然F#现在无法识别扩展方法,但这并不妨碍你在F#里定义这种方法:
代码 19
在F#里定义扩展方法的语法和在VB.NET 9.0里的语法相似,都是显式使用ExtensionAttribute的。接着,把代码编译成DLL,在C# 3.0或者VB.NET 9.0项目里引用一下就可以使用了,编译的时候记得在F#的项目属性里设置.NET Framework 3.5的相关DLL的引用。
噢,说着说着,差点忘记原本的目的了,我们做了这么多,最终还是难逃"foreach"那组Book对象,对每个对象应用变换操作,并把变换结果添加到ListView,那么,F#有没有提供更简单的方法可以用来完成这项任务呢?其实,F#更倾向于通过函数的组合来完成相关的工作:
代码 20
上面这行代码向我们揭示了数据的流动:books2是数据源,它的数据流经Array.map函数和ToListViewItem函数组合而成的变换函数,然后流向listView.Items.AddRange方法。我们也可以这样理解这行代码:用ToListViewItem函数对books2数组的每个元素做变换操作,然后把结果传给listView.Items.AddRange方法。从这行代码可以看出,运用函数式编程方式来思考这个问题,我们最初定义的ToListViewItem函数就已经足够了。
如何查询数据?
C# 3.0最引人注目的地方莫过于使用LINQ查询数据了,前面提到,目前的F#是基于.NET Framework 2.0的,那么它又如何查询数据呢?我们知道,IEnumerable<'a>是LINQ的核心接口,也是执行任何查询操作的必要条件。在F#里,你可以使用seq<'a>或者IEnumerable<'a>,seq<'a>是F#为IEnumerable<'a>提供的类型缩写(Type Abbreviations),相当于C++的typedef,而适用于seq<'a>的函数则位于Microsoft.FSharp.Collections.Seq模块里,例如Seq.filter函数、Seq.map函数、Seq.orderBy函数等。
假设我现在想用F#对代码10的books做一个查询,找出Tags属性里包含"F#"字眼的书,然后根据Price属性进行排序,那么我该如何做呢?
要判断某本书是否符合我的条件,可以把它的Tags属性按";"符号分割成一个字符串数组,然后判断这个数组里面是否包含"F#"字眼。要把一个字符串按指定的符号分割成一个字符串数组,可以使用System.String.Split实例方法,但要判断一个集合是否包含指定的元素,除了通过Lambda表达式,似乎没有更加直接的方法,所以我仿照System.Linq.Enumerable.Contains方法定义了一个contains函数:
代码 21
contains函数使用了Seq.exists函数,并且通过Lambda表达式告知判断条件为是否相等。你可能会感到很奇怪:为什么没有给contains函数的参数指定类型却可以通过编译?如果这个代码是合法的,那么value和source参数的类型是什么?当F#的编译器看到这段代码时,它发现value和source参数将会用作Seq.exists函数的参数,于是便试图从Seq.exists函数的签名推断value和source参数的类型,由于Seq.exists函数是一个泛型函数,而我们又没有在定义contains函数时给出进一步的约束,于是便断定contains函数也是一个泛型函数,并自动为value和source参数添加类型参数, F#把这个过程叫做自动泛型化(automatic generalization)。另外,你也可能感到奇怪:contains函数的参数顺序和Enumerable.Contains方法恰好相反,为什么呢?我们知道, Enumerable.Contains方法把表示集合的参数放在第一个位置是为了满足扩展方法的要求;而我在这里把表示集合的参数放在contains函数的参数列表的最后一个位置则是为了满足"|>"运算符的要求。我们在代码20已经见识过"|>"运算符了,它的作用是可以把目标函数的最后一个参数提到运算符的前面,换句话说,如果我要判断coll集合里是否包含elem元素,contains函数就可以这样用:coll |> contains elem。如果你有兴趣进一步了解"|>"运算符,可以阅读Brian的《Pipelining in F#》。
有了上面的准备,我们就可以开始查询数据了:
代码 22
Seq.filter函数、Seq.map函数和Seq.orderBy函数分别相当于Enumerable.Where方法、Enumerable.Select方法和Enumerable.OrderBy方法。如果我们把Seq.filter函数、Seq.map函数和Seq.orderBy函数分别定义为where函数、select函数和orderby函数:
代码 23
那么代码22就可以写成这样了:
代码 24
我们来看看对应的C# 3.0代码:
代码 25
是否感到有点喜出望外?什么?感到很失望?因为我写了一个多余的select?囧。。。
读到这里,你可能会问:"如果我只想获取Title和Price属性呢?"这个时候就轮到F#的Tuple类型出场了:
代码 26
读到这里,你可能会问:"如果我想从q里提取所有的书名,生成一个新的集合呢?"你可以通过select函数做到,但现在我想换个玩法,试一下F#的序列表达式(Sequence Expression):
代码 27
我们知道,q里的每个元素都是包含两个数据的Tuple类型,也就是说,每个元素都能匹配"(a, b)"模式,其中,书名会匹配到a,而价格则匹配到b,但由于我只关心书名,于是在b的位置上使用"_",表明我不关心b位置的数据。同样地,如果我想从q里提出所有的价格,然后对它们求和,我可以这样:
代码 28
如果你读过我的《我眼中的C# 3.0》,你应该会记得C# 3.0还提供了字典初始化语法,那么,F#是否也支持类似的语法?很遗憾,不支持,至少目前还没看到这种语法,然而,要在F#里把List<'a>变成Map<'key, 'a>却是非常容易的:
代码 29
上面这句话可以这样理解:把books按照"fun b -> (b.Title, b)"方法进行映射,然后把所得结果用作Map.of_list函数的输入。
业余研究语言的人
我们经常使用编程语言,但我们是否曾经停下来想一下:编程语言究竟是什么?或许对你来说,编程语言只不过是用来编写程序的一种工具,所以根本用不着耗费心思去想这个问题,但对我来说,它并非只是一种工具。我有一个好朋友很喜欢研究股票市场,她说通过股票市场可以看到人性的种种;而我则喜欢研究编程语言,因为通过编程语言可以了解设计者和使用者的思维方式,就像其他研究语言的人通过自然语言可以了解使用者的种群文化一样。
当一种新的编程语言出现并且得到很多受众的支持时,可能就会有人出来说某种旧的编程语言要被取代,甚至宣称这种旧的编程语言的消亡,与此同时,也会有人站出来,对新的编程语言的必要性提出种种质疑……。没有人希望自己选择的编程语言失去活力,当你选择一种编程语言时,其实就接受这种编程语言背后的世界观,所以你会誓死捍卫你的选择,无怪乎每次编程语言之间的优劣争论都可以上升到宗教信仰的程度。面对这类争论,我觉得optionsScalper的态度比较实际(原贴):
I tend not to think of "vs." when doing this type of work. F#, C#, C++ and others are nothing more than tools. Used well, each is capable of yielding great results.
今天,我因为C# 3.0踏进了F#的大门;明天,我会因为C# 4.0踏进谁的大门呢?无论答案是什么,可能都是很久以后的事了……