原文: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 ); // 13 myFunc( 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 方法,而非直接編輯對象。該方法將源對象的屬性復制到目標對象中,並將目標對象返回。如果總是用一個空對象作為目標對象,就能通過 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 )); // 13 console.log(myFunc( 3 )); // 13
// 將 x 作為參數傳遞 const x = 10; const myFunc = function ( x, y ) { return x + y; } console.log(myFunc( x, 3 )); // 13 console.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 函數組合生成 addThenSquare const 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 )); // 100 console.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
ES2015 語言規范中已包含了適當的尾部調用的支持,但目前在大部分環境中尚未得到支持。可以在這里查看你能否使用。
小結
函數式編程容納了許多思想,借助它們可以優化代碼。純函數和不可變數據將副作用的危害最小化,聲明式編程讓代碼可讀性大大提高。在與復雜性的斗爭中,這些是我們應當擁抱的重要工具。