有時候,一個關鍵字就是一扇通往新世界的大門。兩年前,身邊開始有人討論函數語言程式設計,拿關鍵字Functional Programming一搜,全是新鮮的概念和知識,順藤摸瓜,看到的技術文章和框架也越來越多。
我有個習慣,在接收新知識的時候,我都會用已有的知識去做對比,我更關注新事物能對現有產品和知識體系帶來哪些好處。
計算機發展到今天,已經很久很久沒有理論層面的升級了,現今絕大部分新的知識都是基於已有的核心做包裝。函數語言程式設計不是新生事物,也不是獨立的知識孤島,函數語言程式設計核心思想離我們也沒那麼遠。
這篇文章會拋開陌生的術語和概念,只站在抽象和基礎的層面,去聊下函數語言程式設計和我們現有程式設計習慣的聯絡。
Functional Programming翻譯為函數語言程式設計,初次接觸的時候會不由自主的認為,這種程式設計正規化的核心在於對Functional的理解,或者說是對函式的理解。函式我們每天都在寫,還有什麼需要特別去理解的嗎?我個人覺得這是個誤區,相對於理解「函式」,我們更需要理解的其實是「狀態」。如果叫做Stateless Functional Programming可能會更貼切一點。
狀態
函式和狀態是人人都熟悉的概念,可越是簡單的每日可見的概念,越難理解的透徹。說到狀態,很多同學會聯想到變數,區域性變數,全域性變數,property,model,這些都可以成為狀態,但變數和狀態又不是一回事,要真正理解狀態,得先理解下面一行程式碼:
1 |
int i = 0 |
簡單的一行程式碼,分析起來卻有不少門道。
「i」就是我們所說的變數,一個變數可以看做是一個實體,真實存在於記憶體空間的實體。int是它的型別資訊,是對於它的一種約束,類似於上帝對於人類性別的約束,每個人都需要有性別。0是它被賦予的一個值,值是外部資訊,類似於人的職業,職業不是與生俱來的,人一生可以選擇從事不同的職業。
變數是我們要分析的目標,它的型別資訊,值資訊雖然會約束變數的行為,但不是我們關注的重點,真正讓變數變得危險的是中間的等號,=是個賦值操作,意味著改變i的值,原本處於靜態的i,由於一個=發生了變化,它的值發生了變化,它可以變為1,或者10000,或者其他任何值,這個看似簡單的改變可以說是我們程式的bug之源,值的變化可以像扔進湖面的石頭,層層疊疊影響其他空間和實體。
一旦一個變數開始與=打交道,一旦變數的值會發生變化,我們就可以說這個變數有了狀態。或者我們可以說,有=就有狀態。
狀態也是個相對的概念,變數都有其生命週期,一旦變數被回收,其所包含的狀態也隨之消失,所以狀態所帶來的影響是受限於變數的生命週期的。我們看下這段程式碼:
1 2 3 4 5 |
- (int)doNothing { int i = 0; return i; } |
i是函式doNothing內部的臨時變數,分配在記憶體的棧上,一旦return,i的生命週期也隨之結束。
站在doNothing函式內部這個空間範疇來說,i是有狀態的,i被賦予了值0,當renturn執行之後,i被記憶體回收了,i隨之消失,其所對應的狀態也消失了,所以一旦出了doNothing,i又變得沒有狀態了。程式碼雖然執行了return i,但返回的其實是i所代表的值,i將自己的值交出來之後,就完成了自己的使命。
所以站在doNothing函式外部空間的角度來說,doNothing的使用者是感受不到i的存在的,doNothing的呼叫方可以認為doNothing是無狀態(stateless)的,無狀態意味著靜止,靜止的事物都是安全的,飛馳而過的火車和靜止的石塊,當然是後者感覺更安全。
我們編寫程式碼的時候會經常談論狀態,函式的狀態,類的狀態,App的狀態,歸根結底,我們所討論的是:在某個空間範疇內會發生變化的變數。
函數語言程式設計當中的函式f(x)強調無狀態,其實是強調將狀態鎖定在函式的內部,一個函式它不依賴於任何外部的狀態,只依賴於它的入參的值,一旦值確定,這個函式所返回的結果就是確定的。可能有人會覺得入參也是狀態,是外部傳入的狀態,其實不然,我前面說過變數才會有狀態,值是沒有狀態的,入參傳入的只是值,而不是變數。下面兩個函式,一個入參是傳值,一個入參是傳變數:
1 2 3 4 5 6 7 8 9 |
- (void)doNothing:(int)v //傳值 { } - (void)doNothing:(NSMutableArray*)arr //傳變數 { } |
第二個版本的doNothing,不但是傳入了變數,還是可以變化的變數,是真正意義上的外部狀態。很有可能在你遍歷這個arr的時候,外部某個同時執行的執行緒正在嘗試改變這個arr裡的元素,是不是很危險?
所以對於下面兩種呼叫來說:
1 2 3 |
[self doNothing:user.userID]; [self doNothing:user.friends]; |
第一個呼叫只是傳入了userID所對應的值,第二個呼叫卻傳入了friends這個變數實體。第一個沒依賴,第二個有依賴,第一個沒狀態,對呼叫方來說是安全的,對整個app來說也是安全的,既避免了依賴外部的狀態,也不會修改外部的狀態,即:不會產生side effect,沒有副作用。
所以讓我來總結函數語言程式設計當中的函式,可以一句話歸結為:隔絕一切外部狀態,傳入值,輸出值。
我們再來看看函數語言程式設計當中的純函式(Pure Function)的定義:
In computer programming, a function may be considered a pure function if both of the following statements about the function hold:
- The function always evaluates the same result value given the same argument value(s). The function result value cannot depend on any hidden information or state that may change while program execution proceeds or between different executions of the program, nor can it depend on any external input from I/O devices (usually—see below).
- Evaluation of the result does not cause any semantically observable side effect or output, such as mutation of mutable objects or output to I/O devices (usually—see below).
純函式即為函數語言程式設計所強調的函式,上述兩點可翻譯為:
- 不依賴外部狀態
- 不改變外部狀態
所以對函數語言程式設計當中函式的理解,最後還是落實到狀態的理解。靜止的狀態是安全的,變化的狀態是危險的,之所以危險可以從兩個維度去理解,時間和空間。
時間
變數一旦有了狀態,它就有可能隨著時間而發生變化,時間是最不可預知的因素,時間會將我們引至什麼樣的遠方不得而知,我們每創造一個變數,真正控制它的不是我們,是時間。
時間的武器是變化,是賦值,賦予變數新的值,在不可預知的未來埋下隱患。
1 2 3 4 5 6 |
- (void)setUserName:(NSString*)name { //before assignment self.userName = name; //after assignment } |
一旦有了賦值操作,時間就找到了空隙,可以對我們程式碼的執行產生影響。或許是在此刻,或許是明天,或許是在appDidFinishLaunch,或許是在didReceiveMemoryWarning。每一個賦值操作都是一顆種子,可以結出新feature或者新bug。
變數會隨著時間變化,有狀態的函式也會隨著時間的流動產生不同的輸出,Pure Function卻是對時間免疫的,純函式沒有狀態,無論站在多長的時間跨度去執行一個純函式,它所輸出的結果永遠不會變,從這一角度看,純函式處於永恆的靜止狀態。
空間
如果把一個執行緒看成一個獨立的空間,在程式的世界當中,空間會產生交叉重疊。一個變數如果可以被兩個執行緒同時訪問,它的值如果可以在兩個空間發生變化,這個變數同樣變得很危險。
Pure Function同樣是對空間免疫的,無論多少個執行緒同時執行一個純函式,純函式總是產生相同的輸出,而且不會對外部環境產生任何干擾。
多執行緒的bug除錯起來非常困難,因為我們的大腦並不擅長多路併發的思考方式,而函數語言程式設計可以幫我們解決這一痛點,每一個純函式都是執行緒安全的。
離不開的狀態
函數語言程式設計通過Pure Function,使得我們的程式碼經得起時間和空間的考驗。
我們可以把一個App的程式碼按照函數語言程式設計的方式,打散成一個個合格的pure function,再通過某種方式串聯起來,要方便的串聯函式,需要把函式變為一等公民,需要能像使用變數一樣方便的使用函式。
一個Pure Function可以是stateless的,但我們的App可以變成stateless嗎?顯然不能。
離開了變數和狀態,我們很難完整的描述業務。使用者購物車裡的商品總是會發生變化,今天或明天,我們總是需要在一個地方接收這種變化,儲存這種變化,繼而反應這種變化。所以,大多數時候,我們離不開狀態,但我們能做的是,將一定會變化的狀態,鎖定在儘可能小的時間和空間跨度之內,通過改變程式碼的組織方式或架構,將必須改變的難以管教的狀態,囚禁在特定的模組程式碼之中,讓不可控變得儘量可控。
其實,即使不嚴格遵從函數語言程式設計,我們同樣可以寫出帶有Functional Programming精髓的程式碼,一切的一切,都是對於狀態(state)的理解。
在我看來,NSMutableArray的copy,也是頗具函數語言程式設計精髓的。
一等公民(First Class)
當我們把函式改造成pure function之後,會產生一些奇妙的化學連鎖反應,這些反應甚至會改變我們的程式設計習慣。
一旦我們有了絕對安全的純函式,我們當然希望能盡最大可能的去發揮它的價值,增加它出現和被使用的場景。為了加大純函式的使用率,我們需要在語言層面做一些改造或者增強,以提高純函式傳遞性。怎麼增強呢?答案是將函式變為一等公民。
何謂公民?有身份證才叫公民,有身份證還能自由遷徙的就叫一等公民。
當我們的變數可以指向函式時,這個變數就有了函式的身份。當我們把這個變數當做函式的引數傳入,或者函式的返回值傳出的時候,這個變數就有了自由遷徙的能力。
一個函式A,可以接收另一個函式B作為引數,然後再返回第三個函式C作為返回值。類似下面的一段swift程式碼:
1 2 3 4 5 |
func funcA(funcB: @escaping (Int) -> Int) -> (Int) -> Int { return { input in return funcB(input) } //funcC } |
在funcA的定義裡,funcB是作為引數傳入,funcC(匿名的)是作為返回值返回。funcB和funcC在這個語境裡就稱之為first class function。而funcA作為funcB和funcC的管理者,有個更高階的稱謂:high order function。
有了first class function和high order function,我們還會收穫另一個成果:語言的表達力更靈活,更簡潔,更強大了。舉個例子,我們寫一段程式碼來實現一個功能:參加party前選一件衣服。用傳統的方式來寫:
1 2 3 4 5 6 7 8 9 10 11 |
func chooseColor(gender: Int) -> Int { return 0 } func dressup(dressColor: Int) -> Int { return 1 } //imperative let dressColor = chooseColor(gender: 1) let dress = dressup(dressColor: dressColor) user.dress = dress |
先定義函式,再分三步依次呼叫chooseColor, dressup,然後賦值。
如果用first class function和high order function的方式來寫就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func gotoParty(dressup: @escaping (Int) -> Int, chooseColor: @escaping (Int) -> Int) -> (Int) -> Int { return { gender in let dressColor = chooseColor(gender) return dressup(dressColor) } } //declarative let prepare = gotoParty(dressup: { color in return 1 }, chooseColor: { gender in return 0 }) user.dress = prepare(1) |
gotoParty函式柔和了dressup和chooseColor,gotoParty成了一個high order function,當我們讀gotoParty的程式碼的時候,這單一一個函式就將我們的目的和結果都表明了。
這就是high order function的神奇之處,原先囉囉嗦嗦的幾句話變成一句話就說清楚了,它更接近我們自然語言的表達方式,比如gotoParty可以這樣閱讀:我要挑選一件顏色適合的衣服去參加party,這樣的程式碼是不是語意更簡潔更美呢?
注意,functional programming並不會減少我們的程式碼量,它改變的只是我們書寫程式碼的方式。
這種更為強大的表達力我們也有個行話來稱呼它:declarative programming。而我們傳統的程式碼表達方式(OOP當中所使用的方式)則叫做:imperative programming。
imperative programming更強調實現的步驟,而declarative programming則重在表達我們想要的結果。這句話理解起來可能有些抽象,實在理解不了也沒啥關係,只要記住declarative programming能更簡潔精煉的表達我們想要的結果即可。
以上都是我們將function變為一等公民所產生的結果,這一改變還有更多的妙用,比如lazy evaluation。
上述程式碼中的dressup和chooseColor雖然都是function,但是他們在傳入gotoParty的時候並不會立馬執行(evaluation),而是等gotoParty被執行的時候再一起執行。這也很大程度上增強了我們的表達能力,dressup和chooseColor都具備了lazy evaluation的屬性,可以被拼裝,被delay,最後在某一時刻才被執行。
所以,functional programming改變了我們使用函式的方式,之前使用OOP,我們對於怎麼處理變數(定義變數,修改值,傳遞值,等)輕車熟路,到了函數語言程式設計的世界,我們要學會如何同函式打交道了,要能像使用變數一樣靈活自如的使用函式,這在剛開始的時候確實需要一段適應期。
總結
函數語言程式設計近幾年頗受技術圈的關注,Peak君覺得對於新接觸的知識,我們更應該關注其誕生的目的,及其背後隱含的思想,抓住了本質,理解那些令人望而生畏的技術術語就更有底氣了。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!