函數式編程初探

前言

前端函數式編程的概念已經出現了蠻久了,我可能或多或少在項目中使用過函數式的方法寫代碼,可是我一直也沒有仔細深刻的研究下什麼是函數式編程,最近恰好有空,查了些資料,看了些書籍,把本身的心得總結下。html

高階函數的定義

要是想要弄明白函數式編程首先要明白什麼是高階函數。前端

高階函數的定義是:git

函數能夠做爲參數被傳遞
函數能夠做爲返回值輸出github

使用ES6自帶的高階函數來編寫代碼

假如,咱們有這樣一個數組:算法

const classA = [
    {
        name: '張三',
        age: 17
    },
    {
        name: '李四',
        age: 15
    },
    {
        name: '王五',
        age: 16
    },
]
複製代碼

有一個需求,是找出班級中16歲年紀的學生,咱們使用低階函數作篩選是這樣的:編程

let student = [];
for (let i = 0; i < classA.length; i++) {
    if (classA[i].age === 16) {
        student.push(class[i])
    }
}
複製代碼

使用高階函數是這樣的:segmentfault

const student = classA.filter( v => v.age === 16 )
複製代碼

那麼使用這樣的高階函數有什麼好處呢,有兩點:數組

  • 第一,同等複雜度的代碼,高階函數能讓實現更加簡單
  • 第二,高階函數可以很是方便的拆分邏輯

好比說,這樣一個篩選學生的函數,能夠拆成兩部分:緩存

const isAge = v => v.age === 16;
const result = classA.filter(isAge);
複製代碼

這樣拆分後,邏輯就分爲了兩個部分,第一部分是判斷年紀的函數,第二部分是篩選結果的函數。bash

若是,之後咱們的需求有了變化,不篩選學生年紀了,改爲了篩選學生姓名,或者一些其它的東西,那麼咱們只須要改動判斷年紀的函數就好了,篩選結果的函數不變。

嗯,可能有人會說,這太簡單了,那麼,稍微來點難度的東西!

假如,咱們有這樣一個數組:

const array = [['張三','26','1000'],['李四','25','3655'],['王五','30','8888']]
複製代碼

咱們要把這個數組變成下面這種形式:

[
    {
        name: '張三',
        age: '26',
        price: '1000'
    },
    {
        name: '李四',
        age: '25',
        price: '3655'
    },
    {
        name: '王五',
        age: '30',
        price: '8888'
    },
]
複製代碼

使用高階函數來作轉換:

const result = array.reduce((value, item, index) => {
  value[index] = {
    name: item[0],
    age: item[1],
    price: item[2]
  };
  return value;
}, []);
複製代碼

這裏咱們使用了ES6的高階函數reduce,具體相關介紹能夠去看凹凸實驗室寫的JavaScript中reduce()方法不徹底指南

ES6中自帶的高階函數,有filtermapreduce等等等等

ok,到了這裏,已經對函數式編程有了些簡單的概念了,我所理解的函數式編程是:

編寫代碼的時候,函數式編程更多的是從聲明式的方法,而傳統的編程更多的是命令式的方法。例如,上面的篩選學生年紀,傳統的編程思想是,我建立了什麼,我循環了什麼,我判斷了什麼,得出了什麼結果;函數式編程的思想是,我聲明瞭一個篩選的函數,我聲明瞭一個判斷的函數,我把這兩個函數結合起來,得出了一個結果。

編寫一個本身的高階函數

當咱們玩了不少ES6自帶的高階函數後,就能夠升級到本身寫高階函數的階段了,好比說用函數式的方式寫一個節流函數,

節流函數說白了,就是一個控制事件觸發頻率的函數,之前能夠一秒內,無限次觸發,如今限制成500毫秒觸發一次

throttle(fn, wait=500) {
    if (typeof fn != "function") {
        // 必須傳入函數
        throw new TypeError("Expected a function")
    }
    
    // 定時器
    let timer,
    // 是不是第一次調用
    firstTime = true;
    
    // 這裏不能用箭頭函數,是爲了綁定上下文
    return function (...args) {
        // 第一次
        if (firstTime) {
            firstTime = false;
            fn.apply(this,args);
        }
        
        if (timer) {
            return;
        }else {
            timer = setTimeout(() => {
                clearTimeout(timer);
                timer = null;
                fn.apply(this, args);
            },wait)
        }

    }
}

// 單獨使用,限制快速連續不停的點擊,按鈕只會有規律的每500ms點擊有效
button.addEventListener('click', throttle(() => {
    console.log('hhh')
}))
複製代碼

寫好了這樣一個高階函數後,咱們就能夠在各處調用了,好比:

// 有一個點擊增長的功能,可是要求最少過了1秒才能增長一次,就能夠
const add = x => x++;
throttle(add,1000);

// 又有了一個減小的功能,可是要求最少2秒減小一次
const cutDown = x => x--;
throttle(cutDown,2000);
複製代碼

到這裏已經明白了什麼是高階函數,可是還不夠,還須要瞭解一些函數式編程的重要概念

純函數

在函數式編程的概念中,還有一個重要的概念是純函數,那麼什麼是純函數呢?

咱們用代碼來解釋什麼是純函數:

const z = 10;
add(x, y) {
    return x + y;
}
複製代碼

上面的add函數就是一個純函數,它讀取xy兩個參數的值,返回它們的和,而且不會受到全局的z變量的影響

把這個函數改一下

const z = 10;
add(x, y) {
    return x + y + z;
}
複製代碼

這個函數就變成了不純的函數了,由於它返回的值會受到全局的z的影響

換句話說,這個函數會被外部環境影響

so,咱們就得出了第一個判斷是否純函數的重要依據

一、純函數不會受到外部環境的影響

再用spliceslice來解釋一下:

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);
//=> []

複製代碼

slice收到一樣的參數,每次返回相同的值,因此是純函數

splice收到一樣的參數,每次返回不一樣的值,因此不是純函數

so,咱們就得出了第二個判斷是否純函數的重要依據

二、純函數相同的輸入,永遠會獲得相同的輸出

來個總結,純函數是:

'純函數是這樣一種函數,即相同的輸入,永遠會獲得相同的輸出,並且沒有任何可觀察的反作用'
複製代碼

那麼什麼是反作用,在純函數裏有這樣一個定義:

一切函數自己計算結果以外發生的事情都叫作反作用

像是上面的例子,函數返回的結果受到外部z變量影響,那麼這個函數是有反作用的,反之,函數影響了外部環境,也是有反作用的。

到了這裏,終於弄明白了什麼是純函數,它有如下的優勢

  • 更加容易被測試,由於它們惟一的職責就是根據輸入計算輸出
  • 結果能夠被緩存,由於相同的輸入總會得到相同的輸出
  • 自我文檔化,由於函數的依賴關係很清晰
  • 更容易被調用,由於你不用擔憂函數會有什麼反作用

使用純函數可以極大的下降編程的複雜度,可是不合理的使用,爲了抽象而去抽象,反而會使代碼變得很是難以理解。

柯里化

柯里化的概念很簡單:只傳遞給函數一部分參數來調用它,讓它返回一個函數去處理剩下的參數。

const add = x => y => x + y;
add(1)(2);
// => 3
複製代碼

上面的例子,就是一個很典型的柯里化函數,在咱們第一次調用的時候,接收了第一次傳入的參數(用閉包記住),返回了一個新的函數;在第二次調用的時候,接收第二次傳入的參數,而且和第一次傳入的函數相加,返回它們的和。

這個例子說明了柯里化的一個特徵,或者說是一個基礎,即柯里化函數有延遲求值的特殊性,而這種特殊性又須要用到一些手段來實現。

運用上面的思想編寫一個的柯里化函數

// 建立柯里化函數,保存了第一次傳入的參數和函數,返回值是一個函數而且接收第二次傳入參數,同時調用傳入的函數進行計算
currying (fn, ...args1) {
    return (...args2) => {
        return fn(...args1, ...args2)
    }
}

// 定義一個通常函數
const add = (x, y) => x + y;

// 使用
const increment = currying(add, 1);
console.log(increment(2));
const addTen = currying(add, 10);
console.log(addTen(2));

// => 3
// => 12
複製代碼

這個列子還有點小問題,即返回的值沒有自動柯里化,能夠改造下:

currying(fn, ...args1) {
  // '判斷傳入的參數是否知足傳入函數須要的參數,好比說add函數須要兩個參數相加,那麼判斷是否傳入了兩個參數,知足調用傳入函數計算結果'
  if (args1.length >= fn.length) {
    console.log(args1, '--1--');
    return fn(...args1);
  }
  // '不知足返回一個新的函數,繼續調用柯里化函數,傳入保存的第一次傳入的函數,傳入保存的第一次傳入的參數,傳入第二次傳入的參數,繼續上面的判斷邏輯,返回計算結果'
  return (...args2) => {
    console.log(args2, '--2--');
    return currying(fn, ...args1, ...args2);
  };
},

// 定義一個通常函數
const add = (x, y) => x + y;

// 使用
const increment = currying(add, 1);
console.log(increment(2));
const addTen = currying(add, 10);
console.log(addTen(2));

// => [2] --2--
// => [1,2] --1--
// => 3
// => [2] --2--
// => [10,2] --1--
// => 12
複製代碼

函數在js中是一等公民,它和其它對象,或者其它數據沒有什麼區別,能夠存在數組,存在對象,賦值給變量,看成參數傳來傳去,因此函數也有下標屬性,用上面的例子證實一下

const add = (x, y) => x + y;
console.log(add.length)
// => 2
複製代碼

在ES6中,...是擴展運算符,他的使用是這樣的

// 放在函數做爲單獨參數,會把一個數組變成參數序列,好比上面例子中的數組[1,2]變成了參數x=1,y=2
fn(...args1)

// 放在函數中做爲第二個參數,會把傳入的值變成一個數組,若是傳入的是一個數組那麼仍是數組,傳入一個對象,會變成一個數組對象
function currying(fn,...x) {
	console.log(x)
}
currying(0,1)
// => [1]

// 放在回調函數中做爲第二個和第三個參數
// 第一次調用會返回一個函數,會在閉包裏存貯值,第二次調用會把閉包裏的值和第二次參數裏的值合併成數組
return currying(fn, ...args1, ...args2);
// => [1,2]

// 可是單獨在函數中這麼使用會報錯
function currying(fn,...x,...y) {
	console.log(x)
}
currying(0,1,2)

複製代碼

理解了這些,上面的例子就很好懂了。

柯里化函數比較重要的思想是:

屢次判斷傳入的參數是否知足計算需求,知足,返回計算結果,若是不知足,繼續返回一個新的柯里化函數

上面的柯里化函數還能夠繼續優化,好比說,this綁定啊,特殊的變量佔位符啊,等等,這樣的工做,一些庫,好比說ramda已經實現,能夠去看它的源代碼裏面是怎樣實現的,重點仍是要明白柯里化函數是怎麼一回事。

代碼組合

首先,先寫一個簡單的組合函數:

const compose = (f, g) => x => f(g(x));
複製代碼

這個組合函數接收兩個函數看成參數,而後返回一個新的函數,x是兩個函數之間都要使用的值,好比說:

// 咱們要實現一個給字符串所有變成大寫,而後加上個感嘆號的功能,只須要定義兩個函數,而後組合一下
const toUpperCase = x => x.toUpperCase();
const exclaim = x => `${x}!`;
const shout = compose(exclaim, toUpperCase);

shout('hello world')
// => HELLO WORLD!
複製代碼

注意:組合函數裏面,g函數比f函數先執行,因此在組合裏面,是從右往左執行的,也就是說,要把先執行的函數放在組合函數的右邊

這個組合函數仍是有點問題,它只能接收2個參數,咱們來稍微改造下,讓它變得強大點:

const compose = (...fns) => (...args) => fns.reduceRight((res, fn) => [fn.call(null, ...res)], args)[0];

// 使用,實現一個功能,字符串變成大寫,加上個感嘆號,還要截取一部分,再在前面加上註釋
const toUpperCase = x => x.toUpperCase();
const exclaim = x => `${x}!`;
const head = x => `slice is: ${x}`;
const reverse = x => x.slice(0, 7);

const shout = compose(exclaim, toUpperCase, head, reverse)
shout('my name is maya')
// => SLICE IS: MY NAME!
複製代碼

組合的原理其實就是數學中的結合律:

(a + b) + c  =  a + (b + c)
複製代碼

so,在組合中你能夠這樣

// 第一種
const one = compose(exclaim, toUpperCase)
const shout = compose(one, head, reverse)
shout('my name is maya')
// => SLICE IS: MY NAME!

// 第二種
const two = compose(toUpperCase, head)
const shout = compose(exclaim, two, reverse)
shout('my name is maya')
// => SLICE IS: MY NAME!

// 第三種
const three = compose(head, reverse)
const shout = compose(exclaim, toUpperCase, three)
shout('my name is maya')
// => SLICE IS: MY NAME!

...
複製代碼

so,到了這裏,我對組合的理解是:

組合是什麼,組合就是運用了數學裏的結合律,像是搭積木同樣,把不一樣的函數聯繫起來,讓數據在裏面流動

在各類庫裏面都有組合的函數,lodashunderscoreramda等等,好比在underscore裏面,組合是這樣的:

// Returns a function that is the composition of a list of functions, each
  // consuming the return value of the function that follows.
  _.compose = function() {
    var args = arguments;
    var start = args.length - 1;
    return function() {
      var i = start;
      var result = args[start].apply(this, arguments);
      while (i--) result = args[i].call(this, result);
      return result;
    };
  };

複製代碼

結合使用

嗯,到了這裏,已經初步瞭解了函數式編程的概念了,那麼咱們怎麼使用函數式編程的方式寫代碼呢,舉個例子:

// 僞代碼,思路
// 好比說,咱們請求後臺拿到了一個數據,而後咱們須要篩選幾回這個數據, 取出裏面的一部分,而且排序

// 數據
const res = {
    status: 200,
    data: [
        {
            id: xxx,
            name: xxx,
            time: xxx,
            content: xxx,
            created: xxx
        },
        ...
    ]
}

// 封裝的請求函數
const http = xxx;

// '傳統寫法是這樣的'
http.post
    .then(res => 拿到數據)
    .then(res => 作出篩選)
    .then(res => 作出篩選)
    .then(res => 取出一部分)
    .then(res => 排序)
    
// '函數式編程是這樣的'
// 聲明一個篩選函數
const a = curry()
// 聲明一個取出函數
const b = curry()
// 聲明一個排序函數
const c = curry()
// 組合起來
const shout = compose(c, b, a)
// 使用
shout(http.post)
複製代碼

如何在項目中正式使用函數式編程

我以爲,想要在項目裏面正式使用函數式編程有這樣幾個步驟:

  • 一、先嚐試使用ES6自帶的高階函數
  • 二、熟悉了ES6自帶的高階函數後,能夠本身嘗試寫幾個高階函數
  • 三、在這個過程當中,儘可能使用純函數編寫代碼
  • 四、對函數式編程有所瞭解以後,嘗試使用相似ramda的庫來編寫代碼
  • 五、在使用ramda的過程當中,能夠嘗試研究它的源代碼
  • 六、嘗試編寫本身的庫,柯里化函數,組合函數等

固然了,這個只是我本身的理解,我在實際項目中也沒有徹底的使用函數式編程開發,個人開發原則是:

不要爲了函數式而選擇函數式編程。若是函數式編程可以幫助你,可以提高項目的效率,質量,可使用;若是不能,那麼不用;若是對函數式編程還不太熟,好比我這樣的,偶爾使用

擴展

函數式編程是在範疇論的基礎上發展而來的,而關於函數式編程和範疇論的關係,阮一峯大佬給出了一個很好的說明,在這裏複製粘貼下他的文章

本質上,函數式編程只是範疇論的運算方法,跟數理邏輯、微積分、行列式是同一類東西,都是數學方法,只是碰巧它能用來寫程序

因此,你明白了嗎,爲何函數式編程要求函數必須是純的,不能有反作用?由於它是一種數學運算,原始目的就是求值,不作其餘事情,不然就沒法知足函數運算法則了。

最後

本人水平有限,有錯漏之處,望大佬們多多指出,同時輕噴!!!

參考

利用函數式編程封裝節流和防抖函數
學會JavaScript函數式編程(第1部分)
Mostly adequate guide to FP
合理的使用純函數式編程
函數式編程入門教程
大佬,JavaScript 柯里化,瞭解一下?

相關文章
相關標籤/搜索