iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
Software Development

30 天的 Functional Programming 之旅系列 第 9

[Day 09] First-Class Functions 和 Higher-Order Functions (2):map 與 reduce

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250923/201682019sOVhJhvd1.png

前言

延續昨天介紹的 forEach 函式,今天要來介紹 mapreduce 這兩個函數式程式設計的核心工具~

map:陣列的變形工廠

map 是函數式程式設計中最常用的工具之一。它的核心任務非常明確:對一個陣列中的每一個元素執行相同的操作,然後回傳一個包含所有結果的全新陣列。

為什麼需要 map

假設我們有一個儲存商品價格的陣列,現在需要根據不同的業務需求,對這組價格進行多種轉換:

  1. 計算加上 5% 營業稅後的價格。
  2. 計算黑色星期五特賣,打九折後的價格。
  3. 將所有價格格式化成美金字串,例如 "$120.00"

如果用 for 迴圈來處理,程式碼可能會是這樣:

const prices = [20, 55, 120];

// 需求 1: 計算含稅價
const pricesWithTax = [];
for (let i = 0; i < prices.length; i++) {
  pricesWithTax.push(prices[i] * 1.05);
}
// console.log(pricesWithTax); -> [21, 57.75, 126]

// 需求 2: 計算折扣價
const discountedPrices = [];
for (let i = 0; i < prices.length; i++) {
  discountedPrices.push(prices[i] * 0.9);
}
// console.log(discountedPrices); -> [18, 49.5, 108]

// 需求 3: 格式化價格字串
const formattedPrices = [];
for (let i = 0; i < prices.length; i++) {
  formattedPrices.push(`$${prices[i].toFixed(2)}`);
}
// console.log(formattedPrices); -> ["$20.00", "$55.00", "$120.00"]

上面程式碼中,有個很常見的重複問題,三個 for 迴圈的處理流程幾乎一模一樣:

  1. 建立一個空陣列
  2. for 迴圈遍歷原始陣列
  3. 將處理過的元素 push 進新陣列

唯一不同的是迴圈內部那一行核心的「轉換邏輯」。

這種重複乍看之下沒什麼問題,程式也可以運作正常,但它有一點隱含的問題在於,它讓程式碼變得冗長,而且如果未來迭代的邏輯需要修改(例如,需要處理 nullundefined 的情況),我們就必須在三個地方同步修改,增加了維護成本和出錯的風險。

那要如何解決呢? 這痛點就是 map 要解決的問題,map 可以將這個重複的「迭代模式」抽象出來,變成可重複使用的工具,每次我們需要類似迴圈處理流程時,只需提供那段獨特的「轉換邏輯」即可。

手動實現一個 map 函式

現在我們來寫一個 map 函式來處理這段重複的邏輯,一個通用的 map 函式需要:

  1. 接收一個陣列 (array) 和一個轉換函數 (transform function) 作為參數。
  2. 在內部建立一個新的空陣列。
  3. 遍歷傳入的陣列中的每一個元素。
  4. 對每一個元素應用轉換函數。
  5. 將轉換後的結果推入新的陣列中。
  6. 回傳這個新的陣列。

根據這個邏輯,我們可以寫出如下的程式碼:

function customMap(array, transform) { // 1. 接收一個陣列和轉換函數作為參數
  const newArray =; // 2. 建立一個新的空陣列
  
  for (let i = 0; i < array.length; i++) { // 3. 遍歷陣列
    const originalElement = array[i];
    const transformedElement = transform(originalElement); // 4. 應用轉換函數
    newArray.push(transformedElement); // 5. 將結果推入新陣列
  }
  
  return newArray; // 6. 回傳新陣列
}

// --- 使用我們自製的 map ---
const prices = [20, 55, 120];

const pricesWithTax = customMap(prices, price => price * 1.05);
const discountedPrices = customMap(prices, price => price * 0.9);
const formattedPrices = customMap(prices, price => `$${price.toFixed(2)}`);

console.log(pricesWithTax);   // [21, 57.75, 126]
console.log(discountedPrices); // [18, 49.5, 108]
console.log(formattedPrices);  // ["$20.00", "$55.00", "$120.00"]

我們將「迭代的邏輯」(for 迴圈)與「要執行的行為」(transform 函數)分離開來。customMap 封裝了繁瑣的迴圈細節,讓我們能專注於定義轉換規則,程式碼變得既簡潔又易於維護。

JavaScript 的 Array.prototype.map() 方法

JavaScript 有一個內建的 Array.prototype.map() 方法,其用途就和我們自己手寫的 customMap 類似,此 map 方法會遍歷呼叫它的陣列中的每個元素,為每個元素呼叫一次我們提供的回呼函數 (callback function),並將每次回呼函數執行的結果蒐集起來,組成一個新的陣列回傳 。

這裡有兩個要注意的特性:

  1. 一對一映射:map 產生的新陣列,其長度必定與原始陣列完全相同。如果原始陣列有 10 個元素,map 之後的新陣列也絕對是 10 個元素。它像一個變形工廠的生產線,每個原料進去,就有一個成品出來。如下示意圖,map 函式會把輸入陣列(X)轉成輸出陣列(Y),因此我們需傳入能將 X 元素指向 Y 元素的函式(此轉換函式接受 X 元素,傳回 Y 元素)。

https://ithelp.ithome.com.tw/upload/images/20250923/20168201uxmgzhOLHD.png
圖 1 map 運作示意圖(資料來源: 自行繪製)

  1. 不可變性 (Immutability):map 不會修改 (mutate) 原始陣列。它會回傳一個全新的陣列,而原始陣列保持原封不動。這是函數式程式設計的核心原則之一,它能避免許多意想不到的副作用 (side effects),讓程式碼的行為更可預測。而為了維持不可變性,傳入 map 的函式應盡量為 Calculation,呼叫 map 的函式才會是 Calculation。

舉個例子,假設我們有一個數字陣列,想要計算每個數字的平方根:

const numbers = [4, 9, 16, 25];

// 傳遞 Math.sqrt 這個函數給 map
const roots = numbers.map(Math.sqrt);

console.log(roots);   // 輸出: [2, 3, 4, 5]
console.log(numbers); // 輸出: [4, 9, 16, 25] (原始陣列安然無恙)

可以從上述程式中看出,原始的陣列 numbers 沒有被改變,並且輸出的陣列長度 roots 也和原始陣列 numbers 長度相同 👍

reduce:陣列聚合器

如果說 map 是陣列的變形工廠,那麼 reduce 就是陣列的聚合器。它可以將一個陣列中的所有元素「濃縮」、「聚合」或「歸納」成一個單一的值。這個「單一的值」可以是任何東西:一個數字、一個字串、一個物件,甚至是一個新的陣列。

為什麼需要 reduce

map 類似,我們先從一個情境出發。假設我們有一個購物車陣列,裡面包含了多個商品物件,每個物件有價格和數量。現在我們需要完成兩個任務:

  1. 計算購物車的總金額。
  2. 計算購物車中所有商品的總數量。

使用 for 迴圈,我們會這樣做:

const cartItems = [
  { name: '鍵盤', price: 800, quantity: 1 },
  { name: '滑鼠', price: 1200, quantity: 1 },
  { name: '螢幕', price: 9500, quantity: 2 }
];

// 任務 1: 計算總金額
let totalCost = 0;
for (let i = 0; i < cartItems.length; i++) {
  totalCost += cartItems[i].price * cartItems[i].quantity;
}
console.log(totalCost); // 21000

// 任務 2: 計算總數量
let totalItems = 0;
for (let i = 0; i < cartItems.length; i++) {
  totalItems += cartItems[i].quantity;
}
console.log(totalItems); // 4

同樣地,我們看到了重複的模式。兩個迴圈都在做同一件事:

  1. 初始化一個變數
  2. 遍歷陣列,然後根據每個元素來持續更新步驟一的變數

這個「從一個初始值開始,遍歷一個列表,並將其濃縮成一個最終值」的過程,就是「化簡」或「歸納」(Reduction)。

如果我們使用一個 reduce 函式,就可以將這個通用的「化簡」模式也抽象出來,我們只需要提供「如何更新累積值」的邏輯,不須管迴圈遍歷的細節。

手動實現一個 reduce 函式

我們可以打造一個 customReduce 函數來封裝這個重複的模式。它需要接收一個陣列、一個「化簡器」函數 (reducer) 和一個初始值 (initial value)。

function customReduce(array, reducer, initialValue) {
  let accumulator = initialValue; // 1. 從初始值開始
  
  for (let i = 0; i < array.length; i++) { // 2. 用索引遍歷陣列
    const element = array[i];
    // 3. 使用 reducer 函數更新 accumulator
    accumulator = reducer(accumulator, element);
  }
  
  return accumulator; // 4. 回傳最終的累積結果
}

// --- 使用我們自製的 reduce ---
const cartItems = [
  { name: '鍵盤', price: 800, quantity: 1 },
  { name: '滑鼠', price: 1200, quantity: 1 },
  { name: '螢幕', price: 9500, quantity: 2 }
];

// 任務 1: 計算總金額
const totalCost = customReduce(
  cartItems,
  (sum, item) => sum + (item.price * item.quantity),
  0
);
console.log(totalCost); // 21000

// 任務 2: 計算總數量
const totalItems = customReduce(
  cartItems,
  (count, item) => count + item.quantity,
  0
);
console.log(totalItems); // 4

透過 customReduce,我們可以將迭代的程式碼分離出去。我們現在可以用一種更宣告式的方式來表達我們的意圖:「將 cartItems 陣列化簡成一個總金額」或「化簡成一個總數量」,而不用關心 for 迴圈的細節。

JavaScript 的 Array.prototype.reduce()

JavaScript 有一個內建的 Array.prototype.reduce() 方法,其用途就和我們自己手寫的 customReduce 類似,它會對陣列中的每個元素執行一個由我們提供的「化簡器」(reducer) 函數,並將其結果逐項累積,最終回傳一個單一的結果值。
最常見的例子就是加總一個數字陣列:

const numbers = [10, 20, 20];

const sum = numbers.reduce((accumulator, currentValue) => {
  return accumulator + currentValue;
});

console.log(sum); // 50

reduce 的關鍵元素:accumulatorinitialValue

reduce 有兩個核心參數:accumulator(累加器)和 initialValue(初始值)。這也是我一開始學 reduce 最困惑的地方。
reduce 的完整語法是 arr.reduce(callbackFn, initialValue)

  • callbackFn(accumulator, currentValue, currentIndex, array)
    • accumulator:累加器。它是上一次回呼函數呼叫所回傳的值。
    • currentValue:陣列中目前正在處理的元素。
  • initialValue (可選):作為第一次呼叫 callbackFnaccumulator 的初始值。

如以下示意圖,reduce 會一邊走訪陣列,一邊「累進」其中元素,最後得出一個合併完成的值。
https://ithelp.ithome.com.tw/upload/images/20250923/20168201dDFpn1Elwc.png
圖 2 reduce 運作示意圖(資料來源: 自行繪製)

一般來說會建議提供 initialValue,因為如果在空陣列上呼叫 reduce,有無 initialValue 會有結果上的差異:

  • 如果沒有提供 initialValue,會直接拋出 TypeError。因為 reduce 無法從空陣列中獲取第一個元素作為初始的 accumulator
  • 如果有提供 initialValuereduce 會安全地直接回傳 initialValue,而不會執行回呼函數。

因此一律提供 initialValue 能讓 reduce 的行為更加一致和可預測,特別是在處理可能為空的陣列時,可以避免執行時錯誤。

reduce() 可以做什麼?

reduce 的威力體現在它能將陣列轉換成任何你想要的資料結構。除了簡單的加總,它在「資料塑形」方面也非常強大,以下舉幾個應用範例。

資料分組 (Grouping Data)

reduce 可以將一個扁平的陣列,根據某個屬性重新組織成一個巢狀的物件結構。
舉例來說,我們可以用來將學生列表按照他們的成績分組。

const students = [
  { name: 'Alice', grade: 'A' },
  { name: 'Bob', grade: 'B' },
  { name: 'Charlie', grade: 'A' },
  { name: 'David', grade: 'C' },
  { name: 'Eve', grade: 'B' }
];

const groupedByGrade = students.reduce((groups, student) => {
  const { grade } = student; // 取得學生的成績
  
  // 如果這個成績的分類還不存在,就建立一個空陣列
  if (!groups[grade]) {
    groups[grade] = [];
  }
  
  // 將當前學生推進對應的分類陣列中
  groups[grade].push(student);
  
  return groups;
}, {});

console.log(groupedByGrade);
/*
{
  A: [ { name: 'Alice', grade: 'A' }, { name: 'Charlie', grade: 'A' } ],
  B: [ { name: 'Bob', grade: 'B' }, { name: 'Eve', grade: 'B' } ],
  C: [ { name: 'David', grade: 'C' } ]
}
*/

計算購物車總價

這是 reduce 的典型應用場景:處理一個物件陣列,並根據其屬性進行計算。

const cartItems = [
  { name: '鍵盤', price: 800, quantity: 1 },
  { name: '滑鼠', price: 1200, quantity: 1 },
  { name: '螢幕', price: 9500, quantity: 2 }
];

const totalCost = cartItems.reduce((total, item) => {
  return total + (item.price * item.quantity);
}, 0); // 初始總價為 0

console.log(totalCost); // 21000 (800 + 1200 + 19000)

陣列扁平化 (Flattening an Array)

將一個二維陣列「攤平」成一維陣列,是 reduce 的另一個常見用途。

const nestedArray = [
  [1, 2],
  [3, 4],
  [5, 6]
];

const flatArray = nestedArray.reduce((flat, currentArray) => {
  return flat.concat(currentArray);
}, []); // 初始值是一個空陣列

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

雖然現在 JavaScript 內建了更方便的 Array.prototype.flat() 方法,但透過 reduce 來實現有助於深入理解其運作原理。

從這些例子可以看出,reduce 是處理列表資料最底層、最通用的計算模式。實際上,陣列的 mapfilter 方法本身都可以用 reduce 來實現 。

補充:不同語言的 reduce() 可能用不同名稱。
不同名稱如:

  • fold()
  • foldLeft() (代表處理陣列的方向)
  • foldRight() (代表處理陣列的方向)

小結

今天介紹了 mapreduce 這兩個 JavaScript 中的高階函式,兩者差異如下:

  • map 專門用於對陣列進行一對一的轉換,並產生一個全新的、長度相同的陣列。
  • reduce 能夠將整個陣列聚合、濃縮成任何形式的單一結果。

我們也見證了從指令式「如何做」到宣告式「做什麼」的思維轉變。這種轉變讓我們能夠撰寫出意圖更明確、更少錯誤、更易於推理的程式碼,接著會再介紹 Currying(柯里化),繼續更深入理解 FP 程式設計~


上一篇
[Day 08] First-Class Functions 和 Higher-Order Functions (1):簡介與 forEach
系列文
30 天的 Functional Programming 之旅9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言