標籤:gen article 階乘 鼓勵 資料庫 修改 turn 支援 理解
原文:https://bethallchurch.github.io/JavaScript-and-Functional-Programming/
譯文:http://www.zcfy.cc/article/1013
譯者註:推薦一篇譯文,《函數式編程術語解析》。
本文是我在 2016 年 7 月 29 號聽 Kyle Simpson 精彩的課程《Functional-Light JavaScript》時所做的筆記(外加個人的深入研究)(投影片在這)。
長久以來,物件導向在 JavaScript 編程範式中佔據著主導地位。不過,最近人們對函數式編程的興趣正在增長。函數式編程是一種編程風格,它強調將程式狀態變化(即副作用[side effect])的次數減到最小。因此,函數式編程鼓勵使用不可變資料(immutable data)和純函數(pure functions)(“純”意味著沒有副作用的)。它也更傾向於使用聲明式的風格,鼓勵使用命名良好的函數,這樣就能使用在我們視線之外的那些打包好的細節實現,通過描述希望發生什麼以進行編碼。
儘管物件導向編程與函數式編程之間有些矛盾,它們卻並非互斥的關係。JavaScript 所擁有的工具,能支援這兩種方式。甚至可以說,就算不把它孤立地當作函數式語言使用,還是有不少來自函數式方法的概念和最佳實務可以協助我們,讓代碼更乾淨,可讀性更強,推理起來更簡單。
副作用最小化
所謂副作用,指的是函數內部產生了超出函數之外的變化。函數可能會做一些事,如操作 DOM、修改更高層範圍中的變數值,或者將資料寫入資料庫。這些帶來的就是副作用。
// 有副作用的函數:修改更高層範圍中的變數值var x = 10;const myFunc = function ( y ) { x = x + y;};myFunc( 3 );console.log( x ); // 13myFunc( 3 );console.log( x ); // 16
副作用並非天生邪惡。不產生任何副作用的程式也無法影響世界,因此也沒有任何意義(除非是作為理論興趣進行研究)。不過,副作用確實是危險的,應當盡量避免使用,除非絕對必要。
當函數產生副作用的時候,僅憑藉輸入輸出的內容,不足以明確函數究竟做了什麼工作。必須瞭解上下文環境、程式狀態的曆史,這讓函數更難理解。在不可預測的互動方式下,副作用可能帶來一些 bug,且函數因上述依賴,測試起來也更困難。
副作用最小化是函數式編程中最基礎的原則,接下來的多數小節都可以當作是避免副作用的一些辦法概要。
視資料為不可變動的(Immutable)
變動(mutation)指的是值在原位置上的變化(an in-place change to a value)。不可變值意味著,一旦建立出來,永遠都不會變化。在 JavaScript 中,簡單值如數字、字串、布爾值這些是不可變的。不過,像對象、數組這樣的資料結構都是可變的。
// push 方法改變了數組const x = [1, 2];console.log( x ); // [1, 2]x.push( 3 );console.log( x ); // [1, 2, 3]
為什麼要避免變動資料呢?
變動是一種副作用。程式中變化的東西越少,需要追蹤記錄的也就越少,程式也就越簡單。
JavaScript 中維持對象、數組等資料結構不可變性的可用工具很有限。通過 Object.freeze 可以強制實現對象的不可變,但作用深度只有一層:
const frozenObject = Object.freeze( { valueOne : 1, valueTwo : { nestedValue : 1 } } );frozenObject.valueOne = 2; // 不允許frozenObject.valueTwo.nestedValue = 2; // 竟然允許了!
不過,還是有一些很棒的工具庫解決了這些問題,其中最著名的要數 Immutable 了。
對多數應用來說,使用工具庫來保證不可變性有些矯枉過正。很多情況下,簡單地將資料當作是不可變的,就能讓我們受益良多。
避免變動:數組
JavaScript 數組方法可以被概括為變動方法 (mutator methods) 和非變動方法。應當儘可能避免變動方法。
舉例來說,concat 方法可以用來替代 push 方法。push 改變了原數組;concat 返回由原數組和作為參數的數組組成的新數組,而原來的數組還是完整的。
// push 改變了數組const arrayOne = [1, 2, 3];arrayOne.push( 4 );console.log( arrayOne ); // [1, 2, 3, 4]// concat 產生了新數組,原數組保持不變const arrayTwo = [1, 2, 3];const arrayThree = arrayTwo.concat([ 4 ]);console.log( arrayTwo ); // [1, 2, 3]console.log( arrayThree ); // [1, 2, 3, 4]
還有一些非變動方法,包括 map、filter、reduce 等。
避免變動:對象
可以使用 Object.assign 方法,而非直接編輯對象。該方法將來源物件的屬性複製到目標對象中,並將目標對象返回。如果總是用一個Null 物件作為目標對象,就能通過 Object.assign 避免直接編輯對象。
const objectOne = { valueOne : 1 };const objectTwo = { valueTwo : 2 };const objectThree = Object.assign( {}, objectOne, objectTwo );console.log( objectThree ); // { valueOne : 1, valueTwo : 2 }
關於 const
const 很有用,卻不會使資料不可變。它只能防止變數被重新賦值。這不能混為一談。
const x = 1;x = 2; // 不允許const myArray = [1, 2, 3];myArray = [0, 2, 3]; // 不允許myArray[0] = 0; // 允許了!
書寫純函數
純函數 不會改變程式的狀態,也不會產生可感知的副作用。純函數的輸出,僅僅取決於輸入值。無論何時何地被調用,只要輸入值相同,傳回值也就一樣。
純函數是最小化副作用的重要工具。另外,與上下文無關的特點,也讓它們有了高可測試性和可複用性。
前面講副作用的小節中的代碼裡, myFunc 函數就是非純函數,注意兩次調用時輸入相同但每次返回結果卻不同。不過,它也能改寫成純函數:
// 將全域變數變為局部變數const myFunc = function ( y ) { const x = 10; return x + y;}console.log(myFunc( 3 )); // 13console.log(myFunc( 3 )); // 13
// 將 x 作為參數傳遞const x = 10;const myFunc = function ( x, y ) { return x + y;}console.log(myFunc( x, 3 )); // 13console.log(myFunc( x, 3 )); // 13
你的程式最終肯定還是會產生一些副作用。當副作用產生的時候,小心應對,儘可能地約束、限制它們的影響。
書寫返回函數的函數(Function-Generating Functions)
找一些程經驗的人,讓他們猜猜下面的代碼做了什麼:
例1
const numbers = [1, 2, 3];for ( let i = 0; i < numbers.length; i++ ) { console.log( numbers[i] );}
例2
const numbers = [1, 2, 3];const print = function ( input ) { console.log( input );};numbers.forEach( print );
我測試過的所有人在例 2 上運氣更好。例 1 展示的是命令式方法,將一列數字列印出來。例 2 展示的是聲明式方法。將迴圈遍曆數組、在控制台列印數字這些細節各種封裝成 forEach 和 print 函數,無需知道如何做,就可以表達我們需要程式做什麼。這讓代碼可讀性更高。例 2 的最後一行看起來,很接近英語句子。
採用這種方法,涉及到編寫大量函數。利用現有函數編寫產生新函數的函數,可以讓這個過程中的重複(DRY-er)更少。
特別地,JavaScript 的兩個特性讓這種形式的函數產生變得可能。第一個是閉包。函數能夠訪問包含範圍中的變數,就算該範圍已不複存在,這就是閉包。第二個特性是,JavaScript 將函數當作值來對待。這使書寫高階函數成為可能,高階函數可以接收函數作為參數,並/或返回函數。
這些特性組合在一起,我們就可以編寫返回函數的函數了。返回的函數能“記住”傳給產生函數的參數,並在程式的其他地方使用這些參數。
函數組合
通過函數組合,可能將函數組合在一起形成新的函數。一起來看例子:
// 通過 add 和 square 函數組合產生 addThenSquareconst add = function ( x, y ) { return x + y;};const square = function ( x ) { return x * x;};const addThenSquare = function ( x, y ) { return square(add( x, y ));};
你可能會發現一直在重複這種利用更小的函數產生一個更複雜的函數的形式。通常編寫一個組合函數會更有效率:
const add = function ( x, y ) { return x + y;};const square = function ( x ) { return x * x;};const composeTwo = function ( f, g ) { return function ( x, y ) { return g( f ( x, y ) ); };};const addThenSquare = composeTwo( add, square );
還可以走得更遠,編寫一個更一般化的組合函數:
// 這個版本的 composeTwo 的初始化函數可以接收任意數量的參數const composeTwo = function ( f, g ) { return function ( ...args ) { return g( f( ...args ) ); };};// composeMany 可以接收任意數量的函數// 其初始化函數能接收任意數量的參數const composeMany = function ( ...args ) { const funcs = args; return function ( ...args ) { funcs.forEach(( func ) => { args = [func.apply( this, args )]; }); return args[0]; };};
組合函數的最終形式取決於你所需的通用性水平,以及偏好的 API 類型。
偏函數(Partial Application)
偏函數 指定一個或多個參數,然後返回另一個函數,該函數稍後會被完整調用。
在下面的例子中,double、triple 和 quadruple 都是 multiply 函數的偏函數。
const multiply = function ( x, y ) { return x * y;};const partApply = function ( fn, x ) { return function ( y ) { fn( x, y ); };};const double = partApply( multiply, 2 );const triple = partApply( multiply, 3 );const quadruple = partApply( multiply, 4 );
柯裡化
柯裡化是將接收多個參數的函數轉換為一系列只接收一個參數的函數的過程。
const multiply = function ( x, y ) { return x * y;};const curry = function ( fn ) { return function ( x ) { return function ( y ) { return fn( x, y ); }; };};const curriedMultiply = curry( multiply );const double = curriedMultiply( 2 );const triple = curriedMultiply( 3 );const quadruple = curriedMultiply( 4 );console.log(triple( 6 )); // 18
柯裡化和偏函數在概念上很相似(可能不會兩個都需要使用),但仍然有所不同。主要區別在於,柯裡化總是產生函數套鏈,每次只接收一個參數,而偏函數返回的函數可以一次接收多個參數。 比較它們作用於最少接收三個參數的函數時,這種差別就更明晰了:
const multiply = function ( x, y, z ) { return x * y * z;};const curry = function ( fn ) { return function ( x ) { return function ( y ) { return function ( z ) { return fn( x, y, z ); }; }; };};const partApply = function ( fn, x ) { return function ( y, z ) { return fn( x, y, z ); };};const curriedMultiply = curry( multiply );const partiallyAppliedMultiply = partApply( multiply, 10 );console.log(curriedMultiply( 10 )( 5 )( 2 )); // 100console.log(partiallyAppliedMultiply( 5, 2 )); // 100
遞迴
遞迴函式是這樣一種函數,它會一直調用自身,直至滿足基本條件。遞迴函式是高度聲明式的。它們也很優雅,寫起來很爽!
下面是計算遞迴計算階乘的例子:
const factorial = function ( n ) { if ( n === 0 ) { return 1; } return n * factorial( n - 1 );};console.log(factorial( 10 )); // 3628800
在 JavaScript 中使用遞迴函式需要細心一些。每次函數調用都會向調用棧(call stack)中加入新的調用幀(call frame),當函數返回的時候,該調用幀從調用棧中彈出。遞迴函式調用在返回之前調用自身,所以很容易就會超出調用棧的限制,導致程式崩潰。
不過,這可以通過尾調用最佳化來避免。
尾調用最佳化
尾調用指的是,某個函數的最後一步動作是調用函數。尾調用最佳化指的是,當語言編譯器識別到尾調用的時候,會對其複用相同的調用幀。這意味著,在編寫尾調用的遞迴函式時,調用幀的限制永遠不會被超出,因為調用幀會被反覆使用。
下面是將前面的遞迴函式採用尾遞迴最佳化重寫之後的例子:
const factorial = function ( n, base ) { if ( n === 0 ) { return base; } base *= n; return factorial( n - 1, base );};console.log(factorial( 10, 1 )); // 3628800
JavaScript 與函數式編程