原文: Functional Programming Principles in Javascript
做者:TK
譯者:博軒
通過很長一段時間的學習和麪向對象編程的工做,我退後一步,開始思考系統的複雜性。javascript
「複雜性是任何使軟件難以理解或修改的東西。」 - John Outerhout
作了一些研究,我發現了函數式編程概念,如不變性和純函數。 這些概念使你可以構建無反作用的功能,而函數式編程的一些優勢,也使得系統變得更加容易維護。java
在這篇文章中,我將經過 JavaScript
中的大量代碼示例向您詳細介紹函數式編程和一些重要概念。算法
函數式編程是一種編程範式,一種構建計算機程序結構和元素的方式,將計算視爲數學函數的評估並避免改變狀態和可變數據 -- 維基百科
當咱們想要理解函數式編程時,咱們學到的第一個基本概念是純函數。 那麼咱們怎麼知道函數是否純粹呢? 這是一個很是嚴格的純度定義:數組
咱們想要實現一個計算圓的面積的函數。 不純的函數將接收半徑:radius
做爲參數,而後計算 radius * radius * PI
:性能優化
const PI = 3.14; const calculateArea = (radius) => radius * radius * PI; calculateArea(10); // returns 314
爲何這是一個不純的功能? 僅僅由於它使用的是未做爲參數傳遞給函數的全局對象。dom
想象一下,數學家認爲 PI
值其實是 42
, 而且改變了全局對象的值。函數式編程
不純的函數如今將致使 10 * 10 * 42 = 4200
.對於相同的參數(radius= 10
),咱們獲得不一樣的結果。函數
咱們來解決它吧!性能
const PI = 3.14; const calculateArea = (radius, pi) => radius * radius * pi; calculateArea(10, PI); // returns 314
如今咱們將 PI
的值做爲參數傳遞給函數。 因此如今咱們只是訪問傳遞給函數的參數。 沒有外部對象(參數)。
radius = 10
和 PI = 3.14
,咱們將始終具備相同的結果:314
radius = 10
和 PI = 42
,咱們將始終具備相同的結果:4200
Node.js
)若是咱們的函數讀取外部文件,它也不是純函數 - 文件的內容能夠更改:
const fs = require('fs'); const charactersCounter = (text) => `Character count: ${text.length}`; function analyzeFile(filepath) { let fileContent = fs.readFileSync(filepath); return charactersCounter(fileContent); }
任何依賴於隨機數生成器的函數都不多是純函數:
function yearEndEvaluation() { if (Math.random() > 0.5) { return "You get a raise!"; } else { return "Better luck next year!"; } }
什麼是可觀察反作用呢?其中一種示例,就是在函數內修改全局的對象,或者參數。
如今咱們要實現一個函數,來接收一個整數值並返回增長 1
的值。
let counter = 1; function increaseCounter(value) { counter = value + 1; } increaseCounter(counter); console.log(counter); // 2
咱們首先定義了變量 counter
。 而後使用不純的函數接收該值並從新爲 counter
賦值,使其值增長 1
。
注意:在函數式編程中不鼓勵可變性。
上面的例子中,咱們修改了全局對象。 可是咱們如何才能讓函數變得純淨呢? 只需返回增長 1
的值。
let counter = 1; const increaseCounter = (value) => value + 1; increaseCounter(counter); // 2 console.log(counter); // 1
能夠看到咱們的純函數 increaseCounter
返回 2
,可是 counter
還保持以前的值。該函數會使返回的數字遞增,並且不更改變量的值。
若是咱們遵循這兩個簡單的規則,就會使咱們的程序更加容易理解。每一個功能都是孤立的,沒法影響到咱們的系統。
純函數是穩定,一致而且可預測的。給定相同的參數,純函數將始終返回相同的結果。咱們不須要考慮,相同的參數會產生不一樣的結果,由於它永遠不會發生。
純函數的代碼更加容易測試。咱們不須要模擬任何執行的上下文。咱們可使用不一樣的上下文對純函數進行單元測試:
A
-> 指望函數返回 B
C
-> 指望函數返回 D
一個簡單的例子,函數接收一個數字集合,並指望數字集合每一個元素遞增。
let list = [1, 2, 3, 4, 5]; const incrementNumbers = (list) => list.map(number => number + 1);
咱們接收到數字數組,使用 map
遞增每一個數字,並返回一個新的遞增數字列表。
incrementNumbers(list); // [2, 3, 4, 5, 6]
對於輸入 [1, 2, 3, 4, 5]
,預期輸出將是 [2, 3, 4, 5, 6]
。
隨着時間的推移不變,或沒法改變
當數據具備不可變性時,它的狀態在建立以後,就不能改變了。你不能去更改一個不可變的對象,可是你可使用新值去建立一個新的對象。
在 JavaScript
中,咱們常使用 for
循環。下面這個 for
循環有一些可變的變量。
var values = [1, 2, 3, 4, 5]; var sumOfValues = 0; for (var i = 0; i < values.length; i++) { sumOfValues += values[i]; } sumOfValues // 15
對於每次迭代,咱們都在改變變量 i
和 sumOfValues
的狀態。可是咱們要如何處理迭代中的可變性?使用遞歸
let list = [1, 2, 3, 4, 5]; let accumulator = 0; function sum(list, accumulator) { if (list.length == 0) { return accumulator; } // 移除數組第一項,並作累加 return sum(list.slice(1), accumulator + list[0]); } sum(list, accumulator); // 15 list; // [1, 2, 3, 4, 5] accumulator; // 0
因此這裏咱們有 sum
函數接收數值向量。 該函數調用自身,直到咱們將列表清空。 對於每一個「迭代」,咱們會將該值添加到總累加器。
使用遞歸,咱們能夠保持變量的不可變性。 列表和累加器變量不會更改,會保持相同的值。
注意
:咱們可使用reduce來實現這個功能。 咱們將在高階函數主題中介紹這個話題。
構建對象的最終狀態也很常見。想象一下,咱們有一個字符串,咱們想將這個字符串轉換爲 url slug。
在 Ruby
中的面向對象編程中,咱們將建立一個類,比方說,UrlSlugify
。 這個類將有一個 slugify
方法將字符串輸入轉換爲 url slug
。
class UrlSlugify attr_reader :text def initialize(text) @text = text end def slugify! text.downcase! text.strip! text.gsub!(' ', '-') end end UrlSlugify.new(' I will be a url slug ').slugify! # "i-will-be-a-url-slug"
他已經實現了!(It’s implemented!
)
這裏咱們使用命令式編程,準確的說明咱們想要在 函數實現的過程當中(slugify
)每一步要作什麼:首先是轉換成小寫,而後移除無用的空格,最後用連字符替換剩餘的空格。
可是,在這個過程當中,函數改變了輸入的參數。
咱們能夠經過執行函數組合或函數鏈來處理這種變異。 換句話說,函數的結果將用做下一個函數的輸入,而不修改原始輸入字符串。
let string = " I will be a url slug "; function slugify(string) { return string.toLowerCase() .trim() .split(" ") .join("-"); } slugify(string); // i-will-be-a-url-slug
這裏咱們:
toLowerCase
:將字符串轉換爲所有小寫trim
:從字符串的兩端刪除空格split
和 join
:用給定字符串中的替換替換全部匹配實例咱們將全部這四個功能結合起來,就能夠實現 slugify
的功能了。
若是表達式能夠替換爲其相應的值而不更改程序的行爲,則該表達式稱爲引用透明。這要求表達式是純粹的,也就是說相同輸入的表達式值必須相同,而且其評估必須沒有反作用。-- 維基百科
讓咱們實現一個計算平方的方法:
const square = (n) => n * n;
在給定相同輸入的狀況下,此純函數將始終具備相同的輸出。
square(2); // 4 square(2); // 4 square(2); // 4 // ...
把 2
傳遞給 square
方法將始終返回 4
。因此,如今咱們可使用 4
來替換 square(2)
。咱們的函數是引用透明的。
基本上,若是函數對同一輸入始終產生相同的結果,則引用透明。
pure functions
+immutable data
=referential transparency
純函數
+ 不可變數據
= 參照透明度
有了這個概念,咱們能夠作一件很 cool
的事情,就是使這個函數擁有記憶(memoize
)。
想象一下咱們擁有這樣一個函數:
const sum = (a, b) => a + b;
咱們用這些參數調用它:
sum(3, sum(5, 8));
sum(5, 8)
等於 13
。這個函數老是返回 13
。所以,咱們能夠這樣作:
sum(3, 13);
這個表達式老是會返回 16
。咱們能夠用一個數值常量替換整個表達式,並記住它。
這裏推薦一篇
淘寶FED關於
memoize
的文章:
性能優化:memoization
函數做爲一等公民,意味着函數也能夠視爲值處理,並當作數據來使用。
函數做爲一等公民有以下特性:
咱們的想法是函數視爲值並將它們做爲參數傳遞。 這樣咱們就能夠組合不一樣的函數來建立具備新行爲的新函數。
想象一下,咱們有一個函數能夠將兩個值相加,而後將該值加倍:
const doubleSum = (a, b) => (a + b) * 2;
如今是一個,將兩值相減,並返回該值加倍的函數:
const doubleSubtraction = (a, b) => (a - b) * 2;
這些函數具備類似的邏輯,可是計算時的運算符不一樣。 若是咱們能夠將函數視爲值並將它們做爲參數傳遞,咱們能夠構建一個函數來接收運算符函數並在函數中使用它。
const sum = (a, b) => a + b; const subtraction = (a, b) => a - b; const doubleOperator = (f, a, b) => f(a, b) * 2; doubleOperator(sum, 3, 1); // 8 doubleOperator(subtraction, 3, 1); // 4
如今咱們有一個函數參數:f
,並用它來處理 a
和 b
。 咱們傳遞了 sum
和 subtraction
函數以使用 doubleOperator
函數進行組合並建立一個新行爲。
當咱們談論高階函數時,一般是指一個函數同時具備:
咱們上面實現的 doubleOperator
函數是一個高階函數,由於它將一個運算符函數做爲參數並使用它。
您可能已經據說過 filter
,map
和 reduce
。 咱們來看看這些。
給定一個集合,咱們但願按照屬性進行過濾。filter
函數須要 true
或者 false
值來肯定元素是否應該包含在結果集合中。基本上,若是回調錶達式返回的是 true
,filter
函數返回的結果會包含該元素。不然,就不會包含該元素。
一個簡單的例子是當咱們有一個整數集合時,咱們只想要過濾偶數。
使用 JavaScript
來實現時,須要以下操做:
evenNumbers
evenNumbers
數組var numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; var evenNumbers = []; for (var i = 0; i < numbers.length; i++) { if (numbers[i] % 2 == 0) { evenNumbers.push(numbers[i]); } } console.log(evenNumbers); // (6) [0, 2, 4, 6, 8, 10]
咱們還可使用 filter
高階函數來接收 even
函數,並返回偶數列表:
const even = n => n % 2 == 0; const listOfNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; listOfNumbers.filter(even); // [0, 2, 4, 6, 8, 10]
我在Hacker Rank FP上解決的一個有趣問題是Filter Array問題。 問題的想法是過濾給定的整數數組,並僅輸出那些小於指定值X的值。
針對此問題,命令式JavaScript
解決方案以下:
var filterArray = function(x, coll) { var resultArray = []; for (var i = 0; i < coll.length; i++) { if (coll[i] < x) { resultArray.push(coll[i]); } } return resultArray; } console.log(filterArray(3, [10, 9, 8, 2, 7, 5, 1, 3, 0])); // (3) [2, 1, 0]
咱們的函數會作以下的事情 - 迭代集合,將集合當前項與 x
進行比較,若是它符合條件,則將此元素推送到 resultArray
。
但咱們想要一種更具聲明性的方法來解決這個問題,並使用過濾器高階函數。
聲明式 JavaScript
解決方案將是這樣的:
function smaller(number) { return number < this; } function filterArray(x, listOfNumbers) { return listOfNumbers.filter(smaller, x); } let numbers = [10, 9, 8, 2, 7, 5, 1, 3, 0]; filterArray(3, numbers); // [2, 1, 0]
在 smaller
函數中使用 this
首先看起來有點奇怪,但很容易理解。
this
將做爲第二個參數傳給 filter
方法。在這個示例中,3
(x
)表明 this
。
這樣的操做也能夠用於集合。 想象一下,咱們有一我的物集合,包含了 name
、 age
屬性。
let people = [ { name: "TK", age: 26 }, { name: "Kaio", age: 10 }, { name: "Kazumi", age: 30 } ];
咱們但願僅過濾指定年齡值的人,在此示例中,年齡超過18
歲的人。
const olderThan18 = person => person.age > 18; const overAge = people => people.filter(olderThan18); overAge(people); // [{ name: 'TK', age: 26 }, { name: 'Kazumi', age: 30 }]
代碼摘要:
oldThan18
。在這種狀況下,對於 people
數組中的每一個人,咱們想要訪問年齡並查看它是否超過 18
歲。map
的概念是轉換一個集合。
map
方法會將集合傳入函數,並根據返回的值構建新集合。
讓咱們使用剛纔的 people
集合。咱們如今不想過濾年齡了。咱們只想獲得一個列表,元素就像:TK is 26 years old
。因此最後的字符串多是 :name is:age years old
其中 :name
和 :age
是 people
集合中每一個元素的屬性。
下面是使用命令式 JavaScript
編碼的示例:
var people = [ { name: "TK", age: 26 }, { name: "Kaio", age: 10 }, { name: "Kazumi", age: 30 } ]; var peopleSentences = []; for (var i = 0; i < people.length; i++) { var sentence = people[i].name + " is " + people[i].age + " years old"; peopleSentences.push(sentence); } console.log(peopleSentences); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']
下面是使用聲明式 JavaScript
編碼的示例:
const makeSentence = (person) => `${person.name} is ${person.age} years old`; const peopleSentences = (people) => people.map(makeSentence); peopleSentences(people); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']
要作的事情是將給定數組轉換爲新數組。
另外一個有趣的 Hacker Rank
問題是更新列表問題。 咱們只想用它們的絕對值更新給定數組的值。
例如,輸入 [1,2,3-4,5]
須要輸出爲 [1,2,3,4,5]
。 -4
的絕對值是 4
。
一種簡單的解決方案是將每一個集合的值進行就地更新 (in-place)。
var values = [1, 2, 3, -4, 5]; for (var i = 0; i < values.length; i++) { values[i] = Math.abs(values[i]); } console.log(values); // [1, 2, 3, 4, 5]
咱們使用 Math.abs
函數將值轉換爲其絕對值,並進行就地更新。
這不是一個函數式的解決方案。
map
來轉換全部數據?個人第一個想法是測試 Math.abs
函數只處理一個值。
Math.abs(-1); // 1 Math.abs(1); // 1 Math.abs(-2); // 2 Math.abs(2); // 2
咱們但願將每一個值轉換爲正值(絕對值)。
如今咱們知道如何對一個值進行取絕對值的操做,咱們能夠將這個函數經過參數的方式傳遞給 map
。你還記得高階函數能夠接收函數做爲參數並使用它嗎? 是的,map
能夠。
let values = [1, 2, 3, -4, 5]; const updateListMap = (values) => values.map(Math.abs); updateListMap(values); // [1, 2, 3, 4, 5]
Wow,鵝妹子嚶!
reduce
函數的概念是,接收一個函數和一個集合,而後組合他們來建立返回值。
一個常見的例子是得到訂單的總金額。想象一下,你正在一個購物網站購物。你增長了 Product 1
,Product 2
,Product 3
,Product 4
到你的購物車。如今咱們要計算購物車的總金額。
使用命令式編程的方式,咱們將迭代訂單列表並將每一個產品金額與總金額相加。
var orders = [ { productTitle: "Product 1", amount: 10 }, { productTitle: "Product 2", amount: 30 }, { productTitle: "Product 3", amount: 20 }, { productTitle: "Product 4", amount: 60 } ]; var totalAmount = 0; for (var i = 0; i < orders.length; i++) { totalAmount += orders[i].amount; } console.log(totalAmount); // 120
使用 reduce
,咱們能夠建立一個用來處理累加的函數,並將其做爲參數傳給 reduce
函數。
let shoppingCart = [ { productTitle: "Product 1", amount: 10 }, { productTitle: "Product 2", amount: 30 }, { productTitle: "Product 3", amount: 20 }, { productTitle: "Product 4", amount: 60 } ]; const sumAmount = (currentTotalAmount, order) => currentTotalAmount + order.amount; const getTotalAmount = (cart) => cart.reduce(sumAmount, 0); getTotalAmount(shoppingCart); // 120
這裏咱們有 shoppingCart
,sumAmount
函數接收當前的 currentTotalAmount
,對全部訂單進行累加。
getTotalAmount
函數會接收 sumAmount
函數 從 0
開始累加購物車的值。
得到總金額的另外一種方法是組合使用 map
和 reduce
。 那是什麼意思? 咱們可使用 map
將 shoppingCart
轉換爲 amount
值的集合,而後只使用 reduce
函數和 sumAmount
函數。
const getAmount = (order) => order.amount; const sumAmount = (acc, amount) => acc + amount; function getTotalAmount(shoppingCart) { return shoppingCart .map(getAmount) .reduce(sumAmount, 0); } getTotalAmount(shoppingCart); // 120
getAmount
函數接收產品對象並僅返回金額值。 因此咱們這裏有 [10,30,20,60]
。 而後,經過 reduce
累加全部金額。Nice~
咱們看了每一個高階函數的工做原理。 我想向您展現一個示例,說明如何在一個簡單的示例中組合全部三個函數。
仍是購物車,想象一下在咱們的訂單中有一個產品列表:
let shoppingCart = [ { productTitle: "Functional Programming", type: "books", amount: 10 }, { productTitle: "Kindle", type: "eletronics", amount: 30 }, { productTitle: "Shoes", type: "fashion", amount: 20 }, { productTitle: "Clean Code", type: "books", amount: 60 } ]
咱們想要購物車中全部圖書的總金額。 就那麼簡單, 須要怎樣編寫算法?
filter
函數過濾書籍類型map
函數將購物車轉換爲數量的集合reduce
函數累加全部項目let shoppingCart = [ { productTitle: "Functional Programming", type: "books", amount: 10 }, { productTitle: "Kindle", type: "eletronics", amount: 30 }, { productTitle: "Shoes", type: "fashion", amount: 20 }, { productTitle: "Clean Code", type: "books", amount: 60 } ] const byBooks = (order) => order.type == "books"; const getAmount = (order) => order.amount; const sumAmount = (acc, amount) => acc + amount; function getTotalAmount(shoppingCart) { return shoppingCart .filter(byBooks) .map(getAmount) .reduce(sumAmount, 0); } getTotalAmount(shoppingCart); // 70
Done!