前言
Monoid
(中文:單位半群
,又名:幺半群
),一個來源于數學的概念;得益于它的抽象特性,Monoid
在函數式編程中起著較為重大的作用。
本篇文章將會以工程的角度去介紹Monoid
的相關概念,并結合幾個有趣的數據結構(如Middleware
、Writer
)來展現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
。
以下為String
和Array
類型實現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
靜態屬性來代表單位元
。
我們再為String
和Array
類型實現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中,我們就很難像上面那樣一直在寫鏈式運算,不然代碼會變得復雜難堪。此時可以基于Sequence
的reduce
方法來定義我們的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 } }
}
比起Todo
,Middleware
在todo
閉包上設置了一個參數,參數的類型為Middleware
中定義了的泛型。
Middleware
的作用就是讓某個值通過一連串的中間件,這些中間件所做的事情各不相同,它們可能會對值進行加工,或者完成一些副作用(打Log、數據庫操作、網絡操作等等)。Monoid
的append
操作將每個中間件組合在一起,形成一個統一的入口,最終我們只需將值傳入這個入口即可。
接下來就是一個簡單使用到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
裝飾字體、背景顏色和前景顏色。standard
和title
則將基本的中間件進行組合,這兩個組合體用于特定的情境下(為作為標題和作為正文的富文本裝飾),最終文字的解析則通過調用指定中間件來完成。
通過以上的例子我們可以認識到:Todo
和Middleware
都是一種對行為的抽象,它們之間的區別在于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,我們首先得聲明一個數組變量,然后再根據每個條件去給數組添加元素。這樣的代碼是沒有美感的!
我們通過使用Array
的Monoid
特性來重構一下上面的代碼:
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
是一個基于Monoid
的Monad(單子)
,旨在執行操作的過程中去順帶記錄特定的信息,如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
運算來源于函數式編程概念Functor
,return
和bind
則來源于Monad
。大家如果對此有興趣的可以查閱相關的內容,或者閱讀我在之前寫的有關于這些概念的文章。
利用Writer Monad
,我們就可以專心于編寫代碼的業務邏輯,而不必花時間在一些信息的記錄上,Writer
會自動幫你去記錄。
尾
這篇文章沒有提及到的Monoid
還有很多,如Any
、All
、Ordering
...,大家可以通過查閱相關文檔來進行。
對于Monoid
來說,重要的不是在于去了解它相關的實現例子,而是要深刻地理解它的抽象概念,這樣我們才能說認識Monoid
,才能舉一反三,去定義屬于自己的Monoid
實例。
事實上Monoid
的概念并不復雜,然而函數式編程的哲學就是這樣,希望通過一個個細微的抽象,將它們組合在一起,最終成就了一個更為龐大的抽象,構建出了一個極其優雅的系統。