coding 優雅指南:函數式編程
函數式編程是一種編程範式,主要思想是把程序用一系列計算的形式來表達,主要關心數據到數據之間的映射關係。同時在 react hook 中,其實存在了大量的函數式編程的思想。所以作爲一個前端,對於函數式編程的能力還是必須要有的。
what is it?
從兩種範式的區別講起
-
命令式編程
-
命令式編程是面向計算機硬件的抽象,變量對應存儲單元,賦值對應寄存器的存取指令,表達式對應內存引用和算術運算,控制語句對應跳轉語句。命令式編程就是將程序用一系列的命令組織起來,將問題的步驟分解成一個個的命令的一種組織方式。
-
函數式編程
-
函數式編程是面向數學的一種抽象,關心的是數據到數據的映射過程,即是將計算過程抽象描述成一種表達式求值。
-
在函數式語言中,函數作爲一等公民,可以在任何地方定義,可以作爲參數和返回值,可以對函數進行組合。
-
函數式編程中的函數不是指的計算機中的函數這個概念,而是數學界函數的概念,在初高中數學中,我們都學到了什麼叫函數,函數是一種從 x -> y 的一種映射關係,如果 f 這個映射規則定了,那麼 f(x) 的值僅與傳入的 x 有關,函數式編程的思想其實就是如此,其執行結果僅與輸入的參數有關,不依賴其他外部的狀態,也不會產生副作用,這種函數我們稱爲純函數(pure Function)。
-
函數式編程中的變量也和命令式編程中的變量的概念不一致,命令式中的變量大多是指存儲單元的狀態,而函數式中的變量值的是數學中代數上的變量,即一個值的名稱,變量的值是不可變的(immutable),即不可以多次給一個變量賦值。函數式編程從理論上說,是通過 lambda 演算來進行的。
pure function(純函數) & 副作用
-
純函數是這樣一種函數,只受輸入影響,與外部狀態無關,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。
-
此函數在相同的輸入值時,需產生相同的輸出。函數的輸出和輸入值以外的其他隱藏信息或狀態 [2] 無關,也和由 I/O 設備產生的外部輸出無關。
-
維基 [1] 上若一個函數符合以下要求,則它可能被認爲是純函數:
-
該函數不能有語義上可觀察的函數副作用 [3],定義:副作用_是在計算結果的過程中,系統狀態的一種變化,或者與外部世界進行的_可觀察的交互。副作用包括:諸如 “觸發事件”,使輸出設備輸出,或更改輸出值以外物件的內容,更詳細的栗子:更改文件系統、往數據庫插入記錄、發送 http 請求、可變數據、打印、獲取輸入、dom 查詢、訪問系統狀態。
純函數的輸出可以不用和所有的輸入值有關,甚至可以和所有的輸入值都無關。但純函數的輸出不能和輸入值以外的任何資訊有關。純函數可以傳回多個輸出值,但上述的原則需針對所有輸出值都要成立。以下一個初中數學的圖,可以很好的說明這個道理。
再往下(從 lambda 演算開始):
計算機科學,尤其是編程語言,經常傾向於使用一種特定的演算:lambda 演算 [4] (Lambda Calculus)。這種演算也廣泛地被邏輯學家用於學習計算和離散數學的結構的本質。Lambda 演算偉大的的原因有很多,其中包括:
-
非常簡單。
-
圖靈完備 [5]
-
容易讀寫。
-
語義足夠強大,可以從它開始做(任意)推理。
-
它有一個很好的實體模型。
-
容易創建變種,以便我們探索各種構建計算或語義方式的屬性。
lambda 演算最重要的兩個規約:Alpha 規約和 Beta 規約,簡單來講,就是
-
參數可以任意命名,參數名改變不影響 lambda 表達式
-
參數標識符可以用參數值代替
初中數學知識,對吧。好的,我們再來引入一點知識:丘奇數 [6], 這樣我們能表示一定的計算了,再引入邏輯定義,和循環定義,這樣是不是就能表達所有計算了。這裏就不展開再繼續了,有興趣可以移步閱讀 https://cgnail.github.io/academic/lambda-1/[7]。函數式編程其實就是基於 lambda 演算衍生出來的。
why use it ?
從一個簡單的例子講起:
const add = (x,y)=>x+y;
const multiply = (x,y)=>x*y; add(multiply(b, add(a, c)), multiply(a, b));
// add(b*(a+c),a*b); // add(add(ab+ac),a*b); // ab+ab+ac multiply(a,add(b,add(b+c)))
從上面代碼可以看出來,函數式的優點,可以任意組合,拆分。
特點:
-
輸出僅與輸入有關。
-
引用透明不依賴外部,舉個栗子,就是外面不管地震海嘯颳風下雨,你的媽媽在拿到番茄和雞蛋這兩個輸入以後,還是會輸出番茄炒蛋這個菜,不管外面發生什麼,你給你的媽媽輸入番茄和雞蛋,總會得到番茄炒蛋這個菜。換到代碼中來就是函數式編程中的函數是沒有上下文的,無論上下文怎麼變,這個函數的調用結果僅依賴於輸入的參數。
-
不產生副作用。一般來講 操作數據庫 發起請求 操作 dom 調用其他副作用函數,這些活動一般會對外部環境產生影響。
優點:
- 輸入輸出顯示,方便溯源,同時不會有隱式的狀態引入,導致該模塊在 A 處工作正常,但是在 B 處工作不正常
// not pure
const a = {x:5,y:10};
const b = ()=>{
console.log(a.x)
}
b();// => 5
// later ...
a.x =10;
// later ...
b(); // =>10
// some thing are wrong
// 誒 輸出爲什麼變了, b裏面怎麼計算的,b依賴的啥呀
// 誒 爺找到他怎麼計算的了
// 爲啥前後不一致呢
// 臥槽 這裏爲啥改了全局變量
// pure
const b = (obj)=>{
console.log(obj?.a)
}
b({a:5});
// 這個東西出問題了.. 參數被改變了 over over
-
輸入輸出流顯式,只有一個渠道也就是輸入參數可以獲得數據。
-
可以得到函數映射表、併發安全 避免競爭、無狀態,不會讀取外部狀態。
-
不產生副作用純函數,可以組裝起來變成高級純函數可讀性高,可測試性,可複製和重構
展開講講:
- 輸入輸出顯示,那麼我們可以得到這個函數的映射表,說明我們可以對這個函數計算結果進行緩存,如果有同樣輸入的調用,那麼我們可以直接返回計算後的值。
const memo = (fn)=>{
const cache = new Map();
return (...args)=>{
const key = JSON.stringify(args);
if(cache.has(key)){
return cache.get(key);
}
const res = fn.call(fn,...args);
cache.set(key,res);
return res;
}
}
const addOne = memo(x=>x+1);
addOne(5); // 計算
addOne(5); // 緩存
- 可以將一個不純的函數轉換成一個純函數
const pureHttpGet = memo((url,params)=>{
return ()=>{
return axios.get(url,params);
}
})
這個函數之所以能稱爲純函數,他滿足純函數的特性,根據輸入,總是返回固定的輸出。
-
可移植性 一句名言:“面嚮對象語言的問題是,它們永遠都要隨身攜帶那些隱式的環境。你只需要一個香蕉,但卻得到一個拿着香蕉的大猩猩... 以及整個叢林”
-
可測試性與引用透明,對於一個純函數,我們可以很清晰的去斷言他的輸入輸出,同時因爲引用透明,可以很簡單的去推導出函數的內部的調用過程,從而去簡化 & 重構這個函數。
-
並行:回憶一下操作系統死鎖的原因,以及爲什麼有鎖這個機制的存在,就是因爲需要使用 / 更改外部的資源,但是純函數不需要訪問共享內存,所以也不會因爲副作用進入競爭態。
core:
- 高階函數
- 高階函數是指對一個函數可以傳入一個參數是函數,或者返回值是函數。javascript 是天生支持高階函數和閉包兩個重要特性的。我們常用的 array 方法中 map reduce filter .. 就是高階函數
Array.prototype.fakemap = function (callback, thisArg) {
if (!Array.isArray(this)) {
throw new Error("Type Error");
}
if(typeof callback!=="function"){
throw new Error(callback.toString()+'is not a function')
}
let resArr = [];
let cb = callback.bind(thisArg);
for (let i = 0; i < this.length; i++) {
resArr.push(cb(this[i], i, this));
}
return resArr;
};
// 高階函數當然也可以組合使用 與純函數性質一致
[1,2,3,4].filter(item=>item>2).map(item=>item -1)
- 偏函數應用 partial function
- 偏函數和柯里化是兩個很容易混淆的概念,偏函數是包裝一個原始函數接受部分參數作爲固定值預設,返回一個新的函數。
// 創建偏函數,固定一些參數
const partial = (f, ...args) =>
// 返回一個帶有剩餘參數的函數
(...moreArgs) =>
// 調用原始函數
f(...args, ...moreArgs)
const add3 = (a, b, c) => a + b + c
// (...args) => add3(2, 3, ...args)
// (c) => 2 + 3 + c
const fivePlus = partial(add3, 2, 3)
fivePlus(4) // 9
js 中最常見的 Function.prototype.bind(會改變 this 指向), 其實就可以實現
const foo = (a:number,b:number)=>{
return a+b;
}
const bar = foo.bind(null,2);
bar(3);//5
簡單來說,偏函數就是固定部分參數,返回新函數做計算,如果需要完整實現的話,可以參考一下 lodash 的 partial.js 這個文件,大致意思簡單的將源碼思路寫一下
function partial(fn) {
const args = Array.slice.call(arguments,1);
return function(){
const length = args.length;
let position = 0;
// 把用佔位符的參數替換掉
for(let i =0 ;i<length,i++){
args[i] = args[i]=== _ ? arguments[position++]:args[i]
}
// 將剩下的參數懟進去
while(position<arguments.length) args.push(arguments[postion++]);
return fn.apply(this,args)
};
}
- 柯里化
- 柯里化和偏函數應用有些區別,是將多個參數函數轉換成單參數的函數。
const curry = fn => {
if (fn.length <= 1) {
return fn;
}
const iter = args =>
args.length === fn.length
? fn(...args)
: arg => iter([...args, arg]);
return iter([]);
};
- 閉包
閉包最初是來源於 lambda 演算中的一個概念,閉包(closure)或者叫完全綁定(complete binding)。在對一個 Lambda 演算表達式進行求值的時候,不能引用任何未綁定的標識符。如果一個標識符是一個閉合 Lambda 表達式的參數,我們則稱這個標識符是(被)綁定的;如果一個標識符在任何封閉上下文中都沒有綁定,那麼它被稱爲自由變量。
-
lambda x . plus x y
:在這個表達式中,y 和 plus 是自由的,因爲他們不是任何閉合的 Lambda 表達式的參數;而 x 是綁定的,因爲它是函數定義的閉合表達式 plus x y 的參數。 -
lambda x y . y x
:在這個表達式中 x 和 y 都是被綁定的,因爲它們都是函數定義中的參數。 -
lambda y . (lambda x . plus x y)
:在內層演算 lambda x . plus x y 中,y 和 plus 是自由的,x 是綁定的。在完整表達中,x 和 y 是綁定的:x 受內層綁定,而 y 由剩下的演算綁定。plus 仍然是自由的。
一個 Lambda 演算表達式只有在其所有變量都是綁定的時候才完全合法。js 中的閉包就不在這囉嗦了,爲什麼需要閉包,我們可以看一個最簡單的柯里化的例子。
const add = (w,x,y,z)=>w+x+y+z;
const curryAdd = curry(add);
// 根據上面 寫的curry函數, 我們來分解一下
const a = curryAdd(1); // 這個時候返回了一個包含1的函數 & add的函數
const b = a(2);
const c = b(3);
const d = c(4);
// 如果沒有閉包,之前傳入的東西參數都無跡可尋了。
從 webstorm 單步調試可以更清楚的知道這個函數的作用域 & 存在哪些閉包。
How do I use it ?
- 去掉無必要的包裹
// Bad
const BlogController = {
index(posts) { return Views.index(posts); },
show(post) { return Views.show(post); },
create(attrs) { return Db.create(attrs); },
update(post, attrs) { return Db.update(post, attrs); },
destroy(post) { return Db.destroy(post); },
};
// Good
const BlogController = {
index: Views.index,
show: Views.show,
create: Db.create,
update: Db.update,
destroy: Db.destroy,
};
BlogController
,雖說添加一些沒有實際用處的間接層實現起來很容易,但這樣做除了徒增代碼量,提高維護和檢索代碼的成本外,沒有任何用處。
另外,如果一個函數被不必要地包裹起來了,而且發生了改動,那麼包裹它的那個函數也要做相應的變更。
foo(a, b => bar(b));
如果 foo
增加回調中處理的函數,那麼不只是要改掉 foo 和 bar,所有涉及到的調用也會更改。
foo(a, (x, y) => bar(x, y));
寫成一等公民函數的形式,要做的改動將會少得多:
foo(a, bar); // 只需要更改foo中執行bar的邏輯和 bar中執行的邏輯
- 工具函數命名更爲通用(拒絕業務化)
// 只針對當前的博客
const validArticles = articles =>
articles.filter(article => article !== null && article !== undefined),
// 對未來的項目更友好
const compact = xs => xs.filter(x => x !== null && x !== undefined);
- use the poor function
使用 javascript api 過程中,不要使用含有副作用的 API,而選擇無副作用的 api。例如 slice 和 splice,肯定是選擇 slice,不要修改傳入的引用對象等。來看一點 case
// bad errInfo 永久的被改動了,如果有其他地方使用到的話,可能會出現問題
const valiateRepeatPhone = (phones: string[], errInfo: IErrorInfo[]) => {
for (let i = 0; i < phones.length; i++) {
if (errInfo[i] === ERROR_TYPE.PHONE_REPEAT) {
errInfo[i] = ERROR_TYPE.NULL;
}
if (
errInfo[i] === ERROR_TYPE.NULL &&
phones[i] !== '' &&
phones.indexOf(phones[i]) !== i
) {
errInfo[i] = ERROR_TYPE.PHONE_REPEAT;
}
}
};
// 稍好一點的
const valiateRepeatPhone = (phones: string[], errInfo: IErrorInfo[]) => {
return errInfo.map((item,index)=>{
if(item === ERRORTYPE.PHONE_REPEAT || item === ERROR_TYPE.NULL){
return (phones[i] !== '' && phones.indexOf(phones[i]) !== i)
? ERROR_TYPE.PHONE_REPEAT
: ERROR_TYPE.NULL
}
return item;
})
}
- 使用聲明式而不是命令式,將依賴當作參數傳遞
// 命令式
const makes = [];
for(let i =0;i<car.length;i++){
makes.push(cars[i].make);
}
const makes = cars.map(item=>item.make)
- compose(將一些純函數組合起來,返回新函數), 讓代碼從右到左運行,而不是由內而外運行。
const compose = (f,g) => {
return x => {
return f(g(x));
}
}
- Monad :上面我們介紹了 compose,但是 compose 的調用方式,總看起來還是沒有那麼舒服,在 js 中鏈式調用很流行,要實現鏈式調用,例如 (5).add(1).add(4),那麼我們肯定需要一個容器,將 5 進行一個包裝。
class Functor {
private val:any;
private constructor (val: any) {
this.val = val;
}
public static of(val){
return new Functor(val);
}
public map(Fn){
return Functor.of(Fn(this.val))
}
}
Functor.of(5).map(add5).map(double)
好的,根據上面,其實我們已經實現了一個函數式編程中比較重要的概念,函子(functor)。functor 是實現了 map 函數並遵守一些特定規則的容器類型。
接下來我們進一步分析,可能會存在一種情況,如果傳入是空值,會導致報錯。所以需要引入一個函子,Maybe 函子, 只需要引入一個三則,這樣我們就能夠過濾空值,防止報錯。
class Maybe {
private val:any;
private constructor (val: any) {
this.val = val;
}
public static of(val){
return new Maybe(val);
}
public map(Fn){
return this.val ? Maybe.of(Fn(this.val)) : Maybe.of(null);
}
}
Maybe.of(5).map(add5).map(double)
好的,我們現在已經得到了一個可以過濾空值的函數,但是我們現在在執行完調用後,我們獲得的是一個什麼呢,是一個對象對吧,我們還需要把值取出來,所以需要添加一個取值的方法
class Maybe {
private val:any;
private constructor (val: any) {
this.val = val;
}
public static of(val){
return new Maybe(val);
}
public map(Fn){
return this.val ? Maybe.of(Fn(this.val)) : Maybe.of(null);
}
public join(){
return this.val;
}
}
Maybe.of(5).map(add5).map(double).join()
好了,我們現在可以拿到值了,但是,如果 may 層次太高,我們是不是需要像洋蔥一樣去剝開他的心,那更簡單的是什麼呢,在我們需要的時候去剝開它。
class Maybe {
private val:any;
private constructor (val: any) {
this.val = val;
}
public static of(val){
return new Maybe(val);
}
public map(Fn){
return this.val ? Maybe.of(Fn(this.val)) : Maybe.of(null);
}
public join(){
return this.val;
}
public chain(Fn) {
return this.map(Fn).join();
}
}
Maybe.of(5).map(add5).chain(Maybe.of(double))
IO monad,一個例子
如果我們要寫一個對 dom 的讀寫操作,將一個文本轉換爲大寫,先定義一下以下方法
const $ = (id: string) => <HTMLElement>document.querySelector(`#${id}`);
const read = (id: string) => $(id).value;
const write = (id: string) => (text: string) => $(id).textContent = text;
// not pure,因爲函數中操作了dom,對外部進行了改變
function syncInputToOutput(idInput: string, idOutput: string) {
const inputValue = read(idInput);
const outputValue = inputValue.toUpperCase();
write(idOutput, outputValue);
}
export default class IO<T> {
private effectFn: () => T;
constructor(effectFn: () => T) {
this.effectFn = effectFn;
}
bind<U>(transform: (value: T) => IO<U>) {
return new IO<U>(() => transform(this.effectFn()).run());
}
run(): T {
return this.effectFn();
}
}
const read = (id: string) => new IO<string>(() => $(id).value);
const write = (id: string) => (text: string) => new IO<string>(() => $(id).textContent = text);
function syncInputToOutput(idInput: string, idOutput: string) {
read(idInput)
.bind((value: string) => new IO<string>(() => value.toUpperCase()))
.bind(write(idOutput)))
.run();
}
延伸閱讀
-
函數式語言在深度學習領域應用很廣泛,因爲函數式與深度學習模型的契合度很高,The Beauty of Functional Languages in Deep Learning — Clojure and Haskell[8] 。深度學習的計算模型本質上是數學模型,而數學模型本質上和函數式編程思路是一致的:數據不可變且函數間可以任意組合。這意味着使用函數式編程語言可以更好的表達深度學習的計算過程,因此更容易理解與維護,同時函數式語言內置的 Immutable 數據結構也保障了併發的安全性。
-
範疇學
-
前端中的 Monad[9]
References:
-
FP jargon 英文 [10]FP jargon 中文 [11]
-
https://github.com/fantasyland/fantasy-land[12]
-
https://github.com/MostlyAdequate/mostly-adequate-guide[13]
-
https://github.com/llh911001/mostly-adequate-guide-chinese[14]
參考資料
[1]
維基: https://zh.wikipedia.org/wiki / 純函數
[2]
狀態: https://zh.wikipedia.org/w/index.php?title = 程式狀態 & action=edit&redlink=1
[3]
函數副作用: https://zh.wikipedia.org/wiki / 函數副作用
[4]
lambda 演算: https://zh.wikipedia.org/wiki/Λ演算
[5]
圖靈完備: https://zh.wikipedia.org/wiki / 圖靈完備性
[6]
丘奇數: https://zh.wikipedia.org/wiki / 邱奇數
[7]
https://cgnail.github.io/academic/lambda-1/: https://cgnail.github.io/academic/lambda-1/
[8]
The Beauty of Functional Languages in Deep Learning — Clojure and Haskell: https://www.welcometothejungle.co/fr/articles/btc-deep-learning-clojure-haskell
[9]
前端中的 Monad: https://zhuanlan.zhihu.com/p/47130217
[10]
FP jargon 英文: https://github.com/hemanth/functional-programming-jargon#partial-application
[11]
FP jargon 中文: https://github.com/shfshanyue/fp-jargon-zh
[12]
https://github.com/fantasyland/fantasy-land: https://github.com/fantasyland/fantasy-land
[13]
https://github.com/MostlyAdequate/mostly-adequate-guide: https://github.com/MostlyAdequate/mostly-adequate-guide
[14]
https://github.com/llh911001/mostly-adequate-guide-chinese: https://github.com/llh911001/mostly-adequate-guide-chinese
❤️ 謝謝支持
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/pf2GSrNLnw6dOmJuHfAPrw