我眼中的 JavaScript 函數語言程式設計

化辰發表於2017-03-20

JavaScript 函數語言程式設計是一個存在了很久的話題,但似乎從 2016 年開始,它變得越來越火熱。這可能是因為 ES6 語法對於函數語言程式設計更為友好,也可能是因為諸如 RxJS (ReactiveX) 等函式式框架的流行。

看過許多關於函數語言程式設計的講解,但是其中大部分是停留在理論層面,還有一些是僅針對 Haskell 等純函數語言程式設計語言的。而本文旨在聊一聊我眼中的函數語言程式設計在 JavaScript 中的具體實踐,之所以是 “我眼中的” 即我所說的僅代表個人觀點,可能和部分 嚴格概念 是有衝突的。

本文將略去一大堆形式化的概念介紹,重點展示在 JavaScript 中到底什麼是函式式的程式碼、函式式程式碼與一般寫法有什麼區別、函式式的程式碼能給我們帶來什麼好處以及常見的一些函式式模型都有哪些。

我理解的函數語言程式設計

我認為函數語言程式設計可以理解為,以函式作為主要載體的程式設計方式,用函式去拆解、抽象一般的表示式

與命令式相比,這樣做的好處在哪?主要有以下幾點:

  • 語義更加清晰
  • 可複用性更高
  • 可維護性更好
  • 作用域侷限,副作用少

基本的函數語言程式設計

下面例子是一個具體的函式式體現

// 陣列中每個單詞,首字母大寫

// 一般寫法
const arr = ['apple', 'pen', 'apple-pen'];
for(const i in arr){
  const c = arr[i][0];
  arr[i] = c.toUpperCase() + arr[i].slice(1);
}

console.log(arr);

// 函式式寫法一
function upperFirst(word) {
  return word[0].toUpperCase() + word.slice(1);
}

function wordToUpperCase(arr) {
  return arr.map(upperFirst);
}

console.log(wordToUpperCase(['apple', 'pen', 'apple-pen']));

// 函式式寫法二
console.log(arr.map(['apple', 'pen', 'apple-pen'], word => word[0].toUpperCase() + word.slice(1)));

當情況變得更加複雜時,表示式的寫法會遇到幾個問題:

  1. 表意不明顯,逐漸變得難以維護
  2. 複用性差,會產生更多的程式碼量
  3. 會產生很多中間變數

函數語言程式設計很好的解決了上述問題。首先參看 函式式寫法一,它利用了函式封裝性將功能做拆解(粒度不唯一),並封裝為不同的函式,而再利用組合的呼叫達到目的。這樣做使得表意清晰,易於維護、複用以及擴充套件。其次利用 高階函式Array.map 代替 for…of 做陣列遍歷,減少了中間變數和操作。

而 函式式寫法一 和 函式式寫法二 之間的主要差別在於,可以考慮函式是否後續有複用的可能,如果沒有,則後者更優。

鏈式優化

從上面 函式式寫法二 中我們可以看出,函式式程式碼在寫的過程中,很容易造成 橫向延展,即產生多層巢狀,下面我們舉個比較極端點的例子。

// 計算數字之和

// 一般寫法
console.log(1 + 2 + 3 - 4)

// 函式式寫法
function sum(a, b) {
  return a + b;
}

function sub(a, b) {
  return a - b;
}

console.log(sub(sum(sum(1, 2), 3), 4);

本例僅為展示 橫向延展 的比較極端的情況,隨著函式的巢狀層數不斷增多,導致程式碼的可讀性大幅下降,還很容易產生錯誤。

在這種情況下,我們可以考慮多種優化方式,比如下面的 鏈式優化 。

// 優化寫法 (嗯,你沒看錯,這就是 lodash 的鏈式寫法)
const utils = {
  chain(a) {
    this._temp = a;
    return this;
  },
  sum(b) {
    this._temp += b;
    return this;
  },
  sub(b) {
    this._temp -= b;
    return this;
  },
  value() {
    const _temp = this._temp;
    this._temp = undefined;
    return _temp;
  }
};

console.log(utils.chain(1).sum(2).sum(3).sub(4).value());

這樣改寫後,結構會整體變得比較清晰,而且鏈的每一環在做什麼也可以很容易的展現出來。函式的巢狀和鏈式的對比還有一個很好的例子,那就是 回撥函式 和 Promise 模式

// 順序請求兩個介面

// 回撥函式
import $ from 'jquery';
$.post('a/url/to/target', (rs) => {
  if(rs){
    $.post('a/url/to/another/target', (rs2) => {
      if(rs2){
        $.post('a/url/to/third/target');
      }
    });
  }
});

// Promise
import request from 'catta';  // catta 是一個輕量級請求工具,支援 fetch,jsonp,ajax,無依賴
request('a/url/to/target')
  .then(rs => rs ? $.post('a/url/to/another/target') : Promise.reject())
  .then(rs2 => rs2 ? $.post('a/url/to/third/target') : Promise.reject());

隨著回撥函式巢狀層級和單層複雜度增加,它將會變得臃腫且難以維護,而 Promise 的鏈式結構,在高複雜度時,仍能縱向擴充套件,而且層次隔離很清晰。

常見的函數語言程式設計模型

閉包(Closure)

可以保留區域性變數不被釋放的程式碼塊,被稱為一個閉包

閉包的概念比較抽象,相信大家都或多或少知道、用到這個特性

那麼閉包到底能給我們帶來什麼好處?

先來看一下如何建立一個閉包:

// 建立一個閉包
function makeCounter() {
  let k = 0;

  return function() {
    return ++k;
  };
}

const counter = makeCounter();

console.log(counter());  // 1
console.log(counter());  // 2

makeCounter 這個函式的程式碼塊,在返回的函式中,對區域性變數 k ,進行了引用,導致區域性變數無法在函式執行結束後,被系統回收掉,從而產生了閉包。而這個閉包的作用就是,“保留住“ 了區域性變數,使內層函式呼叫時,可以重複使用該變數;而不同於全域性變數,該變數只能在函式內部被引用。

換句話說,閉包其實就是創造出了一些函式私有的 ”持久化變數“。

所以從這個例子,我們可以總結出,閉包的創造條件是:

  1. 存在內、外兩層函式
  2. 內層函式對外層函式的區域性變數進行了引用

閉包的用途

閉包的主要用途就是可以定義一些作用域侷限的持久化變數,這些變數可以用來做快取或者計算的中間量等等。

// 簡單的快取工具
// 匿名函式創造了一個閉包
const cache = (function() {
  const store = {};

  return {
    get(key) {
      return store[key];
    },
    set(key, val) {
      store[key] = val;
    }
  }
}());

cache.set('a', 1);
cache.get('a');  // 1

上面例子是一個簡單的快取工具的實現,匿名函式創造了一個閉包,使得 store 物件 ,一直可以被引用,不會被回收。

閉包的弊端

持久化變數不會被正常釋放,持續佔用記憶體空間,很容易造成記憶體浪費,所以一般需要一些額外手動的清理機制。

高階函式

接受或者返回一個函式的函式稱為高階函式

聽上去很高冷的一個詞彙,但是其實我們經常用到,只是原來不知道他們的名字而已。JavaScript 語言是原生支援高階函式的,因為 JavaScript 的函式是一等公民,它既可以作為引數又可以作為另一個函式的返回值使用。

我們經常可以在 JavaScript 中見到許多原生的高階函式,例如 Array.map , Array.reduce , Array.filter

下面以 map 為例,我們看看他是如何使用的

map (對映)

對映是對集合而言的,即把集合的每一項都做相同的變換,產生一個新的集合

map 作為一個高階函式,他接受一個函式引數作為對映的邏輯

// 陣列中每一項加一,組成一個新陣列

// 一般寫法
const arr = [1,2,3];
const rs = [];
for(const n of arr){
  rs.push(++n);
}
console.log(rs)

// map改寫
const arr = [1,2,3];
const rs = arr.map(n => ++n);

上面一般寫法,利用 for...of 迴圈的方式遍歷陣列會產生額外的操作,而且有改變原陣列的風險

而 map 函式封裝了必要的操作,使我們僅需要關心對映邏輯的函式實現即可,減少了程式碼量,也降低了副作用產生的風險。

柯里化(Currying)

給定一個函式的部分引數,生成一個接受其他引數的新函式

可能不常聽到這個名詞,但是用過 undescore 或 lodash 的人都見過他。

有一個神奇的 _.partial 函式,它就是柯里化的實現

// 獲取目標檔案對基礎路徑的相對路徑

// 一般寫法
const BASE = '/path/to/base';
const relativePath = path.relative(BASE, '/some/path');

// _.parical 改寫
const BASE = '/path/to/base';
const relativeFromBase = _.partial(path.relative, BASE);

const relativePath = relativeFromBase('/some/path');

通過 _.partial ,我們得到了新的函式 relativeFromBase ,這個函式在呼叫時就相當於呼叫 path.relative ,並預設將第一個引數傳入 BASE ,後續傳入的引數順序後置。

本例中,我們真正想完成的操作是每次獲得相對於 BASE 的路徑,而非相對於任何路徑。柯里化可以使我們只關心函式的部分引數,使函式的用途更加清晰,呼叫更加簡單。

組合(Composing)

將多個函式的能力合併,創造一個新的函式

同樣你第一次見到他可能還是在 lodash 中,compose 方法(現在叫 flow

// 陣列中每個單詞大寫,做 Base64

// 一般寫法 (其中一種)
const arr = ['pen', 'apple', 'applypen'];
const rs = [];
for(const w of arr){
  rs.push(btoa(w.toUpperCase()));
}
console.log(rs);

// _.flow 改寫
const arr = ['pen', 'apple', 'applypen'];
const upperAndBase64 = _.partialRight(_.map, _.flow(_.upperCase, btoa));
console.log(upperAndBase64(arr));

_.flow 將轉大寫和轉 Base64 的函式的能力合併,生成一個新的函式。方便作為引數函式或後續複用。

自己的觀點

我理解的 JavaScript 函數語言程式設計,可能和許多傳統概念不同。我並不只認為 高階函式 算函數語言程式設計,其他的諸如普通函式結合呼叫、鏈式結構等,我都認為屬於函數語言程式設計的範疇,只要他們是以函式作為主要載體的。

而我認為函數語言程式設計並不是必須的,它也不應該是一個強制的規定或要求。與物件導向或其他思想一樣,它也是其中一種方式。我們更多情況下,應該是幾者的結合,而不是侷限於概念。

相關文章