有趣的 Scala 語言: 函數成了一等公民


Scala 是一種有趣的語言。它一方面吸收繼承了多種語言中的優秀特性,一方面又沒有拋棄 Java 這個強大的平臺,它運行在 JVM 之上,輕鬆實現和豐富的 Java 類庫互聯互通。它既支持面向對象的編程方式,又支持函數式編程。它寫出的程序像動態語言一樣簡潔,但事實上它卻是嚴格意義上的靜態語言。Scala 就像一位武林中的集大成者,將過去幾十年計算機語言發展歷史中的精萃集於一身,化繁爲簡,爲程序員們提供了一種新的選擇。作者希望通過這個系列,可以爲大家介紹 Scala 語言的特性,和 Scala 語言給我們帶來的關於編程思想的新的思考。本文將帶領大家一起回顧函數式編程的歷史,清楚函數式編程的定義,並以一個例子,由易到難爲大家展示函數式編程的優點,最後介紹了柯里化的概念。


  • expand內容

函數式編程是這幾年很受歡迎的一個話題,即使你是一個剛剛踏入職場的新人,如果在面試時能有意無意地透露出你懂那麼一點點函數式編程,也會讓你的面試官眼前一亮。然而函數式編程並不是一個新的概念,它的源頭可以追溯到計算機尚未發明之前。本文將帶領大家回顧一下函數式編程的歷史,並使用 Scala 語言爲大家講解函數式編程的基本概念。

函數式編程的歷史

有機會看到這篇文章的讀者,大概都會知道阿蘭·圖靈(Alan Turing)和約翰·馮·諾伊曼(John von Neumann)。阿蘭·圖靈提出了圖靈機的概念,約翰·馮·諾伊曼基於這一理論,設計出了第一臺現代計算機。由於圖靈以及馮·諾伊曼式計算機的大獲成功,歷史差點淹沒了另外一位同樣傑出的科學家和他的理論,那就是阿隆佐·邱奇(Alonzo Church)和他的λ演算。阿隆佐·邱奇是阿蘭·圖靈的老師,上世紀三十年代,他們一起在普林斯頓研究可計算性問題,爲了回答這一問題,阿隆佐·邱奇提出了λ演算,其後不久,阿蘭·圖靈提出了圖靈機的概念,儘管形式不同,但後來證明,兩個理論在功能上是等價的,條條大路通羅馬。如果不是約翰·麥卡錫(John McCarthy),阿隆佐·邱奇的λ演算恐怕還要在歷史的故紙堆中再多躺幾十年,約翰·麥卡錫是人工智能科學的奠基人之一,他發現了λ演算的珍貴价值,發明了基於λ演算的函數式編程語言:Lisp,由於其強大的表達能力,一推出就受到學術界的熱烈歡迎,以至於一段時間內,Lisp 成了人工智能領域的標準編程語言。很快,λ演算在學術界流行開來,出現了很多函數式編程語言:Scheme 、SML、Ocaml 等,但是在工業界,還是命令式編程語言的天下,Fortran、C、C++、Java 等。隨着時間的流逝,越來越多的計算機從業人員認識到函數式編程的意義,愛立信公司於上世紀八十年代開發出了 Erlang 語言來解決併發編程的問題;在互聯網的發展浪潮中,越來越多的語言也開始支持函數式編程:JavaScript、Python、Ruby、Haskell、Scala 等。可以預見,如果過去找一個懂什麼是函數式編程的程序員很困難,那麼在不久的將來,找一個一點也沒聽過函數式編程的程序員將更加困難。

什麼是函數式編程

狹義地說,函數式編程沒有可變的變量、循環等這些命令式編程方式中的元素,像數學裏的函數一樣,對於給定的輸入,不管你調用該函數多少次,永遠返回同樣的結果。而在我們常用的命令式編程方式中,變量用來描述事物的狀態,整個程序,不過是根據不斷變化的條件來維護這些變量。

廣義地說,函數式編程重點在函數,函數是這個世界裏的一等公民,函數和其他值一樣,可以到處被定義,可以作爲參數傳入另一個函數,也可以作爲函數的返回值,返回給調用者。利用這些特性,可以靈活組合已有函數形成新的函數,可以在更高層次上對問題進行抽象。本文的重點將放在這一部分。

函數式編程有什麼優點

約翰·巴克斯(John Backus)爲人熟知的兩項成就是 FORTRAN 語言和用於描述形式系統的巴克斯範式,因爲這兩項成就,他獲得了 1977 年的圖靈獎。有趣的是他在獲獎後,做了一個關於函數式編程的講演:Can Programming Be Liberated From the von Neumann Style? 1977 Turing Award Lecture。他認爲像 FORTRAN 這樣的命令式語言不夠高級,應該有新的,更高級的語言可以擺脫馮諾依曼模型的限制,於是他又發明了 FP 語言,雖然這個語言未獲成功,但是約翰·巴克斯關於函數式編程的論述卻得到了越來越多的認可。下面,我們就羅列一些函數式編程的優點。

首先,函數式編程天然有併發的優勢。由於工藝限制,摩爾定律已經失效,芯片廠商只能採取多核策略。程序要利用多核運算,必須採取併發,而併發最頭疼的問題就是共享數據,狹義的函數式編程沒有可變的變量,數據只讀不寫,併發的問題迎刃而解。這也是前面兩篇文章中,一直建議大家在定義變量時,使用 val 而不是 var 的原因。愛立信公司發明的 Erlang 語言就是爲解決併發的問題而生,在電信行業取得了不俗的成績。

其次,函數式編程有跡可尋。由於不依賴外部變量,給定輸入函數的返回結果永遠不變,對於複雜的程序,我們可以用值替換的方式(substitution model)化繁爲簡,輕鬆得出一段程序的計算結果。爲這樣的程序寫單元測試也很方便,因爲不用擔心環境的影響。

最後,函數式編程高屋建瓴。寫程序最重要的就是抽象,不同風格的編程語言爲我們提供了不同的抽象層次,抽象層次越高,表達問題越簡潔,越優雅。讀者從下面的例子中可以看到,使用函數式編程,有一種高屋建瓴的感覺。

抽象,抽象,再抽象!

說了這麼多,相信很多性急的讀者都等不及想看看怎麼使用 Scala 進行函數式編程了吧。那麼,先請大家暫時忘掉以前命令式編程的經驗,用一個全新的大腦來開始這段函數式編程之旅。

故事從我上初中的外甥小龍身上開始,像所有聰明的孩子一樣,小龍身上具備了懶,不耐煩以及妄自尊大這些優秀特質。他厭倦了數學作業上那些大量沒有意義的,重複的練習題。還好他有個當程序員的姨夫:在電腦上裝個 Scala,寫程序做吧。於是小龍把 Scala 當作一個計算器,寫出了他有生以來第一段程序:

清單 1. 求立方
35 * 35 * 35
68 * 68 * 68
// 以下省去大量重複的,沒有意義的練習題

作業做完了,雖然大腦得到了休息,但是小龍的手累壞了!作爲一個懶人,小龍是不會滿足於不動腦,但要動手這種狀況的。於是,我教給了他最基本的抽象方式:將算法抽象爲一個函數。小龍很快做完作業,高高興興跟小夥伴們打籃球去了。

清單 2. 求立方函數
def cube(n: Int) = n * n * n
// 有了這個函數,小龍做起作業輕鬆多了
// 以下省去大量重複的,沒有意義的練習題
cube(35)
cube(68)

隨着教學進度的加快,小龍的作業也越來越難了,很快,小龍遇到了這樣的題目:求出 1 到 10 的立方和。聰明如小龍,或者說懶惰如小龍,在前一個函數基礎之上,很快又定義了個新函數,還是個遞歸函數!沒錯,在小龍還沒看見過循環之前,我先教會了他遞歸,他理解起來毫不費力:對 a 到 b 之間的數求立方和,等於 a 的立方和,加上 (a + 1) 到 b 之間的數的立方和。如果讀者對於遞歸還有疑惑,請參考作者的前一篇文章《使用遞歸的方式去思考》。小龍又很快做完作業,高高興興跟着小夥伴們打球去了。

清單 3. 求立方和
def cube(n: Int) = n * n * n
def sumCube(a: Int, b: Int): Int =
if (a > b) 0 else cube(a) + sumCube(a + 1, b)
// 有了這個函數,小龍做起作業輕鬆多了
sumCube(1, 10)

塞翁失馬,焉知非福,好事很快變壞事,由於小龍數學作業做得又快又好,被老師選拔爲奧數培養對象,除過作業,小龍每天還要做大量的額外練習:求 1 到 10 的和,求 1 到 10 的平方和,求 1 到 10 的階乘和等等。這時,小龍已經對定義函數很熟練了,三下五除二,小龍又定義出一堆函數出來。

清單 4. 各種求和函數
def cube(n: Int) = n * n * n
def id(n: Int) = n
def fact(n: Int): Int =
def square(n : Int) = n * n
def sumCube(a: Int, b: Int): Int =
if (n == 0) 1 else n * fact(n - 1)
def sumSquare(a: Int, b: Int): Int =
if (a > b) 0 else cube(a) + sumCube(a + 1, b)
def sumFact(a: Int, b: Int): Int =
if(a > b) 0 else square(a) + sumSquare(a + 1, b)
if(a > b) 0 else id(a) + sumInt(a + 1, b)
if (a > b) 0 else fact(a) + sumFact(a + 1, b) def sumInt(a: Int, b: Int): Int =
sumFact(1, 10)
// 有了這些函數,小龍做起作業輕鬆多了 sumCube(1, 10) sumInt(1, 10)
sumSquare(1, 10)

問題解決了,但小龍總覺得哪裏不對勁,(這時,一個畫外音高喊:Don ’ t Repeat Yourself!),是的,仔細觀察小龍定義的這四個求和函數,幾乎是一模一樣的。能不能也將這些一模一樣的東西抽象出來?我覺得是時候教給他第二項本領了:高階函數(Higher-Order Function),所謂高階函數,就是操作其他函數的函數。以求和爲例,我們可以定義一個新的求和函數,該函數接受另外一個函數作爲參數,這個作爲參數的函數代表了某種對數據的操作。使用高階函數後,抽象層次提高,代碼變得更簡單了。

清單 5. 使用高階函數定義求和函數
def cube(n: Int) = n * n * n
def id(n: Int) = n
def fact(n: Int): Int =
def square(n : Int) = n * n
def sum(f: Int => Int, a: Int, b: Int): Int =
if (n == 0) 1 else n * fact(n - 1) // 高階函數
def sumCube(a: Int, b: Int): Int = sum(cube, a, b)
if (a > b) 0 else f(a) + sum(f, a + 1, b) // 使用高階函數重新定義求和函數
def sumFact(a: Int, b: Int): Int = sum(fact, a, b)
def sumSquare(a: Int, b: Int): Int = sum(square, a, b) def sumInt(a: Int, b: Int): Int = sum(id, a, b)
sumFact(1, 10)
// 有了這些函數,小龍做起作業輕鬆多了 sumCube(1, 10) sumInt(1, 10)
sumSquare(1, 10)

對於簡單的函數,我們還可以將其轉化爲匿名函數,讓程序變得更簡潔一些。在高階函數中使用匿名函數,這是函數式編程中經常用到的一個技巧,多數情況下,我們關心的是高階函數,而不是作爲參數傳入的函數,所以爲其單獨定義一個函數是沒有必要的。值得稱讚的是 Scala 中定義匿名函數的語法很簡單,箭頭左邊是參數列表,右邊是函數體,參數的類型是可省略的,Scala 的類型推測系統會推測出參數的類型。使用匿名函數後,我們的代碼變得更簡潔了:

清單 6. 在高階函數中使用匿名函數
def fact(n: Int): Int =
if (n == 0) 1 else n * fact(n - 1)
// 高階函數
def sum(f: Int => Int, a: Int, b: Int): Int =
if (a > b) 0 else f(a) + sum(f, a + 1, b)
def sumCube(a: Int, b: Int): Int = sum(x => x * x * x, a, b)
// 使用高階函數重新定義求和函數
def sumFact(a: Int, b: Int): Int = sum(fact, a, b)
def sumSquare(a: Int, b: Int): Int = sum(x => x * x, a, b)
sumCube(1, 10)
def sumInt(a: Int, b: Int): Int = sum(x => x, a, b) // 有了這些函數,小龍做起作業輕鬆多了 sumInt(1, 10)
sumFact(1, 10)
sumSquare(1, 10)

小龍的故事到此就結束了,希望讀者能從這一例子中,體會出函數式編程的一些精妙之處。下面我們將進入函數式編程的另一個概念:柯里化(Currying)

柯里化

作爲一個程序員,應該永遠有一顆追求完美的心,上面使用匿名函數後的高階函數還有什麼地方值得改進呢?希望大家還會想起那句話:Don ’ t Repeat Yourself !求和函數的兩個上下限參數 a,b被重複得傳來傳去。我們試着重新定義 sum函數,讓它接受一個函數作爲參數,同時返回另外一個函數。看到沒?使用新的 sum函數,我們再定義各種求和函數時,完全不需要這兩個上下限參數了,我們的程序又一次得到了簡化。

清單 7. 返回函數的高階函數
def fact(n: Int): Int =
if (n == 0) 1 else n * fact(n - 1)
// 高階函數
def sum(f: Int => Int): (Int, Int) => Int = {
def sumF(a: Int, b: Int): Int =
}
if (a > b) 0 else f(a) + sumF(a + 1, b) sumF // 使用高階函數重新定義求和函數
def sumFact: Int = sum(fact)
def sumCube: Int = sum(x => x * x * x) def sumSquare: Int = sum(x => x * x) def sumInt: Int = sum(x => x)
sumFact(1, 10)
// 這些函數使用起來還和原來一樣 ! sumCube(1, 10) sumInt(1, 10)
sumSquare(1, 10)

能不能再簡單一點呢?既然 sum返回的是一個函數,我們應該可以直接使用這個函數,似乎沒有必要再定義各種求和函數了。

清單 8. 直接調用高階函數
def fact(n: Int): Int =
if (n == 0) 1 else n * fact(n - 1)
// 高階函數
def sum(f: Int => Int): (Int, Int) => Int = {
def sumF(a: Int, b: Int): Int =
}
if (a > b) 0 else f(a) + sumF(a + 1, b) sumF // 這些函數沒有必要了
//def sumSquare: Int = sum(x => x * x)
//def sumCube: Int = sum(x => x * x * x) //def sumFact: Int = sum(fact) //def sumInt: Int = sum(x => x)
sum(x => x) (1, 10) //=> sumInt(1, 10)
// 直接調用高階函數 ! sum(x => x * x * x) (1, 10) //=> sumCube(1, 10) sum(x => x * x) (1, 10) //=> sumSquare(1, 10)
sum(fact) (1, 10) //=> sumFact(1, 10)

這種返回函數的高階函數極爲有用,因此 Scala 爲其提供了語法糖,上面的 sum函數可以簡寫爲:

清單 9. 高階函數的語法糖
// 沒使用語法糖的 sum 函數
def sum(f: Int => Int): (Int, Int): Int = {
def sumF(a: Int, b: Int): Int =
sumF
if (a > b) 0 else f(a) + sumF(a + 1, b) } // 使用語法糖後的 sum 函數
if (a > b) 0 else f(a) + sum(f)(a + 1, b)
def sum(f: Int => Int)(a: Int, b: Int): Int =

讀者可能會問:我們把原來的 sum函數轉化成這樣的形式,好處在哪裏?答案是我們獲得了更多的可能性,比如剛開始求和的上下限還沒確定,我們可以在程序中把一個函數傳給 sumsum(fact)完全是一個合法的表達式,待後續上下限確定下來時,再把另外兩個參數傳進來。對於 sum 函數,我們還可以更進一步,把 a,b 參數再轉化一下,這樣 sum 函數就變成了這樣一個函數:它每次只能接收一個參數,然後返回另一個接收一個參數的函數,調用後,又返回一個只接收一個參數的函數。這就是傳說中的柯里化,多麼完美的形式!在現實世界中,的確有這樣一門函數式編程語言,那就是 Haskell,在 Haskell 中,所有的函數都是柯里化的,即所有的函數只接收一個參數!

清單 10. 柯里化
// 柯里化後的 sum 函數
def sum(f: Int => Int)(a: Int) (b: Int): Int =
if (a > b) 0 else f(a) + sum(f)(a + 1)(b)
sum(x => x * x * x)(1)(10) //=> sumCube(1, 10)
// 使用柯里化後的高階函數 !
sum(x => x)(1)(10) //=> sumInt(1, 10)

結束語

本文和大家一起回顧了函數式編程的歷史,並使用了大量示例代碼幫助大家理解函數式編程中的基本概念。在 Scala 類庫中,使用函數式編程的例子比比皆是,特別是對於列表的操作,將高階函數的優勢展示得淋漓盡致,限於篇幅,不能在本文中爲大家作以介紹,作者將在後面的系列文章中,以 Scala 中的列表爲例,詳細介紹高階函數在實戰中的應用。


轉自轉載鏈接


參考資料

學習

發佈了53 篇原創文章 · 獲贊 44 · 訪問量 15萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章