coding優(yōu)雅指南:函數(shù)式編程
函數(shù)式編程是一種編程范式,主要思想是把程序用一系列計算的形式來表達(dá),主要關(guān)心數(shù)據(jù)到數(shù)據(jù)之間的映射關(guān)系。同時在react hook中,其實存在了大量的函數(shù)式編程的思想。所以作為一個前端,對于函數(shù)式編程的能力還是必須要有的。
what is it?
從兩種范式的區(qū)別講起
命令式編程 命令式編程是面向計算機硬件的抽象,變量對應(yīng)存儲單元,賦值對應(yīng)寄存器的存取指令,表達(dá)式對應(yīng)內(nèi)存引用和算術(shù)運算,控制語句對應(yīng)跳轉(zhuǎn)語句。命令式編程就是將程序用一系列的命令組織起來,將問題的步驟分解成一個個的命令的一種組織方式。 函數(shù)式編程 函數(shù)式編程是面向數(shù)學(xué)的一種抽象,關(guān)心的是數(shù)據(jù)到數(shù)據(jù)的映射過程,即是將計算過程抽象描述成一種表達(dá)式求值。 在函數(shù)式語言中,函數(shù)作為一等公民,可以在任何地方定義,可以作為參數(shù)和返回值,可以對函數(shù)進(jìn)行組合。 函數(shù)式編程中的函數(shù)不是指的計算機中的函數(shù)這個概念,而是數(shù)學(xué)界函數(shù)的概念,在初高中數(shù)學(xué)中,我們都學(xué)到了什么叫函數(shù),函數(shù)是一種從x -> y的一種映射關(guān)系,如果 f 這個映射規(guī)則定了,那么f(x) 的值僅與傳入的x有關(guān),函數(shù)式編程的思想其實就是如此,其執(zhí)行結(jié)果僅與輸入的參數(shù)有關(guān),不依賴其他外部的狀態(tài),也不會產(chǎn)生副作用,這種函數(shù)我們稱為純函數(shù)(pure Function)。 函數(shù)式編程中的變量也和命令式編程中的變量的概念不一致,命令式中的變量大多是指存儲單元的狀態(tài),而函數(shù)式中的變量值的是數(shù)學(xué)中代數(shù)上的變量,即一個值的名稱,變量的值是不可變的(immutable),即不可以多次給一個變量賦值。函數(shù)式編程從理論上說,是通過 lambda 演算來進(jìn)行的。
pure function(純函數(shù)) & 副作用
純函數(shù)是這樣一種函數(shù),只受輸入影響,與外部狀態(tài)無關(guān),即相同的輸入,永遠(yuǎn)會得到相同的輸出,而且沒有任何可觀察的副作用。 此函數(shù)在相同的輸入值時,需產(chǎn)生相同的輸出。函數(shù)的輸出和輸入值以外的其他隱藏信息或狀態(tài)[2]無關(guān),也和由I/O設(shè)備產(chǎn)生的外部輸出無關(guān)。 維基[1]上若一個函數(shù)符合以下要求,則它可能被認(rèn)為是純函數(shù): 該函數(shù)不能有語義上可觀察的函數(shù)副作用[3],定義:副作用_是在計算結(jié)果的過程中,系統(tǒng)狀態(tài)的一種變化,或者與外部世界進(jìn)行的_可觀察的交互。副作用包括:諸如“觸發(fā)事件”,使輸出設(shè)備輸出,或更改輸出值以外物件的內(nèi)容,更詳細(xì)的栗子:更改文件系統(tǒng)、往數(shù)據(jù)庫插入記錄、發(fā)送http請求、可變數(shù)據(jù)、打印、獲取輸入、dom查詢、訪問系統(tǒng)狀態(tài)。
純函數(shù)的輸出可以不用和所有的輸入值有關(guān),甚至可以和所有的輸入值都無關(guān)。但純函數(shù)的輸出不能和輸入值以外的任何資訊有關(guān)。純函數(shù)可以傳回多個輸出值,但上述的原則需針對所有輸出值都要成立。以下一個初中數(shù)學(xué)的圖,可以很好的說明這個道理。

再往下(從lambda演算開始):
計算機科學(xué),尤其是編程語言,經(jīng)常傾向于使用一種特定的演算:lambda演算[4] (Lambda Calculus)。這種演算也廣泛地被邏輯學(xué)家用于學(xué)習(xí)計算和離散數(shù)學(xué)的結(jié)構(gòu)的本質(zhì)。Lambda演算偉大的的原因有很多,其中包括:
非常簡單。 圖靈完備[5] 容易讀寫。 語義足夠強大,可以從它開始做(任意)推理。 它有一個很好的實體模型。 容易創(chuàng)建變種,以便我們探索各種構(gòu)建計算或語義方式的屬性。
lambda演算最重要的兩個規(guī)約:Alpha規(guī)約和Beta規(guī)約,簡單來講,就是
參數(shù)可以任意命名,參數(shù)名改變不影響lambda表達(dá)式 參數(shù)標(biāo)識符可以用參數(shù)值代替
初中數(shù)學(xué)知識,對吧。好的,我們再來引入一點知識:丘奇數(shù)[6], 這樣我們能表示一定的計算了,再引入邏輯定義,和循環(huán)定義,這樣是不是就能表達(dá)所有計算了。這里就不展開再繼續(xù)了,有興趣可以移步閱讀 https://cgnail.github.io/academic/lambda-1/[7]。函數(shù)式編程其實就是基于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)))
從上面代碼可以看出來,函數(shù)式的優(yōu)點,可以任意組合,拆分。
特點:
輸出僅與輸入有關(guān)。 引用透明不依賴外部,舉個栗子,就是外面不管地震海嘯刮風(fēng)下雨,你的媽媽在拿到番茄和雞蛋這兩個輸入以后,還是會輸出番茄炒蛋這個菜,不管外面發(fā)生什么,你給你的媽媽輸入番茄和雞蛋,總會得到番茄炒蛋這個菜。換到代碼中來就是函數(shù)式編程中的函數(shù)是沒有上下文的,無論上下文怎么變,這個函數(shù)的調(diào)用結(jié)果僅依賴于輸入的參數(shù)。 不產(chǎn)生副作用。一般來講 操作數(shù)據(jù)庫 發(fā)起請求 操作dom 調(diào)用其他副作用函數(shù),這些活動一般會對外部環(huán)境產(chǎn)生影響。
優(yōu)點:
輸入輸出顯示,方便溯源,同時不會有隱式的狀態(tài)引入,導(dǎo)致該模塊在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});
// 這個東西出問題了.. 參數(shù)被改變了 over over
輸入輸出流顯式,只有一個渠道也就是輸入?yún)?shù)可以獲得數(shù)據(jù)。 可以得到函數(shù)映射表、并發(fā)安全 避免競爭、無狀態(tài),不會讀取外部狀態(tài)。 不產(chǎn)生副作用純函數(shù),可以組裝起來變成高級純函數(shù)可讀性高,可測試性,可復(fù)制和重構(gòu)
展開講講:
輸入輸出顯示,那么我們可以得到這個函數(shù)的映射表,說明我們可以對這個函數(shù)計算結(jié)果進(jìn)行緩存,如果有同樣輸入的調(diào)用,那么我們可以直接返回計算后的值。
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); // 緩存
可以將一個不純的函數(shù)轉(zhuǎn)換成一個純函數(shù)
const pureHttpGet = memo((url,params)=>{
return ()=>{
return axios.get(url,params);
}
})
這個函數(shù)之所以能稱為純函數(shù),他滿足純函數(shù)的特性,根據(jù)輸入,總是返回固定的輸出。
可移植性 一句名言:“面向?qū)ο笳Z言的問題是,它們永遠(yuǎn)都要隨身攜帶那些隱式的環(huán)境。你只需要一個香蕉,但卻得到一個拿著香蕉的大猩猩...以及整個叢林” 可測試性與引用透明,對于一個純函數(shù),我們可以很清晰的去斷言他的輸入輸出,同時因為引用透明,可以很簡單的去推導(dǎo)出函數(shù)的內(nèi)部的調(diào)用過程,從而去簡化&重構(gòu)這個函數(shù)。 并行:回憶一下操作系統(tǒng)死鎖的原因,以及為什么有鎖這個機制的存在,就是因為需要使用/更改外部的資源,但是純函數(shù)不需要訪問共享內(nèi)存,所以也不會因為副作用進(jìn)入競爭態(tài)。
core:
高階函數(shù)
高階函數(shù)是指對一個函數(shù)可以傳入一個參數(shù)是函數(shù),或者返回值是函數(shù)。javascript是天生支持高階函數(shù)和閉包兩個重要特性的。我們常用的array方法中 map reduce filter .. 就是高階函數(shù)
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;
};
// 高階函數(shù)當(dāng)然也可以組合使用 與純函數(shù)性質(zhì)一致
[1,2,3,4].filter(item=>item>2).map(item=>item -1)
偏函數(shù)應(yīng)用 partial function
偏函數(shù)和柯里化是兩個很容易混淆的概念,偏函數(shù)是包裝一個原始函數(shù)接受部分參數(shù)作為固定值預(yù)設(shè),返回一個新的函數(shù)。
// 創(chuàng)建偏函數(shù),固定一些參數(shù)
const partial = (f, ...args) =>
// 返回一個帶有剩余參數(shù)的函數(shù)
(...moreArgs) =>
// 調(diào)用原始函數(shù)
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指向),其實就可以實現(xiàn)
const foo = (a:number,b:number)=>{
return a+b;
}
const bar = foo.bind(null,2);
bar(3);//5
簡單來說,偏函數(shù)就是固定部分參數(shù),返回新函數(shù)做計算,如果需要完整實現(xiàn)的話,可以參考一下lodash的partial.js這個文件,大致意思簡單的將源碼思路寫一下
function partial(fn) {
const args = Array.slice.call(arguments,1);
return function(){
const length = args.length;
let position = 0;
// 把用占位符的參數(shù)替換掉
for(let i =0 ;i<length,i++){
args[i] = args[i]=== _ ? arguments[position++]:args[i]
}
// 將剩下的參數(shù)懟進(jìn)去
while(position<arguments.length) args.push(arguments[postion++]);
return fn.apply(this,args)
};
}
柯里化
柯里化和偏函數(shù)應(yīng)用有些區(qū)別,是將多個參數(shù)函數(shù)轉(zhuǎn)換成單參數(shù)的函數(shù)。
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演算表達(dá)式進(jìn)行求值的時候,不能引用任何未綁定的標(biāo)識符。如果一個標(biāo)識符是一個閉合Lambda表達(dá)式的參數(shù),我們則稱這個標(biāo)識符是(被)綁定的;如果一個標(biāo)識符在任何封閉上下文中都沒有綁定,那么它被稱為自由變量。
lambda x . plus x y:在這個表達(dá)式中,y和plus是自由的,因為他們不是任何閉合的Lambda表達(dá)式的參數(shù);而x是綁定的,因為它是函數(shù)定義的閉合表達(dá)式plus x y的參數(shù)。lambda x y . y x:在這個表達(dá)式中x和y都是被綁定的,因為它們都是函數(shù)定義中的參數(shù)。lambda y . (lambda x . plus x y):在內(nèi)層演算lambda x . plus x y中,y和plus是自由的,x是綁定的。在完整表達(dá)中,x和y是綁定的:x受內(nèi)層綁定,而y由剩下的演算綁定。plus仍然是自由的。
一個Lambda演算表達(dá)式只有在其所有變量都是綁定的時候才完全合法。js中的閉包就不在這啰嗦了,為什么需要閉包,我們可以看一個最簡單的柯里化的例子。
const add = (w,x,y,z)=>w+x+y+z;
const curryAdd = curry(add);
// 根據(jù)上面 寫的curry函數(shù), 我們來分解一下
const a = curryAdd(1); // 這個時候返回了一個包含1的函數(shù) & add的函數(shù)
const b = a(2);
const c = b(3);
const d = c(4);
// 如果沒有閉包,之前傳入的東西參數(shù)都無跡可尋了。
從webstorm單步調(diào)試可以更清楚的知道這個函數(shù)的作用域&存在哪些閉包。
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,雖說添加一些沒有實際用處的間接層實現(xiàn)起來很容易,但這樣做除了徒增代碼量,提高維護和檢索代碼的成本外,沒有任何用處。
另外,如果一個函數(shù)被不必要地包裹起來了,而且發(fā)生了改動,那么包裹它的那個函數(shù)也要做相應(yīng)的變更。
foo(a, b => bar(b));
如果 foo 增加回調(diào)中處理的函數(shù),那么不只是要改掉foo和bar,所有涉及到的調(diào)用也會更改。
foo(a, (x, y) => bar(x, y));
寫成一等公民函數(shù)的形式,要做的改動將會少得多:
foo(a, bar); // 只需要更改foo中執(zhí)行bar的邏輯和 bar中執(zhí)行的邏輯
工具函數(shù)命名更為通用(拒絕業(yè)務(wù)化)
// 只針對當(dāng)前的博客
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 永久的被改動了,如果有其他地方使用到的話,可能會出現(xiàn)問題
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;
})
}
使用聲明式而不是命令式,將依賴當(dāng)作參數(shù)傳遞
// 命令式
const makes = [];
for(let i =0;i<car.length;i++){
makes.push(cars[i].make);
}
const makes = cars.map(item=>item.make)
compose(將一些純函數(shù)組合起來,返回新函數(shù)), 讓代碼從右到左運行,而不是由內(nèi)而外運行。
const compose = (f,g) => {
return x => {
return f(g(x));
}
}
Monad :上面我們介紹了compose,但是compose的調(diào)用方式,總看起來還是沒有那么舒服,在js中鏈?zhǔn)秸{(diào)用很流行,要實現(xiàn)鏈?zhǔn)秸{(diào)用,例如(5).add(1).add(4),那么我們肯定需要一個容器,將5進(jìn)行一個包裝。
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)
好的,根據(jù)上面,其實我們已經(jīng)實現(xiàn)了一個函數(shù)式編程中比較重要的概念,函子(functor)。functor 是實現(xiàn)了 map 函數(shù)并遵守一些特定規(guī)則的容器類型。
接下來我們進(jìn)一步分析,可能會存在一種情況,如果傳入是空值,會導(dǎo)致報錯。所以需要引入一個函子,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)
好的,我們現(xiàn)在已經(jīng)得到了一個可以過濾空值的函數(shù),但是我們現(xiàn)在在執(zhí)行完調(diào)用后,我們獲得的是一個什么呢,是一個對象對吧,我們還需要把值取出來,所以需要添加一個取值的方法
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()
好了,我們現(xiàn)在可以拿到值了,但是,如果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的讀寫操作,將一個文本轉(zhuǎn)換為大寫,先定義一下以下方法
const $ = (id: string) => <HTMLElement>document.querySelector(`#${id}`);
const read = (id: string) => $(id).value;
const write = (id: string) => (text: string) => $(id).textContent = text;
// not pure,因為函數(shù)中操作了dom,對外部進(jìn)行了改變
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();
}
延伸閱讀
函數(shù)式語言在深度學(xué)習(xí)領(lǐng)域應(yīng)用很廣泛,因為函數(shù)式與深度學(xué)習(xí)模型的契合度很高,The Beauty of Functional Languages in Deep Learning?—?Clojure and Haskell[8] 。深度學(xué)習(xí)的計算模型本質(zhì)上是數(shù)學(xué)模型,而數(shù)學(xué)模型本質(zhì)上和函數(shù)式編程思路是一致的:數(shù)據(jù)不可變且函數(shù)間可以任意組合。這意味著使用函數(shù)式編程語言可以更好的表達(dá)深度學(xué)習(xí)的計算過程,因此更容易理解與維護,同時函數(shù)式語言內(nèi)置的 Immutable 數(shù)據(jù)結(jié)構(gòu)也保障了并發(fā)的安全性。 范疇學(xué) 前端中的 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]
參考資料
維基: https://zh.wikipedia.org/wiki/純函數(shù)
[2]狀態(tài): https://zh.wikipedia.org/w/index.php?title=程式狀態(tài)&action=edit&redlink=1
[3]函數(shù)副作用: https://zh.wikipedia.org/wiki/函數(shù)副作用
[4]lambda演算: https://zh.wikipedia.org/wiki/Λ演算
[5]圖靈完備: https://zh.wikipedia.org/wiki/圖靈完備性
[6]丘奇數(shù): https://zh.wikipedia.org/wiki/邱奇數(shù)
[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
?? 謝謝支持
以上便是本次分享的全部內(nèi)容,希望對你有所幫助^_^
喜歡的話別忘了 分享、點贊、收藏 三連哦~。
歡迎關(guān)注公眾號 前端Sharing
