JS的函數式編程範式

一、認識函數式編程

爲什麼學習函數式編程?學吧,不學幹啥,js太原始了,得接收新事物,就很帥,裏面的概念,學的暈乎乎,最直觀的感受就是,套娃

  1. 函數式編程是隨着React的流行受到關注的
  2. Vue3開始擁抱函數式編程
  3. 函數式編程可以拋棄this
  4. 打包過程中可以更好利用tree shaking過濾無用代碼
  5. 方便測試,方便並行處理
  6. 有很多庫可以幫助開發者進行函數式開發, lodash,underscore,ramda

函數式編程(Functional Programming, FP),FP 是編程範式之一,我們常聽說的編程範式還有面向過程編程、面向對象編程。

    • 面向對象編程的思維方式:把現實世界中的事物抽象成程序世界中的類和對象,通過封裝、繼承和多態來演示事物事件的聯繫
    • 函數式編程的思維方式:把現實世界的事物和事物之間的聯繫抽象到程序世界(對運算過程進行抽象)
    • 程序的本質:根據輸入通過某種運算獲得相應的輸出,程序開發過程中會涉及很多有輸入和輸出的函數x -> f(聯繫、映射) -> y,y=f(x)
    • 函數式編程中的函數指的不是程序中的函數(方法),而是數學中的函數即映射關係,例如:y= sin(x),x 和 y 的關係
    • 相同的輸入始終要得到相同的輸出(純函數) * 函數式編程用來描述數據(函數)之間的映射

 1.函數式一等公民

我涉及到的前端知識,還停留在原生js上面,函數的調用涉及的入參,很少會考慮繼續用函數當參數進行傳遞,

所以,學習函數式編程,首先要設定一個函數式一等公民的前提,函數可以傳遞,也可返回,優先級最高。

  • 函數可以存儲在變量裏
  • 函數可以作爲參數
  • 函數作爲返回值

在javascript中函數是一個普通對象(可以通過new Function()),可以把函數存儲在變量或數組中,他也可以最爲另一個函數的參數和返回值,甚至在程序運行的時候通過new Function(alert(‘a’))來構造一個新的函數

2.高階函數

高階函數

  • 可以把函數作爲參數傳遞給另一個函數,
  • 可以把函數作爲另一個函數的返回結果

函數作爲參數:    模擬foreach方法:

//高階函數
function forEach (arr,fn) {
    for(let i = 0; i < arr.length; i++){
        fn(arr[i]);
    }
}

//測試
let arr = [2,3,1,3,6];
forEach(arr,function(v){
    console.log(v);
});

模擬數組array的filter方法:

function filter (arr,fn) {
    let result = [];
    for(let i = 0; i < arr.length; i++){
        if(fn(arr[i])){
            result.push(arr[i]);
        }
    }
    return result;
}
let arr = [2,3,1,3,6];
 let r = filter(arr,function(v){
    return v % 2 === 0;//取偶數
 });
 console.log(r);

函數作爲返回值:  

//函數作爲返回值
function makeFun(){
    let msg = 'Hello World';
    return function(){
        console.log(msg);
    }
}
const fn = makeFun();
fn();
//也可以這樣
makeFun()();
//模擬once,函數只執行一次
//模擬once,函數只執行一次
function once(fn){
    let done = false;//是否執行
    return function(){
        if(!done){//沒執行呢
            done = true; //執行了
            return fn.apply(this, arguments);//arguments,我的理解是function()裏的參數,即使括號裏沒有,在調用傳參,那麼arguments就可以獲取到
        }
    }
}
let pay = once(function(money){
    console.log('支付:' +money+ 'RMB');
});
pay(5);
pay(5);
pay(5);
pay(5);

 使用高階函數的意義:

  • 抽象可以幫助屏蔽細節,只需關注目標
  • 高階函數是用抽象通用的問題

3.常用的高階函數模擬

對map,every,some方法的模擬

//模擬常用的高階函數 map,every, some

const map = (arr,fun) => {
    let result = [];
    for(let v of arr){
        result.push(fun(v));
    }
    return result;
}

// let arr = [1,2,3,4];
// arr = map(arr,v => v*v);
// console.log(arr);

const every = (arr,fun) => {
    let result = true;
    for(let i = 0; i < arr.length; i++){
        result = fun(v);
        if(!result){
            break;
        }
    }
    return result;
}
let arr1 = [11,12,14];
let r = map(arr1,v => v > 10);
console.log(r);


const some = (arr,fun) => {
    let result = false;
    for(let v of arr){
        result = fun(v);
        if(result){
            break;
        }
        
    }
    return result;
}

// let arr2 = [11,12,14];
// let r = map(arr2,v => v % 2 === 0);
// console.log(r);
View Code

4.閉包

閉包(Closure):函數和其周圍的狀態的引用捆綁在一起形成閉包(說人話,好好的不學好),我理解,就是另一個作用域調用一個函數的內部函數並訪問該函數作用域中的成員.

例如,在上述的例子,makeFun(); 裏面的msg,在返回中用到, 還有once();中的done, 在返回函數中調用done,也就是外部作用域,對once的內部有引用,如果有引用,在once執行完之後,done不會被釋放,延長了外部變量的作用範圍

  • 閉包的本質, 函數在執行的時候會放到一個執行棧上,當函數執行完畢後,會從棧上移除, 但是堆上的作用域成員,因爲被外部引用不能釋放,因此內部函數依然可以訪問外部函數的成員

可以通過調試的方式,理解,閉包

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>closure</title>
</head>

<body >
    <script>
        function MakePower(power){
                return function(number){
                    return Math.pow(number,power);
                }
            }
            let power2 = MakePower(2);
            let power3 = MakePower(3);

            console.log(power2(4));
            console.log(power3(5));


    </script>
    

</body>
</html>
View Code

開發者工具打開,對 letpower2=MakePower(2);打斷點

 

 

 Call Stack是調用棧,裏面是匿名內部類,Scope是作用域,其中Closure就是閉包,跟參數相關的power爲2依然存在,外部的Math.pow()調用外部的power

 二、函數式編程基礎

1.純函數

 純函數:相同的輸入永遠會得到相同的輸出,而且沒有任何副作用

  •  純函數類似數學中的y = f(x)的關係

lodash是一個純函數的功能庫,提供了數組、數字、對象、字符串、函數等操作的一些方法

數組的slice和splice分別是:純函數和不純函數:

//純函數和不純
let arr = [4,5,6];

//純函數
console.log(arr.slice(0,3));
console.log(arr.slice(0,3));
console.log(arr.slice(0,3));
//不純函數
console.log(arr.splice(0,3));
console.log(arr.splice(0,3));
console.log(arr.splice(0,3));

結果

[ 4, 5, 6 ]
[ 4, 5, 6 ]     
[ 4, 5, 6 ]     
[ 4, 5, 6 ]     
[]
[]
  • 函數式編程不會保留計算中間的結果,所以變量是不可變
  • 可以把一個函數的執行結果交給另一個函數去處理

 2.lodash

 簡介和文檔可以看官網,https://www.lodashjs.com/

 演示一下lodash

//first last first  reverse each includes find findIndex
const _ = require('lodash');//引用lodash

const arr = ["jack","tom","marry",'lucy'];

console.log(_.first(arr));
console.log(_.last(arr));

console.log(_.toUpper(_.first(arr)));
console.log(_.reverse(arr));

const r = _.each(arr,(v,ind) => {
    console.log(v,ind);
});
console.log(r);

(1)純函數

純函數的好處:

  • 可緩存  因爲純函數對相同的輸入始終有相同的結果, 所以,可以將結果進行緩存  (可以提升性能,一個函數調用多次且耗時 , 可以使用純函數在第一次執行的時候就緩存)
//緩存下來,可以使用lodash 的memoize方法
function getArea(r){
    console.log(r);
    return Math.PI * r * r;
}
let getAreaWithMemory = _.memoize(getArea);
console.log(getAreaWithMemory(4));
console.log(getAreaWithMemory(4));
console.log(getAreaWithMemory(4));
console.log(getAreaWithMemory(4));

結果:從結果可以看出,getArea方法只執行了依一次

4
50.26548245743669
50.26548245743669
50.26548245743669
50.26548245743669

模擬memoize方法

//模擬memoize方法

const memoize = function(fn){
    let cache = {};
    return function(){
        let key = JSON.stringify(arguments);
        cache[key] = cache[key] || fn.apply(fn, arguments);
        return cache[key];
    }
}
let getAreaWithMemory1= memoize(getArea);
console.log(getAreaWithMemory1(4));
console.log(getAreaWithMemory1(4));
console.log(getAreaWithMemory1(4));
console.log(getAreaWithMemory1(4));
  • 可測試; 純函數讓測試更方便
  • 並行處理: 在多線程下並行操作內存數據很可能出現意外,而純函數不需要訪問共享內存數據,所以在並行環境下任意運行 純函數

(2)副作用

 純函數: 對於相同輸入永遠會得到相同的輸出, 而且沒有任何可觀察的副作用

通過下面可以看出,副作用讓一個函數變得不純,純函數的根據相同輸入返回相同輸出,如果函數依賴於外部的狀態就無法保證輸出相同,就會帶來副作用。例:配置文件,數據庫,獲取用戶的輸入等。。。

//不純
let min = 18;
function checkAge(age){
    return age >= min;
}

//純函數, (有硬編碼,後續可以通過柯里化解決)
function checkAge(age){
    let min = 18;
    return age >= min;
}

所有的外部交互都有可能帶來副作用,副作用也使得方法通用性下降不適合擴展和可重用性, 同事副作用會給程序帶來安全隱患以及不確定性,但是副作用不可能完全進制,儘可能控制在可控範圍內

可以通過柯里化來減少副作用

(3)柯里化

通過柯里化的方式,解決上述問題

function checkAge(age){
    return function(min){
        return age >= min;
    }
}

let checkAge18 = checkAge(18);
let checkAge19 = checkAge(19);
console.log(checkAge18(20));
console.log(checkAge18(10));

柯里化(Currying)

  • 當一個函數有多個參數的時候先傳遞一部分參數調用它(這部分參數以後不變)
  • 然後返回一個新的函數接收剩餘參數,返回結果

lodash中的柯里化函數

  • _.curry(func)
    • 功能:創建一個函數,該函數接收一個或多個 func 的參數,如果 func 所需要的參數都被提 供則執行 func 並返回執行的結果。否則繼續返回該函數並等待接收剩餘的參數。
    • 參數:需要柯里化的函數
    • 返回值:柯里化後的函數

 

function getSum (a,b,c) {
    return a + b + c ;
} 
const curry_getSum = _.curry(getSum);
console.log(curry_getSum(1,2,3));
console.log(curry_getSum(1,2)(3));
console.log(curry_getSum(1)(2,3));

結果發現,三次結果展示一樣,這就是柯里化

柯里化案例

const match = _.curry((reg,str)=> str.match(reg));
const haveSpace = match(/\s+/g);//匹配空格

console.log(haveSpace("helloo"));
console.log(match(/\s+/g,'helloworld'));

const filter = _.curry((fun,arr) => arr.filter(fun));
console.log(filter(haveSpace, ['Jhon david', 'Jhonny_hha']));

 柯里化模擬

//柯里化原理,重寫lodash的curry()方法
function sum(a,b,c){
    return a+b+c;
}
const curried = curry(sum); 
console.log(curried(1,2,3));
console.log(curried(1)(2,3));
console.log(curried(1,2)(3));
function curry(func){
    return function curriedFun(...args){
        //判斷實參和形參個數
        if(args.length < func.length ){
            return function(){
                //arguments是僞數組,需要轉換一下
                return curriedFun(...args.concat(Array.from(arguments)));
            }
        }
        return func(...args);
    }
}

總結:

  • 柯里化可以讓我們給一個函數傳遞較少的參數,得到一個已經記住了某些固定參數的新函數
  • 是對函數參數的一種緩存
  • 讓函數變得更靈活, 讓函數的粒度更小
  • 可以把多元函數轉換成一元函數,可以組合使用函數產生強大的功能

(3)函數組合

 純函數和柯里化很容易寫成洋蔥代碼,就像套娃一樣,一層一層又一層,  而函數組合可以讓我們把細顆粒的函數重新組合成一個新的函數

可以把函數組合,想象成一個管道, 我們把管道拆分成多個小管道,  多了中間運算過程,  不管要處理的函數對複雜, 最終得到結果.

fn = compose(f1, f2, f3);

b = fn(a);

函數組合compose: 如果一個函數要經過多個函數處理才能得到最終值,這個時候,可以把中間過程的函數合併成一個函數

  • 函數就像是數據的管道, 函數組合將這些管道連接起來,讓數據穿過多條管道,得到最終結果.
  • 函數組合默認是從右往左執行的

組合函數的演示

//函數組合  洋蔥代碼  
//可以想象成一個管道,很長,把他切成n份,依次傳值,不在乎中間過程
const compose = function (f, g){ //默認從右往左執行
    return function(value){
        return f(g(value))
    }
}

function first(array){
    return array[0];
}
function reverse(array){
    return array.reverse();
}

const last = compose(first,reverse);
console.log(last([1,2,3,4]));

lodash的組合函數,flow()從左往右和flowRight()從右往左

function first(array){
    return array[0];
}
function reverse(array){
    return array.reverse();
}
const toUpper = s => s.toUpperCase();//使用箭頭函數

const last = _.flowRight(toUpper,first,reverse);
console.log(last(['one','two','three']));

對組合函數的原理進行模擬, 會使用reduce方法

var arr = [1, 2, 3, 4];
var sum = arr.reduce(function(prev, cur, index, arr) {
    //console.log(prev, cur, index);
    return prev + cur;
})
//console.log(arr, sum);

//開始模擬
const compose = function(...args){
    return function(value){
    //pre:上次值,value就是初始值,fn對應的當前要處理的
        return args.reverse().reduce(function(pre,fn){
            return fn(pre);
        },value)
    }
}
//使用箭頭函數  模擬函數組合
const compose = (...args) => v => args.reverse().reduce((pre,fn) => fn(pre),v);

函數組合 要適合組合律  例如 a+b+c = a+(b+c) = (a+b)+c,最後結果要一致

下面是怎麼對函數組合進行調試,

//NEVER GIVE UP 轉換 never-give-up
let str = 'NEVER GIVE UP';

//函數組合調試
const trace = _.curry((tag,v)=>{
    console.log(tag, v);
    return v;
});


const split = _.curry((seq,str) => _.split(str,seq));
//toLower
const join = _.curry((seq,array) => _.join(array,seq));

//_.toLower執行後,返回的"never,give,up",字符串,而要求需要返回的數組
const map = _.curry((fn, array) => _.map(array, fn));

const f1 = _.flowRight(join('-'),trace('toLower之後'), _.toLower ,trace('split之後'),split(' '));

console.log(f1(str));
const f = _.flowRight(join('-'),trace('toLower之後'), map(_.toLower) ,trace('split之後'),split(' '));

console.log(f(str));

 

(4)lodash/fp模塊

  • odash的fp模塊提供了使用的對函數式編程友好的方法,
  • 提供了不可變的已經柯里化的, 函數優先,數據滯後的方法

案例,fp是引入的fp模塊,對上述的函數 NEVER GIVE UP 轉換 never-give-up  進行改造

//使用lodash/fp,執行上述的問題
let str = 'NEVER GIVE UP';
const f = fp.flowRight(fp.join('-'),fp.map(fp.toLower),fp.split(' '));
console.log(f(str));

(5)PointFree

Point free 我們可以把數據處理的過程定義成與數據無關的合成運算,不需要用到代表數據的那個參數,只要把簡單的運算步驟合成到一起,在使用這種模式之前我們需要定義一些輔助的基本運算函數。
  • 不需要指明處理的數據
  • 只需要合成運算的國成
  • 需要定義一些輔助的基本運算函數
// 非Point Free 模式
// Hello   World => hello_world
function f(word) {
    return word.toLowerCase().replace(/\s+/g, "_");
}

// Point Free 模式
const fp = require("lodash/fp");
const f = fp.flowRight(fp.replace(/\s+/g, "_"), fp.toLower);

console.log(f('Hello   World'));

案例:

//將每個單詞的首字母提取出來,在大寫,在用點作爲分隔符
//never give up  ---> N. G. U
//[never, give, up]
//[NEVER, GIVE, UP]

const trace = _.curry((tag,v) => {
    console.log(tag,v);
    return v;
});

// const firstWordtoUpper = fp.flowRight(fp.join(". "),trace("f之後"),fp.map(fp.first) ,trace("t之後"),fp.map(fp.toUpper),trace("s之後"),fp.split(" "));
//上述,需要執行兩次map的遍歷,影響性能

const firstWordtoUpper = fp.flowRight(fp.join(". "),fp.map(fp.flowRight(fp.first,fp.toUpper)),fp.split(" "));
console.log(firstWordtoUpper('never give up'));

三、函子Functor

將函數式編程中的副作用,控制在可控的範圍內,異常處理,異步操作等

什麼是 Functor

  • 容器:包含值和值的變形關係(這個變形關係就是函數)
  • 函子:是一個特殊的容器,通過一個普通的對象來實現,該對象具有 map 方法,map 方法可以運行一個函數對值進行處理(變形關係)

下面 這就是函子:會對外提供一個map的方法

class Container{
    constructor (value){
        this._value = value; //_開頭的成員都是私有的
    }
    map(fn){
        return new Container(fn(this._value));//返回新的函子
    }
}
console.log('西西');
//每次用都要new 一次對象
let c = new Container(5)
        .map(x => x + 1)
        .map(x => x * x);
console.log(c);

上面的案例發現,每次使用,都需要new 對象,可以把他封住一下

//改進一下,將對象封裝一下
class Container{
//定義靜態方法,對外提供of方法,調用就會new對象
    static of(value){
        return new Container(value);
    }

    constructor (value){
        this._value = value; //_開頭的成員都是私有的
    }
    map(fn){
        return Container.of((fn(this._value)));//返回新的函子
    }
}
總結:
  •     函數式編程的運算不直接操作值,而是由函子完成
  •     函子就是一個實現了map契約的對象
  •     可以把函子想象成一個盒子,這個盒子裏封裝了一個值
  •     想要處理盒子中的值,要給盒子的map方法傳遞一個處理值的函數(純函數),在由這個函數對值進行處理
  •     最終,map方法返回一個包含新值的盒子(函子)

 那麼如果出現傳值爲null或者undefined的情況出現,該怎麼辦?

1.MayBe函子

爲了解決上述,傳值爲null或undefined的情況,mayBe函子作用就是可以對外部的空值情況做處理(控制副作用在允許的範圍)

//MayBe函子,對可能存在null,undefined進行判斷
class MayBe{
    static of(value){
        return new MayBe(value);
    }

    constructor (value){
        this._value = value; //_開頭的成員都是私有的
    }
    map(fn){
        return this.judge() ? MayBe.of(null) : MayBe.of(fn(this._value));//返回新的函子
    }

    judge(){
        return this._value === null || this._value === undefined;
    }
}

// let b = MayBe.of('hello world')
//         .map(x => x.toUpperCase());
        
// console.log(b);
let b = MayBe.of(null)
        .map(x => x.toUpperCase());
        
console.log(b);

但是, 雖然不報錯了,但是具體那個步驟null,不知道

2. Either函子

  • Either 兩者中的任何一個,類似於 if…else…的處理
  • 異常會讓函數變的不純,Either 函子可以用來做異常處理
class Left {
    static of(value) {
        return new Left(value);
    }
    constructor(value) {
        this._value = value;
    }
    map(fn) {
        return this;
    }
}

class Right {
    static of(value) {
        return new Right(value);
    }
    constructor(value) {
        this._value = value;
    }
    map(fn) {
        return Right.of(fn(this._value));
    }
}
  • 可以通過Either函子來處理異常,並且記錄異常信息
function parseJSON(str) {
    try {
        return Right.of(JSON.parse(str));
    } catch (e) {
        return Left.of({ error: e.message });
    }
}
let r = parseJSON('{name: zs}').map((x) => x.name.toUpperCase());

console.log(r);

3.IO函子

  • IO函子中的_value是一個函數,這裏把函數作爲值處理
  • IO函子可以把不純的動作存儲到_value中, 延遲執行這個不純的操作(惰性執行), 包裝當前的操作
  • 把不純的操作交給調用者來處理
const fp = require('lodash/fp');
class IO {
    static of (value) {
        return new IO(function () {
            return value;
        });
    }

    constructor (fn) {
        this._value = fn;
    }

    map (fn){
        //將當前的value,和傳入的fn組合一個新的函數
        return new IO( fp.flowRight(fn,this._value) );
    }

}

let r = IO.of(process).map(p => p.execPath);
console.log(r);
console.log(r._value());

4.Task異步執行

異步任務的實現過於複雜,可以使用folktale的Task
folktale一個標準函數式編程庫, 和lodash, ramda不同的是,他沒有提供很多功能函數, 只提供一些函數式處理的操作,例如,compose,curry等,一些函子Task,Either,MayBe等, Task處理異步任務 ,先安裝falktale庫

 案例,獲取package.json的帶有version的字段

const fs = require('fs');//讀取文件的
const { task } = require("folktale/concurrency/task");
const { flowRight,split, find } = require('lodash/fp');


function readFile (fileName) {

    return task(resolver => {
        fs.readFile(fileName,'utf-8',(err,data) => {
            if(err) resolver.reject(err);

            resolver.resolve(data);
        })
    });
}

readFile('package.json')
    .map(split('\n'))
    .map(find(v => v.includes('version')))
    .run()
    .listen({
        onRejected: err => {
            console.log(err);
        },
        onResolved: value => {
            console.log(value);
        }
    });

5.Pointed 函子

  • Pointed 函子是實現了 of 靜態方法的函子
  • of 方法是爲了避免使用 new 來創建對象,更深層的含義是 of 方法用來把值放到上下文Context(把值放到容器中,使用 map 來處理值)

 在上述提到的使用靜態方法避免new對象

class Container {
    static of (value) {
        return new Container(value)
    }
    ……
}
Container.of(2).map(x => x + 5)

6.Monad(單子)

IO函子存在的問題.涉及多層嵌套,需要反覆的.點.點方法

// IO函子
const fp = require("lodash/fp");
const fs = require("fs");
class IO {
    static of(value) {
        return new IO(function () {
            return value;
        });
    }
    constructor(fn) {
        this._value = fn;
    }
    map(fn) {
        return new IO(fp.flowRight(fn, this._value));
    }
}

// 模擬cat函數(先讀取文件然後打印文件)
let readFile = function readFile(fileName) {
    return new IO(function () {
        return fs.readFileSync(fileName, "utf-8");
    });
};

let print = function print(x) {
    return new IO(function () {
        console.log(x);
        // 將接收到的IO函子再返回出去,從而可以進行鏈式調用
        return x;
    });
};

let cat = fp.flowRight(print, readFile);

// IO(IO(x)); cat實際上是一個嵌套的函子
// 第一個._value調用的print函數,因爲調用由外部開始 第二個._value調用的是readFile
let r = cat("package.json")._value()._value();
 console.log(r);

發現,如果想要獲取文件中的內容,需要在.value()一次,纔可獲取

  • Monad 函子是可以變扁的 Pointed 函子,IO(IO(x))
  • 一個函子如果具有 join 和 of 兩個方法並遵守一些定律就是一個 Monad
// monad 函子
class IO {
    static of(value) {
        return new IO(value);
    }
    constructor(fn) {
        this._value = fn;
    }
    map(fn) {
        return new IO(fp.flowRight(fn, this._value));
    }
    join() {
        return this._value();
    }
    flatMap(fn) {
        return this.map(fn).join();
    }
}
let r = readFile("package.json").map(fp.toUpper).flatMap(print).join();
console.log(r);

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章