1. 程式人生 > >函數語言程式設計 - 有趣的Monoid(單位半群)

函數語言程式設計 - 有趣的Monoid(單位半群)

前言

Monoid(中文:單位半群,又名:么半群),一個來源於數學的概念;得益於它的抽象特性,Monoid在函數語言程式設計中起著較為重大的作用。

本篇文章將會以工程的角度去介紹Monoid的相關概念,並結合幾個有趣的資料結構(如MiddlewareWriter)來展現Monoid自身強大的能力及其實用性。

Semigroup(半群)

在開始Monoid的表演之前,我們首先來感受一下Semigroup(半群),它在 維基百科上的定義 為:

集合S和其上的二元運算·:S×S→S。若·滿足結合律,即:∀x,y,z∈S,有(x·y)·z=x·(y·z),則稱有序對(S,·)為半群,運算·稱為該半群的乘法。實際使用中,在上下文明確的情況下,可以簡略敘述為“半群S”。

上面的數學概念比較抽象,理解起來可能比較麻煩。下面結合一個簡單的例子來通俗說明:

對於自然數1、2、3、4、5、...而言,加法運算+可將兩個自然數相加,得到的結果仍然是一個自然數,並且加法是滿足結合律的:(2 + 3) + 4 = 2 + (3 + 4) = 9。如此一來我們就可以認為自然數和加法運算組成了一個半群。類似的還有自然數與乘法運算等。

通過以上的例子,半群的概念非常容易就能理解,下面我通過Swift語言的程式碼來對Semigroup進行實現:

// MARK: - Semigroup

infix operator <> : AdditionPrecedence
protocol Semigroup { static func <> (lhs: Self, rhs: Self) -> Self } 複製程式碼

協議Semigroup中聲明瞭一個運算方法,該方法的兩個引數與返回值都是同一個實現了半群的型別。我們通常將這個運算稱為append

以下為StringArray型別實現Semigroup,並進行簡單的使用:

extension String: Semigroup {
    static func <> (lhs: String, rhs: String) -> String {
        return
lhs + rhs } } extension Array: Semigroup { static func <> (lhs: [Element], rhs: [Element]) -> [Element] { return lhs + rhs } } func test() { let hello = "Hello " let world = "world" let helloWorld = hello <> world let one = [1,2,3] let two = [4,5,6,7] let three = one <> two } 複製程式碼

Monoid(單位半群)

定義

Monoid本身也是一個Semigroup,額外的地方是它多了單位元,所以被稱作為單位半群單位元維基百科上的定義 為:

在半群S的集合S上存在一元素e,使得任意與集合S中的元素a都符合 a·e = e·a = a

舉個例子,在上面介紹Semigroup的時候提到,自然數跟加法運算組成了一個半群。顯而易見的是,自然數0跟其他任意自然數相加,結果都是等於原來的數:0 + x = x。所以我們可以把0作為單位元,加入到由自然數和加法運算組成的半群中,從而得到了一個單位半群。

下面就是Monoid在Swift中的定義:

protocol Monoid: Semigroup {
    static var empty: Self { get }
}
複製程式碼

可以看到,Monoid協議繼承自Semigroup,並且用empty靜態屬性來代表單位元

我們再為StringArray型別實現Monoid,並簡單演示其使用:

extension String: Monoid {
    static var empty: String { return "" }
}

extension Array: Monoid {
    static var empty: [Element] { return [] }
}

func test() {
let str = "Hello world" <> String.empty // Always "Hello world"
let arr = [1,2,3] <> [Int].empty // Always [1,2,3]
}
複製程式碼

組合

對於有多個Monoid的連續運算,我們現在寫出來的程式碼是:

let result = a <> b <> c <> d <> e <> ...
複製程式碼

Monoid的數量居多,又或者它們是被包裹在一個數組或Sequence中,我們就很難像上面那樣一直在寫鏈式運算,不然程式碼會變得複雜難堪。此時可以基於Sequencereduce方法來定義我們的Monoid串聯運算concat

extension Sequence where Element: Monoid {
    func concat() -> Element {
        return reduce(Element.empty, <>)
    }
}
複製程式碼

如此一來我們就可以很方便地為位於陣列或Sequence中的若干個Monoid進行串聯運算:

let result = [a, b, c, d, e, ...].concat()
複製程式碼

條件

在開始討論Monoid的條件性質前,我們先引入一個十分簡單的資料結構,其主要是用於處理計劃中即將執行的某些任務,我把它命名為Todo

struct Todo {
    private let _doIt: () -> ()
    init(_ doIt: @escaping () -> ()) {
        _doIt = doIt
    }
    func doIt() { _doIt() }
}
複製程式碼

它的使用很簡單:我們先通過一個即將要處理的操作來構建一個Todo例項,然後在適當的時機呼叫doIt方法即可:

func test() {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    // Wait a second...

    sayHello.doIt()
}
複製程式碼

這裡還未能體現到它的強大,接下來我們就為它實現Monoid

extension Todo: Monoid {
    static func <> (lhs: Todo, rhs: Todo) -> Todo {
        return .init {
            lhs.doIt()
            rhs.doIt()
        }
    }

    static var empty: Todo {
        // Do nothing
        return .init { }
    }
}
複製程式碼

append運算中我們返回了一個新的Todo,它需要做的事情就是先後完成左右兩邊傳入的Todo引數各自的任務。另外,我們把一個什麼都不做的Todo設為單位元,這樣就能滿足Monoid的定義。

現在,我們就可以把多個Todo串聯起來,下面就來把玩一下:

func test() {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    let likeSwift = Todo {
        print("I like Swift.")
    }

    let likeRust = Todo {
        print("And also Rust.")
    }

    let todo = sayHello <> likeSwift <> likeRust

    todo.doIt()
}
複製程式碼

有時候,任務是按照某些特定條件來判斷是否被執行,比如像上面的test函式中,我們需要根據特定的條件來判斷是否要執行三個Todo,重新定義函式簽名:

func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool)

為了能夠實現這種要求,通常來說有以下兩種較為蛋疼的做法:

// One
func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool) {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    let likeSwift = Todo {
        print("I like Swift.")
    }

    let likeRust = Todo {
        print("And also Rust.")
    }

    var todo = Todo.empty
    if shouldSayHello {
        todo = todo <> sayHello
    }
    if shouldLikeSwift {
        todo = todo <> likeSwift
    }
    if shouldLikeRust {
        todo = todo <> likeRust
    }

    todo.doIt()
}

// Two
func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool) {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    let likeSwift = Todo {
        print("I like Swift.")
    }

    let likeRust = Todo {
        print("And also Rust.")
    }

    var arr: [Todo] = []
    if shouldSayHello {
        arr.append(sayHello)
    }
    if shouldLikeSwift {
        arr.append(likeSwift)
    }
    if shouldLikeRust {
        arr.append(likeRust)
    }
    arr.concat().doIt()
}
複製程式碼

這兩種寫法都略為複雜,並且還引入了變數,程式碼一點都不優雅。

這時,我們就可以為Monoid引入條件判斷:

extension Monoid {
    func when(_ flag: Bool) -> Self {
        return flag ? self : Self.empty
    }

    func unless(_ flag: Bool) -> Self {
        return when(!flag)
    }
}
複製程式碼

when方法中,如果傳入的布林值為true,那麼此方法將會原封不動地把自己返回,而如果傳入了false,函式則返回一個單位元,相當於丟棄掉現在的自己(因為單位元跟任意元素進行append運算結果都是元素本身)。unless方法則只是簡單地互換一下when引數中的布林值。

現在,我們就能優化一下剛剛test函式的程式碼:

func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool) {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    let likeSwift = Todo {
        print("I like Swift.")
    }

    let likeRust = Todo {
        print("And also Rust.")
    }

    let todo = sayHello.when(shouldSayHello) <> likeSwift.when(shouldLikeSwift) <> likeRust.when(shouldLikeRust)
    todo.doIt()
}
複製程式碼

比起之前的兩種寫法,這裡優雅了不少。

一些實用的Monoid

接下來我將介紹幾個實用的Monoid,它們能用在日常的專案開發上,讓你的程式碼可讀性更加簡潔清晰,可維護性也變得更強(最重要是優雅)。

Middleware(中介軟體)

Middleware結構非常類似於剛剛在文章上面提到的Todo:

struct Middleware<T> {
    private let _todo: (T) -> ()
    init(_ todo: @escaping (T) -> ()) {
        _todo = todo
    }
    func doIt(_ value: T) {
        _todo(value)
    }
}

extension Middleware: Monoid {
    static func <> (lhs: Middleware, rhs: Middleware) -> Middleware {
        return .init {
            lhs.doIt($0)
            rhs.doIt($0)
        }
    }

    // Do nothing
    static var empty: Middleware { return .init { _ in } }
}
複製程式碼

比起TodoMiddlewaretodo閉包上設定了一個引數,引數的型別為Middleware中定義了的泛型。

Middleware的作用就是讓某個值通過一連串的中介軟體,這些中介軟體所做的事情各不相同,它們可能會對值進行加工,或者完成一些副作用(打Log、資料庫操作、網路操作等等)。Monoidappend操作將每個中介軟體組合在一起,形成一個統一的入口,最終我們只需將值傳入這個入口即可。

接下來就是一個簡單使用到Middleware的例子,假設我們現在需要做一個對富文字NSAttributedString進行裝飾的解析器,在裡面我們可以根據需要來為富文字提供特定的裝飾(修改字型、前景或背景顏色等),我們可以這樣定義:

// MARK: - Parser
typealias ParserItem = Middleware<NSMutableAttributedString>

func font(size: CGFloat) -> ParserItem {
    return ParserItem { str in
        str.addAttributes([.font: UIFont.systemFont(ofSize: size)], range: .init(location: 0, length: str.length))
    }
}

func backgroundColor(_ color: UIColor) -> ParserItem {
    return ParserItem { str in
        str.addAttributes([.backgroundColor: color], range: .init(location: 0, length: str.length))
    }
}

func foregroundColor(_ color: UIColor) -> ParserItem {
    return ParserItem { str in
        str.addAttributes([.foregroundColor: color], range: .init(location: 0, length: str.length))
    }
}

func standard(withHighlighted: Bool = false) -> ParserItem {
    return font(size: 16) <> foregroundColor(.black) <> backgroundColor(.yellow).when(withHighlighted)
}

func title() -> ParserItem {
    return font(size: 20) <> foregroundColor(.red)
}

extension NSAttributedString {
    func parse(with item: ParserItem) -> NSAttributedString {
        let mutableStr = mutableCopy() as! NSMutableAttributedString
        item.doIt(mutableStr)
        return mutableStr.copy() as! NSAttributedString
    }
}

func parse() {
    let titleStr = NSAttributedString(string: "Monoid").parse(with: title())
    let text = NSAttributedString(string: "I love Monoid!").parse(with: standard(withHighlighted: true))
}
複製程式碼

如上程式碼,我們首先定義了三個最基本的中介軟體,分別可用來為NSAttributedString裝飾字型、背景顏色和前景顏色。standardtitle則將基本的中介軟體進行組合,這兩個組合體用於特定的情境下(為作為標題和作為正文的富文字裝飾),最終文字的解析則通過呼叫指定中介軟體來完成。

通過以上的例子我們可以認識到:TodoMiddleware都是一種對行為的抽象,它們之間的區別在於Todo在行為的處理中並不接收外界的資料,而Middleware可從外界獲取某種對行為的輸入。

Order

試想一下我們平時的開發中會經常遇到以下這種問題:

if 滿足條件1 {
    執行優先順序最高的操作...
} else if 滿足條件2 {
    執行優先順序第二的操作
} else if 滿足條件3 {
    執行優先順序第三的操作
} else if 滿足條件4 {
    執行優先順序第四的操作
} else if ...
複製程式碼

這裡可能存在一個問題,那就是優先順序的情況。假設某一天程式要求修改將某個分支操作的優先順序,如將優先順序第三的操作提升到最高,那此時我們不得不改動大部分的程式碼來完成這個要求:比方說將兩個或多個if分支程式碼的位置互換,這樣改起來那就很蛋疼了。

Order就是用於解決這種與條件判斷相關的優先順序問題:

// MARK: - Order
struct Order {
    private let _todo: () -> Bool
    init(_ todo: @escaping () -> Bool) {
        _todo = todo
    }
    
    static func when(_ flag: Bool, todo: @escaping () -> ()) -> Order {
        return .init {
            flag ? todo() : ()
            return flag
        }
    }
    
    @discardableResult
    func doIt() -> Bool {
        return _todo()
    }
}

extension Order: Monoid {
    static func <> (lhs: Order, rhs: Order) -> Order {
        return .init {
            lhs.doIt() || rhs.doIt()
        }
    }

    // Just return false
    static var empty: Order { return .init { false } }
}
複製程式碼

在構建Order的時候,我們需要傳入一個閉包,在閉包中我們將處理相關的邏輯,並返回一個布林值,若此布林值為true,則代表此Order的工作已經完成,那麼之後優先順序比它低的Order將不做任何事情,若返回false,代表在這個Order裡面我們並沒有做好某個操作(或者說某個操作不符合執行的要求),那麼接下來優先順序比它低的Order將會嘗試去執行自身的操作,然後按照這個邏輯一直下去。

Order的優先順序是通過它們排列的順序決定的,比方說let result = orderA <> orderB <> orderC,那麼優先順序就是orderA > orderB > orderC,因為我們在定義append的時候使用到了短路運算子||

靜態方法when能夠更加簡便地通過一個布林值和一個無返回值閉包來構建Order,日常開發可自行選擇使用Order本身的建構函式還是when方法。

func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool) {
    let sayHello = Order.when(shouldSayHello) {
        print("Hello, I'm Tangent!")
    }
    
    let likeSwift = Order.when(shouldLikeSwift) {
        print("I like Swift.")
    }
    
    let likeRust = Order.when(shouldLikeRust) {
        print("And also Rust.")
    }
    
    let todo = sayHello <> likeSwift <> likeRust
    todo.doIt()
}
複製程式碼

如上面例子中,三個Order的操作要麼全部都不會執行,要麼就只有一個被執行,這取決於when方法傳入的布林值,執行的優先順序按照append運算的先後順序。

Array

文章已在之前為Array實現了Monoid,那麼Array在日常的開發中如何可以利用Monoid的特性呢,我們來看下面的這個程式碼:

class ViewController: UIViewController {
    func setupNavigationItem(showAddBtn: Bool, showDoneBtn: Bool, showEditBtn: Bool) {
        var items: [UIBarButtonItem] = []
        if showAddBtn {
            items.append(UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add)))
        }
        if showDoneBtn {
            items.append(UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)))
        }
        if showEditBtn {
            items.append(UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(edit)))
        }
        navigationItem.rightBarButtonItems = items
    }
    
    @objc func add() { }
    @objc func done() { }
    @objc func edit() { }
}
複製程式碼

就像在之前講Todo那樣,這樣的程式碼寫法的確不優雅,為了給ViewController設定rightBarButtonItems,我們首先得宣告一個數組變數,然後再根據每個條件去給陣列新增元素。這樣的程式碼是沒有美感的!

我們通過使用ArrayMonoid特性來重構一下上面的程式碼:

class ViewController: UIViewController {
    func setupNavigationItem(showAddBtn: Bool, showDoneBtn: Bool, showEditBtn: Bool) {
        let items = [UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add))].when(showAddBtn)
            <> [UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done))].when(showDoneBtn)
            <> [UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(edit))].when(showEditBtn)
        navigationItem.rightBarButtonItems = items
    }
    
    @objc func add() { }
    @objc func done() { }
    @objc func edit() { }
}
複製程式碼

這下子就優雅多了~

Writer Monad

Writer Monad是一個基於MonoidMonad(單子),旨在執行操作的過程中去順帶記錄特定的資訊,如Log或者歷史記錄。若你不瞭解Monad沒有關係,這裡不會過多提及與它的相關,在閱讀程式碼時你只需要搞清楚其中的實現原理即可。

// MARK: - Writer
struct Writer<T, W: Monoid> {
    let value: T
    let record: W
}

// Monad
extension Writer {
    static func `return`(_ value: T) -> Writer {
        return Writer(value: value, record: W.empty)
    }
    
    func bind<O>(_ tran: (T) -> Writer<O, W>) -> Writer<O, W> {
        let newOne = tran(value)
        return Writer<O, W>(value: newOne.value, record: record <> newOne.record)
    }
    
    func map<O>(_ tran: (T) -> O) -> Writer<O, W> {
        return bind { Writer<O, W>.return(tran($0)) }
    }
}

// Use it
typealias LogWriter<T> = Writer<T, String>
typealias Operation<T> = (T) -> LogWriter<T>

func add(_ num: Int) -> Operation<Int> {
    return { Writer(value: $0 + num, record: "\($0)\(num), ") }
}
func subtract(_ num: Int) -> Operation<Int> {
    return { Writer(value: $0 - num, record: "\($0)\(num), ") }
}
func multiply(_ num: Int) -> Operation<Int> {
    return { Writer(value: $0 * num, record: "\($0)\(num), ") }
}
func divide(_ num: Int) -> Operation<Int> {
    return { Writer(value: $0 / num, record: "\($0)\(num), ") }
}

func test() {
    let original = LogWriter.return(2)
    let result = original.bind(multiply(3)).bind(add(2)).bind(divide(4)).bind(subtract(1))
    // 1
    print(result.value)
    // 2乘3, 6加2, 8除4, 2減1,
    print(result.record)
}
複製程式碼

Writer為結構體,其中包含著兩個資料,一個是參與運算的值,型別為泛型T,一個是運算時所記錄的資訊,型別為泛型W,並且需要實現Monoid

return靜態方法能夠建立一個新的Writer,它需要傳入一個值,這個值將直接儲存在Writer中。得益於Monoid單位元的特性,return在構建Writer的過程中直接將empty設定為Writer所記錄的資訊。

bind方法所要做的就是通過傳入一個能將運算值轉化成Writer的閉包來對原始Writer進行轉化,在轉化的過程中bind將記錄資訊進行append,這樣就能幫助我們自動進行資訊記錄。

map方法通過傳入一個運算值的對映閉包,將Writer內部的運算值進行轉換。

其中,map運算來源於函數語言程式設計概念Functorreturnbind則來源於Monad。大家如果對此有興趣的可以查閱相關的內容,或者閱讀我在之前寫的有關於這些概念的文章。

利用Writer Monad,我們就可以專心於編寫程式碼的業務邏輯,而不必花時間在一些資訊的記錄上,Writer會自動幫你去記錄。

這篇文章沒有提及到的Monoid還有很多,如AnyAllOrdering ...,大家可以通過查閱相關文件來進行。

對於Monoid來說,重要的不是在於去了解它相關的實現例子,而是要深刻地理解它的抽象概念,這樣我們才能說認識Monoid,才能舉一反三,去定義屬於自己的Monoid例項。

事實上Monoid的概念並不複雜,然而函數語言程式設計的哲學就是這樣,希望通過一個個細微的抽象,將它們組合在一起,最終成就了一個更為龐大的抽象,構建出了一個極其優雅的系統。