iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Software Development

數學老師學函數式程式設計 - 以fp-ts啟航系列 第 7

Day 07. 宣告式程式風格 - Javascript Array

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png

宣告式風格談陣列方法

map函數

傳統命令式撰寫風格(imperative)必須完全掌控程式實作的細節,比較具有彈性,卻也需要花費較多的心思在程式的細節,著重在如何(How),我們以陣列迴圈命令式寫法來將陣列的每個元素平方為例:

const arr = [1, 2, 3, 4, 5];
const squaredArr = [];
for (let i = 0; i < arr.length; i++) {
    squaredArr.push(arr[i] * arr[i]);
}

而宣告式程式風格(declarative)則是將程式實作的細節交由編譯器或內建函式來處理,我們只需要描述我們想要什麼(What),而不需要描述如何(How),這種程式風格通常會使用函數式程式設計(functional programming)的核心概念,例如同樣將一個陣列的每個元素平方,我們可以這樣寫:

const arr = [1, 2, 3, 4, 5];
const square = x => x * x
const squaredArr1 = arr.map(square); // 匿名函數傳入函數
const squaredArr2 = arr.map((x) => x * x); // 匿名函數傳入函數
// squaredArr1 = squaredArr2 = [1, 4, 9, 16, 25]

其中map()函數是Javascript陣列物件的的內建方法,它接受一個函式為參數,而這個函式會被應用於陣列的每個元素,最後返回一個新的陣列。現代程式語言都會提供這種函數式程式設計的語法,例如Python也有類似的map()函數。

傳遞函數式,我們可以事先定好函數,再將函數名稱傳入,也可以用匿名函數傳入。將函數名稱定義好再將函數名稱傳入也稱之為point-free格式(style),它函數的傳入較為抽象,易讀性也更高,將來在函數的複合或接管時會更容易,在本文的系列,更多的時候會使用point-free style。

除了map()函數,Javascript還有其他內建的函數式程式設計的函數,例如filter()、reduce()、forEach()等等,這些函數都是宣告式程式風格的典型代表,接下來我們將會介紹這些函數。

在functional programming的世界中,一個具有map函數的容器被稱為functor,這個概念在函數式程式設計中非常重要,後面我們還會深入介紹Functor的觀念。

filter()

當你需要從一個陣列中篩選出符合某些條件的元素時,如果使用傳統命令式風格的程式設計,你需要寫一個for loop,並使用一個存放結果的陣列,然後在迴圈中檢查每一個元素,將符合條件的元素放入存放結果的陣列。
你時候你可以使用filter()函數,這個函數會遍歷陣列中的每個元素,並根據提供的函式來決定是否保留該元素。
filter()函數接受一個函式為參數,此函數的輸出的type為boolean,這個函式會被應用於陣列的每個元素,如果函式的回傳值為真則保留該元素,反之剔除該元素。最後返回一個新的陣列,其中包含所有被保留的元素。
這種宣告式風格使得程式碼更簡潔且易於理解,因為你不需要手動管理迴圈和條件判斷,而是專注於定義篩選條件。

const arr = [1, 2, 3, 4, 5];
const evenArr = arr.filter((x) => x % 2 === 0); // [2, 4]

filter()函數中作為參數的函式在fnctional programming中被稱為"predicate",它的作用是判斷每個元素是否符合某個條件。

reduce(), reduceRight()

當你需要寫個廻圈遍歷陣列中的所有元素,並針對當下的元素進行某些運算,最後得到一個值做為下一元素初始之用時,此時便是reduce()函數上場的時候。
reduce()函數接受一個二元函數和一個初始值當作參數,這個二元函數的第一個參數是累加器(acc),第二個參數是當前元素(curr),函數每次執行回傳的結果將是下一次迭代時第一個參數(acc)的參數值,我們用下圖來說明它的運作方式:
舉個最簡單的例子,假設我們有一個陣列,我們想要計算陣列中所有元素的總和,我們可以這樣寫:

const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce((acc, curr) => acc + curr, 0);

這段程式碼中,reduce()函數遍歷陣列中的每個元素,初始值為0,第一次迭代時acc為0,curr為1,代入二元函數(acc, curr) => acc + curr,然後acc變成1,此時curr為2,接著進入下一次二迭代,因此相加得到3,進入下一次迭代時,acc便為3,而curr為3,…依次類推,最終得到總和15。

在函數式程式設計中,我們不使用迴圈而是使用宣告式的方式來處理陣列,可以有以下的好處:

  1. 迴圈的操作由內建函式處理,避免了缺一錯誤的可能(off-by-one mistake)。
  2. 避免side effects,讓程式更容易理解和維護。

再舉一個例子,假設我們要反轉一個陣列,我們可以使用reduce()函數來實現:

const arr = [1, 2, 3, 4, 5];
const reversedArr = arr.reduce((acc, curr) => [curr, ...acc], []);

這段程式碼中,reduce()函數從空陣列開始,將每個元素放在acc的前面,最終得到反轉的陣列。
reduce()函數還有一個變體叫做reduceRight(),它的運作方式與reduce()類似,但它是從陣列的右側開始遍歷元素,這在某些情況下可能更有用,例如當你需要從最後一個元素開始進行計算時。
我們實作兩個很常用的boolean函數,分別是all()和some(),這兩個函數可以用來判斷陣列中的元素是否全部為真或至少有一個為真。

const arr = [true, false, true, true, false];
const all = arr.reduceRight((acc, curr) => acc && curr, true);

const some = arr.reduceRight((acc, curr) => acc || curr, false);

一個容器如果有reduce和reduceRight函數,則稱為Foldable。

無參數風格

Point-free

我們重新檢視這個例子

const arr = [1, 2, 3, 4, 5];
const square = x => x * x
const squaredArr1 = arr.map(square); // point-free
const squaredArr2 = arr.map((x) => x * x); // non point-free
// squaredArr1 = squaredArr2 = [1, 4, 9, 16, 25]

arr.map(square)這種寫法我們稱之為point-free風格,或是無參數風格(tacit programming),因為從程式中看不到square的參數簽名。宣告式程式風格較喜歡無參數的寫法,因為更具可讀性,在後面談到函數的複合時,我們會感受到無參數風格對可讀性的提升,也更貼近宣告式程式設計的想法。

Point-free的坑

你如果用point-free執行下列程式碼,結果可能會出乎你的意料之外。

["3.2", "92.87", "100"].map(parseFloat) // [3.2, 92.87, 100]
["3.2", "92.87", "100"].map(parseInt) // [3, NaN, 4]

第一列程式碼的結果和你的預期應該是相同,但是第二列程式碼和我們預期的[3.2, 92.87, 100]不一樣,如果我們將它改為有參數的匿名函數格式又會如何?

["3.2", "92.87", "100"].map(x => parseFloat(x)) // [3.2, 92.87, 100]
["3.2", "92.87", "100"].map(x => parseInt(x)) // [3, 93, 100]

改成有參數的格式,兩列都滿足我們的期待,為何有這種情形?主要是因為我們對javascript內建函數的參數不清楚造成。我們如果查閱MDN關於陣列map函數的說明(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)會發現它會給map中的函數三個參數,第一個參數是陣列的元素,第二個註標,第三個則是陣列本身,我們前面在介紹map函數時刻意不說,因為給予陣列註標會破壞純函數(Pure function)的特性;查閱parseInt(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseInt)會發現它可以有二個參數,除了字串本身,它還可以接受一個進位制基數radix(2-36)的參數。由於我們使用無參數風格,map函數給parseInt函數的參數便是元素、註標、陣列,而parseInt只接受二個參數,因此陣列便被忽略,但是註標卻被傳送給parseInt。因此迭代"3.2"時,執行的是parseInt("3.2", 0),radix為0,parseInt會自動偵測進位制基數;當迭代"92.87"時,執行的是parseInt("92.87", 1),radix=1是無效進位制基數,因此得到NaN的答案;當迭代"100"時,執行的是parseInt("100", 2),進位制基數為2,而二進位的100等於4。當我們使用非point-free的風格時,明顯的表示只傳陣列元素給parseInt,所以便沒有問題。那為何parseFloat沒有這種問題?那是因為parseFloat本身就只接受一個參數。

雖然point-free比較符合宣告式程式設計,但是須要小心,尤其當我們使用內建函數式,常常沒有完全了解它的參數數量和型別,可能會犯下上面舉出的錯誤。我們在後面的發文會介紹fp-ts/Array的模組,它也會提供相對應的map、filter和reduce等javascript相對應的函數,使用fp-ts/Array模組的函數便不會有以上的問題。

今日小結

宣告式程式風格更強調我們的程式要做什麼(What),抽象層次比較高,程式較易讀;而命令式程式風格則著重程式如何執行(How),或許程式會彈性和效率會高一點,但是容易有失一錯誤的風險,拘於細節,可讀性也較差。現今程式設計的取向傾向於讓程式易於維護,大部分的程式開發都採取宣告式程式風格較多,雖然有效率較差的爭議,但是整體程式的效率問題是否由宣告式造成,仍需仔細檢視效率關鍵所在。

今天純綷為了說明宣告式的程式設計風格而介紹了javascript中陣列的內建函數,這種鏈結式格式不是本系列主張的格式,而且使用point-free風格時會有一些坑,將儘量不採用。預計在Day 11的時候,我們會導入fp-ts/Array模組,會以另一種形式呈現這些陣列方法。

今日的分享就到這邊告一段落,明天再見。


上一篇
Day 06. 遞迴函數 - Recurrsion
下一篇
Day 08. 今天來一些咖哩 - Currying
系列文
數學老師學函數式程式設計 - 以fp-ts啟航8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言