[譯]函式式響應程式設計入門指南

龍騎將楊影楓發表於2017-08-18

函式式響應程式設計入門指南

今年,我做了一場有關函式式響應程式設計(functional reactive programming,簡稱 FRP)的演講,演講的內容包括“什麼是函式式響應程式設計”以及“為什麼你應該關注它”。這篇文章是演講的文字版。


介紹

函式式響應程式設計最近幾年非常流行。但是它到底是什麼呢?為什麼你應該關注它呢?

即便是對於現在正在使用 FRP 框架的人 —— 比如 RxJava —— 來說,FRP 背後的基礎理論還是很神祕的。今天我就來揭開這層神祕的面紗,將函式式響應程式設計分解成兩個獨立的概念:響應型程式設計和函數語言程式設計。

響應型程式設計

首先,讓我們來看一下什麼叫做響應型程式碼。

先從一個簡單的例子開始:開關和燈泡。當有人撥動開關時,燈泡隨之發亮或熄滅。

在程式設計術語中,這兩個元件是耦合的。通常人們不太關心它們如何耦合,不過這次讓我們深入研究一下。

讓燈泡隨著開關發光或熄滅的方法之一,是讓開關修改燈泡的狀態。在這種情況下,開關是主動的,用新狀態給燈泡賦值;燈泡是被動的,單純地收到指令改變自己的狀態。

我們在開關旁邊畫一個箭頭來表示這種狀態 —— 這就是說,連線兩個元件的是開關,不是燈泡。

這是主動型解決方法的程式碼:開關類中持有一個燈泡類的例項化物件,通過修改例項物件來完成狀態的修改。

另一種連線這兩個元件的辦法是讓燈泡通過監聽開關的狀態來改變自己的值。在這種模型下,燈泡是響應型的,根據開關的狀態修改自身的狀態;開關是被觀察者observable),其他的觀察者可以觀察它的狀態變化。

這是響應型解決方案的程式碼:燈泡 LightBulb 監聽開關 Switch 的狀態,根據開關狀態改變的事件改變自身的狀態。

對終端使用者來說,主動型和響應型編碼結果是相同的。那麼兩者的差別在哪裡呢?

第一個區別是,燈泡的控制者不同。在主動型模式中,是由另一個元件呼叫了燈泡物件的 LightBulb.power() 方法。但是在響應型裡,是由燈泡自己控制自己的亮度。

第二個區別是,誰決定了開關的控制物件。在主動型模式裡,開關自己決定它控制誰。在響應式模式裡,開關並不關心它控制誰,而其他元件只是在它身上掛了個監聽器。

兩者看起來好像是對方的映象。兩者間是二元對應的。

然而,正是這些微妙的差別造成了兩個元件間是高耦合還是低耦合。在主動型模式中,元件互相直接控制。在響應型模式中,元件自己控制自己,互相之間沒有直接互動。

舉個現實中的例子:

這是 Trello 的主頁面,它從資料庫拿取圖片資料並展示給使用者。那麼採用主動型的資料關係與響應型(的資料關係)有什麼不同呢?

如果是主動型模式,當資料庫的資料更新時,資料庫將最新的資料推送到使用者介面。但是這種做法看起來毫無邏輯:為什麼資料庫需要關心使用者介面?為什麼要由資料庫關心主頁面到底展示了沒有?為什麼它要關心是否需要推送資料到主頁面?主動型編碼讓資料庫和使用者介面之間纏纏綿綿,看起來好像是在做羞羞的事(creates a bizarrely tight coupling between my DB and my UI )。

相對而言,響應型就簡潔多了。使用者介面監聽資料庫的資料變化,如果有需要的話就更新自己的介面。資料庫就是一個傻乎乎的資源堆放地,順便提供了一個監聽器。任何元件都能讀取到資料庫的資料變化,而這些變化也很容易反應到需要的使用者介面上。

用一句好萊塢的拍戲信條概括就是:別給我們打電話,我們會打電話給你的(校對 Tobias Lee :don't call us, we'll call you ,這個似乎是好萊塢演員面試的原則。是否被錄用是由劇組決定的,演員不要主動打電話去詢問)。這種形式會降低程式碼耦合度,允許攻城獅很好地封裝元件。

現在我們可以回答什麼是響應型程式設計了:那就是使用元件響應事件的編碼形式,代替通常使用的主動型編碼。

如果想經常使用響應型編碼的話,簡單的監聽器還是不夠完善。這樣會產生一系列問題:

首先,每一個監聽器都是獨一無二的。我們有 Switch.OnFlipListener ,但是隻能用來監聽開關類 Switch。如果有多個被觀察者,那每一個(被觀察者)元件都需要實現(觀察者)的監聽介面。這不僅帶來一系列無聊繁重的實現介面的工作,還意味著不能重複使用響應型編碼的思維 —— 因為沒有一個共同的架構來實現這種模式。

第二個問題是每一個觀察者必須直接連線被觀察的元件。燈泡物件必須直接和開關物件直連才能開始監聽開關物件的狀態。這其實是一個高耦合的編碼形式,和我們的目標背道而馳。

我們真正希望的是 Switch.flips() 返回一些可以被傳遞的泛型。來看看為了滿足需求,我們應該選擇哪種型別。

Java 函式可以返回四種基本物件。橫軸代表需要返回值的數量:要麼是一個,要麼是多個。縱軸代表是否需要立刻(同步)返回還是需要延遲(非同步)返回。

同步的返回值很簡單。如果是需要返回一個元素,那麼可以用泛型 T。如果是需要多個返回值,可以用 Iterable<T>

編寫同步型別的程式碼比較簡單,因為同步是所見即所用,但理論和現實還是有差距的。響應型程式設計天生具有非同步屬性:鬼知道被觀察者什麼時候會抽風個新狀態出來。

這種情況下,我們需要研究一下非同步返回值。如果需要一個返回值,可以用 Future<T>。看起來不錯,但離需要的(類)還差點 —— 一個被觀察者元件也許有很多返回值(比如說,開關物件就可能多次開開關關)。

我們真正需要的類在右下角,這塊區域的類可以被稱之為 Observable<T>Observable 類是響應型框架的基礎。

來看看 Observable<T> 是如何起作用的。在上面的新程式碼裡,Switch.flips() 返回一個 Observable<Boolean> 物件 —— 換句話說,就是一系列 true 或則 false 的值,代表開關物件 Switch 是處於開啟狀態還是處於關閉狀態。燈泡物件 LightBulb 沒有直接沒有直接受制於 Switch 物件,它只是訂閱了由開關提供的 Observable<Boolean>

這段程式碼和無 Observable 程式碼起著相同的作用,但是足以解決剛才我提到的兩個問題。Observable<T> 是一個基礎型別,在此基礎之上可以進行更高層次的開發。而且它是可以被傳遞的,所以元件間的耦合度就降低了。

再鞏固一下 Observable 是什麼:一個 Observable 是一組隨時間變化的元素集合。

用這張圖來說明的話,橫線代表時間,圈圈代表 Observable 傳送給它的訂閱者的事件。

Observable 可以很好地表示兩種可能的狀態:成功還是報錯。

圖中豎線代表一個成功的訪問。並不是所有的集合都是無限的,所以有必要這麼表示。比如說,如果你在 Netflix 上看視訊的話,在特定的時候視訊就會結束。

X 代表錯誤,即表示在結果流的資料在某個時候會變為非法值。比如說,如果萊因哈特對著開關就是一錘子,那麼還是應該提醒使用者:開關不僅沒法產生任何新狀態,甚至連開關自身都不可能再監聽任何狀態 — 因為它被砸壞了。

函數語言程式設計

讓我們先把響應型程式設計放在一邊,看看函數語言程式設計是什麼。

函數語言程式設計的關鍵詞是函式。嗯,對吧?我不準備講什麼普通的老式函式:我們現在研究的是純函式。

通過這個加法的例子來解釋下什麼是純函式。

假設有一個完美的取兩數之和的 add() 函式。等下,這個函式空缺的部分是啥?

哎呀,看起來 add() 函式把一些文字流輸出到了控制檯。這就是所謂的副作用add() 的目的本不包括顯示結果,僅做相加的動作。但是現在它修改了 app 全域性的狀態。

等下,還有更多。

天啊,這回不光把資料輸出到了控制檯,連函式都強行結束了。如果單純的看函式定義(兩個引數,一個返回值),誰也不知道這個函式會造成什麼樣的破壞。

再來看看另一個例子。

這次的例子是取一組資料,看看資料相加與陣列相積是否一樣。對陣列 [1, 2, 3] 來說,這個結果應該是 true,因為不論相加還是相乘都是6。

然而,檢查一下 sum() 方法是如何實現的。雖然沒有修改 app 的全域性狀態,但是它改變了輸入的引數!這意味著程式碼會失敗,因為隨著 product(numbers) 執行,numbers 最終會變成空集合的。這一系列的問題可能隨時發生在真實的、不純的函式中。

任何改變函式額外狀態的時候,都會產生副作用。如你所見,副作用會使得編碼複雜化。純函式不允許有任何的副作用。

有趣的是,這意味著純函式必須有返回值。如果只有 void 的返回值,意味著純函式啥都沒做,因為它既沒有改變輸入值,也沒有改變函式外的狀態。

這同時意味著,函式的引數必須是不可變的(譯者:比如用 final 修飾?)。不能允許引數可變,否則函式執行的時候有可能修改引數值,從而打破了純函式的原則。順便一提,這也暗示著輸出值也必須是不可變的(不然的話輸出值不能作為其他純函式的引數)

有關純函式的第二個方面,就是對於給定的輸入值,純函式必須返回相同的輸出值。換句話說,純函式不能依靠額外的狀態。

比如說,檢查一下這個歡迎使用者的函式。雖然沒有任何副作用,但是它隨機返回兩種歡迎語。這種隨機性提供了一個額外的、靜態的函式。

這使得編碼從兩方面來說更坑爹了。第一,函式的返回值和輸入值沒什麼關係。如果知道相同的輸入值可以產生相同的返回值,那麼閱讀程式碼會更不容易懵圈。第二,函式中有一個額外的依賴,如果該依賴產生了變化,那麼函式的輸出值也會改變。

物件導向的開發者很可能不理解,純函式不能訪問持有的類的狀態。比如說,Random 的方法自帶不純屬性,因為每次呼叫它都會返回不同的值。

簡單的說:函數語言程式設計依賴於純函式。純函式是不會消耗或者改變外部的狀態 - 他們完全依賴輸入值來產生輸出值。

介紹函數語言程式設計給大家時比較容易被混淆的是:(既然輸入值是不可變的),那如何讓輸出值有變化呢?比如說,如果我有一組整數,想獲得以該組整數每個元素乘 2 的結果為新元素組成的陣列。是不是必須改變列表的值呢?

嗯,其實不全是的。你可以使用純函式改變列表。這是一個可以把集合裡的值做 * 2 操作的純函式。沒有副作用,沒有額外的狀態,也沒有改變輸入值或者輸出值。這個函式做了額外的修改狀態的工作,所以你就不必這麼做的。

然而,我們所寫的這個方法擴充套件性太差了。它能做的只是把陣列的每一個值都乘 2,但是如果想對陣列的值進行其他操作呢?比如乘 3,除 2,想法是無窮無盡的。

讓我們寫一個通用的整數陣列計算器。首先寫一個函式式介面,這樣我們就可以定義如何計算每一個值。

然後寫一個 map() 函式,此函式接受一個整數陣列一個 Function函式 做引數。對每一個陣列的整數來說,都可以用 Function 計算。

讚美太陽!通過一點點額外的程式碼,我們可以對任何整數陣列進行計算。

我們甚至可以把這個例子擴充的更廣泛一些:為什麼不用一個更通用的型別,這樣我們可以把任何列表轉換為另一個其他的列表?只需要簡單的修改一點剛才的程式碼。

現在,我們可以把任何 List<T> 轉換為 List<R>。比如說,我們可以把一組字串陣列轉換為一組每個字串的長度的陣列。

map() 就是所謂的高階函式,因為它的引數之一也是函式。能夠傳遞並且使用函式做引數是一個很牛逼的做法,因為它允許程式碼變的更靈活。不必再寫反覆的、例項化的函式,可以使用泛型度更高的函式比如 map() 來處理具有共性的邏輯。

除了能更輕鬆的處理一系列額外的狀態之外,純函式還可以更容易組織函式。如果有一個 A -> B 的函式,又有一個 B -> C 的函式,我們可以把兩個函式結合起來,以產生 A -> C

當你可以組織不純函式時,總是會發生意料之外的副作用,這意味著組織函式是否正確的執行是個未知數。只有純函式可以保證組織起來的程式碼是安全的。

再舉個栗子。這是另一個簡單的函數語言程式設計的函式 —— filter()filter() 可以幫助我們過濾集合中的元素。現在我們可以在轉換集合之前,先進行過濾操作。

現在我們有了一對很小但是很勥的轉換函式。它們的強力值隨著允許我們自有組裝函式而變的越來越大。

其實函數語言程式設計比我提到的還要多,但是現在講述的東西足夠我們明白函式式響應程式設計裡的函式式了。

函式式響應程式設計

現在可以解釋什麼是函式式響應程式設計了。

還是以開關類 Switch 舉例,這次我們不提供 Observable<Boolean> 類,我們提供一個基於自身狀態列舉流的 Observable<State>

看起來我們沒辦法把開關和燈泡關聯在一起,因為我們的泛型不相容。但是還有一個明顯的方式讓 Observable<State> 酷似
Observable<Boolean> —— 如果可以把一種流轉換為另一種呢?

還記得之前函數語言程式設計裡的 map() 函式嗎?該函式將一個同步集合轉換為另一個。我們能否用相同的思想來把一個非同步集合轉換為另一個呢?

啦啦啦:這就是 map(),但是是用來轉換 Observable的。Observable.map() 就是所謂的操作符(operator)。操作符允許攻城獅把任一 Observable 轉換成基本上其他能所想到的類。

操作符的圖表畫起來比之前見到的要麻煩。讓我們來把它弄清楚:

上面的代表輸入流:一系列的有顏色的圈圈。

中間的代表一系列操作符:把一個圈圈轉換為方塊。

下面的那行代表著輸出流:一系列有顏色的方塊。

本質上,在輸入流裡做的是 1:1 的轉換。

還是以開關的例子來說明。先寫一個 Observable<State> ,然後使用 map() 操作符(對 Observable<State> 進行轉換),這樣每次產生新 狀態 的時候,操作符 map() 返回一個 Observable<Boolean> 物件。現在我們有了正確的返回型別,就可以構造燈泡物件LightBulb了。

好吧,這很有用。但是為什麼一定要用純函式呢?為什麼不能隨便在 map() 寫一點?為什麼引起副作用就有問題呢?當然,可以這麼做,但是馬上就會讓程式碼很難處理。再說,這麼做會錯過不少不允許副作用的操作符。

假設 State 的列舉型別有兩種以上的狀態,但是使用者只關心開啟或者關閉。如果這樣的話,我們要過濾掉其他的狀態。看,這裡有一個 filter() 的操作符。還可以用 map() 來獲得想要的結果。

將函式式響應程式設計的程式碼和之前的函式式程式碼相比較,你會發現兩者非常相似。唯一的區別就是函數語言程式設計的程式碼處理的是同步的集合,函式式響應程式設計處理的是非同步集合。

函式式響應程式設計的程式碼有一大堆操作符,可以把常見的問題轉換成對流的控制,而流最大的好處就是可以多次組裝。舉一個真實的例子:

我之前展示的 Trello 主螢幕很簡單 —— 它只有一個從資料庫到使用者介面的大箭頭。但事實上,主螢幕用的資料來源還有很多。

事實上,每一個資料來源的資料可能有很多的展示位置。我們必須保證同步接收資源,否則可能會出現資料匹配錯誤,造成展示位置沒有對應的資料來源的 bug。

我們使用 combineLatest() 避免這種問題,combineLatest() 接收復數的資料流並且將他們組合成一個資料流。這麼做有什麼好處呢?每次任何一個輸入流改變的時候,它也跟著改變,這樣就可以保證傳送給 UI 的資料包是完整的了。

函式式響應程式設計中有很多有價值的操作符,這裡只給大家看一些簡單的…… 多數情況下,第一次使用函式式響應程式設計的攻城獅看到一大堆操作符都會暈過去。

然而,這些操作符的目標並不是讓人崩潰 —— 它們為了組織典型的資料。它們是你的朋友,不是敵人。

我建議大家可以一步一步的接受他們。並不需要馬上記住所有的操作符;相反,只需要記住當前應該使用什麼操作符。需要的時候去查詢一下,然後經過一系列訓練你就會習慣它們的。

額外的東西

我試圖去回答“什麼是函式式響應程式設計”。現在我們有了答案:所謂函式式響應程式設計,就是響應型資料流與函式式操作符的組合。

但是為什麼要嘗試使用函式式響應程式設計呢?

響應型資料流允許你通過標準方法編寫元件間的模組化編碼。響應型資料可以幫助攻城獅對元件進行解耦。

響應型資料流天生自帶非同步屬性。也許你的工作是同步的,但是大部分我編寫的 app 都是基於非同步的使用者輸入和操作。使用一個基於非同步編寫的框架比自己摸索著寫程式碼的方式要簡單的多。

函式式響應型編碼的函式式部分可以給予攻城獅使用可靠的方法運算元據流的工具,因而特別有用。 函式式操作符允許攻城獅控制資料流之間的互動,同時可以編寫可複用的程式碼模組來應對有共性的邏輯。

函式式響應程式設計不夠直觀。大部分人開始程式設計的時候都是使用非純的函式或者主動的方式,包括我。也許你使用這種方式的時間太久了,而這種方式也深深的印在了你的腦子裡,以至於你認為這種方式是唯一的解決方式。如果能夠打破這種慣性思維,你可以編寫出更多高質量的程式碼。

引用

感謝在以下資料對我演講的幫助:


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章