JS函數語言程式設計究竟是什麼?

前端小智發表於2019-08-07

原文:medium.com/better-prog…

譯者:前端小智

你知道的越多,你不知道的越多

點贊再看,養成習慣


本文 GitHub:github.com/qq449245884… 上已經收錄,更多往期高贊文章的分類,也整理了很多我的文件,和教程資料。歡迎Star和完善,大家面試可以參照考點複習,希望我們一起有點東西。

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

在長時間學習和使用物件導向程式設計之後,我們們退一步來考慮系統複雜性。

在做了一些研究之後,我發現了函數語言程式設計的概念,比如不變性和純函式。這些概念使你能夠構建無副作用的函式,因此更容易維護具有其他優點的系統。

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

什麼是函數語言程式設計

函數語言程式設計是一種程式設計正規化,是一種構建計算機程式結構和元素的風格,它把計算看作是對數學函式的評估,避免了狀態的變化和資料的可變。

純函式

當我們想要理解函數語言程式設計時,需要知道的第一個基本概念是純函式,但純函式又是什麼鬼?

我們們怎麼知道一個函式是否是純函式?這裡有一個非常嚴格的定義:

  • 如果給定相同的引數,則返回相同的結果(也稱為確定性)。

  • 它不會引起任何副作用。

如果給定相同的引數,則得到相同的結果

如果給出相同的引數,它返回相同的結果。 想象一下,我們想要實現一個計算圓的面積的函式。

不是純函式會這樣做,接收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函式的思想是接收一個函式和一個集合,並返回通過組合這些項建立的值。

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

假設你在一個購物網站,已經將產品1、產品2、產品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

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

乾貨系列文章彙總如下,覺得不錯點個Star,歡迎 加群 互相學習。

github.com/qq449245884…

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

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

JS函數語言程式設計究竟是什麼?

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

JS函數語言程式設計究竟是什麼?

相關文章