在類 Unix 系統的終端機 shell 裡,有一個功能叫做管線 (pipeline),可以把A程式的輸出口與 B 程式的輸入口串接起來,使 A 與 B 變成連動的程式。
比如說,我們可以把 ls -al
(把當前資料夾底下全部的檔案用列表方式列出來)跟 less
(具備上下捲動功能的閱讀器)用管線串在一起,就可以用 less
去閱讀檔案列表了:
ls -al | less
在這行命令當中,|
就是所謂的管線運算子,把前面程式的標準輸出 (stdout) 傳遞給後面程式的標準輸入 (stdin)。ls -al
的標準輸出就是檔案列表的文字,而 less
就會讀取這些文字並顯示出來。
透過管線運算子,我們可以把好幾個命令列程式組合在一起,用來達成更複雜的自動化流程。最棒的是,管線的語法相當直覺易懂,寫起來就像是真的在接水管一樣,把資料串流下去。事實上,這整套系統也就真的叫做標準串流 (standard streams)。
如果管線系統真的這麼好用的話,那我們能不能在 Swift 裡使用它呢?
Functional Programming 與管線系統
其實在 Foundation 框架裡就有針對 Unix 管線系統的工具,但是這篇文章要談的是語言層次的管線系統,是不一樣的東西。
在 Swift 裡,其實已經有內建的資料串接系統了。如果你點開 Optional
、Sequence
、或 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
- 先定義一個優先權群組,因為運算子跟一般函數不同,需要考慮多重組合時的優先權。一般函數就是被包在越裡面的越先被運算,但運算子沒有裡外之分,只有左右之分,所以要特別去定義優先權。
- 我們把
PipelinePrecedence
的級別定在只比內建的AssignmentPrecedence
高而已,意思就是所有其它的運算子都會先被執行,然後執行PipelinePrecedence
裡的運算子,最後才是AssignmentPrecedence
。 - 我們把關聯性設成左邊,所以當多個運算子都是屬於
PipelinePrecedence
的時候,越左邊的會越先執行,符合串流從前面留到後面的順序。 - 最後,我們定義夾在兩個參數中間的
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)
}
這個管道函數已經蠻完整的了,但是實際用起來的時候,你可能會覺得有一點點不方便,因為它不支援 Optional
。Optional
這個型別在 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?
- 這是一個很基本的函數,輸入一個
Int
並輸出一個Int
。 - 這裡我們基本上是把
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)
}
}
參考資料
- “Pipeline (Unix).” Wikipedia.
- Marius Schulz. “Implementing a Custom Forward Pipe Operator for Function Chains in Swift.” Mariius Schulz.
- Marcello Seri. “Piping with Swift.” Tales of a Fractal Spectrum Atom.
- Yan. “F# – Pipe Forward and Pipe Backward.” theburningmonk.com.
- “Stderr, Stdin, Stdout.” Stderr, Stdin, Stdout — Global Variables for Standard Input/Output Streams.