「函數是一等公民」背後的含義
在學習一些語言的時候,你經常會聽到「函數是一等公民」這樣的描述。那麼究竟函數在這類語言中扮演著怎麼樣的一個角色?它和函數式編程、無狀態設計、封裝抽象有什麼千絲萬縷的聯繫?
在本文中,我們用JavaScript為例,娓娓道來這其中的故事。當然了,只是我發現的這一部分……
時間的奧秘
我們從最簡單的五行代碼說起。
是的,我寫JavaScript不加分號。當然,關鍵不是這個……
我們可以很輕鬆地寫出關於這個函數的測試用例來。
但是如果我們引入一個全局的變數C。
這個代碼看起來還是很好測試的,只要你在測試中也能訪問到C這個變數。你修改兩三次C的值,然後運行幾次被測試的函數,大概地看下結果是不是正確「就行了」。
慢著,看似平靜的表象下,就是一切問題的開始。我們編寫一個函數,裡面只是簡單地調用addWithC。
foo在這裡成為了addWithC的一個抽象。你怎麼樣較為全面地測試foo?很顯然,你依然還是要在它的測試裡面去引用到C。
好的,在這裡,C就成為了一種狀態(State),它的變化可以左右函數的輸出。
第二句C = 1的玄妙之處在於,它在這三行代碼中創建了「時間」這個緯度。你可能在想,這是什麼鬼話?
別急,請仔細看。在閱讀這份代碼的時候,我們會說:
在C = 1之前,addWithC(1, 2)的結果是3;在C = 1之後,addWithC(1, 2)的結果是4。
看,這不就是時間嗎?我們在這裡有了之前和之後的概念。這也稱作「副作用」 —— C的變化對addWithC的結果產生了副作用。
如果我們回到引用C這個狀態之前的add函數呢?
我們會說:
add(1, 2)的結果就是3;add(4, 5)的結果就是9
add比addWithC來得好測試。為什麼呢?因為對於固定的輸入,add總是可以有固定的輸出。但是addWithC並不是這樣的,因為在不同的「時間」里(也就是狀態取不同的值的時候),它對於同樣的輸入,不一定有同樣的輸出。
其實這一點在編寫測試的時候,編寫行為描述的時候就可以發現了。在進行行為驅動開發編寫行為描述的時候,我們應該描述清楚被測函數的下面幾個方面
它所期待的輸入是什麼
輸入所對應的輸出是什麼
例如,對於add,我就可以寫道
對於addWithC,我們要寫
看到了吧,通過編寫行為描述,我們發現在單元測試中,竟然還引入了外部變數。這還能叫單元測試嗎?
很多時候,我們可能會選擇破例在單元測試裡面引入狀態,而不去思考重新修改代碼。因此,系統中引入了越來越多的狀態,直到混亂不堪,難以測試……
所以我們看到,在這裡,狀態是導致混亂的最主要原因。實際上,它也是導致很多系統難以測試,經常崩潰的原因。
外部量C何去何從?
但是在很多時候,我們是必須要依賴一些外部的量的,比如剛才的C。我們不希望引入狀態,那麼就有一個辦法,那就是讓C變成常量。
這讓它人不再能夠修改這個量,那麼我們就不必要在測試中引入C這個常量了。測試addWithC的代碼就可以變得非常地簡單:
讓我們思考得更深一點,常量就是什麼?實際上就是一個返回固定值的函數。
因此addWithC實際上可以是這樣的。
那麼這個時候,我們發現C和addWithC都符合一個原則。
輸出僅取決於輸入的參數。
對於這樣的函數,我們又稱之為純函數(Pure function),這個概念非常地重要。
奇妙的事情發生了。在一個無狀態(Stateless)的世界裡,所有的常量都被替換成返回固定值的函數,整個程序的運行無非就是一系列的函數調用。而且,這些函數還都是純函數!等等,這難道不就是——
函數是一等公民。(Function is first-class citizen)
這是學過JavaScript語言的人都耳熟能詳一句話了,但是還是不夠準確。畢竟在無狀態的世界裡,我們就可以用函數來抽象出所有的量了,那麼更準確地說——
函數是唯一的一等公民。(Function is the one and only first-class citizen)
我還是不滿意,我必須強調「純函數」這個概念。
純函數是唯一的一等公民。(Pure function is the one and only first-class citizen)
這樣做的目的只有一個,沒有副作用。
好了,所有複雜的問題都解決了,我們不要變數,只要常量,所有的事情都用一層層的純函數調用來解決。程序員們解散吧,這麼簡單的事情,用不著那麼多人來做……
呵呵。
無狀態的烏托邦
上面說的這個世界太理想了。
程序語言給予了我們賦值的能力,給予了我們變數,難道我們就輕易地將它們拋棄嗎?當然不是的。在一個局限的小範圍內,實際上使用狀態還是沒有問題的。例如,一個簡單的for循環本身也是Stateful的。
這裡的result本身依賴於i的取值,i也是一個狀態。但是,如果它們被放在一個函數里:
我們來審視seriesSum。其輸出依然是取決於其輸入,哦耶!它還是一個純函數,雖然它內部不是純函數。seriesSum依然是一個很容易測試的單元。
需要注意的一點是,如果一個函數的輸出取決於一個非純函數的輸出的話,那麼它一定也不是純函數。例如下面的場景中
依賴注入(Dependency Injection)
如果你接觸過Angular.js,你一定知道依賴注入(Dependency Injection)。
純函數之所以易於測試,從某種角度上說是因為它的所有依賴就是它的參數,所以我們可以很容易地在測試的時候模擬其所有需要的依賴的變化進行測試。
依賴注入通過給所有我們需要用到的函數、量統一包裝,也能實現類似的效果。
例如在上面的例子中,如果我們要測試serviceId、directiveName或者filterName的話,那麼只需要注入depService就好了。所以,依賴注入提供了跟虛函數一樣的依賴跟蹤性質,並且相對而言更加分散。但是依賴注入並不能保證每個模塊暴露出來的都是虛函數。
面向對象怎麼辦?
好問題。(咦,好像誇的是我自己……)
一個對象內部的屬性如果發生了變化,那麼這個對象本質上就不再是之前那個對象了。例如下面的類:
我們不希望這樣的事情發生,但又希望做出良好的封裝性,那麼怎麼辦呢?答案是讓類實例不可變(Immuatable)。每次在對象內部的屬性變化的時候,我們不直接修改這個對象,而是返回一個新的對象。
這樣做的理由很簡單,產生一個新的對象不會對現有的對象產生影響,因此這個操作是沒有副作用的,符合我們前面提到的我們的目標。
在JavaScript的世界裡面,我們有Immutable.js。Immutable.js封裝了JavaScript原生類型的Immutable版本。例如Immutable.Map就是一個例子。
實際上,在immutable的世界裡,每一個對象永遠都是它自己,不會被修改。所以,它可以被視為一個常量,被視為一個返回常量的值。這裡精彩的部分在於:
Hey,Immutable將變數給常量化了!
顯而易見,這樣做看似會導致很多不必要的內存開銷。其實Immutable數據結構本身會重複利用很多的內存空間,例如鏈表、Map之類的數據結構,庫都會盡量重用可以重用的部分。
在實在無法重用的時候,完全複製在99%的情況下也是沒有任何問題的。現在內存那麼便宜,你確定你真的對那不必要的幾KB幾MB的開銷很上心嗎?大部分時候,並沒有必要節約那一點內存,尤其是在瀏覽器端。
JavaScript與函數式編程
最後回到我們最熟悉的JavaScript的函數式編程上來,驗證我們之前的一些發現。
首先,map、filter返回的都是一個新的數組,不對原有的數組進行修改。這裡就表現出了Immutable的特性。其次,我們注意到map、filter和forEach函數都不依賴外界的狀態。因此我們可以很容易地把它們拉出來測試。
如果我們依賴了外界的狀態,那麼就再也不是函數式編程了。
總結
總結下來,保持兩點可以讓我們的應用維護、測試複雜度顯著降低。
第一點就是編寫純函數,保持Stateless,並對其進行測試。需要記住的是,我們不需要將所有的東西都變成Stateless的,至於如何設計那就真的是看經驗了。
第二點就是應用Immutable數據結構,將變數常量化。
無論採用什麼方法,總體目標就是消除副作用。這也是函數作為一等公民,將過程和量統一背後的實際意義。
原文:http://blog.leapoahead.com/2015/09/19/function-as-first-class-citizen/
點擊展開全文


※JavaScript 中的面向對象編程
※如何學習Javascript
※當一個程序員寫不出代碼了,該怎麼辦?
※誰說 JavaScript 簡單的?
※菊花綻放:微信是如何識別小程序碼的?
TAG:JavaScript |
※「單位圓」在「三角函數」中的作用太重要,原來是這樣
※如果一個函數作為另一個函數參數使用,那麼這函數叫做回調函數
※JS中把函數作為另一函數的參數傳遞方法(總結)
※曝光是一個函數
※c語言 實現一個函數,判斷一個數是不是素數
※虛函數不能定義為內聯函數
※什麼是窗函數?
※DAX中的表函數和值函數
※在函數中調用函數
※遞歸函數及匿名函數配合內置函數的使用
※函數的默認值
※人的命運真的如同數學函數一樣,註定好了有其規律嗎?
※工作中常用的十個函數公式,必須掌握
※c++虛函數和純虛函數的幾點說明
※關於c語言中函數的調用的兩種方法
※前端程序員必須掌握之三角函數在前端動畫中的應用
※函數參數的傳遞
※利用導數研究含參函數的性質
※MID函數與數組公式,跟輔助列說再見
※函數聲明與函數表達式