從C# 3.0到F#

從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踏進誰的大門呢?無論答案是什麼,可能都是很久以後的事了……

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章