【譯】JavaScript 中的函數語言程式設計原理

joking_zhang發表於2019-05-02
原文:Functional Programming Principles in Javascript
作者:TK
譯者:博軒

經過很長一段時間的學習和麵向物件程式設計的工作,我退後一步,開始思考系統的複雜性。

“複雜性是任何使軟體難以理解或修改的東西。” - John Outerhout

做了一些研究,我發現了函數語言程式設計概念,如不變性和純函式。 這些概念使你能夠構建無副作用的功能,而函數語言程式設計的一些優點,也使得系統變得更加容易維護。

在這篇文章中,我將通過 JavaScript 中的大量程式碼示例向您詳細介紹函數語言程式設計和一些重要概念。

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

維基百科:Functional programming

函數語言程式設計是一種程式設計正規化 - 一種構建計算機程式結構和元素的方式 - 將計算視為數學函式的評估並避免改變狀態和可變資料 - Wikipedia

純函式

當我們想要理解函數語言程式設計時,我們學到的第一個基本概念是純函式。 那麼我們怎麼知道函式是否純粹呢? 這是一個非常嚴格的純度定義:

  • 如果給出相同的引數,它返回相同的結果(它也稱為確定性
  • 它不會引起任何可觀察到的副作用

如果給出相同的引數,它返回相同的結果

我們想要實現一個計算圓的面積的函式。 不純的函式將接收半徑:radius 作為引數,然後計算 radius * radius * PI :

const PI = 3.14;

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

calculateArea(10); // returns 314

為什麼這是一個不純的功能? 僅僅因為它使用的是未作為引數傳遞給函式的全域性物件。

想象一下,數學家認為 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 = 10PI = 3.14,我們將始終具有相同的結果:314
  • 對於引數 radius = 10PI = 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

對於每次迭代,我們都在改變變數 isumOfValues 的狀態。但是我們要如何處理迭代中的可變性?使用遞迴

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:從字串的兩端刪除空格
  • splitjoin :用給定字串中的替換替換所有匹配例項

我們將所有這四個功能結合起來,就可以實現 slugify 的功能了。

參考透明度

維基百科:Referential transparency

如果表示式可以替換為其相應的值而不更改程式的行為,則該表示式稱為引用透明。這要求表示式是純粹的,也就是說相同輸入的表示式值必須相同,並且其評估必須沒有副作用。-- 維基百科

讓我們實現一個計算平方的方法:

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,並用它來處理 ab 。 我們傳遞了 sumsubtraction 函式以使用 doubleOperator 函式進行組合並建立一個新行為。

高階函式

維基百科:Higher-order function

當我們談論高階函式時,通常是指一個函式同時具有:

  • 將一個或多個函式作為引數,或
  • 返回一個函式作為結果

我們上面實現的 doubleOperator 函式是一個高階函式,因為它將一個運算子函式作為引數並使用它。

您可能已經聽說過 filtermapreduce 。 我們來看看這些。

Filter

給定一個集合,我們希望按照屬性進行過濾。filter 函式需要 true 或者 false 值來確定元素是否應該包含在結果集合中。基本上,如果回撥錶達式返回的是 truefilter 函式返回的結果會包含該元素。否則,就不會包含該元素。

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

命令式方法

使用 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 方法。在這個示例中,3x)代表 this

這樣的操作也可以用於集合。 想象一下,我們有一個人物集合,包含了 nameage 屬性。

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 的概念是轉換一個集合。

map 方法會將集合傳入函式,並根據返回的值構建新集合。

讓我們使用剛才的 people 集合。我們現在不想過濾年齡了。我們只想得到一個列表,元素就像:TK is 26 years old。所以最後的字串可能是 :name is:age years old 其中 :name:agepeople 集合中每個元素的屬性。

下面是使用命令式 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

reduce 函式的概念是,接收一個函式和一個集合,然後組合他們來建立返回值。

一個常見的例子是獲得訂單的總金額。想象一下,你正在一個購物網站購物。你增加了 Product 1Product 2Product 3Product 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

這裡我們有 shoppingCartsumAmount函式接收當前的 currentTotalAmount ,對所有訂單進行累加。

getTotalAmount 函式會接收 sumAmount 函式 從 0 開始累加購物車的值。

獲得總金額的另一種方法是組合使用 mapreduce。 那是什麼意思? 我們可以使用 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 函式接收產品物件並僅返回金額值。 所以我們這裡有 [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!

相關文章