在Python中探索函數式響應型編程(FRP)

編程語言 Python 泛函編程 軟件 Python部落 2017-05-29
在Python中探索函數式響應型編程(FRP)

Python部落(python.freelycode.com)組織翻譯,禁止轉載,歡迎轉發。

作為一個致力於開發出強大可調試系統的軟件開發員,我一直對函數式響應型編程之類的框架感興趣,例如Elm架構。不幸的是天天被工作煩著沒有太多時間來仔細瞭解它,直到復活節假期才騰出手來好好摸索一番!。

初步打算是通過Elm文檔裡的示例一樣完成一個簡單的計數器,整個架構建立在函數響應基礎上,通過Python來實現。不幸的是,Elm和FRP好像沒啥關係,所以就只好用兩個不相關的例子來完成整個過程了。想看最終效果的,可以在這個網址看:

https://gitlab.com/Screwtapello/frptui

不過總的來說還是學到不少東西的,今天就來總結下!首先來看下大概背景。

什麼是函數式編程?

函數式編程對不同的人有不同的理解,不過在這裡我們只討論其純粹的數學功能或者就只是功能實現而已。例如,對於一個確定的輸入就會有一個確定的輸出之類的。下面的代碼就是一個純粹的功能實現:

在Python中探索函數式響應型編程(FRP)

當你輸入參數5時,總會返回一個值12,不用關心函數內部如何實現。

在Python中探索函數式響應型編程(FRP)

然而,下面的例子就不是一個純粹的函數式編程了。

在Python中探索函數式響應型編程(FRP)

即使你每次輸入相同的值也不會返回同樣的結果。第二次執行時.readline將會讀取文件下一行而不是原來的那一行。

在Python中探索函數式響應型編程(FRP)

純粹的函數式代碼是非常容易運行的,因為你只需要關心輸入輸出即可,具體的實現過程可以不用管它。而且非常容易進行單元測試,不用在意API調用和測試設置。不過在一個項目中完全使用函數式編程是不可能的,實際中總需要從外部輸入信息並返回一個結果。而且,如果你的有太多的函數式代碼,那麼你的代碼在理解,測試,修復的過程中就會有更大的錯誤機率。

“響應式”體現在哪裡?

函數式響應型編程與純函數式編程不一樣的地方在於,它不是輸入一個值就返回一個值,它是接收一個數據流返回一個新的數據流。也可以說這個函數一直都在隨著輸入流的改變而更新輸出流。例如在上面add_seven這個函數中輸入5,9,32,17這個數據流,FRP就會輸出12,16,39,24這樣的結果。

應用FRP的一個很自然的例子就是服務監控,例如給定一個應用的日誌,能夠不斷的讀取日誌的信息並返回一些統計結果,如“平均請求延遲"或”每個客戶的總計請求“。當然,你還可以增加更多的輸入流,比如5分鐘移動下平均請求延遲或是一個小時移動下,此外也可以加入一個警報信息當五分鐘之內的平均請求比一個小時之內的高20%時。

FRP不僅用在監控上,也可以用在任何事件隨時間變化的情景中,比如在數據庫中插入更新一個事件,或者用鼠標鍵盤觸發一個事件等。

在Python中使用FRP

如果你寫過很多python代碼,那麼一個數據流產生另一個數據流的函數很像python中的生成器。

先完成個生成器函數

你可能用python寫過下面的函數生成器:

在Python中探索函數式響應型編程(FRP)

然而python的生成器和FRP是不一樣的,它是被設計成鏈接在一塊的長鏈,一旦產生一個值必須移動到下一個值,不能產生下一個生成器需要的相同的數。舉個例子,有一個加法函數:

在Python中探索函數式響應型編程(FRP)

你可以用這個函數將兩個相同的數進行相加:

在Python中探索函數式響應型編程(FRP)

但是如果有一個生成器將兩個迭代數相加的函數:

在Python中探索函數式響應型編程(FRP)

你就不能將兩個相同的數進行相加:

在Python中探索函數式響應型編程(FRP)

以上結果不應該是14(5的兩倍不應該是14)

由於python生成器工作方式的緣故,當add_generator函數調用zip時會得到xs的前兩個數,而不是對第一個數進行復制。所以,要想在python中使用FRP我們必須採取不同的策略,必須使這些數據流可重複利用,就像數字5一樣能用到不同的流函數裡,而且永遠用不完。

數據流接口

在FRP中有兩種基本的類型來傳遞信息變化:

1、Push方式:被動等待輸入信號,然後將輸入一直傳遞下去,直到得到所有的輸出

2、Pull方式:根據結果一層層向上流搜尋引起變化的值,直到找到所有影響輸出的輸入值

在python中對輸出值的引用並沒有標準的方式,因此使用push模式是不太方便的;此外python中可以引用輸入值(就是調用函數的形參),因此pull模式是最好的選擇。

假設這裡有一個表示數據流的對象,在pull模式中我們需要一個能查找當前值的方法;

在Python中探索函數式響應型編程(FRP)

作為一個非常基本的例子,我們可以拿一個包含數字序列的流:

在Python中探索函數式響應型編程(FRP)

這按照你希望的方式工作了:

在Python中探索函數式響應型編程(FRP)

這個數據流和add一樣簡單:

在Python中探索函數式響應型編程(FRP)

如果我們把5和7分別給這個數據流函數就會得到12,就像進行加法操作一樣:

在Python中探索函數式響應型編程(FRP)

現在我們可以以FRP的樣式重構前面的生成器了:

在Python中探索函數式響應型編程(FRP)

怎麼計算結果是不對的?這不就是我們前面遇到的問題嗎?

數據流接口2

如果將一個數據流看作隨時間變化的值,那麼在相同的時間對poll的調用返回相同的值是可以說得通的。確切的來說調用poll時會有納秒級別的差別,不過我們都將這看成是一樣的了。也就是說我們需要向IStream引進時間的概念,以便它在檢測輸出流時能保持相同的時間,然後能提前看接下來會發生什麼。

在Python中探索函數式響應型編程(FRP)

繼續添加一個名為phase的新參數。 當調用s.poll時,如果phase的值與之前的調用相同,則該方法必須返回與先前調用相同的值。 如果phase參數自上一次調用以來發生變化,則重新計算輸出。大致就是系統決定它處於藍色階段時會查詢所有它關心的輸入並確保計算都是在同一狀態下進行的,然後可以轉變成綠色狀態,此時所有的輸出都應該和之前不一樣。因為每一個數據流都需要判斷它所處於的狀態,所以將這個函數打包成易於調用的形式:

在Python中探索函數式響應型編程(FRP)

現在再計算我們需要的輸出時可以不用覆蓋.poll了,因為它只能調用同一狀態的值。注意._poll仍是一個參數,它需要來自其它流的數據,當在查詢這些數據時,會將結果傳遞迴當前的狀態。接下來,重新寫下NumberStream:

在Python中探索函數式響應型編程(FRP)

確保NumberStream代表的狀態:

在Python中探索函數式響應型編程(FRP)

同時, AddStream與之前幾乎相同

在Python中探索函數式響應型編程(FRP)

但是這一次我們加入了狀態顯示,所以可以計算相同的值:

在Python中探索函數式響應型編程(FRP)

狀態流函數

到目前為止,我們可以像純函數那樣實現數據流函數的加法了,而且可以非常容易理解數據流函數的加法本質。然而數據流隱藏了當前數據的位置,這意味著我們可以寫出像純函數那樣更多的流函數。雖然不能夠像全局變量那樣獲取任意位置的變量,但是對於流函數只要對於給定的輸入就有確定的輸出即可,而且還可以保留其易於理解,測試的函數式編程的特性。

假設我們有一個間歇性輸入流,也就是有時有有用的值有時沒有,但是,我們總是希望有一個值(可以拿來和其它數據流進行比較),那麼我們就需要填補這些間隙來讓輸出總能產生有意義的值。就像下面這樣:

在Python中探索函數式響應型編程(FRP)

GapFiller以._last_good_value屬性的形式維護了額外的狀態,使其行為完全由輸入流確定,因此可以像純函數一樣對它做測試:

在Python中探索函數式響應型編程(FRP)

使它更完美

現在我們已經有了基本的FBR功能來工作了,但是它是不好用的,創建一個新的BaseStream子類來覆蓋._poll,接著在輸入流上調用.poll,這整個都是流函數的邏輯過程。

流函數裝飾器

接下來讓我們做個裝飾器將純函數轉變成純流函數:

在Python中探索函數式響應型編程(FRP)

Inner類與AddStream類類似,除了處理任意數目的輸入,還能調用包裝函數而不是硬編碼x+y,現在我們可以通過裝飾器讓一個純函數變成流函數了。

在Python中探索函數式響應型編程(FRP)

現在所有的數據流查找與狀態檢測都會自動進行了:

在Python中探索函數式響應型編程(FRP)

真正的狀態流函數

儘管python的生成器不能在FRP中正確迭代,但是它們仍是可恢復計算的便利方法。如果有一些基於非迭代器的方法來從輸入流中提供新的值,那就更方便了。幸運的是,Python2.5引入了“yield"表達式,可以讓生成器暫停,產生一個值,然後調用之後傳入的新值。我們可以用這個功能寫一個裝飾器來使狀態流函數從生成器中脫離出來。

在Python中探索函數式響應型編程(FRP)

這個代碼比stream_function更復雜,它必須處理python的生成器對象API,但是基本結構都是一樣的。現在可以用生成器功能來重構GapFiller狀態流函數:

在Python中探索函數式響應型編程(FRP)

x的初始值作為參數傳入,並從yield表達式返回後續值。注意最後一行x之後的逗號。 因為stateful_stream_function被設計為適用於具有任意數量參數的函數,並且生成器.send方法只需要一個值,所以我們總是發送一個輸入值列表。 如果這個功能需要多個輸入,我們可以寫出下面的形式:

在Python中探索函數式響應型編程(FRP)

但是現在只有一個輸入,所以要x,來表示一維列表。雖然這個生成器函數不完全符合python風格,但是它還是非常直觀的,而且它產生了一個正確的FRP流。

在Python中探索函數式響應型編程(FRP)

如何做的更好?

FRP是一種非常有前景的技術,在python中可以非常簡潔人性化的實施。不過在實踐中還是有一些潛在問題的:

1、就我所知,目前還沒有標準的模型來處理結束流,只有無限的數據流。但是在實際中,數據流總會由某種原因而終止。

2、如果你有多個輸入流(像網絡套接字或計時器),則需要並行框架(Twisted Python或asvncio)來管理他們,我會非常高興聽到你能在它們上面構建FRP系統

3、在實踐的過程中需要輸入的同時立即有相應的輸出,但是事實上卻是會有一定的延遲

4、stream_function和stateful_stream_function裝飾器無法處理關鍵字參數或除了明確位置參數之外的其它內容。

5、stream_function和stateful_stream_function裝飾器要求每個參數都是一個數據流。 如果您希望某些參數保持不變,則必須將它們包裝在一個流中,該流將永遠返回相同的值。 這是可以理解的,但是請謹慎使用。

對於我能想到的特殊用途(一個輸入流,即curses模塊中的window.get_wch ),目前還沒有發現問題,可能不同的人處理有不同的效果,所以如果你有問題可以聯繫我。

英文原文:http://zork.net/~st/jottings/FRP_in_Python.html
譯者:天高

相關推薦

推薦中...