Swift中的函數式編程:一個不平常且強大的範例 0. 譯者序 1. 概述 2. 目錄

0. 譯者序

        筆者讀了《函數式Swift》、各種網文等,對函數式編程的理解依然有些模糊。
        出現這種現象與以下幾種原因有關:

  • 函數式程序設計所依賴的理論基礎(Lambda演算)很深奧(相對於程序員這種更多是技術型工作的人來說),導致其理論與實現都難於理解
  • 日常接觸的編程語言大多都是命令式的,所以對函數式編程這種是聲明式的編程範式缺乏直觀感受
  • 沒有時間親自動手實踐(^_^,比較懶的委婉說法)

        幸運的是無意中發現的這篇文章——Functional Programming in Swift: an Unusual yet Powerful Paradigm大大提高了我對函數式編程的理解。之前,對基於Swift的函數式編程理解更多的是盲人摸象——瞭解高階函數、不變性、單子、函子等概念。讀了這篇文章,對於函數式編程的理論基礎、適用場景、代碼示例、與OOP的對比等都有比以前深入的理解,基本上能夠達到對“大象”有全貌的認識。
        爲了加深自己的理解,同時也有助於其他網友理解的目的,決定對此文進行翻譯。本文就是藉助Google翻譯的譯文,由於E文不好,盡最大努力做到——信達雅。感興趣的讀者可以看原文。

譯者注
這篇文章的深度依然不高,適合對函數式編程不瞭解的程序猿學習函數式編程的入門級指導。

1. 概述


        自從Swift問世以來,“函數式編程”這個詞語就在iOS社區廣泛流傳開來。
        這是因爲許多Swift語言的特性都是受函數式編程語言的啓發,而被引入到Swift語言。

譯者注
像高階函數、值類型(不可變性)、包裝值(Result等)等這些語言特性。

        這些引入的特性使某些任務更加容易完成。 一個示例是易於配置的網絡請求回調
        雖然Swift不是一種純的函數式編程語言,但是我們可以從函數式編程語言中吸取許多經驗,以便在我們的iOSApp中寫出更好的Swift代碼。

2. 目錄

2.1 Swift是多範式的程序設計語言


        Swift不是純函數式編程語言,它是一種支持多種編程範式的語言。可以使用Swift寫出符合函數式思想的代碼,但是你應該在合適的場景使用函數式編程思想。

2.1.1 Swift不是純函數式編程語言並且iOS的SDK是面向對象的

        在我們探討Swift中的函數編程思想之前,值得一提的是,Swift不是一種純函數式編程語言,同時也不意味着Swift想成爲一種純的函數式編程語言。
        Swift與純粹的函數式語言(例如Haskell)不同,你習慣的面向對象範式與函數式編程範式有很大不同。
        例如,純函數式編程語言沒有像for或while那樣的循環結構(稍後我將向你展示如何在沒有它們的情況下仍然可以編寫程序)。 此外,在這些語言中,您需要了解函子和單子之類的概念。

譯者注
如果想要了解函子、適用函子、單子之類的概念,可以自行百度,這裏就不展開了。

        在Swift中情況並非如此。
        諸如Lisp之類的某些函數式語言是多範例的,因爲它們還允許面向對象的編程。 但是它們始終保持函數式的核心思想,並且像Clojure這樣的Lisp方言避開了傳統的面向對象方法。
        但是,在iOS中,我們必須主要使用面向對象的範例來編寫程序
        一些開發人員試圖將函數式編程概念塞入iOS開發的各個方面,甚至想要視圖控制器中也使用它。
        我發現這樣做是適得其反的。
        你寫的函數式代碼對大多數其他iOS開發人員都不熟悉。 更重要的是,你很快就會遇到平臺的限制。 視圖控制器是具有特定生命週期的類。

譯者注
VC是具有生命週期的,這與函數式編程理念具有嚴重的衝突。

        同時,沒有辦法規避這一點。

2.1.2 函數式編程的支柱:將輸入轉換爲輸出

        在本文中,我將構建一個小型的Eliza聊天機器人,向您展示如何在實際應用中使用函數式編程概念。 您可以在GitHub上找到完整的項目


        Eliza是基於Rogerian心理療法的自然語言處理程序。
        雖然這聽起來可能很複雜,但Eliza所做的只是將你寫的所有內容變成一個問題。 例如,如果你說“我感到難過”,它將詢問諸如“你經常感到難過嗎?”之類的內容。
        Eliza不使用任何像深度學習或神經網絡這樣的奇特的AI算法。 它僅查詢預定義答案的列表。
        Eliza的核心是圍繞函數式編程的支柱構建的:它將輸入轉換爲輸出。 這使它成爲廣泛的函數式編程練習,您可以在網絡上找到許多實現。
        實際上,我將在本文中向你展示的實現是我十年前編寫的用於學習函數式編程的代碼。

2.1.3 以從高層到底層來替代從底層到高層的方式來編寫一個App

        我們將以自上而下的方式實現此應用。 我們將從用戶界面開始,再到視圖控制器級別,最後到底層算法細節。
        我通常在許多文章中都採用自下而上的方法。 這使我更容易分解問題並逐步解釋代碼。
        但是,自上而下編寫代碼也是一種合理的方法,有時甚至是更可取的方法,尤其是在你遵循依賴關係反轉原則的情況下。
        因此,我們將從應用程序的故事板開始。 它僅包含一個簡單的表視圖控制器,該控制器將顯示與Eliza的對話。



        並非所有UI都適合storyboard。 我們的視圖控制器需要設置一個輸入視圖,用戶可以在其中輸入消息。 儘管可以在storyboard要場景中佈置視圖,但我們還需要一些設置代碼。

class ChatViewController: UITableViewController {
    @IBOutlet var messageInputView: UIView!
    @IBOutlet var textField: UITextField!
    
    override var inputAccessoryView: UIView? {
        return messageInputView
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        textField.becomeFirstResponder()
    }
    
    override var canBecomeFirstResponder: Bool {
        return true
    }
}

2.1.4 視圖控制器和其他一些UIKit代碼不能轉換爲函數式

        我們的視圖控制器所需要做的就是填充其表視圖。
        通常,我將爲動態UITableView創建一個單獨的數據源類,但是爲了使此示例保持簡單,我將使用視圖控制器。

class ChatViewController: UITableViewController {
    @IBOutlet var messageInputView: UIView!
    @IBOutlet var textField: UITextField!
    
    var messages: [String] = ["Hello, I'm Eliza. What is bothering you today?"]
    
    override var inputAccessoryView: UIView? {
        return messageInputView
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        textField.becomeFirstResponder()
    }
    
    override var canBecomeFirstResponder: Bool {
        return true
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return messages.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let row = indexPath.row
        let identifier = row.isMultiple(of: 2) ? "ElizaCell" : "UserCell"
        let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! MessageCell
        cell.message = messages[row]
        return cell
    }
}
 
class MessageCell: UITableViewCell {
    @IBOutlet weak var label: UILabel!
    
    var message: String? {
        didSet { label.text = message }
    }
}

        在這裏,我們將聊天消息保存在簡單的字符串數組中。 然後,要爲氣泡使用正確的顏色,我們爲具有偶數索引的行的Eliza單元出隊,爲用戶爲奇數行的單元出隊。 (不要忘記在storyboard中設置單元格標識符。)
        將新消息推送到聊天中很簡單。 我們要做的就是:

  • 在message數組的末尾附加新消息;
  • 在表格視圖的底部插入新行; 和
  • 滾動表格視圖,以使最後一條消息始終可見。
private extension ChatViewController {
    func push(_ message: String) {
        messages.append(message)
        let newMessageIndexPath = IndexPath(row: messages.count - 1, section: 0)
        tableView.insertRows(at: [newMessageIndexPath], with: .fade)
        tableView.scrollToRow(at: newMessageIndexPath, at: .bottom, animated: true)
    }
}

        從較高的視角來看,我們的Eliza聊天機器人所需要做的就是回覆信息。

struct Eliza {
    func reply(to message: String) -> String {
        return ""
    }
}

        最後,當用戶發送消息時,我們將其推送到聊天中,然後推送Eliza的回覆。

class ChatViewController: UITableViewController {
    @IBOutlet var messageInputView: UIView!
    @IBOutlet var textField: UITextField!
    
    var messages: [String] = ["Hello, I'm Eliza. What is bothering you today?"]
    
    override var inputAccessoryView: UIView? {
        return messageInputView
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        textField.becomeFirstResponder()
    }
    
    override var canBecomeFirstResponder: Bool {
        return true
    }
    
    @IBAction func send(_ sender: Any) {
        guard let message = textField.text, !message.isEmpty else {
            return
        }
        push(message)
        textField.text = nil
        push(Eliza().reply(to: message))
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return messages.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let row = indexPath.row
        let identifier = row.isMultiple(of: 2) ? "ElizaCell" : "UserCell"
        let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! MessageCell
        cell.message = messages[row]
        return cell
    }
}

        由於我們是自上而下編寫代碼的,因此,目前Eliza結構的Reply(to :)方法僅返回一個空字符串。
        這使我們可以在專注於Eliza算法之前完成ChatViewController的實現。 在整篇文章中,我將遵循這種方法。

2.2 基於Swift的函數式編程


        Swift的函數式編程範例不只是一種編寫代碼的方式。它是一種使你以一種新的視角思考代碼的不一樣的編程範式。

2.2.1 命令式範式着重於描述程序的工作方式

        在開始編寫功能代碼之前,我們首先需要回答一個基本問題。
        什麼使函數式編程如此不同?
        顧名思義,就是它使用函數。 但是我們已經將所有Swift代碼都放入了函數中。 所以,那不是函數式編程嗎?
        顯然不是,否則我不會寫這篇文章。 區別在於範例。你(以及大多數開發人員)習慣的編程範例稱爲命令式。
        在命令式編程中,我們編寫了一系列更改程序內部狀態的指令。 我們關注的是描述程序的運行方式。
        命令式編程基於稱爲圖靈機的數學計算模型。
        簡單來說,一個圖靈機有以下特點:

  • 具有內部狀態
  • 無限的存儲空間
  • 可以在存儲空間左右移動並進行讀寫操作
  • 一張描述其如何工作的指令
            每條指令讀取機器的狀態和磁帶上的數據。 結果,磁頭可以在磁帶上移動並寫入新數據,並且機器可以更改狀態。



            你不難發現該模型與你編寫的程序之間的相似性。
            我們在上面的ChatViewController中編寫的代碼就是命令式的。 每個方法均由一系列指令組成,這些指令可更改應用程序的內部狀態。

2.2.2 函數式編程關注與程序做什麼而不是怎麼做

        圖靈機不是唯一的數學計算模型。
        還有另一種叫做Lambda演算的方法,它是在Turing機器之前發明的。 雖然公平地說,圖靈機是基於一個世紀前的分析引擎。
        乍一看,lambda演算看起來很奇怪。



        儘管這是一個有趣的話題,但我們無需關心lambda演算的工作原理(如果您想知道爲什麼我對它如此瞭解,我就此做了一篇碩士論文)。
        我們關心的是它基於純函數的數學思想。
        一個純函數:

  • 返回值基於其參數
    它不訪問任何全局變量(如單例,非局部變量(如類或結構的屬性)或任何其他輸入流(文件,設備傳感器等)。
  • 不產生任意的副作用
    它僅返回一個值,而不會在其範圍外改變任何狀態。

        我們在Eliza結構中編寫的平凡的Reply(to :)方法是一個純函數。 我們將在本文中找到更多示例。
        事實證明,圖靈機和Lambda演算是等效的。 這意味着我們還可以基於Lambda微積分建立編程範例,這使我們能夠執行命令式編程中可以做的任何事情。
        這正是Lisp的創建者所做的,他們創建了函數式編程。
        函數式程序不是命令式的,而是聲明性的,這意味着它專注於程序的功能而不是程序的工作方式。

2.2.3 在Swift代碼中使用函數式編程的好處

        此時,出現了一個明顯的問題。
        我們爲什麼要關心呢?
        畢竟,我們已經可以使用命令式範例編寫Swift程序了。 誰在乎很少開發人員使用的精美學術語言。
        事實證明,純函數具有一些理想的屬性。

  • 相同的輸入必定輸出也相同
    這使得它們易於測試。 要測試產生副作用的代碼,我們需要數倍的測試工作和其他複雜的技術。
  • 易於閱讀和理解
    讀取一段代碼使用純函數時,無需擔心其內部實現。 只關心返回值,因爲你確定它不會產生任何副作用。
  • 天生支持併發
    由於它們不訪問任何共享資源,因此不會引起競爭條件的風險。 你可以從並行線程安全地調用它們,而不必依賴鎖或操作隊列,從而避免了死鎖的風險。
            如上所述,Swift不是一種純函數式語言,因此無法使iOS應用程序中的所有函數/方法都純粹。 這就是爲什麼我們的ChatViewController的代碼是命令式的。
            但是,在實現應用程序的業務邏輯時,函數式編程會起作用

2.2.4 通過方法鏈來編寫聲明式的函數式Swift代碼

        現在,我們已經從理論角度瞭解命令式和函數式範式之間的區別,現在讓我們從實踐角度進行研究。
        這種差異不會立即顯現,因此我們將在本文的其餘部分中逐步探索。
        在命令式編程中,我們通常會考慮獲得結果所需經歷的離散步驟。 爲此,我們使用賦值,分支語句和for循環。
        相反,在函數式編程中,我們考慮如何通過一系列連續的函數將輸入轉換爲輸出。
        Eliza算法使用預定義的模式表爲用戶消息創建回覆。
        由於我們必須將用戶消息與其中一種模式進行匹配,因此清理用戶輸入,刪除符號並使所有字母都變爲小寫字母非常有用。

        在這裏,我們可以看到聲明式編程的第一個示例:

  • 從用戶消息中刪除符號
  • 將上一步的結果轉換爲小寫
  • 將上一步的結果轉換爲答覆
struct Eliza {
    func reply(to message: String) -> String {
        let message = message
            .removingAllSymbols()
            .lowercased()
        return transform(message: message)
    }
}
 
private extension Eliza {
    func transform(message: String) -> String {
        return message
    }
}
 
extension String {
    func removingAllSymbols() -> String {
        return self
    }
}

        該代碼之所以具有函數式的特性,是因爲它僅使用純函數來轉換前一個函數的輸出。
        在這裏,你可以看到在Swift中我們有兩種編寫純函數的方法:

  • transform(message :)函數將其輸入作爲參數。
  • 相反,removeingAllSymbols()和lowercased()函數是String類型的方法。 它們的輸入就是被調用的值。 它們在技術上仍然是純函數,因爲它們僅取決於輸入,不會產生副作用。

2.3 函數式編程的核心概念


        函數式編程使用的結構與你在命令式編程中發現的結構不同。 函數式編程不依賴多態性和循環,而使用高階函數進行抽象,而使用map,filter和reduce函數進行迭代。

2.3.1 使用高階函數在函數式編程中進行代碼抽象

        在任何程序中,我們經常需要重用代碼以避免重複。 當然,函數是重用代碼的主要方法。
        當我們重用代碼時,我們使其變得更加抽象,以接受盡可能多的情況。
        但是,僅使用函數作爲一種抽象機制尚不完整。
        在Swift的面向對象或面向協議的程序設計中,我們通過以下方式多種多態類型來使我們的代碼更加抽象:

  • 將方法移到超類或協議中,以便所有子類型都可以繼承它
  • 使用超類,協議和泛型作爲函數參數的類型
            在純函數式語言中,沒有類或協議。 你猜到了,唯一可用的抽象是函數。
            我們仍然可以使用高階函數使代碼更通用。
            高階函數是具有以下特性的之一的函數:
  • 函數的參數是函數
  • 返回值是函數
            當然了,同時具有以上兩個特性,依然是高階函數。
            高階功能可實現函數組合,使你可以將兩個函數組合在一起以創建一個新函數。 在Swift中,你可能已經使用了許多高階函數。
            例如,URLSession的dataTask(with:completionHandler :)方法是一個高階函數。 完成處理程序是在網絡傳輸完成時調用的函數。
            在函數式編程中,有三個重要的高階函數:map,filter和reduce。

譯者注
這裏講到高階函數,可以參考譯者寫的這篇文章——Swift高階函數解析,以加深對高階函數的理解。

2.3.2 filter從序列中刪除特定的元素

        讓我們從filter開始,它是三個常用高階函數中最簡單的一個。
        簡而言之,filter從序列中刪除所有不滿足謂詞的元素。
        更詳細地講,在Swift中,Sequence協議的filter(_ :)函數:

  • 接受一個返回布爾值的謂詞;
  • 返回一個數組,其中包含謂詞爲其返回true的元素。
            例如,我們可以使用過濾器來獲取數組中所有等於或大於另一個數字的數字。
let numbers = [1, 9, 7, 2, 5, 4, 6]
 
numbers.filter { $0 > 3 }
// returns [9, 7, 5, 4, 6]
 
numbers.filter { $0.isMultiple(of: 2) }
// returns [2, 4, 6]

        在我們的Eliza中,可以使用filter把符號從信息中移除。

extension String {
    func removingAllSymbols() -> String {
        return self.filter { character in
            return character
                .unicodeScalars
                .allSatisfy{ CharacterSet.symbols.contains($0) }
        }
    }
}

譯者注
以上代碼中character是一個字符,從人類閱讀角度來說的一個字符;但是,一個字符可能包含多個unicode標量。

        在Swift中字符串可以看成字符的序列,所以filter可以應用在字符串之上。
        在Swift中,沒有直接的方法可以知道字符是否是符號。 因此,在上面的代碼中,謂詞是一個函數序列,當字符的Unicode標量不在符號的CharacterSet中時,該謂詞將返回true。
        allSatisfy(_ :)函數是另一個高階函數。
        在這裏,您可以看到關於如何編寫無循環程序的第一部分解釋。 像filter(_ :)之類的函數會在一個序列上迭代並返回另一個序列,因此我們不需要循環。
        我已經聽到您的反對:“但是,等等,這只是一個把戲! 循環只是隱藏在filter函數中”。

@inlinable
public __consuming func filter(
    _ isIncluded: (Element) throws -> Bool
    ) rethrows -> [Element] {
    return try _filter(isIncluded)
}
 
@_transparent
public func _filter(
    _ isIncluded: (Element) throws -> Bool
    ) rethrows -> [Element] {
    var result = ContiguousArray<Element>()
    var iterator = self.makeIterator()
    while let element = iterator.next() {
        if try isIncluded(element) {
            result.append(element)
        }
    }
    return Array(result)
}

        正如我所說,這只是部分解釋。 稍後我會給你完整的答案。

2.3.3 map函數把序列轉換爲另外一個序列

        一組“迭代”函數中的下一個是map函數。
        簡而言之,map將序列中的所有元素一一轉換並返回結果
        更詳細地講,在Swift中,Sequence協議的`map(_ :)1函數:

  • 接收一個轉換函數;
  • 返回一個數組,其中包含將轉換函數應用於序列的每個元素的結果。
            例如,我們可以使用map(_ :)來計算數組中所有數字的平方。
func square(x: Int) -> Int {
    return x * x
}
 
let numbers = [1, 9, 7, 2, 5, 4, 6]
 
numbers.map(square)
//returns [1, 81, 49, 4, 25, 16, 36]

        Eliza回覆信息時,必須更改人稱代詞和動詞到第一或第二人稱。
        例如,“我不能和父親說話”這樣的句子應該變成“您和父親說話需要什麼?”。 回覆的第一部分來自模板,第二部分來自用戶消息,其中我已更改爲你。
        讓我們開始編寫一個反射單個單詞的函數。 我們可以通過將所有可能的轉換放入字典中來完成此操作,因爲它們並不多。

struct Reflector {
 
}
 
private extension Reflector {
    func reflect(word: String) -> String {
        return StaticData.reflections[word] ?? word
    }
}
 
struct StaticData {}
 
extension StaticData {
    static var reflections: [String: String] {
        return [
            "am": "are",
            "was": "were",
            "i": "you",
            "i'm": "you are",
            "i'd": "you would",
            "i've": "you have",
            "i'll": "you will",
            "my": "your",
            "me": "you",
            "are": "am",
            "you're": "I am",
            "you've": "I have",
            "you'll": "I will",
            "your": "my",
            "yours": "mine",
            "you": "me"
        ]
    }
}

        爲了反射完整的句子,我們可以將其分解爲單詞,然後在它們上面調用reflect(word:_)函數。 之後,我們加入結果數組以返回一個字符串。

struct Reflector {
    func reflect(sentence: String) -> String {
        return sentence
            .components(separatedBy: .whitespaces)
            .map (reflect(word:))
            .joined(separator: " ")
    }
}
 
private extension Reflector {
    func reflect(word: String) -> String {
        return StaticData.reflections[word] ?? word
    }
}

        這樣做可以,但是我們的函數不再是純函數。reflect(word:_)函數不但依賴輸入word,而且依賴StaticData
        由於我們沒有使用純粹的函數式語言,因此我們不必使每個函數都是純函數。 但是,如果您想要或需要,可以使用一個簡單的技巧。
        反射字典只是reflect(word:_)的另一個輸入。 我們只需要明確一點即可。

struct Reflector {
    func reflect(sentence: String) -> String {
        return sentence
            .components(separatedBy: .whitespaces)
            .map { reflect(word: $0, with: StaticData.reflections) }
            .joined(separator: " ")
    }
}
 
private extension Reflector {
    func reflect(word: String, with reflections: [String: String]) -> String {
        return reflections[word] ?? word
    }
}

        reflect(word:with:)現在是純函數了。
        然而reflect(sentence:_)依然不是純函數,你可以使用相同的技巧來實現。但是,總會在一些地方,你將有一個非純函數。這是iOS的App的必然現象。

2.3.4 reduce函數把序列的元素進行結合

        這三個迭代函數中的最後一個是reduce函數。
        簡單來說,reduce函數把序列的元素值結合成一個單一的值。
        詳細來講,在Swift中,Sequence協議的reduce(_:)函數是:

  • 具有一個初始值
  • 具有一個函數,此函數把兩個元素的值累加到一個
  • 迭代序列的元素,並將當前元素與先前組合的結果組合在一起
            在這三個迭代函數中,reduce(_ :)是最難使用且最不常用的函數。 但是還是有必要不時進行。
            例如,我們可以使用reduce(_ :)對一個數組中的所有數字求和或相乘,或者合併一個布爾值列表。
let numbers = [1, 9, 7, 2, 5, 4, 6]
 
numbers.reduce(0, +)
// returns 34
 
numbers.reduce(1, *)
// returns 15120
 
let booleans = [true, false, true, true, false]
 
booleans.reduce(true, { $0 && $1 })
// returns false
 
booleans.reduce(false, { $0 || $1 })
// returns true

        如您所見,初始值取決於您在序列中組合元素的方式。 總而言之,我們從0開始,它是加法運算的標識元素。 在產品中,我們從1開始。
        對於布爾值,&& 僅當所有值都爲true時,operator才返回true,這就是我們的初始值。 || 當至少一個值是true時,operator會返回true,因此我們從false開始。
        reduce(_ :)函數的初始值並不總是很簡單。
        例如,如果您想連接單詞,則僅當你不需要單詞之間的空格時,纔可以從空字符串開始。
        在reflect(sentence :)函數中,我們使用Arrayjoind(separator :)函數將反射詞連接到一個句子中。 這是一個依賴於reduce(_ :)的便捷函數,使我們免於選擇初始值的麻煩。
        如果我們沒有該功能,而必須使用reduce(_ :),則這就是我們寫reflect(sentence :)的方式:

struct Reflector {
    func reflect(sentence: String) -> String {
        let reflected = sentence
            .components(separatedBy: .whitespaces)
            .map { reflect(word: $0, with: StaticData.reflections) }
        return reflected
            .dropFirst()
            .reduce("\(reflected[0])", { $0 + " " + $1 })
    }
}

        爲了避免在句子的開頭放置空格,reduce(_ :)的初始值必須是序列的第一個元素。 這意味着我們只需要在序列的其餘元素上調用reduce(_ :)

2.3.5 Eliza的函數式算法

        現在,我們已經爲我們的應用奠定了基礎,讓我們詳細瞭解一下Eliza算法。
        Eliza聊天機器人基於一組預定義的規則。 每個規則都提供了一些固定的答案,聊天機器人可以使用這些答案將用戶所說的內容轉換爲問題。
        每個規則都有:

  • 用於匹配信息的模式
    每個模式都包含一個或多個通配符(*),它們可以匹配句子的任意部分。 例如,我需要的模式*可以匹配“我需要冰淇淋”或“我需要我父親愛我”之類的句子。
  • 一組回覆
    回覆中還可以包含通配符,該通配符將被匹配的feflect所替代。 例如,“我需要”模式的響應之一是“爲什麼需要?”,它會產生諸如“爲什麼需要冰淇淋?”之類的答案。 或“爲什麼您需要父親愛您?”
            讓我們開始定義規則的類型。
struct Rule {
    let pattern: String
    let replies: [String]
    
    func matchFor(sentence: String) -> String? {
        return nil
    }
}

        matchFor(sentence :)函數將返回模式中通配符匹配的句子部分。 “我需要冰淇淋”和我需要*之間的匹配是“冰淇淋”。
        顯然,規則僅匹配特定類型的句子。 例如,上面的模式與句子“我認爲我很沮喪”不匹配。 這就是爲什麼match(sentence :)返回可選內容的原因。

2.3.6 命令式的代碼有時比函數式的代碼更容易寫

        我們把規則的列表保存在StaticData結構之中。

extension StaticData {
    static var rules: [Rule] = [
            Rule(pattern: "", replies: ["Speak up! I can't hear you."]),
            
            Rule(pattern: "I need *", replies: [
                "Why do you need *?",
                "Would it really help you to get *?",
                "Are you sure you need *?"])
        ]
}

        有太多的規則了,這裏就不一一列舉了,完整的列表請看Xcode Project

        現在,我們可以編寫整個Eliza算法,其中:

  • 尋找與用戶的信息匹配的規則;
  • 選擇一個隨機答覆;
  • 在回覆中映射代詞和動詞;
  • 用映射的匹配替換答案中的通配符
            該算法的描述不是函數式的。 沒關係。 我們仍在編寫Swift。 我們可以使用for循環編寫算法。
private extension Eliza {
    func transform(message: String) -> String {
        for rule in StaticData.rules {
            guard let match = rule.matchFor(sentence: message) else {
                continue
            }
            let reply = Int.random(in: 0 ..< rule.replies.count)
            return rule
                .replies[reply]
                .replacingOccurrences(of: "*", with: Reflector().reflect(sentence: match))
        }
        return "..."
    }
}

        你當然也可以把這些代碼轉換爲函數式的代碼。
        但是,這樣做並非易事。 有時,以循環的方式思考比考慮轉換序列更自然。
        這是transform(message :)的樣子。

private extension Eliza {
    func transform(message: String) -> String {
        return StaticData.rules
            .map { ($0, $0.matchFor(sentence: message)) }
            .first(where: { (rule, result) in result != nil })
            .map { (rule, result) -> String in
                guard let result = result else { return "" }
                return rule.replies[Int.random(in: 0 ..< rule.replies.count)]
                    .replacingOccurrences(of: "*", with: Reflector().reflect(sentence: result))
            } ?? "..."
    }
}

        如你所見,循環需要轉換爲mapfilterreduce的順序應用程序(或派生類,如上面的first(where :)便利函數)。 這不像循環那樣可讀,並且性能也可能更差。
        請注意,最後一個map(_ :)是在Optional類型上而不是在序列上調用的。 在我的Swift可選內容指南中,我對此進行了更詳細的討論。

2.4 遞歸


        在函數式編程中,可以編寫任何命令式編程範式編寫的程序。 這意味着函數式範式需要一種複製循環的機制。
        這種機制是遞歸的,它不僅在函數式編程中有用,而且在處理命令式程序中的複雜數據結構時也很有用。

2.4.1 函數式的標識循環的方法:遞歸

        我們終於要了解函數式編程的核心了。
        怎樣編寫程序可以不用循環?!
        答案就是:遞歸。
        通過遞歸,我們將一個問題分解爲相同類型的較小問題,然後組合其結果來解決
        像往常一樣,示例比定義容易理解。
        遞歸的一個典型示例是計算正整數n的階乘,用n!表示,n是n之前的所有正整數的乘積。
        例如: 5! = 5 ✕ 4 ✕ 3 ✕ 2 ✕ 1 = 120
        這是一個迭代的描述。 接下來,我們可以編寫一個使用循環的命令式函數。
        但是,如果從遞歸的角度來看問題,則整數的階乘就是該整數乘以前一個階乘的階乘。
        因此,我們可以重寫5! 如5✕4!


        在編程中,遞歸函數使用對問題的子集進行調用的結果。

func factorial(of number: Int) -> Int {
    if number == 0 {
        return 1
    }
    return number * factorial(of: number - 1)
}

        注意一個重要的細節。 任何遞歸函數都需要一個或多個基本案例,對此我們有一個預定義的解決方案。 有必要使用基本情況來停止遞歸。 否則,遞歸將無限期進行。
        在我們的factorial(of :)函數中,基本情況爲0。這不是任意的。 數學定義爲0! 是1。
        遞歸是lambda演算再次引入的概念,但它不僅限於函數式編程。
        即使在命令式編程中,遞歸也是解決某些問題的更直觀的方法。 主要是在使用複雜的數據結構(例如鏈表,樹和圖)時纔是正確的。
        你可以使用循環來解決這些問題,但是這比使用遞歸要困難得多。

2.4.2 使用遞歸對序列進行迭代

        由於遞歸是表達循環的函數式方法,因此必須瞭解遞歸如何在序列上起作用。
        我們將從一個簡單的例子開始。 假設我們要編寫一個函數,該函數把一個數字數組的元素加倍。
        遞歸包括解決同一問題的較小部分併合並解決方案。
        在這種情況下,我們能想到的最小問題是什麼?
        空數組。
        從那裏開始,我們可以一次將問題擴大一個步驟。

  • 將空數組加倍會產生一個空數組。 這是我們的基本情況。
  • 將數字數組加倍意味着將第一個元素加倍,並在其後面附加數組其餘部分的兩倍。
            在某個時候,遞歸將到達數組的末尾,其餘元素爲空數組。 這是停止遞歸的基本情況。
func double(of numbers: [Int]) -> [Int] {
    if numbers.isEmpty {
        return []
    }
    let first = numbers[0] * 2
    return [first] + double(of: Array(numbers.dropFirst()))
}
 
double(of: [1, 2, 3, 4, 5])
// retunrs [2, 4, 6, 8, 10]

        一般而言,這是你對序列進行遞歸迭代的方式。
        你將處理應用於第一個元素,並將其與對其餘項目的遞歸調用結合在一起。 要停止遞歸,需要爲空序列提供一個基本案例。
        例如,我們現在可以不使用循環就重寫map(_ :)函數。

extension Array {
    func recursiveMap<T>(transform: (Element) -> T) -> [T] {
        if self.isEmpty {
            return []
        }
        let first = transform(self[0])
        return [first] + Array(self.dropFirst()).recursiveMap(transform: transform)
    }
}
 
let numbers = [1, 2, 3, 4, 5]
numbers.recursiveMap { $0 * 3 }
// returns [3, 6, 9, 12, 15]

2.4.3 遞歸表達問題的解決方案

        現在你已經知道遞歸的要點,我們將爲Eliza實現匹配算法。 這不是一個小問題,通過遞歸比使用循環更容易解決。
        在繼續之前,有個警告——此功能將很複雜。
        可以說,這不是學習遞歸的最直接的例子,但是可以在網上找到很多這樣的例子。
        只有複雜的問題,才能瞭解遞歸的功能。 如果想熟悉這個概念,則必須通過簡單的示例並嘗試解決更棘手的問題。
        首先,我們需要一個遞歸定義,以找到模式和句子之間的匹配。 通常,僅當以下情況之間存在匹配

  • 可以將句子的一部分放入模式的通配符中
  • 句子中的其他字母與模式完全匹配。



            由此,我們可以對將使用遞歸的方式有所瞭解:每個字符串中的字符。

2.4.4 定義遞歸的終止事例

        匹配算法特別複雜,因爲我們有兩個需要使用遞歸的算法。
        這也意味着我們將在函數中包含許多基本案例和許多遞歸調用,以涵蓋所有可能的組合。
        讓我們開始定義基本情況。
        我們在兩個字符串上使用遞歸,因此當我們到達一個或兩個的結尾時,我們需要一個基本的情況。

  1. 如果兩個字符串都爲空,則它們匹配。 因此,匹配項爲空字符串。
  2. 如果兩個字符串之一爲空,但另一個不是,則沒有匹配項。 在這種情況下,我們返回nil。
struct Rule {
    let pattern: String
    let replies: [String]
    
    func matchFor(sentence: String) -> String? {
        return match(between: pattern.lowercased(), and: sentence)
    }
}
 
private extension Rule {
    func match(between pattern: String, and sentence: String) -> String? {
        switch (pattern, sentence) {
        case ("", ""): return ""
        case ("", _), (_, ""): return nil
        default: return ""
        }
    }
}

        一些注意事項:

  • matchFor(sentence :)方法僅使用一個參數,因爲它使用Rule結構的pattern屬性。 由於我們需要對兩個參數使用遞歸,因此我們需要一個額外的match(between:and :)方法。
  • 我正在使用帶模式匹配的switch語句,這是功能語言的另一個常見功能。

2.4.5 使用尾調遞歸來解決問題

        現在讓我們進入遞歸調用。
        我們的遞歸僅對每個字符串的第一個字符進行操作。 這給了我們兩種組合:模式的第一個字符是或者不是通配符。
        這是簡單的部分。 每個案例都有其子案例。
        我們將從模式的第一個字符不是通配符開始,因爲這是兩者中最簡單的一個。

  1. 如果模式字符與句子中的第一個字符不同,則沒有匹配項。
  2. 否則,我們將在其餘兩個字符串中尋找匹配項。



            通常,使用遞歸時,可以通過兩種方式找到問題的解決方案:

  • 解決方案是遞歸調用的結果,或者
  • 解決方案是根據遞歸調用的結果和其他一些值組合而成的。
            在這種情況下,我們使用兩個選項中的第一個,稱爲尾調用。 函數式語言使用一種稱爲尾部調用優化的技術來使遞歸與其他語言中的循環一樣有效。
            有時可以將非尾調用遞歸轉換爲尾調用遞歸,但是Swift不能保證尾調用的優化,因此煩惱沒有多大意義。
            我們針對這種情況的代碼如下:
private extension Rule {
    func match(between pattern: String, and sentence: String) -> String? {
        switch (pattern, sentence) {
        case ("", ""): return ""
        case ("", _), (_, ""): return nil
        case (let pattern, let sentence) where pattern.first != "*":
            return pattern.first == sentence.first
                ? match(between: pattern.droppingFirst(), and: sentence.droppingFirst())
                : nil
        default: return ""
        }
    }
}
 
extension String {
    func droppingFirst() -> String {
        return String(self.dropFirst())
    }
}

        在這裏,我使用了更簡潔的三元運算符,但是如果願意,可以使用普通的if語句。
        在函數式語言中,所有語句都返回值,因此不需要return關鍵字。 在這裏使用三元運算符看起來“功能更強大”。
        在撰寫本文時,正在積極審查一項Swift提案,以使return關鍵字在某些情況下是可選的。

2.4.6 遞歸調用後組裝問題的解決方案

        當模式的第一個字符是通配符時,發生第二個遞歸情況。
        並非所有模式的末尾都有通配符。 例如,有一條規則可以檢測您提及“母親”一詞的任何句子。

extension StaticData {
    static var rules: [Rule] = [
        Rule(pattern: "", replies: ["Speak up! I can't hear you."]),
        
        Rule(pattern: "I need *", replies: [
            "Why do you need *?",
            "Would it really help you to get *?",
            "Are you sure you need *?"]),
        
        Rule(pattern: "* mother *", replies: [
            "Tell me more about your mother.",
            "What was your relationship with your mother like?",
            "How do you feel about your mother?",
            "How does this relate to your feelings today?",
            "Good family relations are important."])
    ]
}

        與其他規則不同,此規則在通配符後包含更多文本。 這意味着在我們的匹配算法中,當找到通配符時,我們必須查看兩個字符串的其餘部分以查看是否存在匹配項。

  • 如果兩個字符串的其餘部分產生匹配項,則該匹配項是句子的第一個字符。
  • 否則,通配符可能會匹配更多字母。 在此,匹配由句子的第一個字符以及整個模式與句子其餘部分之間的匹配組成。
  • 如果以上兩種情況均不成立,則沒有匹配項。
            這是算法中最難的部分。 僅當您同時考慮所有情況時,該解決方案纔有意義。
            當找到通配符時,我們將在句子上前進,直到達到最長的匹配,從而允許模式結尾與句子匹配。



            這是一個非常複雜的問題的示例,可以通過遞歸更輕鬆地解決。 這不是尾遞歸,也沒有辦法做到。

private extension Rule {
    func match(between pattern: String, and sentence: String) -> String? {
        switch (pattern, sentence) {
        case ("", ""): return ""
        case ("", _), (_, ""): return nil
        case (let pattern, let sentence) where pattern.first != "*":
            return pattern.first == sentence.first
                ? match(between: pattern.droppingFirst(), and: sentence.droppingFirst())
                : nil
        default:
            if let _ = match(between: pattern.droppingFirst(), and: sentence.droppingFirst()) {
                return String(sentence.first!)
            } else if let longMatch = match(between: pattern, and: sentence.droppingFirst()) {
                return String(sentence.first!) + longMatch
            } else {
                return nil
            }
        }
    }
}

        我花了一些時間來整理這段代碼。
        我甚至不知道從哪裏開始使用for循環編寫此算法。 因此,不用擔心,如果你不明白的話。 希望您仍然從本文的其餘部分中學到很多東西。
        我們的Eliza算法終於完成了。


2.5 結論

        如我們所見,函數式編程具有一些與可讀性,測試和併發性相關的理想屬性。
        因此,儘管Swift不是一種純函數式語言,但你可以通過以一種功能風格編寫部分代碼來獲得一些好處。
        像其他任何東西一樣,它可能會失控。

  • 某些代碼以命令式格式更具可讀性
    你可以嘗試在所編寫的任何代碼上強制使用函數式範例,但是在某些情況下,這只是徒勞的。
  • 還請記住,大多數Swift開發人員只熟悉命令性代碼
    新的團隊成員可能很難理解您的功能代碼,尤其是如果您開始使用自定義運算符或Swift功能編程庫時。
  • 最後,遞歸併不總是最有效的代碼編寫方式
    當然,通常您的數據是如此之小,以至於沒有明顯的差異。 儘管如此,僅在尾部遞歸的情況下,遞歸的複雜性纔可與循環之一相媲美。 使用循環的命令式解決方案性能更高,因此僅在使問題更易於管理時才使用遞歸。
            就個人而言,我以命令式的方式編寫了大多數代碼,但是我確實在有意義的地方編寫了一些函數式代碼。 總的來說,我使用應用程序體系結構中的函數式編程概念來獲得我上面列出的好處。
            一個明確的示例是,遵循MVC模式,將值類型用於應用程序的模型。 此外,在SwiftUI中,視圖也是值類型,因此MVC模式更爲關鍵。 您可以在下面的免費指南中找到原因。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章