javascript函數式編程基礎隨筆
- 2020 年 11 月 16 日
- 筆記
- javascript 函數式編程
JavaScript 作為一種典型的多範式程式語言,這兩年隨著React\vue的火熱,函數式編程的概念也開始流行起來,lodashJS、folktale等多種開源庫都使用了函數式的特性。
一.認識函數式編程
程式的本質是:根據輸入通過某種運算得到輸出
函數式編程(Functional programming)是一種編程思想/範式 ,其核心思想是將運算過程抽象成函數(指數學的函數而不是程式的方法或函數即純函數),也就是面向函數編程,描述函數/數據 之間的映射,做到最大程度的復用; 學習函數式編程真正的意義在於讓你認識到一種用函數的角度去抽象問題的思路。如果你手中只有一個鎚子,那你看什麼都像釘子。
沒有最好的,只有最適合的
函數式編程起源是一門叫做範疇論(Category Theory)的數學分支。理解函數式編程的關鍵,就是理解範疇論。它是一門很複雜的數學,認為世界上所有的概念體系,都可以抽象成一個個的”範疇”(category)。
-
- 所有成員是一個集合
- 變形關係是函數
引用維基百科
“範疇就是使用箭頭連接的物體。”(In mathematics, a category is an algebraic structure that comprises “objects” that are linked by “arrows”. )
也就是說,彼此之間存在某種關係的概念、事物、對象等等,都構成”範疇”。只要能找出它們之間的關係,就能定義一個”範疇”,程式碼中稱之為容器
-
- 值(value)
- 值的變形關係(函數)
class container { constructor(v) { this._v = v; } addOne(x) { return x + 1; } }
container是一個類,也是一個容器,裡面包含一個值(this._v)和一種變形關係(addOne)。這裡的範疇,就是所有彼此之間相差1的數字。
本質上,函數式編程只是範疇論的運算方法,跟數理邏輯、微積分、行列式是同一類東西,都是數學方法,只是碰巧它能用來寫程式。
二. 函數相關知識
1. 函數是一等公民 (MON first class function)
函數可以存儲變數 即函數表達式
var fn = function (){};
函數可以是參數
var fn = function (fn1){…}
fn(function(){…})
函數可以是返回值
function fn(){return function(){…}}
2.高階函數(higher order function),用於抽象通用問題;
把函數作為參數傳遞給另一個函數
function forEach(arr ,fn){ for(var i = 0; i < arr.length; i++){ fn1(arr[i], i); } } forEach([1,2,3], function(el, i){...})
函數作為另一個函數的返回結果
function fn(){ return function(){ console.log(1) } } fn()();
3.閉包(Closure),函數按值傳遞的引用都是閉包
function fn(){ var a = 1; return function(){ console.log(a) } } var r = fn(); r(); r(); function Once(fn, context) { return function () { fn.apply(context || this, arguments); runOnce = null; } }
三.函數式編程基礎
1.純函數:相同輸入永遠得到相同輸出,並沒有任何副作用,其是描述輸入輸出的關係;
面向對象語言的問題是,它們永遠都要隨身攜帶那些隱式的環境。你只需要一個香蕉,但卻得到一個拿著香蕉的大猩猩…以及整個叢林
by Erlang 作者:Joe Armstrong
所以純函數的好處:
可快取(Cacheable)
可測試 (Testable)
並行處理 (Parallel Code)
可移植性/自文檔化(Portable / Self-Documenting)
function fn(a){ return function(b){ return a + b; } } var n = fn(20) n(20)// 40 n(20)// 40 //純函數可快取 function fn(a){ let obj = {}; return function(b){ let key = b.toString(); return obj[key]? obj[key]:(obj[key]=a + b,console.log('相同輸入執行一次'),obj[key]); } } var n = fn(20) n(20)// 40 n(20)// 40 n(30)// 50 n(30)// 50
純函數的副作用會讓純函數變得不純,副作用產生來源來自外部交互/數據;副作用不能完全禁止;
副作用是在計算的過程中,系統狀態的一種變化,或者與外部世界進行的可觀察的交互。
只要是跟函數外部環境發生的交互就都是副作用,這一點可能會讓你懷疑無副作用編程的可行性。
函數式編程的哲學就是假定副作用是造成不正當行為的主要原因。
Shared mutable state is the root of all evil
共享可變狀態是萬惡之源
by Pete Hunt
//不純的函數 保留計算中間的結果 function fn(a){ var res = 0; return function(b){ res += a + b; return res; } } var n = fn(20) n(20)// 40 n(20)// 80 n(20)// 120 // 不純的函數 引用外部數據 var a = 2; function fn(n){ return n > a; // 這裡 a 就是副作用來源 } // 修改即 function fn(n){ var a = 2; return n > a; // 雖然改成純函數了但引發了硬編碼問題 } // 再次修改 此處用高階函數 function fn(base){ return function(n){ return base > n } }
2.珂里化:當一個函數有多個參數時可以先傳遞一部分參數(這部分參數不再改變)並返回新的函數用於接收剩餘參數 並返回計算結果 柯里化強調的是生成單元函數,部分函數應用強調的固定任意元參數,而我們平時生活中常用的其實是部分函數應用,這樣的好處是可以固定參數,降低函數通用性,提高函數的適合用性。
珂里化函數也是高階函數
降維處理 轉化為一元函數 粒度小更靈活
function test(a,b,c){ console.log(a,b,c) } function curry(fn){ return function fn1(...a){ if(arguments.length >= fn.length)return fn(...a) return function(...b){ return fn1(...a.concat(b)) } } } var s = curry(test); s(1)(2)(3)// 純珂里化 s(1)(2,4)// 部分函數應用 這裡也是 高級珂里化 s(1,4)(2)// 部分函數應用 這裡也是 高級珂里化
不難發現以上的curry函數其實不是一個真正的珂里化函數,如果你用過lodash.Ramda 這些庫中實現的 curry 函數的行為會發現他們是一樣的。其實,這些庫中的 curry 函數都做了很多優化導致這些庫中實現的柯里化其實不是純粹的柯里化,我們可以把他們理解為「高級柯里化」。實現可以根據你輸入的參數個數,返回一個柯里化函數/結果值。參數個數滿足了函數條件,則返回值。這樣可以解決一個問題,就是如果一個函數是多輸入,就可以避免使用 s(1)(2)(3) 這種形式傳參了。
我們可以用高級柯里化去實現部分函數應用,但是柯里化不等於部分函數應用。
3.函數組合(compose),解決洋蔥程式碼 fn(fn1(fn2(fn3(x)))); 默認從右到左執行
function fn(a){ return a + 2 } function fn1(a){ return a * 2 } function fn2(a){ return a * a } function compose(...a){ a.reverse(); return function(v){ return f(a, v)() } function f(arr, v){ var i = 0; var d = v; return function c(){ if(i >= arr.length)return d; return (d = arr[i](d),i++, c()); } } } var r = compose(fn1, fn2, fn) r(2) // 64 r(4) // 144 r(3) // 100 console.log(r(2),r(3),r(4)) // 函數組合要滿足結合律即數學中的結合律 var r = compose(fn2, compose(fn1, fn)) r(2) // 64 r(4) // 144 r(3) // 100 var r = compose(compose(fn2, fn1), fn) r(2) // 64 r(4) // 144 r(3) // 100 // 函數組合調試 借用輔助函數即可 var log = function(tar, v){ console.log(tar, v) return v } var r = compose(fn1,log('fn2函數處理結果:'), fn2, log('fn函數處理結果:'), fn) r(5)
4.pointfree 是一種編程風格 組合函數就是pointfree風格
不需要指明處理的數據
只需要合成運算過程
需要定義一些輔助的基本函數
函數式編程就是把運算過程抽象成函數 而pointfree是在此基礎上在合成新的函數
// 非pointfree function fn(v){ return v.split('').reverse().join('') } // pointfree function split(v, reg){ return v.split(reg) } function reverse(v){ return v.reverse() } function join(v, reg){ return v.join(reg) } var f1 = curry(split) // 珂里化函數, var f2 = curry(join) var r = compose(f2(''), reverse, f1('')) // 函數組合
4.函子(functor),函子是函數式編程裡面最重要的數據類型,也是基本的運算單位和功能單位。 函子首先是一容器,包含了值和變形關係。比較特殊的是,它的變形關係可以依次作用於每一個值,將當前容器變形成另一個容器。就是將一個容器轉成另一個容器;
函子是一個特殊的對象,其對外提供一個map方法對值進行操作 該方法接受一個純函數作為參數
可以鏈式調用
class container{ constructor(v){ this._v = v } map(fn){ return new container(fn(this._v)) } } var r = new container(2).map(function(v){ return v + 2 }).map(function(v){ return v * v }); console.log(r) // 此時 r是一個函子對象 {_v: 16} 函子將數據包裝在內部不對外公布 若對值進行操作/使用在map中完成; // 將new 也封裝在函子內部 class container{ static of(v){ return new container(v) } constructor(v){ this._v = v } map(fn){ return container.of(fn(this._v)) } } var r = container.of(2).map(function(v){ return v + 2 }).map(function(v){ return v * v }); console.log(r) // {_v: 16} // 函子 null || undefined 問題 var r = container.of(null) .map(function(v){ return v + 2 }) .map(function(v){ return v * v }); console.log(r) // error ---- Uncaught SyntaxError: Unexpected token 'null'
以上程式碼最後 null 引發了副作用 使得map的參數函數變成非純函數, 純函數是相同的輸入始終有相同的輸出; 而這裡直接拋出異常了.
MayBe 函子 處理異常空值 上面問題 由MayBe函子來捕獲處理
class MayBe{ static of(v){ return new MayBe(v) } constructor(v){ this._v = v; } map(fn){ return this.isNull()? MayBe.of(this._v):MayBe.of(fn(this._v)) } isNull(){ return this._v === null || this._v === undefined; } } var r = MayBe.of(null) .map(function(v){ return v + 2 }); console.log(r) // {_v: null} // 此時maybe函子雖然解決了異常空值但延伸了另一個問題 var r = MayBe.of(null) .map(v => v+2) .map(function(v){ return null; }) .map(function(v){ return v + 2 }) console.log(r) //{_v: null}
MayBe函子雖然處理了空值異常,但捕獲不到異常具體資訊,無法定位哪個函子出錯;
Either 函子 類似if…else..,下面示例將處理捕獲異常資訊,及定位出錯函子;
class left { static of(v){ return new left(v) } constructor(v) { this._v = v } map(fn){ return this } } class right { static of(v){ return new right(v) } constructor(v) { this._v = v } map(fn){ return right.of(fn(this._v)) } } function test(v){ try{ return right.of(JSON.parse(v)) }catch(e){ return left.of({ error: e.message}) } } var r = test('{a": "1"}') .map(x => x.a+2) console.log(r) // {_v: {error: "Unexpected token a in JSON at position 1"}}
IO函子: input output 惰性執行不純的操作,使當前函數變為純函數,其實就是通過compose組合函數根據map調用次數依次組合成一個純函數返回
class IO { static of(v){ return new IO(function(){ return v }) } constructor(fn) { this._v = fn } map(fn){ return new IO(compose(fn, this._v)) } } var r = IO.of(location).map(l => l.href) console.log(r) // {_v: function} // _v就是組合函數生成的新的純函數 需要調用的話 可以直接 r._v(); 但是這樣就不符合屬性私有化了 更重要的是如果函子嵌套就要不停的._v() var io1 = function(x){ return new IO(function(){ return x * 2 }) } var io2 = function(x){ return new IO(function(){ return x }) } var comIo = compose(io2, io1); var r = comIo(2)._v()._v()
monad 函子 解決函子嵌套問題
如果一個函子具有join,map兩個方法並遵守一些定律 就是一個monad
class IO { static of(v){ return new IO(function(){ return v }) } constructor(fn) { this._v = fn } map(fn){ return new IO(compose(fn, this._v)) } join(){ return this._v() } } var r = io1(2) .map(io2) .join() .join() console.log(r) // 4 // 雖然封裝進了join方法但還要去一次調用,還可以在改進下 class IO { static of(v){ return new IO(function(){ return v }) } constructor(fn) { this._v = fn } map(fn){ return new IO(compose(fn, this._v)) } join(){ return this._v() } flatMao(fn){ return this.map(fn).join() } } var r = io1(2) .flatMao(io2) .join() console.log(r)// 4 // flatMao 是處理返回值是函子的情況 而map 則是處理數據 var r = io1(2) .map(x => x + 2) .flatMao(io2) .map(v => v * v) .join() console.log(r) // 36
pointed 函子 指實現了of靜態方法的函子 以上函子均屬於pointed函子;
函數式編程往往會導致函數過度包裝,影響性能
為減少函數副作用也會使資源佔用不能及時釋放 使得 Garbage Collection 壓力增加
函數式編程尾遞歸使用頻繁,不利於編譯器優化