在 Swift 利用 Forward Pipe Operator 達成複雜的自動化流程!


在類 Unix 系統的終端機 shell 裡,有一個功能叫做管線 (pipeline),可以把A程式的輸出口與 B 程式的輸入口串接起來,使 A 與 B 變成連動的程式。

pipeline-1

比如說,我們可以把 ls -al(把當前資料夾底下全部的檔案用列表方式列出來)跟 less(具備上下捲動功能的閱讀器)用管線串在一起,就可以用 less 去閱讀檔案列表了:

ls -al | less

在這行命令當中,| 就是所謂的管線運算子,把前面程式的標準輸出 (stdout) 傳遞給後面程式的標準輸入 (stdin)。ls -al 的標準輸出就是檔案列表的文字,而 less 就會讀取這些文字並顯示出來。

透過管線運算子,我們可以把好幾個命令列程式組合在一起,用來達成更複雜的自動化流程。最棒的是,管線的語法相當直覺易懂,寫起來就像是真的在接水管一樣,把資料串流下去。事實上,這整套系統也就真的叫做標準串流 (standard streams)

如果管線系統真的這麼好用的話,那我們能不能在 Swift 裡使用它呢?

Functional Programming 與管線系統

其實在 Foundation 框架裡就有針對 Unix 管線系統的工具,但是這篇文章要談的是語言層次的管線系統,是不一樣的東西。

在 Swift 裡,其實已經有內建的資料串接系統了。如果你點開 OptionalSequence、或 Result 的官方文件,就會發現它們都有一個叫做 map(_:) 的方法。這個方法的功能跟管線非常的相似,也是把前面的輸出傳遞給後面的輸入:

Optional(1).map { $0 + 1 }
    .map(String.init)
    .map { print($0) } // "2"

這其實是所謂的 functional programming 的寫法。雖然裡面的資料不一定是持續流動的串流,但基本的概念一樣是使資料流過一個又一個的程式(函數),從前面流到後面。

在這個例子裡面,我們使 Optional(1)1 流到 { $0 + 1 } 這個函數,然後流到 String.init 這個函數,最後再流到 { print($0) } 這個函數。

那如果我們用 map(_:) 就可以組合函數成一條資料串接,我們還有需要去實作管線系統嗎?這個問題就要看個人喜好了。剛剛的案例如果用管線系統改寫的話,可以長成這個樣子:

1 |> { $0 + 1 } 
    |> String.init 
    |> { print($0) } // "2"

有沒有覺得比較容易讀呢?如果覺得這樣比較好懂的話,就讓我們來看看要怎麼實作吧!

一、自訂運算子

首先,如果我們要用一個沒有定義過的運算子的話,就必須先定義它才能用。

// 1.
precedencegroup PipelinePrecedence {

    // 2.
    higherThan: AssignmentPrecedence
    lowerThan: TernaryPrecedence

    // 3.
    associativity: left

    assignment: false
}

// 4.
infix operator |>: PipelinePrecedence
  1. 先定義一個優先權群組,因為運算子跟一般函數不同,需要考慮多重組合時的優先權。一般函數就是被包在越裡面的越先被運算,但運算子沒有裡外之分,只有左右之分,所以要特別去定義優先權。
  2. 我們把 PipelinePrecedence 的級別定在只比內建的 AssignmentPrecedence 高而已,意思就是所有其它的運算子都會先被執行,然後執行 PipelinePrecedence 裡的運算子,最後才是 AssignmentPrecedence
  3. 我們把關聯性設成左邊,所以當多個運算子都是屬於 PipelinePrecedence 的時候,越左邊的會越先執行,符合串流從前面留到後面的順序。
  4. 最後,我們定義夾在兩個參數中間的 infix 運算子 |>,並且給它剛定義好的 PipelinePrecedence

我們在這個範例裡所採用的符號 |> 是跟隨 Erlang 與 F# 等語言的慣例。之所以不用 Bash 的 |,是因為它在 Swift 裡面已經有別的用途了。雖然可以重載它,但為了不混淆意思,這裡還是使用 |>

二、定義管道函數

接著就是棘手的部分了,我們需要去決定 |> 到底會有甚麼功能。比如說,我們可以仿照 Unix 標準串流的 stdout、stdin,設計出這樣的管道函數:

func |> (stdin: Any, program: (Any) -> Any) -> Any {
    return program(stdin)
}

但是 Swift 是一個強型別語言,如果我們給一些型別資訊的話,程式運作起來會更安全:

func |> <T, U>(stdin: T, program: (T) -> U) -> U {
    return program(stdin)
}

Unix 標準串流除了 stdout 與 stdin 之外,還有 stderr(標準錯誤)的存在,所以也許我們會想加個錯誤處理:

func |> <T, U>(stdin: T, program: (T) throws -> U) rethrows -> U {
    return try program(stdin)
}

這個管道函數已經蠻完整的了,但是實際用起來的時候,你可能會覺得有一點點不方便,因為它不支援 OptionalOptional 這個型別在 Swift 裡面是特殊的存在,有諸如 optional chaining 與 optional binding 等的語法糖內建在語言裡面。如果我們也想利用 Optional 的特性,使整條管道串接可以用 nil 來終止執行的話,那我們可以把 stdin 與 stdout 都改成 Optional

extension Optional {

    static func |> <U>(
        stdin: Wrapped?, program: (Wrapped) throws -> U?
    ) rethrows -> U? {
        guard let stdin = stdin else { return nil }
        return try program(stdin)
    }
}

如此一來,只要任何一個 program 輸出 nil,它的下游就會全部都輸出 nil,是除了丟錯誤之外的另一種中斷資料流方式。

如果你熟悉 functional programming 的話,也可以用 flatMap(_:) 來實作:

extension Optional {

    static func |> <U>(
        stdin: Wrapped?, program: (Wrapped) throws -> U?
    ) rethrows -> U? {
        return try stdin.flatMap(program)
    }
}

三、創造管道串接

定義好所需的工具之後,就可以來實際串接管道了。讓我們從最基本的形式開始:

// 1.
func addOne(x: Int) -> Int {
    return x + 1
}

// 2. 
Optional(1) |> addOne(x:) // 2?
  1. 這是一個很基本的函數,輸入一個 Int 並輸出一個 Int
  2. 這裡我們基本上是把 Optional(1) 檢查不是 nil 之後,傳遞給 addOne(x:) 這個程式(函數)去執行。要注意這邊並不是直接去呼叫 addOne(x:),而是把這個函數用管道接起來的意思。所以,我們在這裡寫的是函數的簽名(函數名(引數1標籤:引數2標籤:)),而不是呼叫。

現在,第二步的整個陳述句會產出 Optional(2),所以編譯器可能會發出警告說 “Result of operator ‘|>’ is unused”。我們可以在陳述句前面加上 _ =  來壓制警告,但如果我們在管道串接的尾端接上一個回傳 Void 的函數,整個陳述句就會產出 Optional(Void),如此一來就不會有警告了。讓我們直接用閉包來做吧:

Optional(1) |> addOne(x:) |> { print($0) } // 2

{ print($0) } 是一個大幅簡化了的無名函數,又名閉包。$0 代表了它的 stdin,而 Void 則是它的 stdout。用閉包可以省去另外定義與命名函數的麻煩,但越是簡化就越依賴 Swift 編譯器的型別推測功能,有可能會拖慢編譯速度。

Optional 有一個有趣的副作用,就是即使我們不明寫 Optional 出來,Swift 編譯器也可以把任何變數在適當的地方透過型別推測把它裝到 Optional 裡面。所以我們其實可以直接寫成這樣:

1 |> addOne(x:) |> { print($0) } // 2

編譯器會直接把 1 當作是 Optional(1),因為它知道管線運算子左邊的型別是 Optional。另外,不知道你有沒有發現到,它也把 addOne(x:) 的型別看作 (Int) -> Int? 了。

管線之間還可以串接很多有趣的東西,比如說一開始的例子裡,我們就串接了 String.init,因為 initializer 基本上就是一個回傳該型別實體的函數。另外,我們也可以串接有 associated value 的 enum case:

enum Identifier {
    case string(String), int(Int)
}

1 |> addOne(x:) |> Identifier.int |> { print($0) } // int(2)

或者用三元運算子來做過濾:

1 
    |> addOne(x:) 
    |> { $0 < 0 ? $0 : nil } // nil
    |> Identifier.int // 不被執行
    |> { print($0) } // 不被執行

甚至在 Swift 5.2 之後,還可以串接 key path。

結論

其實本篇文章只挖掘了管道系統的冰山一角,因為管道系統本身其實是一個跨程序的協定,它從一開始就支援非同步運算,而我們只用了同步運算的 Optional 來實作。但即使我們只用了它的語法,也可以立刻感受到宣告式程式設計的那種氛圍:直覺、簡單明瞭的語句。如果你原本就常常使用 map(_:)flatMap(_:),不妨試用看看管道運算子。

以下附上本文最終的運算子宣告:

precedencegroup PipelinePrecedence {
    higherThan: AssignmentPrecedence
    lowerThan: TernaryPrecedence
    associativity: left
    assignment: false
}

infix operator |>: PipelinePrecedence

extension Optional {

    static func |> <U>(
        stdin: Wrapped?, program: (Wrapped) throws -> U?
    ) rethrows -> U? {

        return try stdin.flatMap(program)
    }
}

參考資料


iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]

blog comments powered by Disqus
Shares
Share This