函數式編程 - 有趣的Monoid(單位半羣)

前言

Monoid(中文:單位半羣,又名:幺半羣),一個來源於數學的概念;得益於它的抽象特性,Monoid在函數式編程中起着較爲重大的做用。數據庫

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

Semigroup(半羣)

在開始Monoid的表演以前,咱們首先來感覺一下Semigroup(半羣),它在 維基百科上的定義 爲:api

集合S和其上的二元運算·:S×S→S。若·知足結合律,即:∀x,y,z∈S,有(x·y)·z=x·(y·z),則稱有序對(S,·)爲半羣,運算·稱爲該半羣的乘法。實際使用中,在上下文明確的狀況下,能夠簡略敘述爲「半羣S」。數組

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

對於天然數一、二、三、四、五、...而言,加法運算+可將兩個天然數相加,獲得的結果仍然是一個天然數,而且加法是知足結合律的:(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,並進行簡單的使用:app

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,額外的地方是它多了單位元,因此被稱做爲單位半羣單位元維基百科上的定義 爲:less

在半羣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的概念並不複雜,然而函數式編程的哲學就是這樣,但願經過一個個細微的抽象,將它們組合在一塊兒,最終成就了一個更爲龐大的抽象,構建出了一個極其優雅的系統。

相關文章
相關標籤/搜索