1. 程式人生 > >函式式思維: 函式設計模式,第 1 部分

函式式思維: 函式設計模式,第 1 部分

函式世界中的一些經驗主義者認為設計模式的概念有缺陷,在函數語言程式設計中不需要。在模式 的狹義解釋下該觀點可能成立,但這是一個更多關於語義而非使用的論點。設計模式的概念(針對常見問題的指定編目解決方案)是合理的。但是,模式有時在不同的正規化下以不同的形式出現。因為構建塊和問題解決方法在函式世界中是不同的,一些傳統的 Gang of Four 模式(參閱 參考資料)消失了,而其他模式存在問題,但解決問題的方式大相徑庭。本期和下一期將研究一些傳統的設計模式,並以函式式思維從全新角度來思考它們。

在函式程式設計領域,傳統設計模式通常以三種方式之一表現:

  • 模式由語言吸收。
  • 模式解決方案仍然存在於函式正規化中,但是實現細節有所不同。
  • 解決方案使用其他語言或正規化缺乏的功能實現。(例如,許多使用超程式設計的解決方案簡潔且優雅,但無法通過 Java 實現。)

我會依次研究這三種情況,在本期中從一些熟悉的模式入手,大部分模式全部或部分地納入現代語言。

區域性套用 (Currying) 是許多函式語言的一種特性。它是以數學家 Haskell Curry 的名字命名的(Haskell 程式語言也是以該數學家命名),能夠對多引數函式進行轉換,以便將它用作一串單引數函式進行呼叫。與此密切相關的是部分應用 (partial application),該技術可以將固定值分配給函式的一個或多個引數,從而生成另一個更小的元數 (arity)

 函式(元數是函式引數的個數)。我在 “函式式思維:運用函式式思維,第 3 部分” 中討論過這兩種技術。

在設計模式上下文中,區域性套用充當一個函式工廠。函數語言程式設計語言中的一個常見特性是一等(first-class)(或高階)函式,它允許函式充當任何其他資料結構。多虧這一特性,我可以輕鬆建立基於一些條件返回其他函式的函式,這就是工廠的精髓。例如,如果您有一個將兩個數字相加的通用函式,您可以將區域性套用用作一個工廠來建立總是將其引數加 1 的函式,即一個增量器,如清單 1 所示,使用 Groovy 語言實現:


清單 1. 區域性套用作為函式工廠
				
def adder = { x, y -> return x + y }
def incrementer = adder.curry(1)

println "increment 7: ${incrementer(7)}" // prints "increment 7: 8"

在 清單 1 中,我將第一個引數區域性套用為 1,返回一個接受單一引數的函式。本質上,我建立了一個函式工廠。

當您的語言本機支援這種行為時,它往往被用作其他大小物件的構建塊。例如,看看如清單 2 所示的 Scala 示例:


清單 2. Scala 對區域性套用的 “隨意” 使用
				
object CurryTest extends Application {

  def filter(xs: List[Int], p: Int => Boolean): List[Int] =
    if (xs.isEmpty) xs
    else if (p(xs.head)) xs.head :: filter(xs.tail, p)
    else filter(xs.tail, p)

  def dividesBy(n: Int)(x: Int) = ((x % n) == 0)

  val nums = List(1, 2, 3, 4, 5, 6, 7, 8)
  println(filter(nums, dividesBy(2)))
  println(filter(nums, dividesBy(3)))
}

清單 2 中的程式碼是 Scala 文件中遞迴和區域性套用的示例之一(參閱 參考資料)。filter() 方法通過 p 引數以遞迴的方式過濾一個整數列表。p 是一個謂詞函式,函式領域中用於布林函式的一個常見術語。filter() 方法檢檢視列表是否為空,如果為空,就直接返回;否則它通過謂詞檢查列表中的第一個元素(xs.head),以確定是否應將其包含在過濾的列表中。如果它通過謂詞測試,返回的就是一個新列表,其頭在前面,過濾的尾部作為剩餘部分。如果第一個元素沒有通過謂詞測試,返回的就只是列表的已過濾剩餘部分。

從模式角度來看 清單 2 中比較有趣的是在 dividesBy() 方法中對區域性套用的 “隨意” 使用。注意,dividesBy() 接受兩個引數,並根據第二個引數是否均衡地分為第一個引數,返回 true 或 false。但是,當該方法被作為 filter() 方法呼叫的一部分被呼叫時,它只在具有一個引數的情況下被呼叫,呼叫結果是一個區域性套用過的函式,然後該函式被用作 filter() 方法中的謂詞。

本例展示模式在函數語言程式設計中表現的前兩種方式,我在本文開始列出過它們。首先,區域性套用被構建到語言或執行時中,因此函式工廠的概念是生來就有的,且不需要額外的結構。其次,它展示了我對不同實現的觀點。如 清單 2 那樣使用區域性套用可能從來不會在傳統的 Java 程式設計員身上發生;我們從未真正有過可移植程式碼,當然也從未想過從更通用的函式構建特定函式。事實上,在這裡大部分的開發人員不會想到使用一個設計模式,因為從一個更通用的方法建立一個特定的 dividesBy() 方法似乎是一個小問題,而設計模式(很大程度上依賴於結構來解決問題,因而需要大量開銷來實現)似乎是一個針對大問題的解決方案。按照本來意圖使用區域性套用不會證明一個特殊名稱的程式的合理性,除了它已經擁有的名稱。

一等函式大大簡化了許多常用的設計模式。(命令設計模式甚至消失了,因為您不再需要一個針對可移植功能的物件包裝器。)

模板方法

一等函式使模板方法設計模式(參閱 參考資料)更易於實現,因為它們能夠移除可能不需要的結構。模板方法定義一個方法中演算法的框架,把一些步驟委託給子類,並強制他們在不更改演算法結構的情況下定義這些步驟。模板方法的典型實現如清單 3 所示,使用 Groovy 實現:


清單 3. 模板方法的 “標準” 實現
				
abstract class Customer {
  def plan
    
  def Customer() {
    plan = []
  }
    
  def abstract checkCredit()
  def abstract checkInventory()
  def abstract ship()
    
  def process() {
    checkCredit()
    checkInventory()
    ship()
  }
}

在 清單 3 中,process() 方法依賴於 checkCredit()checkInventory() 和 ship() 方法,其定義必須由子類提供,因為它們是抽象方法。

由於一等函式可充當任何其他資料結構,我可以使用程式碼塊重新定義 清單 3 中的示例,如清單 4 所示:


清單 4. 具有一等函式的模板方法
				
class CustomerBlocks {
  def plan, checkCredit, checkInventory, ship
    
  def CustomerBlocks() {
    plan = []
  }
    
  def process() {
    checkCredit()
    checkInventory()
    ship()
  }
}

class UsCustomerBlocks extends CustomerBlocks{
  def UsCustomerBlocks() {
    checkCredit = { plan.add "checking US customer credit" }
    checkInventory = { plan.add "checking US warehouses" }
    ship = { plan.add "Shipping to US address" }
  }
}

在 清單 4 中,演算法中的步驟只是類的屬性,像任何其他屬性一樣是可分配的。在這個示例中,語言特性主要地吸收實現細節。將這一模式看作一個問題的解決方案(把步驟委派給後續的處理程式)仍然很有用,不過實現起來比較簡單。

兩種解決方案不是等同的。在 清單 3 中的 “傳統” 模板方法示例中,抽象類需要子類來實現依賴的方法。當然,子類可能僅建立一個空的方法體,不過抽象方法定義形成一種文件,提醒 subclasser 將其考慮在內。另一方面,死板的方法宣告可能不適合於需要更多靈活性的情景中。例如,我可以建立我的 Customer 類的一個版本,該類接受任何方法列表以供進行處理。

對程式碼塊等功能的深度支援使語言更具有開發人員友好性。考慮這樣一種情況,比如您想讓 subclasser 跳過一些步驟。Groovy 有一種特殊的受保護訪問運算子 (?.),該運算子確保在呼叫一個物件的方法前該物件不為空。考慮清單 5 中的 process() 定義:


清單 5. 新增對程式碼塊呼叫的保護
				
def process() {
  checkCredit?.call()
  checkInventory?.call()        
  ship?.call()
}

在 清單 5 中,實現子類的任何人可以選擇要將程式碼分配哪些子方法,保留其他方法為空。

策略

一等函式簡化的另一種流行設計模式是策略模式。策略定義一系列演算法,封裝每一種演算法並使它們能夠進行互換。它允許演算法隨使用它的客戶不同而有所不同。一等函式使得構建和操作策略更簡單。

用於計算產品數目的策略設計模式的一種傳統實現如清單 6 所示:


清單 6. 為具有兩個數目的產品使用策略設計模式
				
interface Calc {
  def product(n, m)
}

class CalcMult implements Calc {
  def product(n, m) { n * m }
}

class CalcAdds implements Calc {

  def product(n, m) {
    def result = 0
    n.times {
      result += m
    }
    result
  }
}

在 清單 6 中,我為具有兩個數目的產品定義了一個介面。我使用兩個不同的具體類(策略)實現介面:一個使用乘法,另一個使用加法。為測試這些策略,我建立了一個測試用例,如清單 7 所示:


清單 7. 測試產品策略
				
class StrategyTest {
  def listOfStrategies = [new CalcMult(), new CalcAdds()]

  @Test
  public void product_verifier() {
    listOfStrategies.each { s ->
      assertEquals(10, s.product(5, 2))
    }
  }
}

如 清單 7 所示,兩個策略都返回同一個值。將程式碼塊用作一等函式,我可以降低上一個示例的複雜性。考慮乘方策略用例,如清單 8 所示:


清單 8. 以更低的複雜性測試乘方
				
@Test
public void exp_verifier() {
  def listOfExp = [
      {i, j -> Math.pow(i, j)},
      {i, j ->
        def result = i
        (j-1).times { result *= i }
        result
      }]

  listOfExp.each { e ->
    assertEquals(32, e(2, 5))
    assertEquals(100, e(10, 2))
    assertEquals(1000, e(10, 3))
  }
}

在 清單 8 中,我使用 Groovy 程式碼塊直接定義了兩個內聯乘方策略。如 模板方法示例 中所示,我以簡化繁。傳統的方法強制圍繞每個策略使用名稱和結構,有時這是我們需要的。但是,注意,我建議對 清單 8 中的程式碼加入更嚴格的保護措施,鑑於我無法輕鬆繞過更傳統的方法施加的限制,傳統方法是一種動態與靜態對比引數,而非函數語言程式設計與設計模式對比的引數。

受一等函式影響的模式主要是語言吸收的模式示例。接下來,我要展示保持語義但又更改實現的一種模式。

享元模式是一種使用共享來支援大量細粒度物件引用的優化技術。您要保持物件池可用,為特定檢視建立到該池的引用。享元使用一種規範物件(canonical object) 的思想,即一種表示該型別中所有其他物件的代表性物件。例如,您有一個特定的消費產品,產品的規範版本表示該型別的所有產品。在一個應用程式中,不要為每個使用者建立一個產品列表,而要建立一個規範產品列表,每個使用者擁有對他們產品列表的引用。

考慮清單 9 中建模計算機型別的類:


清單 9. 建模計算機型別的簡單類
				
class Computer {
  def type
  def cpu
  def memory
  def hardDrive
  def cd
}

class Desktop extends Computer {
  def driveBays
  def fanWattage
  def videoCard
}

class Laptop extends Computer 
  def usbPorts
  def dockingBay
}

class AssignedComputer {
  def computerType
  def userId

  public AssignedComputer(computerType, userId) {
    this.computerType = computerType
    this.userId = userId
  }
}

在這些類中,比方說為每個使用者建立一個新的 Computer 例項是效率低下的,假定所有計算機具有相同的規格。一個AssignedComputer 會將一種計算機與一個使用者關聯起來。

讓該程式碼更高效的一種常見方式是將工廠和享元模式相結合起來。考慮生成規範計算機型別的單例工廠,如清單 10 所示:


清單 10. 享元計算機例項的單例工廠
				
class ComputerFactory {
  def types = [:]
  static def instance;
  
  private ComputerFactory() {
    def laptop = new Laptop()
    def tower = new Desktop()
    types.put("MacBookPro6_2", laptop)
    types.put("SunTower",  tower)
  }

  static def getInstance() {
    if (instance == null)
      instance = new ComputerFactory()
    instance
  }

  def ofType(computer) {
    types[computer]
  }  
}

ComputerFactory 類構建可能的計算機型別快取,然後通過其 ofType() 方法交付適當的例項。這是一種傳統的單例工廠,因為您使用 Java 編寫它。

但是,單例也是一種設計模式(參閱 參考資料),它是執行時吸收模式的另一個好示例。考慮簡化的 ComputerFactory,其中使用 Groovy 提供的 @Singleton 註釋,如清單 11 所示:


清單 11. 簡化的單例工廠
				
@Singleton class ComputerFactory {
  def types = [:]
  
  private ComputerFactory() {
    def laptop = new Laptop()
    def tower = new Desktop()
    types.put("MacBookPro6_2", laptop)
    types.put("SunTower",  tower)
  }

  def ofType(computer) {
    types[computer]
  }
}

為測試工廠返回規範例項,我編寫了一個單元測試,如清單 12 所示:


清單 12. 測試規範型別
				
@Test
public void flyweight_computers() {
  def bob = new AssignedComputer(ComputerFactory.instance.ofType("MacBookPro6_2"), "Bob")
  def steve = new AssignedComputer(ComputerFactory.instance.ofType("MacBookPro6_2"), 
  "Steve") assertTrue(bob.computerType == steve.computerType)
}

跨例項儲存常見資訊是一個不錯想法,這是我在涉足函數語言程式設計時想保留的想法。但是,實現細節相當不同。這是在更改(更合適的說說是簡化)實現時保留模式語義 的一個示例。

在 函式式思維:Groovy 中的函式式特性,第 3 部分 中,我介紹了記憶體化 特性,它能夠構建到程式語言中,支援自動快取遞迴的函式返回值。換言之,一個記憶體化函式支援執行時為您快取值。Groovy 的最新版本支援記憶體化(參閱 參考資料)。考慮清單 13 中定義的函式:


清單 13. 享元的記憶體化
				
def computerOf = {type ->
  def of = [MacBookPro6_2: new Laptop(), SunTower: new Desktop()]
  return of[type]
}

def computerOfType = computerOf.memoize()

在 清單 13 中,規範型別在 computerOf 函式內定義。為了建立一個函式的記憶體化例項,我直接呼叫 Groovy 執行時定義的 memoize()方法。

清單 14 顯示對比兩種方法呼叫的一個單元測試:


清單 14. 對比方法
				
@Test
public void flyweight_computers() {
  def bob = new AssignedComputer(ComputerFactory.instance.ofType("MacBookPro6_2"), "Bob")
  def steve = new AssignedComputer(ComputerFactory.instance.ofType("MacBookPro6_2"), 
  "Steve") assertTrue bob.computerType == steve.computerType

  def sally = new AssignedComputer(computerOfType("MacBookPro6_2"), "Sally")
  def betty = new AssignedComputer(computerOfType("MacBookPro6_2"), "Betty")
  assertTrue sally.computerType == betty.computerType
} 

最終結果是一樣的,但注意實現細節卻有著巨大差別。對於 “傳統” 設計模式,我建立了一個新類來充當工廠,實現兩個模式。對於函式版本,我實現了一個方法,然後返回了一個記憶體化版本。解除安裝快取等細節到執行時意味著手寫的實現不太可能失敗。在本用例中,我保留了享元模式的語義,但具有一個非常簡單的實現。

結束語

在本期中,我介紹了設計模式的語義在函數語言程式設計中表現的三種方式。首先,它們可以被語言或執行時吸收。我使用工廠、策略、單例和模板方法模式展示了相關示例。其次,模式可保留其語義,但具有完全不同的實現;我展示了使用類和使用記憶體化的享元模式示例。第三,函式語言和執行時可以有完全不同的特性,從而支援它們以完全不同的方式解決問題。

在下一期,我將繼續研究設計模式和函數語言程式設計的交叉,並展示第三種方法的示例。