iT邦幫忙

2021 iThome 鐵人賽

DAY 9
1
Modern Web

Javascript 從寫對到寫好系列 第 9

Day 9 - Functional Programming 初探 (2) - Currying 與 Composition

前言

今天會繼續來聊聊 FP 的一些重要觀念,而且會更偏向實際的做法,看看 Javascript 怎麼結合昨天聊到的 First-class、HoF、pure function,並且落實「以 function 為主體」來寫程式。

Imperative vs. Declarative

新手們通常看到這兩個單字就直接轉台了,如果你成功看到第二行,我會盡量講清楚來報答你(?)。

Imperative

中文翻成命令式,是一個胼手胝足、努力向上的好青年,他做事的每一個步驟你都看在眼裡,他關注的是細節,是「如何做到」。

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 },
];

let totalPrice = 0;
arr.forEach(item => {
    if(item.price > 10000) {
        totalPrice += item.price;
    }
});

Declarative

中文翻成宣告式,是主管的類型,在公司的重要會議裡面,他只會告訴你大方向及一些策略,他關注的是整體,是「要做什麼」。

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 },
];

const totalPrice = arr.filter(item => item.price > 10000)
                      .map(item => item.price)
                      .reduce((prev, curr) => prev + curr, 0);

兩者差異

有沒有覺得上面兩個範例有點眼熟。。。沒錯就是我們在 Day 2 - Array 陣列組合技 (1) 討論到的,關於 forEach v.s. 聯合軍(filter/map/reduce) 的寫法。

當時提到了會有效能與可讀性方面的 trade-off,但沒有特別提到命令式與宣告式,因為我忘了我想讓大家多感受一些陣列與物件的用法,等到現在正式來討論 FP 的時候,才不會覺得越級打怪。

現在是不是覺得很多線索都串連起來了?

雖然 forEach 比起一般的 for 迴圈來說,已經算是比較偏向宣告式了,當你看到 forEach 這個關鍵字的時候,你會立馬知道:

  • forEach:我要跑迴圈,而且會把陣列中每個元素跑過一遍

但比起聯合軍(filter/map/reduce)的成員來說,他們更宣告了一點,因為每個都更明確告訴你:

  • filter:我不只要跑迴圈,還要做一個篩選的動作
  • map:我不只要跑迴圈,還要做一個轉換的動作
  • reduce:我不只要跑迴圈,還要做一個整合的動作

誰好誰壞?

首先,沒有好壞,雖然很多人比較想當主管,但那只是個擬人的形容,不要太入戲,兩種都是程式寫法的差異罷了,之前在 Day 2 也討論過,有些時候選擇不同的寫法都只是 trade-off 而已,沒有絕對正確的答案。

不過正是因為沒有正確答案,才更要去理解,這兩種的擅長點各自在哪,才能夠在對的地方發揮出來。

屠龍寶刀雖然厲害,但對於豬肉販來說,一般的鐵菜刀反而更順手。
如果我是豬肉販,我會選屠龍寶刀,然後賣掉買很多把鐵菜刀((拖走

Currying

籃球迷要瘋狂了!咖哩控也要暴走了!怎麼學 FP 還有球可以打有咖哩可以吃,原來是來源於一位邏輯學家叫做 Haskell Curry

currying 其實是一種數學方法,只是套用到 FP 來用,他的重點在於將 function 的「多個參數轉成單個參數」。

直接舉個例,原本要把兩個數字相加,需要帶兩個參數進去:

const addNum = (x, y) => x + y;

使用 currying 方法轉換之後會變成

const addNum = x => y => x + y;

有沒有很眼熟?我們在 Day 8 - Functional Programming 初探 (1) 就看到這種寫法了,但沒有特別提 currying,因為我忘了當時主要在講 HoF,但也透過同樣一個範例了解到,其實我們能夠使用 currying 方法,都多虧了 HoF 呢!

但這乍看之下完全是多此一舉吧!要相加就丟兩個參數啊,何必變成一個?

沒錯,如果我們真的只有「相加」一個函式,那大可用第一種方法就好,但當我們要開始寫 FP,別忘記主體是 function,最小單位是 function,基本上做什麼都要從 function 去排列組合。

function 的拆解與組裝

currying 絕對是 FP 非常重要的一步,因為有了 currying,我們才可以將 function 拆成比較小的積木,然後用這些積木去「組裝」更多 function:

const addNum = x => y => x + y;

const addFive = addNum(5);
const addTen = addNum(10);

addFive(3); // 8
addTen(3); // 13

看起來很複雜,但如果發揮前面學到 Declarative 的精神,先不要那麼在意這些程式碼每一步動作,而是先看出這些英文的意圖

// 這邊有個函式,addNum 應該就是把數字加起來吧?
const addNum = x => y => x + y;

// 這邊有個變數不知道存什麼東西,但它叫 addFive,應該就是用來 +5 吧?
const addFive = addNum(5);
// 這邊有個變數不知道存什麼東西,但它叫 addTen,應該就是用來 +10 吧?
const addTen = addNum(10);

// addFive 是 +5,還放了一個 3 進去,那大概是 8 吧?
addFive(3); // 8
// addTen 是 +10,還放了一個 3 進去,那大概是 13 吧?
addTen(3); // 13

沒錯,英文不好來學 FP 好像真的比較吃虧XD

離題了,重點是透過 currying,我們可以創造小積木,然後把小積木組成中積木,最後再堆成一整個城堡(生產線)。

pipe & compose

「組合」function 的兩大支柱,可以把兩個或多個 function 組合成像生產線一樣,A 執行完丟給 B,B 再接續執行。

直接上語法,如果要組合兩個 function:

const pipe = (f, g) => (...args) => g(f(...args));
const compose = (f, g) => (...args) => f(g(...args));

pipe 是先左再右,compose 是先右再左,所以兩者基本上是挑一個來用即可:

const pipe = (f, g) => (...args) => g(f(...args));
const compose = (f, g) => (...args) => f(g(...args));

const multiplyBy3 = num => num * 3;
const makePositive = num => Math.abs(num);
const multiplyBy3AndPositive = pipe(multiplyBy3, makePositive);
// 等同於
// const multiplyBy3AndPositive = compose(makePositive, multiplyBy3);

multiplyBy3AndPositive(-5); // 15

可以看到 multiplyBy3AndPositive 是一個完全透過「組裝」製造的 function,有順序地執行裡生產線裡面的兩個 function,開始有 FP 的感覺囉!

這部份我們明天會有大量可以實戰的案例!敬請期待

結語

今天介紹的東西其實滿難的,一方面是因為 FP 本來就充滿了許多數學元素,另一方面,當這些都只是概念的話,也很難跟實際的案例結合,但直接上實戰又好容易死在路上QQ

所以今天先介紹概念們,明天我們集合這兩天的大成,試著應用在實戰吧!

從合成到分離
從原子到宇宙
連結了散落一地的星點

參考資料

hannahpun - Function Programming In JS


上一篇
Day 8 - Functional Programming 初探 (1) - HoF 與 Side Effects
下一篇
Day 10 - Functional Programming 初探 (3) - 實戰購物車流程
系列文
Javascript 從寫對到寫好30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
0
TD
iT邦新手 4 級 ‧ 2021-09-25 09:31:52

第一次看到 pipe 和 compose 的實作!謝謝分享

1
Chiahsuan
iT邦新手 4 級 ‧ 2021-09-26 20:20:35
 const pipe = (f, g) => (...args) => g(f(...args));
 const compose = (f, g) => (...args) => f(g(...args));

你好~關於語法的部分,我知道args代表實際傳入function中的引數,...是展開運算子,但他倆組合起來我不太理解它在這個語法裡的作用

我嘗試拿掉後,發現這樣會報錯QQ

 const pipe = (f, g) => g(f(...args));
 const compose = (f, g) => f(g(...args));

謝謝!!!

ycchiuuuu iT邦新手 4 級 ‧ 2021-09-26 22:34:29 檢舉

你好:)

我們拆開來看會比較清楚一點:

先把引數跟展開運算子合在一起,印出來,會發現我丟 3 個引數,args 就會是一個長度為 3 的陣列

const test = (...args) => { console.log(args) };
test('Joey');
test('Joey', 123);
test('Joey', 123, 456);

執行結果

["Joey"]
["Joey", 123]
["Joey", 123, 456]

所以你在 pipe 看到的這個 g(f(...args)),這個 args 就是一個陣列哦,它被展開之後丟進去 f 這個函式了

之所以加上 ... 運算子,是為了要讓 pipe 可以傳多個參數進去,反正不管丟幾個進去,都會變成一個陣列,生產線的長度就不會受限了~

不曉得這樣有解到疑惑嗎?這個 pipe 的函式是全場最難的大魔王,希望有任何疑問都可以提出唷~

Chiahsuan iT邦新手 4 級 ‧ 2021-09-27 08:54:07 檢舉

謝謝你的解釋,覺得有再看懂一點點這個語法

之所以加上 ... 運算子,是為了要讓 pipe 可以傳多個參數進去,反正不管丟幾個進去,都會變成一個陣列,生產線的長度就不會受限了~

原本也有猜測是不是為了因應流程多於兩個步驟的狀況,所以需要展開運算子,謝謝你解開了我的疑惑。

想追問如果一個流程有三個步驟(或更多),那pipe的語法是否會變成:
const pipe = (f, g, h) => (...args) => h(g(f(...args)));

這樣式子變得好難閱讀QQ,覺得應該有更好的寫法,所以想請教你,感謝~

看了其他文章,查到有人的寫法是這樣

const pipe =
  (...functions) =>
  (x) =>
    functions.reduce((acc, fn) => fn(acc), x);
ycchiuuuu iT邦新手 4 級 ‧ 2021-09-27 21:07:17 檢舉

沒錯!你很會舉一反三耶,已經猜到隔天的內容了XD

我在 Day 10 就有帶到這個問題,如何從 2 個步驟,變成多個步驟,建議你可以先過去看看,用的方式跟你貼的是滿類似的,可以想想為什麼換成這種寫法,就可以從 2 個變多個?

composition 是 FP 很重要的核心,如果搞懂 pipe 怎麼用,對於學習 FP 是很大的幫助哦~

如果還是看不懂歡迎再發問唷~

1
qpalzm
iT邦新手 1 級 ‧ 2021-10-21 15:35:39

對於生產線的我有點卡住煩請解惑:
1.請問如果函式中有涉及到與後端進行資料的戶傳,是會以promise then的流程去寫嗎?
2.以上述的例子有辦法與ajax進行實作嗎?
3.如果有3個函式,參數個數分別為1,2,3,請問這樣去拆分的時候仍然要以currying的特性,單獨拆成一項一項嗎?
這個好有難度喔/images/emoticon/emoticon02.gif

ycchiuuuu iT邦新手 4 級 ‧ 2021-10-22 21:38:14 檢舉

哈囉你好,我們有一個共通點,就是都覺得 FP 很難XD

這篇文章提到的 pipe 範例,只能夠給同步的函式使用,如果涉及後端資料傳送,便是非同步函式,肯定要用到 promise 與 async/await 的概念,可以參考這篇文章,pipe 會進化成這樣:

function asyncPipe(...fns) {
  return async function(arg) {
    let res = arg;
    for (fn of fns) {
      res = await fn(res);
    }
    return res;
  }
}

而 currying 只是一種「將 function 的多個參數轉成單個參數」的方法,目的是透過拆解 function 成為小積木,再用這些小積木來組成其他東西。

不過 FP 不一定要把所有 function 都變成單個參數,單純是描述一種「以 function 為主體」的思考方式,所以可以自己根據狀況調整,只要可以透過 pipe 順利接起來,沒有硬規定一定要單個參數唷。

可以參考下一篇文章講實際的例子,或許會比較好理解唷!

qpalzm iT邦新手 1 級 ‧ 2021-10-26 08:15:36 檢舉

了解~謝謝幫忙解惑~這類型的文章讀起來有難度 但懂了就覺得好方便~謝謝您

0
justicebai
iT邦新手 5 級 ‧ 2022-11-03 17:09:55

Hi,不知道作者還在不在,這段對新手來說有點複雜

const pipe = (f, g) => (...args) => g(f(...args));

不知道能不能理解成這樣? 感覺就是closure的概念嗎

const multiplyBy3 = num => num*3
const makePositive = num => Math.abs(num)

function result(x){
  x = multiplyBy3(x)
  return function(){
    return makePositive(x)
  }
}

console.log(result(-5)()) //15
ycchiuuuu iT邦新手 4 級 ‧ 2022-11-04 14:14:05 檢舉

你好,作者還活著~

的確 pipe 是使用 closure 的概念,初學的話也可以先用你寫的來理解,兩層的概念其實就是有個接力棒,A 計算完的結果,接力給傳給 B

不過基本上子 function(即 multiplyBy3makePositive)會透過參數傳進去,以達到「組合」的功能。

這個範例只有兩個 function,如果需要組合三個甚至四個 function 時,展開就會變得相當複雜了,而這也是 carrying 使用的好時機。

BTW,在你寫的 code 中,x 代表帶入的資料,建議可以另外給一個 y 來代表 multiplyBy3 計算後的結果:

function result(x){
  const y = multiplyBy3(x)
  return function(){
    return makePositive(y)
  }
}

感謝回復!!

其實一直卡在,箭頭符號要return ...args這個問題

就這樣從昨天研究到現在終於成功展開了 QAQ
展開來對我來說就很好理解了 Orz

const pipe = function(f,g){
  return function(...args){
    return g(f(...args))
  }
}

console.log(pipe(multiplyBy3,makePositive)(-5))
ycchiuuuu iT邦新手 4 級 ‧ 2022-11-07 18:05:08 檢舉

太好了!原來問題是因為沒有展開啊~

我一直隱約覺得沒有回答到你的問題,好險你成功理解了XD

我要留言

立即登入留言