函式響應式程式設計(FRP)從入門到”放棄”——基礎概念篇

一縷殤流化隱半邊冰霜發表於2016-07-10
1194012-903db38916fcf0cb
前言

研究ReactiveCocoa一段時間了,是時候總結一下學到的一些知識了。

一.函式響應式程式設計

說道函式響應式程式設計,就不得不提到函數語言程式設計,它們倆到底有什麼關係呢?今天我們就詳細的解析一下他們的關係。

現在有下面4個概念,需要我們理清一下它們之間的關係:
物件導向程式設計 Object Oriented Programming
響應式程式設計 Reactive Programming
函數語言程式設計 Functional Programming
函式響應式程式設計 Functional Reactive Programming

我們先來說說什麼是函數語言程式設計Functional Programming,我們先來看看wikipedia上的相關定義:

Functional Programming is a programming paradigm

  1. treats computation as the evaluation of mathematical functions.
  2. avoids changing-state and mutable data

總結一下函數語言程式設計具有以下幾個特點:

  1. 函式是”第一等公民”
  2. 閉包和高階函式
  3. 不改變狀態(由此延伸出”引用透明”的概念)
  4. 遞迴
  5. 只用”表示式”,不用”語句”,沒有副作用

接下來我們依次說明一下這些特點。

一. 函式是”第一等公民”

所謂”第一等公民”(first class),指的是函式與其他資料型別一樣,處於平等地位,可以賦值給其他變數,也可以作為引數,傳入另一個函式,或者作為別的函式的返回值。

一等函式的理念可以追溯到 Church 的 lambda 演算 (Church 1941; Barendregt 1984)。此後,包括 Haskell,OCaml,Standard ML,Scala 和 F# 在內的大量 (函式式) 程式語言都不同程度地借鑑了這個概念。

Ps:世界上最純粹的函數語言程式設計語言非Haskell莫屬。

二.閉包和高階函式

閉包是起函式的作用並可以像物件一樣操作的物件。與此類似,函數語言程式設計語言支援高階函式。高階函式可以用另一個函式(間接地,用一個表示式) 作為其輸入引數,在大多數情況下,它甚至返回一個函式作為其輸出引數。這兩種結構結合在一起使得可以用優雅的方式進行模組化程式設計,這是使用函數語言程式設計的最大好處。

三. 不改變狀態(由此延伸出”引用透明”的概念)

不改變狀態:
函數語言程式設計只是返回新的值,不修改系統變數。因此,不修改變數,也是它的一個重要特點。在其他型別的語言中,變數往往用來儲存”狀態”(state)。不修改變數,意味著狀態不能儲存在變數中。函數語言程式設計使用引數儲存狀態,最好的例子就是遞迴。

避免使用程式狀態和可變物件,是降低程式複雜度的有效方式之一,而這也正是函數語言程式設計的精髓。函數語言程式設計強調執行的結果,而非執行的過程。我們先構建一系列簡單卻具有一定功能的小函式,然後再將這些函式進行組裝以實現完整的邏輯和複雜的運算,這是函數語言程式設計的基本思想。

引用透明:
如果提供同樣的輸入,那麼函式總是返回同樣的結果。就是說,表示式的值不依賴於可以改變值的全域性狀態。這使您可以從形式上推斷程式行為,因為表示式的意義只取決於其子表示式而不是計算順序或者其他表示式的副作用。

這裡有出現了一個問題:

面試題: 純函式式的閉包是否滿足函數語言程式設計裡面不改變函式狀態的特性?

根據純函式的定義

在計算機程式設計中,假如滿足下面這兩個句子的約束,一個函式可能被描述為一個純函式:

  1. 給出同樣的引數值,該函式總是求出同樣的結果。該函式結果值不依賴任何隱藏資訊或程式執行處理可能改變的狀態或在程式的兩個不同的執行,也不能依賴來自I/O裝置的任何外部的輸入(通常是這樣的–看下面的描述)。
  2. 結果的求值不會促使任何可語義上可觀察的副作用或輸出,例如易變物件的變化或輸出到I/O裝置。

函式的返回值是不需要依賴所有(或任何)引數值,必須不依賴引數值以外的東西。函式可能返回多重結果值,並且對於被認為是純函式的函式,這些條件必須應用到所有返回值。假如一個引數通過引用呼叫,任何內部引數變化將改變函式外部的輸入引數值,它將使函式變為非純函式。

回到我們討論的這個問題上來:

閉包雖然可以把閉包外部的變數捕獲到閉包內部,但是閉包還是滿足不改變狀態的特性的。假設f(x)的返回值是g(x),而g(x)是會依靠f(x)的引數返回的,g(x)相當於擁有f(x)的閉包。這個時候就會有一種錯誤的感覺,g(x)捕捉了f(x)入參的變數,從而產生了不同的閉包。從而得出g(x)不是純函式式的,因為它改變了狀態。如果我們站在更高的層面去看待這個問題,函式在函數語言程式設計裡面是一等值,和結構體,整型,布林型別沒有區別。回到上述的問題中來,由於我們傳入了不同引數,但是閉包裡面的整體演算法是沒有變化的。更加詳細的例子,f(x)返回一個計算x平方的函式g(x),g(x)雖然每次都會由f(x)傳入的x值變化而變化,但是g(x)整體演算法就是計算x的平方,這個計算方法是沒有變化的,不根據外部狀態改變而改變的。那麼這個g(x)的block是滿足函數語言程式設計的不改變函式狀態的特性的。所以它也是引用透明的。

額外需要說明的一點,__block這個關鍵字其實是破壞了函數語言程式設計的。

面試題:如何理解引用透明?

如果一個函式只會受到入參的變化,那麼這個函式每次的呼叫都會是相同的
一個函式f(x),裡面呼叫了g(x),g(x)裡面又呼叫了h(x),h(x)最終計算出了結果,作為f(x)的返回值返回了。如果所有的狀態都沒有改變,f(x)下一次再呼叫相同的引數的時候,應該會得到完全一樣的結果,那這個時候其實不用再呼叫g(x)和h(x)了,也可以得到完全一樣的結果。當一個函式,不依賴“外部”變數和狀態,只依賴入參的變化而影響函式最終返回值,也就是說入參相同,得到的返回值結果一定相同,如果函式具有這種性質,就可以說這個函式是引用透明的。

在上述例子中可以看到,如果result裡面有我們需要的值了,我們就不會再去呼叫回撥的閉包,這樣transparent的函式每次傳入相同的值,肯定會返回相同的結果。

一個純函式在執行的過程中,只跟入參有關,在函式體中並不會引用外部全域性變數,或者說是一個類方法裡面的其他成員變數。另外,純函式除了返回值之外,也不會去改變外部的變數值。滿足上面這兩點的純函式,就可以說它是引用透明的。也有說法叫這種特性為冪等性

四.遞迴

函數語言程式設計是用遞迴做為控制流程的機制。

五.只用”表示式”,不用”語句”,沒有副作用

“表示式”(expression)是一個單純的運算過程,總是有返回值;”語句”(statement)是執行某種操作,沒有返回值。函數語言程式設計要求,只使用表示式,不使用語句。也就是說,每一步都是單純的運算,而且都有返回值。
原因是函數語言程式設計的開發動機,一開始就是為了處理運算(computation),不考慮系統的讀寫(I/O)。”語句”屬於讀寫操作,所以就被排斥在外。
函數語言程式設計強調沒有”副作用”,意味著函式要保持獨立,所有功能就是返回一個新的值,沒有其他行為,尤其是不得修改外部變數的值。

舉個例子來說明一下函數語言程式設計和指令式程式設計的區別:

上面這個例子就是計算階乘的例子。我們先來看看指令式程式設計。指令式程式設計,像機器一條條命令一樣思考問題。指令式的思想就類似於彙編,一條條指令告訴計算機該怎麼去處理這個問題。所以在指令式程式設計裡面就有很多的狀態量語句。而在函數語言程式設計裡面,思想是利用數學方法來思考問題。階乘在數學定義裡面就是f(n) = n * f(n – 1) (n > 1),f(n) = 1(n = 1)。在函數語言程式設計裡面是基本上沒有狀態量,只有表示式,也沒有賦值語句。利用了遞迴解決了問題。

再來看看指令式程式設計和響應式程式設計的區別

在指令式程式設計裡面,計算是一種瞬間的操作。而響應式程式設計,計算是相互相應的,相互之間都存在關係,某些變化了,相互之間的關係會使相應的值隨之變化。響應式程式設計有2個典型的例子:Excel,當單元格變化了,相互之間的單元格也會立即變化。Autolayout,當父View變化了,根據相互之間的關係Constraint,子View的frame也會隨之變化。

在面嚮物件語言中也是可以實現響應式程式設計的,具體做法應該是,把關係抽象出來,然後把變化抽象出來,用關係把變化事件傳遞下去。Cocoa框架下RAC的實現就是如此。

最後再來說說函式響應式程式設計。
首先函式響應式程式設計肯定是滿足函數語言程式設計的上述特性的。函式響應式程式設計是面向離散事件流的,在一個時間軸上會產生一些離散事件,這些事件會依次向下傳遞。

RAC就是Cocoa框架下的函式響應式程式設計的實現。它提供了基於時間變化的資料流的組合和變化。

接著再來說說之前說的4種程式設計正規化,總結出來,如果按照類似繼承圖譜來看的話,應該如下圖:

a1194012-92e858ac89ee627f

首先在宣告式程式設計裡面有2大家族,那就是函數語言程式設計和資料流程式設計,資料流程式設計下面就是響應式程式設計,而函式響應式程式設計是”繼承”於函數語言程式設計和響應式程式設計的。

1194012-b7b30d442802c2d0

物件導向程式設計就屬於指令式程式設計的範疇。從上面2張圖來看,我們可以很明顯看出這4者是什麼關係了。

面試題:函數語言程式設計是物件導向程式設計的升級產品
由上面的說明來看,這個說法肯定是錯誤的,關係根據上面2圖來看就很明顯了。

面試題:函式式語言主張不變數的原因是什麼?

  1. 函式保持獨立,所有功能就是返回一個新的值,沒有其他行為,尤其是不修改外部變數的值。由於這一主張,我們不需要考慮執行緒”死鎖”問題,執行緒之間一定是安全的,因為它不修改變數,所以根本不存在”鎖”執行緒的問題。
  2. 進一步,函式式語言更加趨向於數學公式的推導,在數學公式裡面其實是完全不存在變數這一概念的,此時如果又不存在變數了,那整個程式的執行順序其實就不必要了,這樣可以使我們更加容易的進行併發程式設計,更加有效率的利用多核cpu的計算處理能力。

二.鏈式呼叫

定義:f(x),表示的是一種態射,從x的定義域到f(x)值域的態射。如果定義域和值域是完全相同的話,這種對映也成為單元態射。那麼滿足單元態射的函式,就可以進行鏈式呼叫。

以RAC為例,把RACSignal鏈式傳遞下去,subscribeNext就會返回一個RACSignal,定義域和值域都是RACSignal,那麼就滿足了單元態射的要求,就可以鏈式呼叫下去。

面試題:組成鏈式呼叫的必要條件就是在方法裡面返回物件自己

這個說法是錯誤,舉個例子:RAC每次做訊號變換的時候,都產生了一個新的訊號,所以返回自己就並不是必要條件。其實如果返回自己的同類或者和自己類似的型別,裡面也包含可以繼續鏈式呼叫的方法,也是可以組成鏈式呼叫的。

三.關於RAC的其他一些概念

面試題:ReactiveCocoa是Facebook出的一個FRP開源庫

錯誤,是寫Github客戶端時候的附屬品,附帶開發出的一個開源框架。

面試題:ReactiveCocoa是基於KVO的一個開源庫

錯誤。KVO是RAC非常次要的部分,甚至可以說沒有KVO,RAC依舊可以存在。

面試題:ReactiveCocoa是一個純函數語言程式設計的庫

錯誤,由於Cocoa框架並不是函式式,RAC又是在Cocoa框架下,所以就不是純函式式。在指令式程式設計的語言範疇裡面實現純函式程式設計,需要折中的方法,我們可以封裝指令式程式設計,使其向上層可以形成純函式式的,但是下層肯定就是指令式程式設計實現的。

最後我們再來區分一個概念:

面試題:RAC中Pull-driver和Push-driver的區別?

Pull-driver是指的是任何時刻,我們如果需要資料了,都可以從pull-driver裡面拿走資料,因為資料先儲存了。整個取資料的時間控制在呼叫者手上。典型的例子就是for-in迴圈,這就是一個pull-driver的操作。不管你迴圈幾次,每次迴圈如何操作,陣列或者字典裡面的資料都一直存在在那裡,“躺”在那裡。
Push-driver是相反的,在任何時刻,當有資料或者事件產生,都會push給你,如果你此時沒有處理,該事件或者資料就丟失了。整個取資料的時間並不控制在呼叫者的手裡。

Pull-driver可以類比看書,知識和文字不管你看不看,一直都在書裡。
Push-driver可以類比看電視,節目不管你看不看,都一直播放,你錯過了就是錯過了。

在RAC裡面,Sequence就是一個pull-driver,Signal就是一個push-driver。

未完待續……

我會不定期把關於RAC相關難理解易混淆的概念都整理進來……歡迎大家指點。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

函式響應式程式設計(FRP)從入門到”放棄”——基礎概念篇 函式響應式程式設計(FRP)從入門到”放棄”——基礎概念篇

相關文章