函數式編程離我們有多遠?

函數式編程(Functional Programming)其實相對于計算機的歷史而言是一個非常古老的概念,甚至早于第一臺計算機的誕生。函數式編程的基礎模型來源于 λ 演算,而 λ 演算并非設計于在計算機上執行,它是由 Alonzo Church 和 Stephen Cole Kleene 在 20 世紀三十年代引入的一套用于研究函數定義、函數應用和遞歸的形式系統。

functional programming

隨著計算機語言、Web和前端的發展,JavaScript這門語言被越來越廣泛地使用。作為現代編程語言的代表作之一的JavaScript,與和它具有相似性的一些現代編程語言一樣,都有著函數式的某些特性,以至于 Douglas Crockford 在他寫的 《JavaScript : The Good Parts》中說,JavaScript 是披著 C 外衣的 Lisp。

函數式編程究竟是什么

函數式編程是一種編程思想或者說編程泛型,網上的各種資料和技術書籍中都會有對它的基本定義。根據基本定義,通常情況下,函數式編程強調函數計算本身,而不是如同經典的命令式編程模型那樣強調指令執行。

以上定義只是定義,就像面向對象編程的定義是將計算機程序用單個能夠起到子程序作用的單元或對象組合而成。這種定義總是正確的,但有時候無助于我們真正理解它,因此我們還是需要通過代碼來理解。

如同面向過程面向對象一樣,函數式編程思想體現在代碼上,會帶有明顯的特征,以至于我們可以將這種編程思想同程序的其他部分區別開來。但這里我并不是要談標準的、純粹的或者規范的"函數式"風格,那是一種類似于LISP或者Scheme語言的風格,顯然并不適合于JavaScript或者類似于JavaScript的現代編程語言。就像我們討論面向對象不必強調純粹性一樣,我們在實際工作中很少用到單一的純粹的泛型,通常情況下都是幾種模型混合使用。如同面向對象和面向過程不可分割一樣,函數式、面向對象和面向過程的編程泛型常常混合于我們的代碼中,如果不去刻意區分它們,很難將它們徹底獨立開來。

先來看一段簡單的示例代碼:

function add(x, y){
        return x + y;
    }

    function mul(x, y){
        return x * y;
    }

    function concat(arr1, arr2){
        return arr1.concat(arr2);
    }

    console.log(add(1, add(2, 3)),     //6
        mul(1, mul(2, mul(3, 4))),        //24
        concat([1, 2], concat([3, 4], [5, 6]))); //[1,2,3,4,5,6]

上面的代碼中,我們有 3 個簡單的函數,用來計算兩個數的和、積以及兩個數組的連接。我們看到,當我們要對兩個以上的數進行操作的時候,調用起來略顯繁瑣,所以我們可以對它們進行一系列修改,使得它們支持多個數連續的操作:

版本1

function add(...args){
        return args.reduce((x, y) => x + y);
    }

    function mul(...args){
        return args.reduce((x, y) => x * y);
    }

    function concat(...args){
        return args.reduce((arr1, arr2) => arr1.concat(arr2));
    }

    console.log(add(1, 2, 3),     //6
        mul(1, 2, 3, 4),        //24
        concat([1, 2], [3, 4], [5, 6])); //[1,2,3,4,5,6]

上面的代碼通過對三個函數的分別修改,將它們改造成了支持多個操作數的版本,實現了我們簡化函數調用的目的。不過,這不是一種足夠聰明的做法,因為如果我們還有更多類似函數,我們還得對這些函數進行一一的改寫。

注意到,我們完全可以一次性抽取出一個通用的版本 reduce ,它的作用是當任意兩個參數的函數如果傳入多余2個函數時,用這些參數進行 reduce 迭代:

版本二

function reduce(fn, ...args){
        return args.reduce(fn);
    }

    function add(x, y){
        return x + y;
    }

    function mul(x, y){
        return x * y;
    }

    function concat(arr1, arr2){
        return arr1.concat(arr2);
    }

    console.log(reduce(add, 1, 2, 3),     //6
        reduce(mul, 1, 2, 3, 4),        //24
        reduce(concat, [1, 2], [3, 4], [5, 6])); //[1,2,3,4,5,6]

上面的代碼好處是顯而易見,我們不再需要對 add、mul、concat 分別進行修改,并且 reduce 可以應用在任何一個兩個參數的函數上。不過呢,我們還是修改了函數調用的過程,改變了調用的函數和參數。然而,我們可以進一步進行優化:

版本三

function reduce(fn, ...args){
        return args.reduce(fn);
    }

    function add(x, y){
        return x + y;
    }

    function mul(x, y){
        return x * y;
    }

    function concat(arr1, arr2){
        return arr1.concat(arr2);
    }

    add = reduce.bind(null, add);
    mul = reduce.bind(null, mul);
    concat = reduce.bind(null, concat);

    console.log(add(1, 2, 3),     //6
        mul(1, 2, 3, 4),        //24
        concat([1, 2], [3, 4], [5, 6])); //[1,2,3,4,5,6]

在這里,我們利用 reduce 和 bind 方法改變了 add、mul 和 concat。當然我們這么做帶來一個小小的副作用,那就是 this 被限定為 null。我們還有更好的做法:

版本四

function reduce(fn){
        return function(...args){
            return args.reduce(fn.bind(this));
        }
    }

    function add(x, y){
        return x + y;
    }

    function mul(x, y){
        return x * y;
    }

    function concat(arr1, arr2){
        return arr1.concat(arr2);
    }

    add = reduce(add);
    mul = reduce(mul);
    concat = reduce(concat);

    console.log(add(1, 2, 3),     //6
        mul(1, 2, 3, 4),        //24
        concat([1, 2], [3, 4], [5, 6])); //[1,2,3,4,5,6]

在這個版本中,我們干脆讓 reduce 返回 function,實際上我們相當于讓 reduce 對函數進行了變換。我們可以這樣理解,函數變換基于原始函數包裝了一個新的函數,新的函數在原始函數的基礎上具備某些增強的能力。在這里我們得到了一顆函數式編程的"糖果"----我們可以像操作數據那樣操作一組函數,使得這些函數具備某些新的能力。而這,就是過程抽象的基本思想。這些變換函數的函數我們稱之為"高階函數"

procedural abstraction

上面的代碼我們是否還能進一步優化?事實上還是可以,如果 reduce 的函數是異步的,那么我們前面的函數是處理不了的,因此我們可以再設計一下 reduce,讓它支持異步函數:

function reduce(fn, async){
      if(async){
        return function(...args){
          return args.reduce((a, b)=>{
            return Promise.resolve(a).then((v)=>fn.call(this, v, b));
          });
        }
      }else{
        return function(...args){
          return args.reduce(fn.bind(this));
        }
      }
    }

    function add(x, y){
      return x + y;
    }

    function mul(x, y){
      return x * y;
    }

    function concat(arr1, arr2){
      return arr1.concat(arr2);
    }

    function asyncAdd(x, y){
      return new Promise((resolve, reject)=>{
        setTimeout(()=> resolve(x+y), 100); 
      });
    }

    add = reduce(add);
    mul = reduce(mul);
    concat = reduce(concat);

    console.log(add(1, 2, 3),   //6
      mul(1, 2, 3, 4),    //24
      concat([1, 2], [3, 4], [5, 6])); //[1,2,3,4,5,6]

    asyncAdd = reduce(asyncAdd, true);
    asyncAdd(1, 2, 3).then((v)=>console.log(v));

過程抽象的具體應用

函數調用的頻度控制

在實際項目中,我們有時候會遇到限制某函數調用頻率的需求。例如,防止一個按鈕短時間的的重復點擊,防止 resize、scroll 和 mousemove 事件過于頻繁地觸發等。

throttle

throttle 可以限制函數調用的頻率,常用來防止按鈕被重復點擊。

//限制button在500ms內只能被點擊一次
    $("#btn").click(throttle(function(evt){
        do sth.
    }, 500);

throttle 的簡單實現

function throttle(fn, wait){
        var timer;
        return function(...args){
            if(!timer){
                timer = setTimeout(()=>timer=null, wait);
                return fn.apply(this, args);
            }
        }
    }

    //按鈕每500ms一次點擊有效
    btn.onclick = throttle(function(){
        console.log("button clicked");
    }, 500);

debounce

有時候我們希望函數在某些操作執行完成之后被觸發。例如,實現搜索框的 Suggest 效果,如果數據是從服務器端讀取的,為了限制從服務器讀取數據的頻率,我們可以等待用戶輸入結束 100ms 之后再觸發 Suggest 查詢:

searchBox.addEventListener("input", debounce(function(){
        loadSuggestion();
    }, 100));

debouce 的簡單實現:

function debounce(fn, delay){
        var timer = null;
        return function(...args){
            clearTimeout(timer);
            timer = setTimeout(() => fn.apply(this, args), delay);
        }
    }

    window.addEventListener("scroll", debounce(function(){
        console.log("scrolled");
    }, 500));

DOM的批量操作

jQuery 的語法糖特點包括批量操作與鏈式調用。我們可以看一下,實際上用函數式的過程抽象思想很容易實現類似的"語法糖"。我們先考慮如何支持函數的"批量操作"。

function multicast(fn){
      return function(list, ...args){
        if(Array.isArray(list)){
          return list.map((item)=>fn.apply(this, [item,...args]));
        }else{
          return fn.apply(this, [list,...args]);
        }
      }
    }

    function add(x, y){
        return x + y;
    }

    add = multicast(add);

    console.log(add([1,2,3], 4)); //5,6,7

有了 multicast,我們來嘗試用它批量操作一個 ul 中的 li 元素:

<ul>
      <li>1</li>
      <li>2</li>
      <li>3</li>
      <li>4</li>
      <li>5</li>
      <li>6</li>
      <li>7</li>
    </ul>
function multicast(fn){
      return function(list, ...args){
        if(Array.isArray(list)){
          return list.map((item)=>fn.apply(this, [item,...args]));
        }else{
          return fn.apply(this, [list,...args]);
        }
      }
    }

    function setColor(el, color){
      return el.style.color = color;
    }

    setColor = multicast(setColor);

    var list = document.querySelectorAll("li:nth-child(2n+1)");
    setColor(Array.from(list), "red");

演示例子

Wrapper and decorators

我們可以對上面的例子進行進一步的處理,添加對 selector 參數的支持。這一次,我們使用一個叫做 wrap 的新的高階函數,它可以對原始函數的參數和返回值進行再包裝。

function multicast(fn){
      return function(list, ...args){
        if(Array.isArray(list)){
          return list.map((item)=>fn.apply(this, [item,...args]));
        }else{
          return fn.apply(this, [list,...args]);
        }
      }
    }

    function wrap(fn, before, after){
      return function(...args){
        if(before){
            args = before.apply(this, args);
        }
        let ret = fn.apply(this, args);
        if(after){
            ret = after.call(this, [ret, ...args]);
        }
        return ret;
      }
    }

    function setColor(el, color){
      return el.style.color = color;
    }

    function setFontSize(el, size){
      return el.style.fontSize = size;
    }

    function cast(fn){
      return wrap(multicast(fn), (...args)=>{
          if(typeof args[0] === "string"){
              args[0] = Array.from(document.querySelectorAll(args[0]));
          }
          return args;
      });
    }

    [setColor, setFontSize] = multicast(cast)([setColor, setFontSize]);

    setColor("li:nth-child(2n+1)", "red");

    setFontSize("li:nth-child(2n+1)", "32px");

演示例子

接下來,我們繼續對上面的代碼進行優化,我們可以構造一個高階函數 zip,它結合面向對象和過程抽像:

function zip(props){
      function Class(...args){
        this.__args = args;
      }
      let keys = Object.keys(props);
      keys.forEach((key) => {
        Class.prototype[key] = function(...args){
          return props[key].apply(this, [...this.__args, ...args]);
        };
      });
      return (...args)=>new Class(...args);
    }

    function add(x, y){
      return x + y;
    }

    function sub(x, y){
      return x - y;
    }

    let N = zip({add, sub});

    console.log(N(9).add(8)); //17

    console.log(N(3).sub(5)); //-2

有了 zip,我們就可以實現鏈式調用:

function zip(props){
      function Class(...args){
        this.__args = args;
      }
      let keys = Object.keys(props);
      keys.forEach((key) => {
        Class.prototype[key] = function(...args){
          return props[key].apply(this, [...this.__args, ...args]);
        };
      });
      return (...args)=>new Class(...args);
    }

    function multicast(fn){
      return function(list, ...args){
        if(Array.isArray(list)){
          return list.map((item)=>fn.apply(this, [item,...args]));
        }else{
          return fn.apply(this, [list,...args]);
        }
      }
    }

    function wrap(fn, before, after){
      return function(...args){
        if(before){
        args = before.apply(this, args);
        }
        let ret = fn.apply(this, args);
        if(after){
            ret = after.apply(this, [ret, ...args]);
        }
        return ret;
      }
    }

    function setColor(el, color){
      return el.style.color = color;
    }

    function setFontSize(el, size){
      return el.style.fontSize = size;
    }

    function cast(fn){
      return wrap(multicast(fn), (...args)=>{
          if(typeof args[0] === "string"){
              args[0] = Array.from(document.querySelectorAll(args[0]));
          }
          return args;
      }, (ret,...args)=>$(args[0]));
    }

    [setColor, setFontSize] = multicast(cast)([setColor, setFontSize]);

    let $ = zip({setColor, setFontSize});

    $("li:nth-child(2n+1)").setColor("red").setFontSize("32px");

演示例子

通過上面的例子,我們體會一下函數式編程的基本思想:

  • 設計高階函數:操作函數的函數,例如例子中的 multicast、wrap、cast、zip
  • 高階函數之間可以組合調用,例如 cast 調用 wrap, wrap 調用 multicast,cast 后的函數再被 zip 調用。組合調用可以給函數擴展出復雜的功能。

防御性編程

我們還可以使用上面的 wrap 高階函數進行防御性編程:

function wrap(fn, before, after){
      return function(...args){
        if(before){
        args = before.apply(this, args);
        }
        let ret = fn.apply(this, args);
        if(after){
            ret = after.call(this, [ret, ...args]);
        }
        return ret;
      }
    }

    Object.defineProperty(window, "ERROR_IF_MISSING", {
      get: function(){
        throw new TypeError("missing parameter")
      },
      writeable: false
    });

    function add(x, y){
        return x + y;
    }

    var add = wrap(add, 
              (x = ERROR_IF_MISSING, y = ERROR_IF_MISSING)=>[x, y]);

    //Uncaught TypeError: missing parameter
    console.log(add());

演示例子

上面的代碼如果不給 x 或 y 賦值,就會強制拋出一個 TypeError。

總結

我們說函數式編程是一種編程思想或者編程范式,上面的例子演示了函數式編程思想本身的基本應用場景。其實不管是號稱支持函數式編程的 lodash、underscore 或者更強大一些的 ramdajs 庫,它們的基本原理和使用場景也都包括上面的這些點。

通過上面的討論我們還可以得出結論,函數式編程擁有強大的抽象能力,也正是因為抽象能力強,函數式編程的模型才擁有巨大的潛力。

函數式編程是程序設計范式的一種,就像面向對象編程一樣,它是我們解決問題可以選擇的模式和思路,它和命令式編程(面向過程、面向對象)之間并不意味著非此即彼的選擇,而是可以并存。所以,關鍵問題不在于函數式編程實不實用,而在于學習一種新的思考模式,這種思考模式能夠幫助我們更深入理解程序設計原理和本質,深入了解函數式編程的優點和缺點,從而寫出更通用抽象能力更強質量更好的代碼。

這不會是月影的最后一篇關于函數式編程的討論,而僅僅是一個開始,因為函數式編程領域很大,這是一個很深的話題,讓我們一起繼續探索吧。


所屬標簽

無標簽

25选5玩法中奖