JS函數式編程到底是什麼?

原文:medium.com/better-prog…javascript

譯者:前端小智html


阿里雲最近在作活動,低至2折,有興趣能夠看看promotion.aliyun.com/ntms/yunpar…前端


爲了保證的可讀性,本文采用意譯而非直譯。java

在長時間學習和使用面向對象編程以後,我們退一步來考慮系統複雜性。git

在作了一些研究以後,我發現了函數式編程的概念,好比不變性和純函數。這些概念使你可以構建無反作用的函數,所以更容易維護具備其餘優勢的系統。github

在這篇文章中,將通大量代碼示例來詳細介紹函數式編程和一些相關重要概念。編程

什麼是函數式編程

函數式編程是一種編程範式,是一種構建計算機程序結構和元素的風格,它把計算看做是對數學函數的評估,避免了狀態的變化和數據的可變。數組

純函數

當咱們想要理解函數式編程時,須要知道的第一個基本概念是純函數,但純函數又是什麼鬼?bash

我們怎麼知道一個函數是不是純函數?這裏有一個很是嚴格的定義:dom

  • 若是給定相同的參數,則返回相同的結果(也稱爲肯定性)。

  • 它不會引發任何反作用。

若是給定相同的參數,則獲得相同的結果

若是給出相同的參數,它返回相同的結果。 想象一下,咱們想要實現一個計算圓的面積的函數。

不是純函數會這樣作,接收radius 做爲參數,而後計算radius * radius * PI

let PI = 3.14;

const calculateArea = (radius) => radius * radius * PI;

calculateArea(10); // returns 314.0
複製代碼

爲何這是一個不純函數?緣由很簡單,由於它使用了一個沒有做爲參數傳遞給函數的全局對象。

如今,想象一些數學家認爲圓周率的值其實是42而且修改了全局對象的值。

不純函數獲得10 * 10 * 42 = 4200。對於相同的參數(radius = 10),咱們獲得了不一樣的結果。

修復它:

let PI = 3.14;

const calculateArea = (radius, pi) => radius * radius * pi;

calculateArea(10, PI); // returns 314.0
複製代碼

如今把 PI 的值做爲參數傳遞給函數,這樣就沒有外部對象引入。

  • 對於參數radius = 10PI = 3.14,始終都會獲得相同的結果:314.0
  • 對於 radius = 10PI = 42,老是獲得相同的結果:4200

讀取文件

下面函數讀取外部文件,它不是純函數,文件的內容隨時可能都不同。

const charactersCounter = (text) => `Character count: ${text.length}`;

function analyzeFile(filename) {
  let fileContent = open(filename);
  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,使其值增長1

函數式編程不鼓勵可變性。咱們修改全局對象,可是要怎麼作才能讓它變得純函數呢?只需返回增長1的值。

let counter = 1;

const increaseCounter = (value) => value + 1;

increaseCounter(counter); // 2
console.log(counter); // 1
複製代碼

純函數increaseCounter返回2,可是counter值仍然是相同的。函數返回遞增的值,而不改變變量的值。

若是咱們遵循這兩條簡單的規則,就會更容易理解咱們的程序。如今每一個函數都是孤立的,不能影響系統的其餘部分。

純函數是穩定的、一致的和可預測的。給定相同的參數,純函數老是返回相同的結果。

我們不須要考慮相同參數有不一樣結果的狀況,由於它永遠不會發生。

純函數的好處

純函數代碼確定更容易測試,不須要 mock 任何東西,所以,咱們可使用不一樣的上下文對純函數進行單元測試:

  • 給定一個參數 A,指望函數返回值 B
  • 給定一個參數C,指望函數返回值D

一個簡單的例子是接收一組數字,並對每一個數進行加 1 這種沙雕的操做。

let list = [1, 2, 3, 4, 5];

const incrementNumbers = (list) => list.map(number => number + 1);
複製代碼

接收numbers數組,使用map遞增每一個數字,並返回一個新的遞增數字列表。

incrementNumbers(list); // [2, 3, 4, 5, 6]
複製代碼

對於輸入[1,2,3,4,5],預期輸出是[2,3,4,5,6]

不可變性

儘管時間變或者不變,純函數大佬都是不變的。

當數據是不可變的時,它的狀態在建立後不能更改。

我們不能更改不可變對象,若是非要來硬的,剛須要深拷貝一個副本,而後操做這個副本。

在JS中,咱們一般使用for循環,for的每次遍歷 i是個可變變量。

var values = [1, 2, 3, 4, 5];
var sumOfValues = 0;

for (var i = 0; i < values.length; i++) {
  sumOfValues += values[i];
}

sumOfValues // 15
複製代碼

對於每次遍歷,都在更改isumOfValue狀態,可是咱們如何在遍歷中處理可變性呢? 答案就是使用遞歸

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 函數,它接收一個數值向量。函數調用自身,直到 list爲空退出遞歸。對於每次「遍歷」,咱們將把值添加到總accumulator中。

使用遞歸,我們保持變量不變。不會更改listaccumulator變量。它保持相同的值。

觀察:咱們可使用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"
複製代碼

上面使用的有命令式編程方式,首先用小寫字母表示咱們想在每一個slugify進程中作什麼,而後刪除無用的空格,最後用連字符替換剩餘的空格。

這種方式在整個過程當中改變了輸入狀態,顯然不符合純函數的概念。

這邊能夠經過函數組合或函數鏈來來優化。換句話說,函數的結果將用做下一個函數的輸入,而不修改原始輸入字符串。

const string = " I will be a url slug   ";

const slugify = string =>
  string
    .toLowerCase()
    .trim()
    .split(" ")
    .join("-");

slugify(string); // i-will-be-a-url-slug
複製代碼

上述代碼主要作了這幾件事:

  • toLowerCase:將字符串轉換爲全部小寫字母。

  • trim:刪除字符串兩端的空白。

  • splitjoin:用給定字符串中的替換替換全部匹配實例

引用透明性

接着實現一個square 函數:

const square = (n) => n * n;
複製代碼

給定相同的輸入,這個純函數老是有相同的輸出。

square(2); // 4
square(2); // 4
square(2); // 4
// ...
複製代碼

2做爲square函數的參數傳遞始終會返回4。這樣我們能夠把square(2)換成4,咱們的函數就是引用透明的。

基本上,若是一個函數對於相同的輸入始終產生相同的結果,那麼它能夠看做透明的。

有了這個概念,我們能夠作的一件很酷的事情就是記住這個函數。假設有這樣的函數

const sum = (a, b) => a + b;
複製代碼

用這些參數來調用它

sum(3, sum(5, 8));
複製代碼

sum(5, 8) 總等於13,因此能夠作些騷操做:

sum(3, 13);
複製代碼

這個表達式老是獲得16,我們能夠用一個數值常數替換整個表達式,並把它記下來。

函數是 JS 中的一級公民

函數做爲 JS 中的一級公民,很風騷,函數也能夠被看做成值並用做數據使用。

  • 從常量和變量中引用它。
  • 將其做爲參數傳遞給其餘函數。
  • 做爲其餘函數的結果返回它。

其思想是將函數視爲值,並將函數做爲數據傳遞。經過這種方式,咱們能夠組合不一樣的函數來建立具備新行爲的新函數。

假如咱們有一個函數,它對兩個值求和,而後將值加倍,以下所示:

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參數並用它來處理ab, 這裏傳遞了sum函數和subtraction並使用doubleOperator函數進行組合並建立新行爲。

高階函數

當咱們討論高階函數時,一般包括如下幾點:

  • 將一個或多個函數做爲參數

  • 返回一個函數做爲結果

上面實現的doubleOperator函數是一個高階函數,由於它將一個運算符函數做爲參數並使用它。

咱們常常用的filtermapreduce都是高階函數,Look see see。

Filter

對於給定的集合,咱們但願根據屬性進行篩選。filter函數指望一個truefalse值來決定元素是否應該包含在結果集合中。

若是回調錶達式爲真,過濾器函數將在結果集合中包含元素,不然,它不會。

一個簡單的例子是,當咱們有一個整數集合,咱們只想要偶數。

命令式

使用命令式方式來獲取數組中全部的偶數,一般會這樣作:

  • 建立一個空數組evenNumbers

  • 遍歷數組 numbers

  • 將偶數 push 到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高階函數來接收偶函數並返回一個偶數列表:

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的那些值。

命令式作法一般是這樣的:

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]
複製代碼

聲明式方式

對於上面的老是,咱們更想要一種更聲明性的方法來解決這個問題,以下所示:

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,一開始看起來有點奇怪,可是很容易理解。

filter函數中的第二個參數表示上面 this, 也就是 x 值。

咱們也能夠用map方法作到這一點。想象一下,有一組信息

let people = [
  { name: "TK", age: 26 },
  { name: "Kaio", age: 10 },
  { name: "Kazumi", age: 30 }
]
複製代碼

咱們但願過濾 age 大於 21 歲的人,用 filter 方式

const olderThan21 = person => person.age > 21;
const overAge = people => people.filter(olderThan21);
overAge(people); // [{ name: 'TK', age: 26 }, { name: 'Kazumi', age: 30 }]
複製代碼

map

map函數的主要思路是轉換集合。

map方法經過將函數應用於其全部元素並根據返回的值構建新集合來轉換集合。

假如咱們不想過濾年齡大於 21 的人,咱們想作的是顯示相似這樣的:TK is 26 years old.

使用命令式,咱們一般會這樣作:

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']
複製代碼

聲明式會這樣作:

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']
複製代碼

整個思想是將一個給定的數組轉換成一個新的數組。

另外一個有趣的HackerRank問題是更新列表問題。咱們想要用一個數組的絕對值來更新它的值。

例如,輸入[1,2,3,- 4,5]須要輸出爲[1,2,3,4,5]-4的絕對值是4

一個簡單的解決方案是每一個集合中值的就地更新,很危險的做法

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]
複製代碼

Reduce

reduce函數的思想是接收一個函數和一個集合,並返回經過組合這些項建立的值。

常見的的一個例子是獲取訂單的總金額。

假設你在一個購物網站,已經將產品一、產品二、產品3和產品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,咱們能夠構建一個函數來處理量計算sum並將其做爲參數傳遞給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 = (shoppingCart) => shoppingCart.reduce(sumAmount, 0);

getTotalAmount(shoppingCart); // 120
複製代碼

這裏有shoppingCart,接收當前currentTotalAmount的函數sumAmount,以及對它們求和的order對象。

我們也可使用mapshoppingCart轉換爲一個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接收product對象並只返回amount值,即[10,30,20,60],而後,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 }
]
複製代碼

假如相要想要購物車裏類型爲 books的總數,一般會這樣作:

  • 過濾 type 爲 books的

  • 使用map將購物車轉換爲amount集合。

  • 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
複製代碼

代碼部署後可能存在的BUG無法實時知道,過後爲了解決這些BUG,花了大量的時間進行log 調試,這邊順便給你們推薦一個好用的BUG監控工具 Fundebug

交流(歡迎加入羣,羣工做日都會發紅包,互動討論技術)

阿里雲最近在作活動,低至2折,有興趣能夠看看:promotion.aliyun.com/ntms/yunpar…

乾貨系列文章彙總以下,以爲不錯點個Star,歡迎 加羣 互相學習。

github.com/qq449245884…

我是小智,公衆號「大遷世界」做者,對前端技術保持學習愛好者。我會常常分享本身所學所看的乾貨,在進階的路上,共勉!

關注公衆號,後臺回覆福利,便可看到福利,你懂的。

每次整理文章,通常都到2點才睡覺,一週4次左右,挺苦的,還望支持,給點鼓勵

相關文章
相關標籤/搜索