C#中的函數語言程式設計:序言(一)

D.H_Lee發表於2018-03-13

學了那麼久的函數語言程式設計語言,一直想寫一些相關的文章。經過一段時間的考慮,我決定開這個坑。

至於為什麼選擇C#,在我看來,程式語言分三類:一類是難以進行函數語言程式設計的語言,這類語言包括Java6、C語言等。這類語言由於不支援匿名函式等特性,進行函數語言程式設計會比較困難;一類是自稱“函數語言程式設計語言”的語言,包括Scala、Clojure、F#、Haskell等。這類語言比較重視函數語言程式設計,它的教學資料通常會包含函數語言程式設計知識,因此這些語言的使用者大多也都已經掌握了函數語言程式設計技巧;還有一類程式語言,它們不被稱作函數語言程式設計語言,卻可以進行函數語言程式設計。這些語言的使用者中懂得函數語言程式設計的人相對較少,學習資料也較少提及函數語言程式設計。這些語言包括Java8、C++11、C#、Rust、Kotlin、TypeScript、Python、Ruby等。

既然我的文章是要介紹函數語言程式設計,首先我肯定不能選第一類,它們無法使用;而第二類程式語言的使用者已經掌握了函數語言程式設計的技能。考慮到受眾面,我的選擇範圍定在第三類語言內。最終我通過隨機數選中了C#,如果我有精力我也會嘗試一下其他語言。

說了這麼多,那究竟什麼是函數語言程式設計呢?根據Scala之父Martin Odersky的說法,函數語言程式設計有狹義和廣義之分:狹義的函數語言程式設計指的是表示式沒有副作用的程式設計,滿足這一特性的程式語言有Pure Lisp和不包含IO Monad與Unsafe operations的Haskell子集;而廣義的函數語言程式設計指的是函式是第一公民的語言,這個範圍就大了很多,前面提到的第二類與第三類語言都屬於廣義的函數語言程式設計。

而函數語言程式設計的核心,就和這兩個定義相關:沒有副作用、函式是第一公民。

我們先來看副作用。我記得以前學C語言時有人喜歡用x++ + ++x為例去黑某個人寫的臭名昭著的C語言的書。這個表示式實際上是一種未定義行為。但是,如果我們把它換成(x + 1) + (x + 2),這個語句就毫無歧義。問題在於x++、++x是有副作用的。如果一個表示式是無副作用的,我們就可以用這個表示式的值替換成它,而程式的行為不會發生改變。我們稱這個性質為引用透明(Referential transparency)。就剛才的例子,假設x的值是3,那麼對於(x + 1) + (x + 2)而言,我們可以把x + 1替換成它的值4,則表示式改寫成4 + (x + 2),或者把x + 2替換成5而改寫成(x + 1) + 5,這樣的改寫不會改變表示式的值。但是x++ + ++x就不可以,如果我們把x++換成3,那麼表示式的值就會變。所以x++和++x不是引用透明的。

引用透明的一大特性是,我們可以改變引用透明的表示式的執行次序,而不用擔心程式行為的變化。之所以x++ + ++x是未定義行為,是因為x++和++x不是引用透明的,從而導致x++和++x執行的先後順序會影響整個表示式的值。而x + 1和x + 2的先後順序則對錶達式的值沒有影響。這個特性在後面我們會用到。

下面再給一個例子,考慮這段C#程式碼

 1 class Program
 2 {
 3     static void Main()
 4     {
 5         for (int i = 0; i < 10; ++i)
 6         {
 7             System.Threading.Tasks.Task.Factory.StartNew(() =>
 8             {
 9                 System.Threading.Thread.Sleep(100);
10                 System.Console.WriteLine(i);
11             });
12         }
13         System.Console.ReadLine();
14     }
15 }

這段程式碼會輸出什麼?

你可能會以為它會以某種次序輸出數字0到9,但實際輸出是10個數字10.

為了能讓程式輸出數字0到9,我們需要這樣修改程式:

 1 class Program
 2 {
 3     static void Main()
 4     {
 5         for (int i = 0; i < 10; ++i)
 6         {
 7             int _i = i;
 8             System.Threading.Tasks.Task.Factory.StartNew(() =>
 9             {
10                 System.Threading.Thread.Sleep(100);
11                 System.Console.WriteLine(_i);
12             });
13         }
14         System.Console.ReadLine();
15     }
16 }

如果你是JavaScript程式設計師,你可能會對這個策略有所熟悉。這是在迴圈中建立閉包(即使用了外部變數的匿名函式)時常遇到的坑。對於前一個程式,由於迴圈變數的i是變化的,因此i不滿足引用透明,我們不能在建立閉包時就用i的值替換掉i,而由於Sleep語句存在,最終輸出的時候i的值是10。而第二個程式輸出的不是i,而是_i,_i滿足一經初始化後不再被重新賦值,這是一個變數滿足引用透明的重要特徵。此時我們就可以用_i的值替換掉_i,從而程式能輸出數字0~9.

從上面的例子可以看出,使用副作用可能會產生不經意的bug。因此,在函數語言程式設計中,我們會盡量的少產生副作用。比如上面這段程式碼,最完美的方案是用我們後面會提到的尾遞迴。

函數語言程式設計的另一個特點是函式是第一公民。在很多傳統的程式語言中,函式有很多限制,比如我們不能在函式內部定義函式,我們不能建立一個函式型別的變數(注意:C語言的函式指標嚴格來講不算。因為函式指標無法指向帶閉包的函式)、我們不能將函式當成引數傳給一個函式、不能建立一個沒有名字的函式字面量等等。“函式是第一公民”的意思是,函式不應該受這些“歧視”。函式應該和其他型別擁有同等地位。當然,嚴格的滿足函式是第一公民的語言也並不多。C#也是到了7才支援在函式內部建立函式。但對於函數語言程式設計而言,函式至少要有的“權力”包括:建立沒有名字的函式字面量(即匿名函式或Lambda表示式)、將函式作為引數傳給其他引數。

我相信大家都用過Linq吧。Linq就是一個典型的把函式當第一公民的例子。在函數語言程式設計中,我們將深挖函式作為第一公民的價值。

相關文章