遞歸是“以一種自相似的方式來重復事物的過程”,也是向運行時托付操作細節的一個例子,而且和函數式編程有著極為密切的聯系。以具體的實踐來說,遞歸是以一種帶點計算機科學味道的方式來對一組事物進行迭代,讓事物的集合反復對自身調用同樣的方法,使集合隨著每次迭代不斷縮小,同時要始終小心地保證退出條件的有效性。
問題核心就是對一個不斷變短的列表反復地做同一件事,把遞歸用在這樣的場合,寫出來的代碼就容易理解。
換個角度看列表
在C或類C語言(包括Java)角度,列表是一個帶索引的集合。
依靠(不一定直接露面的)索引來完成的列表遍歷:
Groovy還提供了eachWithIndex()
迭代子,要求傳給它的代碼塊帶有索引參數,適用于需要顯式訪問索引的場合。
作為“帶索引的格子”的列表形象:
在函數式語言角度,列表是由列表的第一個元素(叫作頭部)和列表的其余元素(叫作尾部)這兩部分組合而成。
分成頭和尾兩部分的列表形象:
把列表想象成頭部和尾部的組合,有利于使用遞歸的方式來組織迭代:
以遞歸方式進行的列表遍歷:
recurseList()
方法首先檢查傳入的列表里還有沒有元素。如果沒有,那就表示迭代工作已經完成,可以返回了。如果還有元素,那么用Groovy提供的head()
方法取出第一個元素,把它打印出來,然后繼續對列表的余下部分調用recurseList()
方法。
遞歸方法的缺點
遞歸操作往往受制平臺而存在一些固有的技術限制,因此這種技法絕非萬靈藥。但對于長度不大的列表來說,遞歸操作是安全的。
從長遠來看,還是應該更多地投入到良好的代碼結構上,技術限制總會隨著時間減少或者消失化中。遞歸寫法作為一種有缺點的代碼結構,其優點并不那么直觀。
命令式和遞歸式的對比
命令式寫法的篩選函數,Groovy實現:
先創建一個新列表來存放希望保留的元素,然后對原列表進行迭代,讓謂詞判定每個元素的去留,最后返回保留元素的列表。
遞歸式寫法的篩選函數,Groovy實現:
filter()
函數首先檢查傳入的列表的大小,若列表中已經沒有元素,則返回列表。否則用篩選條件檢查列表的頭部,如果頭部滿足篩選條件,就把它放入列表(代碼中用了一個空列表“[]”作為初始值來保證返回類型是正確的),不然就繼續遞歸地對列表的尾部篩選下去。
誰來管理狀態?在命令式的寫法中,是“我”在管理狀態。“我”必須創建一個叫new_list
的新變量,“我”負責向新列表添加元素,“我”負責在篩選完成后返回新列表。而在遞歸寫法中,是語言在管理返回值,它從遞歸棧里收集每次方法調用的返回結果,構造出最終的返回值。遞歸例子中的filter()
方法的每一條結束路徑都返回到遞歸調用的上一層,隨著棧中的調用一層一層地返回,各層得到的中間結果也自動匯集到一起。于是程序員卸下了對new_list
的管理責任,交由語言去替我們照料。
利用遞歸,把狀態的管理責任推給運行時。
Scala語言遞歸的實現(還含柯里化)
Scala的列表構造運算符“
::
”起到了提高代碼可讀性的作用,篩選通過和不通過這兩種情形下返回結果的變動,表述得清晰易懂。filter()
方法遞歸地使用參數p
來篩選一個整數列表,其中參數p
是一個布爾函數,或者按照函數式領域的術語叫作“謂詞”(predicate)函數。filter()
方法檢查列表是否為空,若是則直接返回;否則用謂詞來檢驗列表的第一個元素(xs.head
),判斷是否應放入篩選后的列表。
如果頭部滿足謂詞條件,那么就返回以該頭部為首,再加上尾部的篩選結果組成的新列表。如果頭部通不過謂詞的檢驗,返回的就只有列表余下部分的篩選結果。
遞歸對開發者的解放效果或許不像垃圾收集那么顯著,不過它切實地揭示了編程語言的一個重要的發展方向:通過移交“不確定因素”的控制權給運行時來消解它們。如果程序員不準插手列表操作的中間結果,那么就不會引入那些在交互中產生的錯誤。
尾調用優化
遞歸沒有成為一種平常的操作,其中一個主要原因是棧的增長。遞歸操作一般的實現方式,都是把中間結果放在棧里,于是沒有為遞歸專門優化的語言就會遇到棧溢出的問題。
而像Scala、Clojure這些語言則各自采用了不同的方式來規避這方面的局限。開發者也可以使用尾調用優化(tail-call optimization)的寫法來幫助運行時克服棧的增長問題。當遞歸調用是函數執行的最后一個調用的時候,運行時往往可以在棧里就地更新,而不需要增加新的棧空間。
很多函數式語言實現了沒有棧增長的尾遞歸。Erlang用尾遞歸來實現長時間運行的進程,相當于運行在應用里面的一系列微服務,它們從別的進程接收消息,并按照消息中的要求來代表別的進程執行任務。這些接收消息并受消息左右的尾遞歸循環還有調整微服務內部狀態的能力,因為對不可變的當前狀態的任何作用結果,都可以放在表示新狀態的變量里傳入下一輪遞歸而生效。
遞歸在開發中用得不多的原因
應該部分地歸咎于大多數命令式語言呆滯的語法配合,讓一件不太容易的事情變得難上加難。函數式語言的簡潔語法和靈活配合,才使遞歸成為簡單可行的代碼重用選項之一。