1. 相關面試題?
1.1. 什么是純函數?
純函數是一種函數,其返回值僅由其輸入參數決定,不產生任何可觀察的副作用,如修改全局對象或外部狀態。
純函數具有以下特性:
1. 確定性:相同的輸入永遠得到相同的輸出;
2. 無副作用:函數執行過程中不會改變任何外部狀態;
純函數在函數式編程中非常重要,因為它們提供了可預測性和可測試性。由于純函數不依賴于且不修改外部環境的狀態,它們在并發環境下特別有用,可以減少在多線程環境中由狀態改變引起的錯誤。此外,純函數的可組合性使得開發者能夠構建更加模塊化和可重用的代碼。
1.2. 什么是高階函數?
高階函數是至少滿足下列一個條件的函數:
1.?接受一個或多個函數作為輸入;
2.?返回一個函數作為輸出;
高階函數在函數式編程中用于抽象或隔離行為,不直接操作數據,而是創建包含操作的函數,這增強了代碼的可重用性和模塊化。例如,.map()和 .filter()都是數組的高階函數,因為它們接受一個函數作為參數。
// 創建一個高階函數,該函數接受一個數字并返回一個新的函數,新函數將其輸入乘以該數字
const multiplier = factor =>number => number * factor;
const double = multiplier(2);
console.log(double(3));
// 輸出 6
1.3. 解釋并實現 Currying 函數
Currying 是一種將接受多個參數的函數轉換成一系列使用一個參數的函數的技術。這是通過返回一個新函數來實現的,該函數期待下一個參數。這一過程一直持續,直到傳遞了所有參數。
在函數式編程中,Currying 幫助提高函數的通用性,提高函數的可應用性。通過 Currying,可以創建更加定制化的函數,從而提高代碼的重用性。
實現示例:
function curry(fn){return function curried(...args1){if(args1.length >= fn.length){return fn.apply(this, args1);}else {return function(...args2){return curried.apply(this,args1.concat(args2));}}}
}const sum=(a,b,c)=>a+b+c;
const curriedSum =curry(sum);
console.log(curriedSum(1)(2)(3)); // 輸出 6
console.log(curriedSum(1,2,3)); // 輸出 6
2. 函數式編程思想基本概念
函數式編程思想,將解決問題的方法聚焦到過程,我們著眼于解決問題所需的函數封裝,強調解決問題子方案的組合。
2.1.?面向對象編程思想
回答這個問題,我們需要先拉出面向對象編程思想,細數一下面向對象編程存在的問題,以及用函數式編程思維如何改進。
面向對象編程(OOP)是一種強大的編程范式,它使用類和對象來組織代碼,以模擬現實世界的實體和交互。盡管 OOP 在許多方面非常有用,例如在編寫大型軟件系統時提供了清晰的模塊化結構,但它也存在一些問題,特別是與狀態管理和副作用相關的問題。下面我們將探討 OOP 的一些常見問題,通過代碼示例說明,并討論如何使用函數式編程思想來解決這些問題。
?
2.2.?面向對象編程存在的問題
2.2.1.?狀態管理復雜
在 OOP 中,對象通常包含狀態(數據),狀態可以通過方法(行為)來改變。當應用程序復雜時,對象的狀態可能在多個方法中被不同的方式改變,導致狀態管理變得復雜和困難。
2.2.2. 不可預測性
對象方法可能會改變全局狀態或者對象的內部狀態,這些副作用會使得程序的行為變得不可預測和難以理解。
2.2.3.?繼承的問題
繼承可以導致高度耦合的代碼,使得修改和理解代碼更加困難。子類依賴于父類的實現細節,父類的改動可能會影響到所有子類。
2.2.4. 代碼示例
假設我們有一個購物車類,它包含一組商品項和方法來添加商品、刪除商品和計算總價:
class ShoppingCart {constructor() {this.items = [];}addItem(item) {this.items.push(item);}removeItem(itemIndex) {this.items.splice(itemIndex, 1);}calculateTotal() {return this.items.reduce((total, item) => total + item.price, 0);}
}const cart = new ShoppingCart();
cart.addItem({product: 'Apple', price: 1.00 });
cart.addItem({product: 'Banana', price: 1.50 });console.log(cart.calculateTotal()); // 2.50
cart.removeItem(0);
console.log(cart.calculateTotal()); // 1.50
這里的問題是 ShoppingCart的狀態 items 可以被任意修改,可能會導致不可預測的結果,特別是在多線程環境或復雜應用中。
2.3.?使用函數式編程解決問題?
函數式編程通過以下方式解決上述問題:
2.3.1.?不可變性
數據結構是不可變的,這意味著一旦創建,數據就不能更改。所有的變化都通過返回新的數據副本來實現。
2.3.2.?無副作用的函數
函數不修改外部狀態,它們只依賴于輸入參數,返回新的數據而不是修改已有數據。
2.3.3.?使用純函數
純函數增加了代碼的可測試性和可預測性。
2.3.4.?代碼示例
我們可以用函數式編程重寫購物車的邏輯,使之不改變原有數據:
const addItem = (cart, item) => [...cart, item];
const removeItem = (cart, itemIndex) => cart.filter((_, index) => index != itemIndex);
const calculateTotal = (cart) => cart.reduce((total, item) => total + item.price, 0);let cart = [];
cart = addItem(cart, { product: 'Apple', price: 1.00 });
cart = addItem(cart, { product: 'Banana', price: 1.50 });
console.log(calculateTotal(cart)); // 2.50
cart = removeItem(cart, 0);
console.log(calculateTotal(cart)); // 1.50
在這個函數式版本中,所有的函數都不直接修改輸入的購物車數組,而是返回一個新的數組。這使得每個操作都是可預測的,并且函數不會產生副作用,從而增加了代碼的穩定性和可維護性。
2.4.?函數式編程核心概念
在JavaScript中,函數式編程有幾個核心概念,每一個都對編寫清晰、可維護的代碼起著重要作用。以下是這些概念的解釋和相應的代碼示例。
2.4.1. 函數為一等公民
概念說明:當函數在一個語言中被視為一等公民時,這意味著函數可以像任何其他數據類型一樣被傳遞和操作。它們可以作為變量存儲、作為參數傳遞給其他函數、作為其他函數的返回值或者賦值給對象的屬性。
代碼示例:
// 將函數賦值給變量
const greet = function(name) {return `Hello, ${name}!`;
}// 將函數作為參數傳遞
function greetUser(user, fn) {return fn(user);
}console.log(greetUser("Alice", greet)); // 輸出: Hello, Alice!
2.4.2. 純函數
概念說明:純函數是這樣一種函數,它的返回值僅由其輸入參數決定,并且在執行過程中不產生副作用,如修改外部變量或對象狀態。
代碼示例:
// 純函數示例
function add(a, b) {return a + b;
}console.log(add(3, 2)); // 輸出: 5
這個add函數是純的,因為給定相同的輸入,它總是返回相同的輸出,而且沒有修改任何外部狀態或產生副作用。
2.4.3. 不可變性
概念說明:不可變性指的是數據狀態一旦創建,就不能被改變。在函數式編程中,任何數據變更都應通過創建和返回新的數據副本來實現,而不是直接修改原始數據。
代碼示例:
const originalArray = [1, 2, 3];
const newArray = originalArray.map(item => item * 2); // 創建新數組,不修改原數組console.log(originalArray); // 輸出: [1, 2, 3]
console.log(newArray); // 輸出: [2, 4, 6]
在這個示例中,原始數組保持不變,所有的修改都在新的數組中進行。
2.4.4. 函數組合
概念說明:函數組合是將兩個或多個函數組合成一個單一函數的過程。在組合中,一個函數的輸出成為另一個函數的輸入。
代碼示例:
function multiplyByTwo(x) {return x * 2;
}
function addThree(x) {return x + 3;
}
function compose(fn1, fn2) {return function(value) {return fn2(fn1(value));}
}
const multiplyAndAdd = compose(multiplyByTwo, addThree);
console.log(multiplyAndAdd(5)); // 先 5 * 2 = 10, 然后 10 + 3 = 13, 輸出: 13
這個 compose 函數接受兩個函數作為參數,返回一個新函數,這個新函數會先調用 fn1,再將結果傳遞給 fn2 。
2.4.5. 高階函數
概念說明:高階函數是指至少滿足以下一個條件的函數:接受一個或多個函數作為參數,或者返回一個函數作為結果。
代碼示例:
// 高階函數,接受一個函數作為參數
function repeat(times, fn) {for (let i = 0; i < times; i++) {fn(i);}
}repeat(3, console.log); // 輸出: 0 1 2
在這個示例中,repeat 是一個高階函數,因為它接受一個函數 console.log 作為參數。
3. 純函數
3.1.?什么是純函數?
純函數是這樣一種函數,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。
比如數組的 slice和 splice,這兩個函數的作用并無不同,但是注意,它們各自的方式卻大不同,但不管怎么說作用還是一樣的。
slice:符合純函數的定義:因為對相同的輸入它保證能返回相同的輸出;
splice:不符合純函數的定義:會產生可觀察到的副作用,即這個數組永久地改變了;
var xs = [1,2,3,4,5];// 純的
xs.slice(0,3);
//=> [1,2,3]xs.slice(0,3);
//=> [1,2,3]xs.slice(0,3);
//=> [1,2,3]// 不純的
xs.splice(0,3);
//=> [1,2,3]xs.splice(0,3);
//=> [4,5]xs.splice(0,3);
//=> []
在函數式編程中,我們追求的是那種可靠的,每次都能返回同樣結果的函數,而不是像 splice 這樣每次調用后都把數據弄得一團糟的函數。
來看看另一個例子:
// 不純的
var minimum = 21;var checkAge = function(age) {return age >= minimum;
};// 純的
var checkAge = function(age) {var minimum = 21;return age >= minimum;
};
在不純的版本中,checkAge 的結果將取決于 minimum 這個可變變量的值。換句話說,它取決于系統狀態,因為它引入了外部的環境,從而增加了認知負荷。
這個例子可能還不是那么明顯,但這種依賴狀態是影響系統復雜度的罪魁禍首。輸入值之外的因素能夠左右 checkAge 的返回值,不僅讓它變得不純,而且導致每次我們思考整個軟件的時候都痛苦不堪。
另一方面,使用純函數的形式,函數就能做到自給自足。我們也可以讓 minimum 成為一個不可變對象,這樣就能保留純粹性,因為狀態不會有變化。要實現這個效果,必須得到一個對象,然后調用 Object.freeze 方法。
var immutableState = Object.freeze({ minimum: 21 });
3.2.?副作用
“副作用”的關鍵部分在于“副”,就像一潭死水中的“水”本身并不是幼蟲的培養器,“死”才是生成蟲群的原因。同理,副作用中的“副”是滋生 Bug 的溫床。
副作用是在計算結果的過程中,系統狀態的一種變化,或者與外部世界進行的可觀察的交互。
副作用可能包含,但不限于:
1. 更改文件系統;
2. 往數據庫插入記錄;
3. 發送一個請求;
4. 可變數據;
5. 打印日志;
6. 獲取用戶輸入;
7. DOM 查詢;
8. 訪問系統狀態;
概括來講,只要是跟函數外部環境發生的交互就都是副作用,這一點可能會讓你懷疑無副作用編程的可行性。函數式編程的哲學就是假定副作用是造成不正當行為的主要原因。
這并不是說,要禁止使用一切副作用,而是說,要讓它們在可控的范圍內發生。
副作用讓一個函數變得不純是有道理的:從定義上來說,純函數必須要能夠根據相同的輸入返回相同的輸出;如果函數需要跟外部事物打交道,那么就無法保證這一點了。
3.3. 示例?
函數是兩種數值之間的關系:輸入和輸出。盡管每個輸入都只會有一個輸出,但不同的輸入卻可以有相同的輸出。下圖展示了一個合法的從 x 到 y 的函數關系;
下面這張圖表展示的就不是一種函數關系,因為輸入值 5 指向了多個輸出:
函數可以描述為一個集合,這個集合里的內容是輸入輸出對:[(1,2), (3,6), (5,10)]。
或者:
var toLowerCase = {'A':'a', 'B': 'b', 'C': 'c', 'D': 'd', 'E': 'e', 'D': 'd'};toLowerCase['C']; //=> 'c'var isPrime = {1:false, 2: true, 3: true, 4: false, 5: true, 6:false};isPrime[3]; //=> true
當然實際情況中,可能需要進行一些計算而不是手動指定各項值,不過上例表明了另外一種思考函數的方式。
從數學的概念上講,純函數就是數學上的函數,而且是函數式編程的全部。使用這些純函數編程能夠帶來大量的好處,讓我們來看一下為何要不遺余力地保留函數的純粹性的原因。
3.4. 純函數的特點
3.4.1.?可緩存性
首先,純函數總能夠根據輸入來做緩存。實現緩存的一種典型方式是memoize技術:
var memoize = function(f) {var cache = [];return function() {var arg_str = JSON.stringify(arguments);cache[arg_str] = cache[arg_str] || f.apply(f, arguments);return cache[arg_str];};
};var squareNumber = memoize(function(x){ return x*x; });
squareNumber(4);
//=> 16squareNumber(4); // 從緩存中讀取輸入值為 4 的結果
//=> 16squareNumber(5);
//=> 25squareNumber(5); // 從緩存中讀取輸入值為 5 的結果
//=> 25
值得注意的一點是,可以通過延遲執行的方式把不純的函數轉換為純函數:
var pureHttpCall = memoize(function(url, params){return function() { return $.getJSON(url, params); }
});
這里有趣的地方在于我們并沒有真正發送 http 請求,只是返回了一個函數,當調用它的時候才會發請求。這個函數之所以有資格成為純函數,是因為它總是會根據相同的輸入返回相同的輸出:給定了 url 和 params 之后,它就只會返回同一個發送 http 請求的函數。
memoize 函數工作起來沒有任何問題,雖然它緩存的并不是 http 請求所返回的結果,而是生成的函數。
3.4.2.?可移植性/自文檔化
純函數是完全自給自足的,它需要的所有東西都能輕易獲得。仔細思考思考這一點,這種自給自足的好處是什么呢?首先,純函數的依賴很明確,因此更易于觀察和理解。
// 不純的
var signUp = function(attrs) {var user = saveUser(attrs);welcomeUser(user);
};var saveUser = function(attrs) {var user = DB.save(attrs);...
};var welcomeUser = function(user) {Email(user, ...);...
};// 純的
var signUp = function(DB, Email, attrs) {return function() {var user = saveUser(DB, attrs);welcomeUser(Email, user);};
};var saveUser = function(DB, attrs) {...
};var welcomeUser = function(Email, user) {...
};
這個例子表明,純函數對于其依賴必須要明確,這樣我們就能知道它的目的。
僅從純函數版本的?signUp?的簽名就可以看出,它將要用到?DB、?Email?和?attrs,這在最小程度上給了我們足夠多的信息。
其次,通過“強迫”注入“依賴”,或者把它們當作參數傳遞,我們的應用也更加靈活,因為數據庫或者郵件客戶端等等都參數化了。如果要使用另一個?DB,只需把它傳給函數就行了。如果想在一個新應用中使用這個可靠的函數,盡管把新的?DB?和?Email?傳遞過去就好了,非常簡單。
命令式編程中“典型”的方法和過程都深深地根植于它們所在的環境中,通過狀態、依賴和有效作用達成,純函數與此相反,它與環境無關,只要我們愿意,可以在任何地方運行它。
3.4.3.?可測試性
純函數讓測試更加容易,我們不需要偽造一個“真實的”支付網關,或者每一次測試之前都要配置、之后都要斷言狀態,只需簡單地給函數一個輸入,然后斷言輸出就好了。
3.4.4.?合理性
很多人相信使用純函數最大的好處是引用透明性。如果一段代碼可以替換成它執行所得的結果,而且是在不改變整個程序行為的前提下替換的,那么我們就說這段代碼是引用透明的。
由于純函數總是能夠根據相同的輸入返回相同的輸出,所以它們就能夠保證總是返回同一個結果,這也就保證了引用透明性。
我們來看一個例子:
var Immutable = require('immutable');var decrementHP = function(player) {return player.set("hp", player.hp-1);
};var isSameTeam = function(player1, player2) {return player1.team === player2.team;
};var punch = function(player, target) {if(isSameTeam(player, target)) {return target;} else {return decrementHP(target);}
};var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"});
var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});punch(jobe, michael);
//=> Immutable.Map({name:"Michael", hp:19, team: "green"})
decrementHP、isSameTeam 和 punch 都是純函數,所以是引用透明的。我們可以使用一種叫做"等式推導"的方法來分析代碼,所謂"等式推導"就是"一對一"替換,有點像在不考慮程序性執行的怪異行為的情況下,手動執行相關代碼。我們借助引用透明性來剖析一下這段代碼。
首先內聯 isSameTeam 函數:
var punch = function(player, target) {if(player.team === target.team) {return target;} else {return decrementHP(target);}
};
因為是不可變數據,我們可以直接把 team替換為實際值:
var punch = function(player, target) {if("red" == "green") {return target;} else {return decrementHP(target);}
};
if 語句執行結果為 false,所以可以把整個 if 語句都刪掉:
var punch = function(player, target) {return decrementHP(target);
};
如果再內聯 decrementHP,我們會發現這種情況下,punch 變成了一個讓 hp 的值減 1 的調用:
var punch = function(player, target) {return target.set("hp", target.hp-1);
};
等式推導帶來的分析代碼的能力對重構和理解代碼非常重要。事實上,我們重構程序使用的正是這項技術:利用加和乘的特性。
3.4.5.?并行代碼
最后一點,也是決定性的一點:我們可以并行運行任意純函數。
因為純函數根本不需要訪問共享的內存,而且根據其定義,純函數也不會因副作用而進入競爭態。
并行代碼在服務端環境以及使用了web worker的瀏覽器那里是非常容易實現的,因為它們使用了線程。不過出于對非純函數復雜度的考慮,當前主流觀點還是避免使用這種并行。
4. 函數柯里化
4.1.?什么是柯里化?
柯里化是函數式編程中的一個技術,它涉及將一個多參數的函數轉換成一系列使用一個參數的函數。柯里化的函數通常返回另一個接受剩余參數的函數,這個過程一直持續,直到所有參數都被消耗掉。
4.2.?柯里化的用途
柯里化的主要用途包括:
參數復用:柯里化可以創建可以被多次調用的函數,每次調用使用部分參數來執行任務,這有助于減少重復代碼和提高代碼復用。
延遲計算:通過柯里化,函數的執行可以被延遲。只有當接收到所有必要的參數之后,才會進行計算。
動態生成函數:可以根據柯里化的中間步驟動態生成新的函數,這些函數可以適用于特定的情況,從而增強了函數的適應性和靈活性。
簡化函數的調用:柯里化有助于減少函數調用時需要的參數數量,使得函數調用更加簡潔。
???????
4.3.?代碼演示
4.3.1. 基本實現
我們可以通過一個簡單的例子來展示如何實現一個柯里化函數,該函數將接受三個參數并逐步處理它們。
function curry(fn) {return function curried(...args) {if (args.length >= fn.length) {return fn.apply(this, args);} else {return function(...args2) {return curried.apply(this, args.concat(args2));}}};
}// 使用柯里化的函數
function sum(a, b, c) {return a + b + c;
}// 柯里化sum函數
const curriedSum = curry(sum);// 逐步傳遞參數
console.log(curriedSum(1)(2)(3)); // 輸出6
console.log(curriedSum(1, 2)(3)); // 輸出6
console.log(curriedSum(1, 2, 3)); // 輸出6
這個例子中的 curry 函數是一個柯里化函數生成器,它接受一個函數 fn 作為參數,并返回一個新的函數 curried。這個返回的函數檢查是否收到了足夠的參數來調用原始函數 fn。如果沒有收到足夠的參數,它會返回另一個函數,等待更多的參數。如果收到了足夠的參數,它就直接調用原始函數 fn。
通過這種方式,柯里化的函數可以靈活地以多種方式調用,每次調用可以傳遞一個或多個參數,直到所有的參數都被提供。這種技術在需要構建高度可配置的API或進行復雜的函數組合時特別有用。
讓我們通過一個更復雜的實際示例來進一步探索柯里化的概念。這個示例將會涉及到一個簡單的購物車場景,我們將通過柯里化實現一系列的折扣函數,這些函數可以應用于商品的價格上,從而計算出最終的折扣價格。
4.3.2. 復雜應用
我們將定義幾個函數:
1. 一個基本的柯里化函數,用于創建接受單個參數的函數;
2.?一個應用特定折扣的函數;
3.?一個應用稅收的函數;
這些函數將用于計算應用了折扣和稅收后商品的最終價格。
1. 實現基礎的柯里化函數
function curry(fn) {return function curried(...args) {if (args.length >= fn.length) {return fn.apply(this, args);} else {return function(...moreArgs) {return curried.apply(this, args.concat(moreArgs));}}};
}
2. 創建應用折扣的函數
function applyDiscount(price, discount) {return price * (1 - discount);
}const curriedApplyDiscount = curry(applyDiscount);
3. 創建應用稅收的函數
function addTax(price, taxRate) {return price * (1 + taxRate);
}const curriedAddTax = curry(addTax);
4. 柯里化的使用
現在我們可以使用這些柯里化函數來計算一個商品在應用了10%的折扣和5%的稅收之后的最終價格。
// 假設商品原價100元
let originalPrice = 100;// 應用10%的折扣
let discountedPrice = curriedApplyDiscount(originalPrice)(0.10); // 90// 在折扣的基礎上應用5%的稅率
let finalPrice = curriedAddTax(discountedPrice)(0.05); // 94.5console.log(`原價: ${originalPrice}元, 折后價: ${discountedPrice}元, 含稅價: ${finalPrice}元。`);
5.?總結
這個例子展示了柯里化如何在實際應用中提供靈活性和復用性。通過柯里化,我們可以創建可配置的折扣和稅率函數,這些函數可以在不同的場景中重復使用,而不需要每次都重新定義計算邏輯。此外,通過將復雜函數分解為接受單一參數的多個函數,我們能夠更容易地追蹤每一步的計算過程,這對于調試和維護代碼非常有幫助。
這種模式在需要處理多層參數配置的商業邏輯中特別有用,比如在電子商務平臺的價格計算、金融服務的費率計算等場景中。
5. 函數組合
函數式編程中的函數組合是一個核心概念,它允許我們將多個函數鏈接在一起,創建出新的函數。這種方法的強大之處在于它提供了一種模塊化方式來構建程序,使得每個部分都可以獨立測試和重用。
5.1. 特性
函數組合的主要特性包括:
1.?模塊性:通過將小而專一的函數組合成復雜的行為,增強代碼的模塊性;
2.?可讀性:適當的函數組合可以使代碼更加直觀和易于理解;
3.?復用性:獨立的函數可以在多個地方被復用,減少代碼重復;
4.?聲明性:通過組合方式,代碼更加聲明性,聚焦于“做什么”而非“怎么做”;
5.2.?概念
5.2.1. Pointfree
Pointfree 模式指的是,永遠不必說出你的數據。它的意思是說,函數無須提及將要操作的數據是什么樣的。一等公民的函數、柯里化以及組合協作起來非常有助于實現這種模式。
// 非 pointfree, 因為提到了數據: word
var snakeCase = function (word) {return word.toLowerCase().replace(/\s+/ig, '_');
};// pointfree
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);
這里所做的事情就是通過管道把數據在接受單個參數的函數間傳遞。利用 curry,我們能夠做到讓每個函數都先接收數據,然后操作數據,最后再把數據傳遞到下一個函數那里去。另外注意在 pointfree 版本中,不需要 word 參數就能構造函數;而在非 pointfree 的版本中,必須要有 word 才能進行一切操作。
再來看一個例子:
// 非 pointfree, 因為提到了數據: name
var initials = function (name) {return name.split(' ').map(compose(toUpperCase, head)).join('. ');
};// pointfree
var initials = compose(join('.'),map(compose(toUpperCase, head)), split(' '));initials("hunter stockton thompson");
// 'H. S. T'
Pointfree 模式能夠幫助我們減少不必要的命名,讓代碼保持簡潔和通用。對函數式代碼來說,Pointfree 是非常好的石蕊試驗,因為它能告訴我們一個函數是否是接受輸入返回輸出的小函數。比如,while 循環是不能組合的。不過你也要警惕,Pointfree 就像是一把雙刃劍,有時候也能混淆視聽。并非所有的函數式代碼都是 Pointfree 的,不過這沒關系。可以使用它的時候就使用,不能使用的時候就用普通函數。
5.2.2. Debug
組合的一個常見錯誤是,在沒有局部調用之前,就組合類似 map 這樣接受兩個參數的函數。
// 錯誤做法:我們傳給了 `angry` 一個數組,根本不知道最后傳給 `map` 的是什么東西。
var latin = compose(map, angry, reverse);latin(['frog', 'eyes']);
// error// 正確做法:每個函數都接受一個實際參數。
var latin = compose(map(angry), reverse);latin(['frog', 'eyes']);
// ["EYES!", "FROG!"]
如果在 debug 組合的時候遇到了困難,那么可以使用下面這個實用的,但是不純的 trace 函數來追蹤代碼的執行情況。
var trace = curry(function(tag, x){console.log(tag, x);return x;
});var dasherize = compose(join('-'), toLower, split(' '), replace(/\s{2,}/ig, ' '));dasherize('The world is a vampire');
// TypeError: Cannot read property 'apply' of undefined
toLower 的參數是一個數組,所以需要先用 map 調用一下它。
var dasherize = compose(join('-'), map(toLower), split(' '), replace(/\s{2,}/ig, ' '));dasherize('The world is a vampire');// 'the-world-is-a-vampire'
5.2.3. 范疇學
范疇學是數學中的一個抽象分支,能夠形式化諸如集合論、類型論、群論以及邏輯學等數學分支中的一些概念。范疇學主要處理對象、態射和變化式,而這些概念跟編程的聯系非常緊密。下圖是一些相同的概念分別在不同理論下的形式:
在范疇學中,有一個概念叫做范疇,有著以下這些組件的搜集就構成了一個范疇:
1. 對象的搜集;
2. 態射的搜集;
3. 態射的組合;
4. identity 這個獨特的態射;
范疇學抽象到足以模擬任何事物,不過目前我們最關心的還是類型和函數,所以讓我們把范疇學運用到它們身上看看。
1.?對象的搜集
對象就是數據類型,例如 String、Boolean、Number 和 Object 等等。通常我們把數據類型視作所有可能的值的一個集合,像 Boolean 就可以看作是 true和false的集合,Number 可以是所有實數的一個集合,把類型當作集合對待是有好處的,因為我們可以利用集合論。
2.?態射的搜集
態射是標準的、普通的純函數。
3.?態射的組合
這張圖展示了什么是組合:
這里有一個具體的例子:
var g = function(x){ return x.length; };
var f = function(x){ return x === 4; };
var isFourLetterWord = compose(f, g);
4.?identity 這個獨特的態射
讓我們介紹一個名為 id 的實用函數,這個函數接受隨便什么輸入然后原封不動地返回它。
var id = function(x){ return x; };
id 函數跟組合一起使用簡直完美,下面這個特性對所有的一元函數 f 都成立:
// identity
compose(id, f) == compose(f, id) == f;
// true
5.3.?子函數定義
在JavaScript中,我們可以使用多種方式來實現函數組合,例如通過手動嵌套函數調用、使用數組的reduce 方法,或使用專門的庫如 Lodash 的 flow 函數。
1. 手動組合
這是最基本的組合方式,直接通過將一個函數的輸出作為另一個函數的輸入來實現。
function double(x) {return x * 2;
}function increment(x) {return x + 1;
}// 手動組合
const doubleThenIncrement = (x) => increment(double(x));console.log(doubleThenIncrement(3)); // 輸出 7 (3*2+1)
2.?自動化組合
如果有多個函數需要組合,可以使用 reduce方法來自動化這個過程。
function compose(...fns) {return function(x) {return fns.reduceRight((value, fn) => fn(value), x);};
}const incrementThenDouble = compose(double, increment);console.log(incrementThenDouble(3)); // 輸出 8 ((3+1)*2)
5.4.?組合示例
假設我們有一個電商網站的訂單處理流程,需要依次對訂單金額應用多種折扣和附加費用。我們可以通過函數組合的方式,將這些獨立的計算步驟組合成一個整體的計算流程。
// 函數定義
function applyCoupon(discount) {return price => price * (1 - discount);
}function addShippingFee(fee) {return price => price + fee;
}function addTax(rate) {return price => price * (1 + rate);
}// 組合函數
function compose(...fns) {return fns.reduce((f, g) => (...args) => g(f(...args)));
}// 創建訂單處理流程
const processOrder = compose(applyCoupon(0.10), // 應用10%的優惠券addShippingFee(5), // 添加5元運費addTax(0.05) // 應用5%的稅
);// 使用組合函數處理訂單
const finalPrice = processOrder(100);
console.log(`最終價格: ${finalPrice.toFixed(2)}元`); // 計算100元的最終價格
在這個例子中,compose 函數接受任意多個函數,并將它們組合成一個新的函數。這樣,processOrder 函數就能夠以聲明性和易于理解的方式,依次應用折扣、運費和稅費。這種方式使得每個計算步驟都是獨立的,便于測試和維護,同時也便于修改或擴展計算流程。
總結來說,函數組合是函數式編程的一個強大工具,它提供了一種高效和清晰的方式來構建復雜的業務邏輯。通過利用函數的組合性,可以創建出既簡潔又靈活的代碼結構。
6. 不可變性
在函數式編程中,不可變性是一個核心概念,指的是數據一旦被創建就不能改變。任何對數據的修改或更新操作都會生成一個新的數據副本,而不是改變原有的數據。這種做法有助于避免副作用和數據狀態的不一致性,使得程序的行為更可預測,簡化了復雜程序中的狀態管理。這一特性在 React 中用途頗廣。單向數據流狀態的更改需要我們使用這一特性來實現。
6.1.?不可變性的優勢
1. 提高程序的可預測性,由于數據不會被意外修改,函數的行為和輸出更加可預測;
2. 簡化并發編程,不可變數據結構自然是線程安全的,因為沒有線程可以修改數據,從而避免了鎖和競態條件的需求;
3. 便于歷史數據追蹤,保持歷史版本的數據不被改變,可以輕松地實現撤銷和重做功能;
6.2.?React 中的不可變性
在 React 中,組件的狀態應當是不可變的。這意味著在更新狀態時,你應該創建狀態的一個新副本而不是直接修改狀態。這種做法有助于優化 React 應用的性能,尤其是在利用shouldComponentUpdate、PureComponent 或 React.memo 等性能優化手段時,因為 React 可以快速地檢查 props 或 state 是否發生變化,從而決定是否需要重新渲染組件。
import React, { useState } from 'react';function App() {const [items, setItems] = useState([{ id: 1, text: 'Learn React' }]);const addItem = () => {// 使用展開運算符創建新數組,而不是直接修改原數組setItems([...items, { id: items.length + 1, text: 'Learn more about immutability' }]);};return (<div><ul>{items.map(item => (<li key={item.id}>{item.text}</li>))}</ul><button onClick={addItem}>Add Item</button></div>);
}
6.3.?Immer 使用
Immer 是一個流行的庫,用于簡化不可變數據結構的處理。它允許你編寫看似“可變”的代碼,但實際上會產生不可變的數據更新。在 React 中,使用 Immer 可以簡化復雜狀態的更新邏輯。
import React, { useState } from 'react';
import produce from 'immer';function App() {const [items, setItems] = useState([{ id: 1, text: 'Learn React' }]);const addItem = () => {const newItem = { id: items.length + 1, text: 'Learn Immer' };// 使用 Immer 的 produce 函數來處理不可變更新setItems(produce(items, draft => {draft.push(newItem);}));};return (<div><ul>{items.map(item => (<li key={item.id}>{item.text}</li>))}</ul><button onClick={addItem}>Add Item</button></div>);
}
在這個例子中,produce 函數接受當前的狀態 items 和一個"修改器"函數。在修改器函數內部,你可以"修改"傳入的 draft 對象,而 Immer 會產生一個新的不可變狀態,這使得狀態更新即符合不可變性原則,又易于編寫和理解。
總結來說,不可變性是確保數據一致性和簡化復雜 UI 邏輯的有效手段。在現代 JavaScript 和 React 開發中,理解和利用不可變性,特別是通過庫如 Immer,可以顯著提高應用的可維護性和性能。
7. 重點提要
函數式編程在以下場景使用非常廣泛,我們日常開發過程中都有接觸,可能之前沒有重點關注對應函數的實現細節,這里我們重點說明一下常用場景:
在函數式編程中,compose 和 curry 是兩個非常基礎且強大的工具。下面提供了它們的簡單實現:
7.1.?curry 函數
curry函數用于將一個接受多個參數的函數轉化為一系列只接受一個參數的函數。
function curry(fn) {return function curried(...args) {if (args.length >= fn.length) {return fn.apply(this, args);} else {return function(...moreArgs) {return curried.apply(this, args.concat(moreArgs));};}};
}// 示例使用
function sum(a, b, c) {return a + b + c;
}const curriedSum = curry(sum);
console.log(curriedSum(1)(2)(3)); // 輸出 6
console.log(curriedSum(1, 2)(3)); // 輸出 6
console.log(curriedSum(1, 2, 3)); // 輸出 6
這個?curry?函數檢查是否已經接收到足夠的參數來調用原始函數?fn。如果接收到足夠的參數,則直接調用?fn。如果沒有,則返回一個新的函數,等待更多的參數。這通過遞歸調用?curried?函數實現,逐步累積接收到的參數,直到參數足夠為止。
在函數式編程中,?pipe?是?compose?的反向操作,即它從從左到右的順序執行函數,而?compose?是從右到左。?pipe?函數對于創建可讀的代碼序列特別有用,尤其是當你想按照邏輯流順序應用函數時。
7.2.?compose 函數
compose 函數用于將多個函數組合成一個新的函數,其中每個函數的輸出是下一個函數的輸入。
function compose(...fns) {return function(x) {return fns.reduceRight((value, fn) => fn(value), x);};
}// 示例使用
const double = x => x * 2;
const increment = x => x + 1;const doubleThenIncrement = compose(increment, double);
console.log(doubleThenIncrement(3)); // 輸出 7 (3*2+1)
這里的 compose 函數接受一個函數數組 fns,并返回一個新的函數,該函數接受一個參數 x。它使用 reduceRight 方法來從右向左應用這些函數,確保第一個函數最后被調用。
7.3.?簡單的 pipe 函數
下面是一個簡單的 pipe?函數的實現,它接受一系列的函數作為參數,并返回一個新的函數,這個新函數將按順序應用這些函數:
function pipe(...fns) {return function(x) {return fns.reduce((value, fn) => fn(value), x);};
}// 示例使用
const double = x => x * 2;
const increment = x => x + 1;
const square = x => x * x;const process = pipe(increment, double, square);
console.log(process(3)); // 輸出 ((3+1)*2)^2 = 64
在這個例子中,pipe 函數接收一系列函數 fns 并返回一個新的函數,這個函數接受一個初始值 x。它通過 reduce 方法來從左向右依次調用這些函數,每個函數的輸出作為下一個函數的輸入。在上述例子中,數字 3 首先被加 1得到 4,然后乘以 2 得到 8,最后求平方得到 64。
pipe 函數在數據轉換流水線或在需要順序處理步驟的任何場景中特別有用。例如,在Web開發中處理HTTP請求的多個中間件,或者在數據分析中連續應用多個數據轉換操作。
7.4.?對比 pipe 和 compose
盡管 pipe 和 compose 功能相似,主要區別在于函數的應用順序。選擇使用哪一個通常取決于你想如何組織代碼:
1. 使用 compose 當你想從右到左應用函數,這在數學上更符合傳統的復合函數記法;
2. 使用 pipe 當你希望按照步驟順序,從左到右應用函數,這在邏輯流和閱讀上更直觀;
使用這些工具可以極大地提高代碼的聲明性和模塊化,使其更易于維護和擴展。
8. 補充資料
FP 完整概念:https://en.wikipedia.org/wiki/Functional_programming
柯里化:https://zh.javascript.info/currying-partials
V8 function optimization:https://erdem.pl/2019/08/v-8-function-optimization
redux compose 實現:https://github.com/reduxjs/redux/blob/ef57856c0d16f0c99fce75d9252be60d1c72e15b/src/compose.ts#L31
rxjs:https://rxjs.dev/guide/overview#values
ramda:https://github.com/ramda/ramda
rambda:https://selfrefactor.github.io/rambda/#/???????