摘要: JS函數語言程式設計入門。

Fundebug經授權轉載,版權歸原作者所有。

本系列的其他文章:

組合函式 (Function Composition)

作為程式設計師,我們是懶惰的。我們不想構建、測試和部署我們編寫的一遍又一遍的程式碼。我們總是試圖找出一次性完成工作的方法,以及如何重用它來做其他事情。

程式碼重用聽起來很棒,但是實現起來很難。如果程式碼業務性過於具體,就很難重用它。如時程式碼太過通用簡單,又很少人使用。所以我們需要平衡兩者,一種製作更小的、可重用的部件的方法,我們可以將其作為構建塊來構建更復雜的功能。

在函數語言程式設計中,函式是我們的構建塊。每個函式都有各自的功能,然後我們把需要的功能(函式)組合起來完成我們的需求,這種方式有點像樂高的積木,在程式設計中我們稱為 組合函式

看下以下兩個函式:

var add10 = function(value) {
    return value + 10;
};
var mult5 = function(value) {
    return value * 5;
};

上面寫法有點冗長了,我們用箭頭函式改寫一下:

var add10 = value => value + 10;
var mult5 = value => value * 5;

現在我們需要有個函式將傳入的引數先加上 10 ,然後在乘以 5, 如下:

var mult5AfterAdd10 = value => 5 * (value + 10)

儘管這是一個非常簡單的例子,但仍然不想從頭編寫這個函式。首先,這裡可能會犯一個錯誤,比如忘記括號。第二,我們已經有了一個加 10 的函式 add10 和一個乘以 5 的函式 mult5 ,所以這裡我們就在寫已經重複的程式碼了。

使用函式 add10mult5 來重構 mult5AfterAdd10

var mult5AfterAdd10 = value => mult5(add10(value));

我們只是使用現有的函式來建立 mult5AfterAdd10,但是還有更好的方法。

在數學中, f ∘ g 是函式組合,叫作“f 由 g 組合”,或者更常見的是 “f after g”。 因此 (f ∘ g)(x) 等效於f(g(x)) 表示呼叫 g 之後呼叫 f

在我們的例子中,我們有 mult5 ∘ add10 或 “add10 after mult5”,因此我們的函式的名稱叫做 mult5AfterAdd10。由於Javascript本身不做函式組合,看看 Elm 是怎麼寫的:

add10 value =
    value + 10
mult5 value =
    value * 5
mult5AfterAdd10 value =
    (mult5 << add10) value

Elm 中 << 表示使用組合函式,在上例中 value 傳給函式 add10 然後將其結果傳遞給 mult5。還可以這樣組合任意多個函式:

f x =
   (g << h << s << r << t) x

這裡 x 傳遞給函式 t,函式 t 的結果傳遞給 r,函式 t 的結果傳遞給 s,以此類推。在Javascript中做類似的事情,它看起來會像 g(h(s(r(t(x))))),一個括號噩夢。

Point-Free Notation

Point-Free Notation就是在編寫函式時不需要指定引數的程式設計風格。一開始,這風格看起來有點奇怪,但是隨著不斷深入,你會逐漸喜歡這種簡潔的方式。

multi5AfterAdd10 中,你會注意到 value 被指定了兩次。一次在引數列表,另一次是在它被使用時。

// 這個函式需要一個引數

mult5AfterAdd10 value =
    (mult5 << add10) value 

但是這個引數不是必須的,因為該函式組合的最右邊一個函式也就是 add10 期望相同的引數。下面的 point-free 版本是等效的:

// 這也是一個需要1個引數的函式

mult5AfterAdd10 =
    (mult5 << add10)

使用 point-free 版本有很多好處。

  1. 首先,我們不需要指定冗餘的引數。由於不必指定引數,所以也就不必考慮為它們命名。
  2. 由於更簡短使得更容易閱讀。本例比較簡單,想象一下如果一個函式有多個引數的情況。

天堂裡的煩惱

到目前為止,我們已經瞭解了組合函式如何工作以及如何通過 point-free 風格使函式簡潔、清晰、靈活。

現在,我們嘗試將這些知識應用到一個稍微不同的場景。想象一下我使用 add 來替換 add10

add x y =
    x + y
mult5 value =
    value * 5

現在如何使用這兩個函式來組合函式 mult5After10 呢?

我們可能會這樣寫:

-- 這是錯誤的!!!

mult5AfterAdd10 =
    (mult5 << add) 10 

但這行不通。為什麼? 因為 add 需要兩個引數。

這在 Elm 中並不明顯,請嘗試用Javascript編寫:

var mult5AfterAdd10 = mult5(add(10)); // 這個行不通

這段程式碼是錯誤的,但是為什麼?

因為這裡 add 函式只能獲取到兩個引數(它的函式定義中指定了兩個引數)中的一個(實際只傳遞了一個引數),所以它會將一個錯誤的結果傳遞給 mult5。這最終會產生一個錯誤的結果。

事實上,在 Elm 中,編譯器甚至不允許你編寫這種格式錯誤的程式碼(這是 Elm 的優點之一)。

我們再試一次:

var mult5AfterAdd10 = y => mult5(add(10, y)); // not point-free

這個不是point-free風格但是我覺得還行。但是現在我不再僅僅組合函式。我在寫一個新函式。同樣如果這個函式更復雜,例如,我想使用一些其他的東西來組合mult5AfterAdd10,我真的會遇到麻煩。

由於我們不能將這個兩個函式對接將會出現函式組合的作用受限。這太糟糕了,因為函式組合是如此強大。

如果我們能提前給add函式一個引數然後在呼叫 mult5AfterAdd10 時得到第二個引數那就更好了。這種轉化我們叫做 柯里化

柯里化 (Currying)

Currying 又稱部分求值。一個 Currying 的函式首先會接受一些引數,接受了這些引數之後,該函式並不會立即求值,而是繼續返回另外一個函式,剛才傳入的引數在函式形成的閉包中被儲存起來。待到函式被真正需要求值的時候,之前傳入的所有引數都會被一次性用於求值

上例我們在組合函式 mult5add(in) 時遇到問題的是,mult5 使用一個引數,add 使用兩個引數。我們可以通過限制所有函式只取一個引數來輕鬆地解決這個問題。我只需編寫一個使用兩個引數但每次只接受一個引數的add函式,函式柯里化就是幫我們這種工作的。

柯里化函式一次只接受一個引數。

我們先賦值 add 的第1個引數,然後再組合上 mult5,得到 mult5AfterAdd10 函式。當 mult5AfterAdd10 函式被呼叫的時候,add 得到了它的第 2 個引數。

JavaScript 實現方式如下:

var add = x => y => x + y

此時的 add 函式先後分兩次得到第 1 個和第 2 個引數。具體地說,add函式接受單參x,返回一個也接受單參 y的函式,這個函式最終返回 x+y 的結果。

現在可以利用這個 add 函式來實現一個可行的 mult5AfterAdd10* :

var compose = (f, g) => x => f(g(x));
var mult5AfterAdd10 = compose(mult5, add(10));

compose 有兩個引數 fg,然後返回一個函式,該函式有一個引數 x,並傳給函式 f,當函式被呼叫時,先呼叫函式 g,返回的結果作為函式 f的引數。

總結一下,我們到底做了什麼?我們就是將簡單常見的add函式轉化成了柯里化函式,這樣add函式就變得更加自由靈活了。我們先將第1個引數10輸入,而當mult5AfterAdd10函式被呼叫的時候,最後1個引數才有了確定的值。

柯里化與重構(Curring and Refactoring)

函式柯里化允許和鼓勵你分隔複雜功能變成更小更容易分析的部分。這些小的邏輯單元顯然是更容易理解和測試的,然後你的應用就會變成乾淨而整潔的組合,由一些小單元組成的組合。

例如,我們有以下兩個函式,它們分別將輸入字串用單花括號和雙花括號包裹起來:

bracketed = function (str) {
  retrun "{" + str + "}"
}
    
doubleBracketed = function (str) {
  retrun "{{" + str + "}}"
} 

呼叫方式如下:

var bracketedJoe =  bracketed('小智')

var doubleBracketedJoe =  doubleBracketed('小智')

可以將 bracketdoubleBracket 轉化為更變通的函式:

generalBracket = function( prefix , str ,suffix ) {
  retrun  prefix ++ str ++ suffix
}

但每次我們呼叫 generalBracket 函式的時候,都得這麼傳參:

var bracketedJoe = generalBracket("{", "小智", "}")

var doubleBracketedJoe = generalBracket("{{", "小智", "}}")

之前引數只需要輸入1個,但定義了2個獨立的函式;現在函式統一了,每次卻需要傳入3個引數,這個不是我們想要的,我們真正想要的是兩全其美。

因為生成小括號雙括號功能但一,重新調整一下 我們將 generalBracket 三個引數中的 prefix,str 各柯里化成一個函式,如下:

generalBracket = function( prefix ) {
  return  function( suffix ){
      return function(str){
          return prefix + str + suffix
      }
  }
}

這樣,如果我們要列印單括號或者雙括號,如下:

// 生成單括號
var bracketedJoe = generalBracket('{')('}')
bracketedJoe('小智') // {小智}

// 生成雙括號
var bracketedJoe = generalBracket('{{')('}}')
bracketedJoe('小智') // {{小智}}
 

常見的函式式函式(Functional Function)

函式式語言中3個常見的函式:Map,Filter,Reduce

如下JavaScript程式碼:

for (var i = 0; i < something.length; ++i) {
      // do stuff
 }
 

這段程式碼存在一個很大的問題,但不是bug。問題在於它有很多重複程式碼(boilerplate code)。如果你用命令式語言來程式設計,比如Java,C#,JavaScript,PHP,Python等等,你會發現這樣的程式碼你寫地最多。這就是問題所在

現在讓我們一步一步的解決問題,最後封裝成一個看不見 for 語法函式:

先用名為 things 的陣列來修改上述程式碼:

var things = [1, 2, 3, 4];
for (var i = 0; i < things.length; ++i) {
    things[i] = things[i] * 10; // 警告:值被改變!
}
console.log(things); // [10, 20, 30, 40]

這樣做法很不對,數值被改變了!

在重新修改一次:

var things = [1, 2, 3, 4];
var newThings = [];
for (var i = 0; i < things.length; ++i) {
    newThings[i] = things[i] * 10;
}
console.log(newThings); // [10, 20, 30, 40]

這裡沒有修改things數值,但卻卻修改了newThings。暫時先不管這個,畢竟我們現在用的是 JavaScript。一旦使用函式式語言,任何東西都是不可變的。

現在將程式碼封裝成一個函式,我們將其命名為 map,因為這個函式的功能就是將一個數組的每個值對映(map)到新陣列的一個新值。

var map = (f, array) => {
    var newArray = [];
    for (var i = 0; i < array.length; ++i) {
        newArray[i] = f(array[i]);
    }
    return newArray;
};

函式 f 作為引數傳入,那麼函式 map 可以對 array 陣列的每項進行任意的操作。

現在使用 map 重寫之前的程式碼:

var things = [1, 2, 3, 4];
var newThings = map(v => v * 10, things);

這裡沒有 for 迴圈!而且程式碼更具可讀性,也更易分析。

現在讓我們寫另一個常見的函式來過濾陣列中的元素:

var filter = (pred, array) => {
    var newArray = [];
for (var i = 0; i < array.length; ++i) {
        if (pred(array[i]))
            newArray[newArray.length] = array[i];
    }
    return newArray;
};

當某些項需要被保留的時候,斷言函式 pred 返回TRUE,否則返回FALSE。

使用過濾器過濾奇數:

var isOdd = x => x % 2 !== 0;
var numbers = [1, 2, 3, 4, 5];
var oddNumbers = filter(isOdd, numbers);
console.log(oddNumbers); // [1, 3, 5]

比起用 for 迴圈的手動程式設計,filter 函式簡單多了。最後一個常見函式叫reduce。通常這個函式用來將一個數列歸約(reduce)成一個數值,但事實上它能做很多事情。

在函式式語言中,這個函式稱為 fold

var reduce = (f, start, array) => {
    var acc = start;
    for (var i = 0; i < array.length; ++i)
        acc = f(array[i], acc); // f() 有2個引數
    return acc;
});

reduce函式接受一個歸約函式 f,一個初始值 start,以及一個數組 array

這三個函式,map,filter,reduce能讓我們繞過for迴圈這種重複的方式,對陣列做一些常見的操作。但在函式式語言中只有遞迴沒有迴圈,這三個函式就更有用了。附帶提一句,在函式式語言中,遞迴函式不僅非常有用,還必不可少。

原文:

https://medium.com/@cscalfani
https://medium.com/@cscalfani

編輯中可能存在的bug沒法實時知道,事後為了解決這些bug,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具Fundebug。

你的點贊是我持續分享好東西的動力,歡迎點贊!

一個笨笨的碼農,我的世界只能終身學習!

更多內容請關注公眾號《大遷世界》!

關於Fundebug

Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了9億+錯誤事件,付費客戶有Google、360、金山軟體、百姓網等眾多品牌企業。歡迎大家免費試用!