摘要:函數式編程導論從屬于筆者的前端入門與工程實踐。函數式編程即是在軟件開發的工程中避免使用共享狀態可變狀態以及副作用。
JavaScript 函數式編程JavaScript 函數式編程導論從屬于筆者的Web 前端入門與工程實踐。本文很多地方是講解函數式編程的優勢,就筆者個人而言是認可函數式編程具有一定的好處,但是不推崇徹底的函數式編程化,特別是對于復雜應用邏輯的開發。筆者在應用的狀態管理工具中就更傾向于使用MobX而不是Redux,詳見2016-我的前端之路:工具化與工程化。
近年來,函數式編程(Functional Programming)已經成為了JavaScript社區中炙手可熱的主題之一,無論你是否欣賞這種編程理念,相信你都已經對它有所了解。即使是前幾年函數式編程尚未流行的時候,我已經在很多的大型應用代碼庫中發現了不少對于函數式編程理念的深度實踐。函數式編程即是在軟件開發的工程中避免使用共享狀態(Shared State)、可變狀態(Mutable Data)以及副作用(Side Effects)。函數式編程中整個應用由數據驅動,應用的狀態在不同純函數之間流動。與偏向命令式編程的面向對象編程而言,函數式編程其更偏向于聲明式編程,代碼更加簡潔明了、更可預測,并且可測試性也更好。。函數式編程本質上也是一種編程范式(Programming Paradigm),其代表了一系列用于構建軟件系統的基本定義準則。其他編程范式還包括面向對象編程(Object Oriented Programming)與過程程序設計(Procedural Programming)。
純函數顧名思義,純函數往往指那些僅根據輸入參數決定輸出并且不會產生任何副作用的函數。純函數最優秀的特性之一在于其結果的可預測性:
var z = 10; function add(x, y) { return x + y; } console.log(add(1, 2)); // prints 3 console.log(add(1, 2)); // still prints 3 console.log(add(1, 2)); // WILL ALWAYS print 3
在add函數中并沒有操作z變量,即沒有讀取z的數值也沒有修改z的值。它僅僅根據參數輸入的x與y變量然后返回二者相加的和。這個add函數就是典型的純函數,而如果在add函數中涉及到了讀取或者修改z變量,那么它就失去了純潔性。我們再來看另一個函數:
function justTen() { return 10; }
對于這樣并沒有任何輸入參數的函數,如果它要保持為純函數,那么該函數的返回值就必須為常量。不過像這種固定返回為常量的函數還不如定義為某個常量呢,就沒必要大材小用用函數了,因此我們可以認為絕大部分的有用的純函數至少允許一個輸入參數。再看看下面這個函數:
function addNoReturn(x, y) { var z = x + y }
注意這個函數并沒有返回任何值,它確實擁有兩個輸入參數x與y,然后將這兩個變量相加賦值給z,因此這樣的函數也可以認為是無意義的。這里我們可以說,絕大部分有用的純函數必須要有返回值。總結而言,純函數應該具有以下幾個特效:
絕大部分純函數應該擁有一或多個參數值。
純函數必須要有返回值。
相同輸入的純函數的返回值必須一致。
純函數不能夠產生任何的副作用。
共享狀態與副作用共享狀態(Shared State)可以是存在于共享作用域(全局作用域與閉包作用域)或者作為傳遞到不同作用域的對象屬性的任何變量、對象或者內存空間。在面向對象編程中,我們常常是通過添加屬性到其他對象的方式共享某個對象。共享狀態問題在于,如果開發者想要理解某個函數的作用,必須去詳細了解該函數可能對于每個共享變量造成的影響。譬如我們現在需要將客戶端生成的用戶對象保存到服務端,可以利用saveUser()函數向服務端發起請求,將用戶信息編碼傳遞過去并且等待服務端響應。而就在你發起請求的同時,用戶修改了個人頭像,觸發了另一個函數updateAvatar()以及另一次saveUser()請求。正常來說,服務端會先響應第一個請求,并且根據第二個請求中用戶參數的變更對于存儲在內存或者數據庫中的用戶信息作相應的修改。不過某些意外情況下,可能第二個請求會比第一個請求先到達服務端,這樣用戶選定的新的頭像反而會被第一個請求中的舊頭像覆寫。這里存放在服務端的用戶信息就是所謂的共享狀態,而因為多個并發請求導致的數據一致性錯亂也就是所謂的競態條件(Race Condition),也是共享狀態導致的典型問題之一。另一個共享狀態的常見問題在于不同的調用順序可能會觸發未知的錯誤,這是因為對于共享狀態的操作往往是時序依賴的。
const x = { val: 2 }; const x1 = () => x.val += 1; const x2 = () => x.val *= 2; x1(); x2(); console.log(x.val); // 6 const y = { val: 2 }; const y1 = () => y.val += 1; const y2 = () => y.val *= 2; // 交換了函數調用順序 y2(); y1(); // 最后的結果也受到了影響 console.log(y.val); // 5
副作用指那些在函數調用過程中沒有通過返回值表現的任何可觀測的應用狀態變化,常見的副作用包括但不限于:
修改任何外部變量或者外部對象屬性
在控制臺中輸出日志
寫入文件
發起網絡通信
觸發任何外部進程事件
調用任何其他具有副作用的函數
在函數式編程中我們會盡可能地規避副作用,保證程序更易于理解與測試。Haskell或者其他函數式編程語言通常會使用Monads來隔離與封裝副作用。在絕大部分真實的應用場景進行編程開始時,我們不可能保證系統中的全部函數都是純函數,但是我們應該盡可能地增加純函數的數目并且將有副作用的部分與純函數剝離開來,特別是將業務邏輯抽象為純函數,來保證軟件更易于擴展、重構、調試、測試與維護。這也是很多前端框架鼓勵開發者將用戶的狀態管理與組件渲染相隔離,構建松耦合模塊的原因。
不變性不可變對象(Immutable Object)指那些創建之后無法再被修改的對象,與之相對的可變對象(Mutable Object)指那些創建之后仍然可以被修改的對象。不可變性(Immutability)是函數式編程的核心思想之一,保證了程序運行中數據流的無損性。如果我們忽略或者拋棄了狀態變化的歷史,那么我們很難去捕獲或者復現一些奇怪的小概率問題。使用不可變對象的優勢在于你在程序的任何地方訪問任何的變量,你都只有只讀權限,也就意味著我們不用再擔心意外的非法修改的情況。另一方面,特別是在多線程編程中,每個線程訪問的變量都是常量,因此能從根本上保證線程的安全性。總結而言,不可變對象能夠幫助我們構建簡單而更加安全的代碼。
在JavaScript中,我們需要搞清楚const與不可變性之間的區別。const聲明的變量名會綁定到某個內存空間而不可以被二次分配,其并沒有創建真正的不可變對象。你可以不修改變量的指向,但是可以修改該對象的某個屬性值,因此const創建的還是可變對象。JavaScript中最方便的創建不可變對象的方法就是調用Object.freeze()函數,其可以創建一層不可變對象:
const a = Object.freeze({ foo: "Hello", bar: "world", baz: "!" }); a.foo = "Goodbye"; // Error: Cannot assign to read only property "foo" of object Object
不過這種對象并不是徹底的不可變數據,譬如如下的對象就是可變的:
const a = Object.freeze({ foo: { greeting: "Hello" }, bar: "world", baz: "!" }); a.foo.greeting = "Goodbye"; console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);
如上所見,頂層的基礎類型屬性是不可以改變的,不過如果對象類型的屬性,譬如數組等,仍然是可以變化的。在很多函數式編程語言中,會提供特殊的不可變數據結構Trie Data Structures來實現真正的不可變數據結構,任何層次的屬性都不可以被改變。Tries還可以利用結構共享(Structural Sharing)的方式來在新舊對象之間共享未改變的對象屬性值,從而減少內存占用并且顯著提升某些操作的性能。JavaScript中雖然語言本身并沒有提供給我們這個特性,但是可以通過Immutable.js與Mori這些輔助庫來利用Tries的特性。我個人兩個庫都使用過,不過在大型項目中會更傾向于使用Immutable.js。估計到這邊,很多習慣了命令式編程的同學都會大吼一句:在沒有變量的世界里我又該如何編程呢?不要擔心,現在我們考慮下我們何時需要去修改變量值:譬如修改某個對象的屬性值,或者在循環中修改某個循環計數器的值。而函數式編程中與直接修改原變量值相對應的就是創建原值的一個副本并且將其修改之后賦予給變量。而對于另一個常見的循環場景,譬如我們所熟知的for,while,do,repeat這些關鍵字,我們在函數式編程中可以使用遞歸來實現原本的循環需求:
// 簡單的循環構造 var acc = 0; for (var i = 1; i <= 10; ++i) acc += i; console.log(acc); // prints 55 // 遞歸方式實現 function sumRange(start, end, acc) { if (start > end) return acc; return sumRange(start + 1, end, acc + start) } console.log(sumRange(1, 10, 0)); // prints 55
注意在遞歸中,與變量i相對應的即是start變量,每次將該值加1,并且將acc+start作為當前和值傳遞給下一輪遞歸操作。在遞歸中,并沒有修改任何的舊的變量值,而是根據舊值計算出新值并且進行返回。不過如果真的讓你把所有的迭代全部轉變成遞歸寫法,估計得瘋掉,這個不可避免地會受到JavaScript語言本身的混亂性所影響,并且迭代式的思維也不是那么容易理解的。而在Elm這種專門面向函數式編程的語言中,語法會簡化很多:
sumRange start end acc = if start > end then acc else sumRange (start + 1) end (acc + start)
其每一次的迭代記錄如下:
sumRange 1 10 0 = -- sumRange (1 + 1) 10 (0 + 1) sumRange 2 10 1 = -- sumRange (2 + 1) 10 (1 + 2) sumRange 3 10 3 = -- sumRange (3 + 1) 10 (3 + 3) sumRange 4 10 6 = -- sumRange (4 + 1) 10 (6 + 4) sumRange 5 10 10 = -- sumRange (5 + 1) 10 (10 + 5) sumRange 6 10 15 = -- sumRange (6 + 1) 10 (15 + 6) sumRange 7 10 21 = -- sumRange (7 + 1) 10 (21 + 7) sumRange 8 10 28 = -- sumRange (8 + 1) 10 (28 + 8) sumRange 9 10 36 = -- sumRange (9 + 1) 10 (36 + 9) sumRange 10 10 45 = -- sumRange (10 + 1) 10 (45 + 10) sumRange 11 10 55 = -- 11 > 10 => 55 55高階函數
函數式編程傾向于重用一系列公共的純函數來處理數據,而面向對象編程則是將方法與數據封裝到對象內。這些被封裝起來的方法復用性不強,只能作用于某些類型的數據,往往只能處理所屬對象的實例這種數據類型。而函數式編程中,任何類型的數據則是被一視同仁,譬如map()函數允許開發者傳入函數參數,保證其能夠作用于對象、字符串、數字,以及任何其他類型。JavaScript中函數同樣是一等公民,即我們可以像其他類型一樣處理函數,將其賦予變量、傳遞給其他函數或者作為函數返回值。而高階函數(Higher Order Function)則是能夠接受函數作為參數,能夠返回某個函數作為返回值的函數。高階函數經常用在如下場景:
利用回調函數、Promise或者Monad來抽象或者隔離動作、作用以及任何的異步控制流
構建能夠作用于泛數據類型的工具函數
函數重用或者創建柯里函數
將輸入的多個函數并且返回這些函數復合而來的復合函數
典型的高階函數的應用就是復合函數,作為開發者,我們天性不希望一遍一遍地重復構建、測試與部分相同的代碼,我們一直在尋找合適的只需要寫一遍代碼的方法以及如何將其重用于其他模塊。代碼重用聽上去非常誘人,不過其在很多情況下是難以實現的。如果你編寫過于偏向具體業務的代碼,那么就會難以重用。而如果你把每一段代碼都編寫的過于泛化,那么你就很難將這些代碼應用于具體的有業務場景,而需要編寫額外的連接代碼。而我們真正追尋的就是在具體與泛化之間尋求一個平衡點,能夠方便地編寫短小精悍而可復用的代碼片,并且能夠將這些小的代碼片快速組合而解決復雜的功能需求。
在函數式編程中,函數就是我們能夠面向的最基礎代碼塊,而在函數式編程中,對于基礎塊的組合就是所謂的函數復合(Function Composition)。我們以如下兩個簡單的JavaScript函數為例:
var add10 = function(value) { return value + 10; }; var mult5 = function(value) { return value * 5; };
如果你習慣了使用ES6,那么可以用Arrow Function重構上述代碼:
var add10 = value => value + 10; var mult5 = value => value * 5;
現在看上去清爽多了吧,下面我們考慮面對一個新的函數需求,我們需要構建一個函數,首先將輸入參數加10然后乘以5,我們可以創建一個新函數如下:
var mult5AfterAdd10 = value => 5 * (value + 10)
盡管上面這個函數也很簡單,我們還是要避免任何函數都從零開始寫,這樣也會讓我們做很多重復性的工作。我們可以基于上文的add10與mult5這兩個函數來構建新的函數:
var mult5AfterAdd10 = value => mult5(add10(value));
在mult5AfterAdd10函數中,我們已經站在了add10與mult5這兩個函數的基礎上,不過我們可以用更優雅的方式來實現這個需求。在數學中,我們認為f ° g是所謂的Function Composition,因此`f ° g可以認為等價于f(g(x)),我們同樣可以基于這種思想重構上面的mult5AfterAdd10。不過JavaScript中并沒有原生的Function Composition支持,在Elm中我們可以用如下寫法:
add10 value = value + 10 mult5 value = value * 5 mult5AfterAdd10 value = (mult5 << add10) value
這里的<<操作符也就指明了在Elm中是如何組合函數的,同時也較為直觀的展示出了數據的流向。首先value會被賦予給add10,然后add10的結果會流向mult5。另一個需要注意的是,(mult5 << add10)中的中括號是為了保證函數組合會在函數調用之前。你也可以組合更多的函數:
f x = (g << h << s << r << t) x
如果在JavaScript中,你可能需要以如下的遞歸調用來實現該功能:
g(h(s(r(t(x)))))
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/81159.html
摘要:循環的函數式改造翻譯自。循環的設計思想深受可變狀態與副作用的影響,不過函數式編程中認為可變狀態與副作用是導致潛在錯誤與不可預測性的罪魁禍首,是應該盡力避免的模式。 JavaScript For 循環的函數式改造翻譯自Rethinking JavaScript: Death of the For Loop。前兩天筆者整理了一篇JavaScript 函數式編程導論,筆者個人不是很喜歡徹底的...
摘要:本文最早為雙十一而作,原標題雙大前端工程師讀書清單,以付費的形式發布在上。發布完本次預告后,捕捉到了一個友善的吐槽讀書清單也要收費。這本書便從的異步編程講起,幫助我們設計快速響應的網絡應用,而非簡單的頁面。 本文最早為雙十一而作,原標題雙 11 大前端工程師讀書清單,以付費的形式發布在 GitChat 上。發布之后在讀者圈群聊中和讀者進行了深入的交流,現免費分享到這里,不足之處歡迎指教...
摘要:本文最早為雙十一而作,原標題雙大前端工程師讀書清單,以付費的形式發布在上。發布完本次預告后,捕捉到了一個友善的吐槽讀書清單也要收費。這本書便從的異步編程講起,幫助我們設計快速響應的網絡應用,而非簡單的頁面。 本文最早為雙十一而作,原標題雙 11 大前端工程師讀書清單,以付費的形式發布在 GitChat 上。發布之后在讀者圈群聊中和讀者進行了深入的交流,現免費分享到這里,不足之處歡迎指教...
閱讀 2060·2021-11-23 09:51
閱讀 3353·2021-09-28 09:36
閱讀 1120·2021-09-08 09:35
閱讀 1758·2021-07-23 10:23
閱讀 3258·2019-08-30 15:54
閱讀 2998·2019-08-29 17:05
閱讀 438·2019-08-29 13:23
閱讀 1294·2019-08-28 17:51