函式式思維: 為什麼函數語言程式設計越來越受關注

developerworks發表於2013-07-09

  到目前為止,在本系列的每期文章中,我都說明了為什麼理解函數語言程式設計非常重要。但是,有些原因是在多期文章中進行說明的,只有在綜合思路的更大背景中,才可以完全瞭解這些原因。在本期文章中,我會探討函數語言程式設計方興未艾的所有原因,並綜合前幾期文章中的一些個人經驗教訓。

  在電腦科學短短的發展歷史中,技術的主流有時會產生分支,包括實用分支和學術分支。20 世紀 90 年代的 4GL(第四代語言)是一個實用分支,而函數語言程式設計是來自學術界的一個示例。每隔一段時間,都會有一些分支加入主流,函數語言程式設計目前也是這種情況。函式式語言不僅在 JVM 上剛剛嶄露頭腳(其中兩個最有趣的新語言是 Scala 和 Clojure),在 .NET 平臺上也是才開始得到應用,在 .NET 平臺上,F# 是頭等公民。為什麼所有平臺都如此歡迎函數語言程式設計?答案是,隨著時間的推移,隨著執行時都要能夠處理更多的繁忙工作,開發人員已經能夠將日常任務的更多控制權割讓給它們。

  割讓控制權

  在 20 世紀 80 年代初,在我上大學的時候,我們使用一個被稱為 Pecan Pascal 的開發環境。其獨特的特性是,相同的 Pascal 程式碼可以在 Apple II 或 IBM PC 上執行。Pecan 工程師使用某個稱為 “位元組碼” 的神祕東西實現了這一壯舉。開發人員將 Pascal 程式碼編譯為 “位元組碼”,它可以在每個平臺本地編寫的 “虛擬機器” 上執行。這是一個可怕的體驗!所生成的程式碼慢得讓人痛苦,甚至簡單的類賦值也非常緩慢。當時的硬體還沒有準備好迎接這個挑戰。

  在釋出 Pecan Pascal 之後的十年,Sun 釋出了 Java,Java 使用了相同的架構,對於 20 世紀 90 年代中期的硬體環境,執行該程式碼顯得有些緊張,但最終取得了成功。Java 還增加了其他開發人員友好的特性,如自動垃圾收集。使用過像 C++ 這樣的語言之後,我再也不想在沒有垃圾收集的語言中編寫程式碼。我寧願花將時間花在更高層次上的抽象上,思考解決複雜業務問題的方法,也不願意在記憶體管理等複雜的管道問題上浪費時間。

  Java 緩解了我們與記憶體管理的互動;函數語言程式設計語言使我們能夠用高層次的抽象取代其他核心構建塊,並更注重結果而不是步驟。

  結果比步驟更重要

  函數語言程式設計的特點之一是存在強大的抽象,它隱藏了許多日常操作的細節(比如迭代)。我在本系列文章中一直使用的一個示例是數字分類:確定某個數字是 perfectabundant 還是 deficient。清單 1 中顯示的 Java 實現可以解決這個問題:

  清單 1. 自帶快取總數的 Java 數字分類器

				
import static java.lang.Math.sqrt;

public class ImpNumberClassifier {
    private Set<Integer> _factors;
    private int _number;
    private int _sum;

    public ImpNumberClassifier(int number) {
        _number = number;
        _factors = new HashSet<Integer>();
        _factors.add(1);
        _factors.add(_number);
        _sum = 0;
    }

    private boolean isFactor(int factor) {
        return _number % factor == 0;
    }

    private void calculateFactors() {
        for (int i = 1; i <= sqrt(_number) + 1; i++)
            if (isFactor(i))
                addFactor(i);
    }

    private void addFactor(int factor) {
        _factors.add(factor);
        _factors.add(_number / factor);
    }

    private void sumFactors() {
        calculateFactors();
        for (int i : _factors)
            _sum += i;
    }

    private int getSum() {
        if (_sum == 0)
            sumFactors();
        return _sum;
    }

    public boolean isPerfect() {
        return getSum() - _number == _number;
    }

    public boolean isAbundant() {
        return getSum() - _number > _number;
    }

    public boolean isDeficient() {
        return getSum() - _number < _number;
    }
}

  清單 1 中的程式碼是典型的 Java 程式碼,它使用迭代來確定和彙總係數。在使用函數語言程式設計語言時,開發人員很少關心細節(比如迭代,由 calculateFactors() 使用)和轉換(比如彙總一個列表,該列表由 sumFactors() 使用),寧願將這些細節留給高階函式和粗粒度抽象。

  粗粒度的抽象

  用抽象來處理迭代等任務,使得需要維護的程式碼變得更少,因此可能出現錯誤的地方也就更少。清單 2 顯示了一個更簡潔的數字分類器,用 Groovy 編寫,借用了 Groovy 的函式風格方法:

  清單 2. Groovy 數字分類器

				
import static java.lang.Math.sqrt

class Classifier {
  def static isFactor(number, potential) {
    number % potential == 0;
  }

  def static factorsOf(number) {
    (1..number).findAll { isFactor(number, it) }
  }

  def static sumOfFactors(number) {
    factorsOf(number).inject(0, {i, j -> i + j})
  }

  def static isPerfect(number) {
    sumOfFactors(number) == 2 * number
  }

  def static isAbundant(number) {
    sumOfFactors(number) > 2 * number
  }

  def static isDeficient(number) {
    sumOfFactors(number) < 2 * number
  }
}

  清單 2 中的程式碼使用很少的程式碼完成 清單 1 的所有工作(減去快取總數,這會重新出現在下面的示例中)。例如,用於確定factorsOf() 中的係數的迭代消失了,替換為使用 findAll() 方法,它接受一個具有我的篩選器條件的程式碼塊(一個高階函式)。Groovy 甚至允許使用更簡潔的程式碼塊,它允許單引數塊使用 it 作為隱含引數名稱。同樣,sumOfFactors() 方法使用了 inject(),它(使用 0 作為種子值)將程式碼塊應用於每個元素,將每個對減少為單一的值。{i, j -> i + j} 程式碼塊返回兩個引數的總和;每次將列表 “摺疊” 成一個對時,都會應用此塊,產生總和。

  Java 開發人員習慣於框架 級別的重用;在物件導向的語言中進行重用所需的必要構件需要非常大的工作量,他們通常會將精力留給更大的問題。函式式語言在更細化的級別提供重用,在列表和對映等基本資料結構之上通過高階函式提供定製,從而實現重用。

  少量資料結構,大量操作

  在物件導向的指令式程式設計語言中,重用的單元是類以及與這些類進行通訊的訊息,這些資訊是在類圖中捕獲的。該領域的開創性著作是《設計模式:可複用物件導向軟體的基礎》,至少為每個模式提供一個類圖。在 OOP 的世界中,鼓勵開發人員建立獨特的資料結構,以方法的形式附加特定的操作。函數語言程式設計語言嘗試採用不同的方式來實現重用。它們更喜歡一些關鍵的資料結構(如列表、集和對映),並且在這些資料結構上採用高度優化的操作。傳遞資料結構和高階函式,以便 “插入” 這種機制,針對某一特定用途對其進行定製。例如,在 清單 2 中,findAll() 方法接受使用一個程式碼塊作為 “外掛” 高階函式(該函式確定了篩選條件),而該機制以有效方式應用了篩選條件,並返回經過篩選的列表。

  函式級的封裝支援在比構建自定義類結構更細的基礎級別上進行重用。此方法的優勢之一已經體現在 Clojure 中。最近,庫中的一些巧妙創新重寫了 map 函式,使它可以自動並行化,這意味著所有對映操作都可以受益於沒有開發人員干預的效能提升。

  例如,考慮一下解析 XML 的情況。大量的框架可用於在 Java 中完成這個任務,每個框架都有自定義的資料結構和方法語義(例如,SAX 與 DOM)。Clojure 將 XML 解析為一個標準的 Map 結構,而不是強迫您使用自定義的資料結構。因為 Clojure 中包含大量與對映配合使用的工具,如果使用內建的列表理解函式 for,那麼執行 XPath 樣式的查詢就會很簡單,如清單 3 所示:

  清單 3. 將 XML 解釋為 Clojure

				
(use 'clojure.xml)

(def WEATHER-URI "http://weather.yahooapis.com/forecastrss?w=%d&u=f")

(defn get-location [city-code]
  (for [x (xml-seq (parse (format WEATHER-URI city-code))) 
        :when (= :yweather:location (:tag x))]
    (str (:city (:attrs x)) "," (:region (:attrs x)))))

(defn get-temp [city-code]
  (for [x (xml-seq (parse (format WEATHER-URI city-code))) 
        :when (= :yweather:condition (:tag x))]
    (:temp (:attrs x))))

(println "weather for " (get-location 12770744) "is " (get-temp 12770744))

  在 清單 3 中,我訪問雅虎的氣象服務來獲取某個給定城市的氣象預報。因為 Clojure 是 Lisp 的一個變體,所有從內部讀取是最簡單的。對服務端點的實際呼叫發生在 (parse (format WEATHER-URI city-code)) 上,它使用了 String 的 format() 函式將 city-code嵌入字串。列表理解函式 for 放置瞭解析後的 XML,使用 xml-seq 將它投放到名稱為 x 的可查詢對映中。:when 謂詞確定了匹配條件;在本例中,我要搜尋一個標籤(轉換成一個 Clojure 關鍵字) :yweather:condition

  如欲瞭解從資料結構中讀取值所用的語法,那麼檢視該語法中包含的內容會非常有用。在解析的時候,氣象服務的相關呼叫會返回在此摘錄中顯示的資料結構:

({:tag :yweather:condition, :attrs {:text Fair, :code 34, :temp 62, :date Tue, 
   04 Dec 2012 9:51 am EST}, :content nil})

  因為已經為了與對映配合使用而優化了 Clojure,所以關鍵字在包含它們的對映上成為了函式。在 清單 3 中,對 (:tag x) 的呼叫是一個縮寫,它等同於 “從儲存在 x 中的對映檢索與 :tag 鍵對應的值”。因此,:yweather:condition 產生與該鍵關聯的對映值,其中包括我使用相同語法從中提取 :temp 的 attrs

  最初,Clojure 中令人生畏的細節之一是:與對映和其他核心資料結構進行互動的方法似乎有無限多種。然而,它反映了這樣一個事實:在 Clojure 中,大多數內容都嘗試解決這些核心的、優化的資料結構。它沒有將解析的 XML 困在一個獨特的框架中,相反,它試圖將其轉換為一個已存在相關工具的現有結構。

  對基礎資料結構的依賴性的優點體現在 Clojure 的 XML 庫中。為了遍歷樹形結構(如 XML 文件),1997 年建立了一個有用的資料結構,名為 zipper(參閱 參考資料)。zipper 通過提供座標系方向,讓您可以結構性地導航樹。例如,可以從樹的根開始,發出 (-> z/down z/down z/left) 等命令,導航到第二級的左側元素。Clojure 中已經有現成的函式可將解析的 XML 轉換為 zipper,在整個樹形結構中實現一致的導航。

  新的、不同的工具

  函數語言程式設計提供了新的工具型別,以優雅的方式解決棘手的問題。例如,Java 開發人員不習慣盡能延遲生成其值的惰性 資料結構。而未來的函式式語言將對這種高階特性提供支援,一些框架將此功能加裝到 Java 中。例如,清單 4 所示的數字分類器版本使用了 Totally Lazy 框架:

  清單 4. Java 數字分類器通過 Totally Lazy 使用惰性和函式式資料結構

				
import com.googlecode.totallylazy.Predicate;
import com.googlecode.totallylazy.Sequence;

import static com.googlecode.totallylazy.Predicates.is;
import static com.googlecode.totallylazy.numbers.Numbers.*;
import static com.googlecode.totallylazy.predicates.WherePredicate.where;

public class Classifier {
  public static Predicate<Number> isFactor(Number n) {
      return where(remainder(n), is(zero));
  }

  public static Sequence<Number> getFactors(final Number n){
      return range(1, n).filter(isFactor(n));
  }

  public static Sequence<Number> factors(final Number n) {
      return getFactors(n).memorise();
  }

  public static Number sumFactors(Number n){
      return factors(n).reduce(sum);
  }

  public static boolean isPerfect(Number n){
      return equalTo(n, subtract(sumFactors(n), n));
  }

  public static boolean isAbundant(Number n) {
    return greaterThan(subtract(sumFactors(n), n), n);
  }

  public static boolean isDeficient(Number n) {
    return lessThan(subtract(sumFactors(n), n), n);
  }

}

  Totally Lazy 增加了惰性集合和流暢介面方法,大量使用靜態匯入,使程式碼具有可讀性。如果您羨慕下一代語言中的某些特性,那麼一些研究可能會提供可以解決某個特定問題的特定擴充套件。

  讓語言遷就問題

  大多數開發人員都將他們的工作誤解為接受一個複雜的業務問題,將它轉換成 Java 等語言。他們的這種誤解是因為 Java 並不是一種特別靈活的語言,它迫使您讓自己的想法適應於已經存在的剛性結構。但是,當開發人員使用可塑語言時,他們看到了讓語言遷就問題,而不是讓問題遷就語言的機會。像 Ruby(它為領域特定語言 (DSL) 提供了比主流更友好的支援)等語言證明了這種潛在可能。現代函式式語言甚至走得更遠。Scala 旨在協調內部 DSL 的託管,並且所有 Lisp(包括 Clojure)都可以提供無與倫比的靈活性,使開發人員能夠讓語言適應問題。例如,清單 5 使用了 Scala 中的 XML 基元來實現 清單 3 的天氣示例:

  清單 5. Scala 的 XML 語法修飾

				
import scala.xml._
import java.net._
import scala.io.Source

val theUrl = "http://weather.yahooapis.com/forecastrss?w=12770744&u=f"

val xmlString = Source.fromURL(new URL(theUrl)).mkString
val xml = XML.loadString(xmlString)

val city = xml \\ "location" \\ "@city"
val state = xml \\ "location" \\ "@region"
val temperature = xml \\ "condition" \\ "@temp"

println(city + ", " + state + " " + temperature)

  Scala 是為獲得可塑性而設計的,它支援操作符過載和隱式型別等擴充套件。在 清單 5 中,Scala 被擴充套件為可以使用 \\ 操作符支援類似 XPath 的查詢。

  與語言的趨勢相一致

  函數語言程式設計的目標之一是最大程度地減少可變狀態。在 清單 1 中,有兩種型別的共享狀態清單。_factors 和 _number 都存在,它們使程式碼測試變得更容易(編寫原始碼版本是為了說明最大可測試性),並可以摺疊成更大的函式,從而消除它們。但是,_sum 是因為各種原因而存在。我預計,這段程式碼的使用者可能需要檢查多個分類。(例如,如果一個完美的檢查失敗,那麼下一次我可能會檢查百分比。)合計係數總數的操作可能很昂貴,所以我為它建立了一個經過惰性初始化的訪問器。在第一次呼叫時,它會計算總和,並將它儲存在 _sum 成員變數中,以便優化未來的呼叫。

  像垃圾收集一樣,現在快取也可以降級用於語言。清單 2 中的 Groovy 數字分類器忽略了 清單 1 中總數的惰性初始化。如果想要實現同樣的功能,可以修改分類器,如清單 6 所示:

  清單 6. 手動新增一個快取

				
class ClassifierCachedSum {
  private sumCache

  ClassifierCachedSum() {
    sumCache = [:]
  }

  def sumOfFactors(number) {
    if (sumCache.containsKey(number))
      return sumCache[number]
    else {
      def sum = factorsOf(number).inject(0, {i, j -> i + j})
      sumCache.putAt(number, sum)
      return sum
    }
  }
  // ... other code omitted

  在最新版的 Groovy 中,清單 6 中的程式碼不再是必要的。考慮使用清單 7 中的改進版的分類器:

  清單 7. 備忘數字分類器

				
class ClassifierMemoized {
  def static dividesBy = { number, potential ->
    number % potential == 0
  }
  def static isFactor = dividesBy.memoize()

  def static factorsOf(number) {
    (1..number).findAll { i -> isFactor.call(number, i) }
  }

  def static sumFactors = { number ->
    factorsOf(number).inject(0, {i, j -> i + j})
  }
  def static sumOfFactors = sumFactors.memoize()

  def static isPerfect(number) {
    sumOfFactors(number) == 2 * number
  }

  def static isAbundant(number) {
    sumOfFactors(number) > 2 * number
  }

  def static isDeficient(number) {
    sumOfFactors(number) < 2 * number
  }
}

  任何純函式(沒有副作用的函式)都可以備忘,比如 清單 7 中的 sumOfFactors() 方法。備忘函式允許執行時快取重複出現的值,從而消除手工編寫快取的需要。事實上,請注意執行實際工作的 getFactors() 和 factors() 方法之間的關係,該方法是備忘版本的getFactors()。Totally Lazy 還為 Java 增加了備忘功能,這是反饋到主流中的另一個高階函式特性。

  由於執行時獲得了更多的能力並且有多餘的開銷,開發人員可以將繁忙的工作割讓給語言,將我們解放出來,去思考更重要的問題。Groovy 中的備忘功能就是眾多示例中的一個;因為基礎執行時允許這樣做,所有現代語言都新增了函式式構造,包括 Totally Lazy 等框架。

  結束語

  因為執行時的能力變得更強,並且語言獲得了更強大的抽象,所以開發世界變得更加函式化,這使開發人員可以花費更多的時間來思考結果的影響,而不是思考如何生成結果。由於高階函式等抽象出現在語言中,它們將成為高度優化的操作的自定義機制。您不需要建立框架來處理問題(如 XML),您可以將其轉換成您已經可以使用工具來處理的資料結構。

  隨著第 20 期文章的釋出,函式式思維 將告一段落,我將準備開始一個新的系列,探索下一代的 JVM 語言。Java 下一代 會讓您對不久的將來有一個大致瞭解,並幫助您對必須投入新語言學習的時間作出明智選擇。

相關文章