本系列內容是基於 Eloquent JavaScript 4th edition (2024) 所整理的精簡筆記。
There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies.──C.A.R. Hoare, 1980 ACM Turing Award Lecture

本文探討了高階函數在軟體設計中的重要性,介紹了一些常用的高階函數操作,如:filter、map、reduce 等,並以處理 Unicode 字符資料集的實際範例說明了它們的應用。
基礎概念
1. 程式的複雜性
1.1 規模與錯誤
大型程式不僅開發時間長,更重要的是會越來越複雜。當程式變得複雜,工程師就更容易感到困難。因而就會不小心增加更多錯誤(bug)在程式裡,至於要如何解決這些問題,我們可以先來探討程式表達方式:
1.2 程式表達方式
- 直接實現
- 完全自給自足
- 所有步驟清晰可見
- 較低層次實現
- 包含許多實作細節
javascriptlet total = 0, count = 1; while (count <= 10) { total += count; count += 1; } console.log(total); - 使用抽象函數
- 使用外部函數
- 程式碼更簡潔
- 使用更貼近問題本質的詞彙
- 隱藏了實作細節
javascriptconsole.log(sum(range(1, 10)));
兩種方式比較
| 面向 | 直接實現 | 使用抽象函數 |
|---|---|---|
| 程式長度 | 6 行 | 1 行 |
| 可讀性 | 需要理解實作細節 | 直觀表達目的 |
| 維護性 | 修改需要理解所有細節 | 只需關注高層邏輯 |
| 重用性 | 較難重用 | 容易重用 |
| 抽象層次 | 低(循環和計數) | 高(範圍和求和) |
2. 抽象的概念
2.1 什麼是抽象?
抽象是一種「簡化複雜事物」的方法,透過將問題提升到更高的層次來討論,可以隱藏不必要的細節,專注於重要的部分。
2.2 抽象實例
以豌豆湯食譜作為比喻就是:
- 詳細版本:逐步詳細說明每個動作(低層次方式)
將每人一杯乾豌豆放入容器中,加入水,直到豌豆完全浸泡,將豌豆浸泡在水中至少 12 小時。將豌豆從水中取出,放入烹飪鍋中,每人加入 4 杯水,蓋上鍋蓋,將豌豆燉煮 2 小時。每人取半個洋蔥,用刀切成塊。加入豌豆中,每人取一根芹菜,用刀切成塊,加入豌豆中。每人取一根胡蘿蔔,用刀切成塊,加入豌豆中。繼續烹飪 10 分鐘。 - 抽象版本:使用更高層次的指令(高層次方式)
每人:一杯干豌豆、4 杯水、半个切碎的洋葱、一根芹菜和一根胡萝卜。 將豌豆浸泡 12 小時。炖煮 2 小時。切碎並加入蔬菜。繼續烹飪 10 分鐘。
第二個顯然更短也更容易看懂,但需要理解更多烹飪相關詞語(如:浸泡、燉煮等等)。在寫程式的過程中我們也不能期待所有需要的詞語都能掌握,所以我們常常會陷入第一個食譜的模式──逐一敲出程式執行的步驟,而忽略了很多程式其實可以直接用抽象去表達。
這種抽象的概念在程式設計中非常重要,它能幫助我們建立更清晰、更容易維護的程式碼,同時也減少了出錯的機會。就像食譜一樣,好的抽象可以讓複雜的任務變得更容易理解和執行。
小結
抽象化能簡化複雜性、提高可維護性、提升開發效率。
高階函數
1. 定義
高階函數是符合以下任一條件的函數:
- 接受一個或多個函數作為參數
- 返回一個函數作為結果
1.1 函數作為參數
function doTwice(func, value) {
return func(func(value));
}
1.2 返回參數
function multiplier(factor) {
return function(x) {
return x * factor;
};
}
2. 常見形式
高階函數能夠對操作而不僅僅是值進行抽象,有如下幾種抽象形式:
2.1 創建新函數
// 判斷是否比 n 大
function greaterThan(n) {
return m => m > n;
}
let greaterThan10 = greaterThan(10);
console.log(greaterThan10(11));
// → true
2.2 改變其他函數
// 在函式執行前後增加 log
function noisy(f) {
return (...args) => {
console.log("calling with", args);
let result = f(...args);
console.log("called with", args, ", returned", result);
return result;
};
}
noisy(Math.min)(3, 2, 1);
// → calling with [3, 2, 1]
// → called with [3, 2, 1] , returned 1
2.3 提供新型控制流
// 1. unless 函數:與 if 相反的控制結構
function unless(test, then) {
if (!test) then();
}
// 2. repeat 函數:自定義的循環結構
function repeat(times, action) {
for (let i = 0; i < times; i++) {
action(i);
}
}
// 使用示例
repeat(3, n => {
unless(n % 2 == 1, () => {
console.log(n, "is even");
});
});
3. 主要的高階函數
3.1 filter(過濾)
filter() 方法會建立一個新的陣列,其中包含通過測試函式的所有元素。簡單來說,它就像一個篩子,只保留我們想要的元素。
- 手動實現
javascript
const numbers = [1, 2, 3, 4, 5, 6]; function filter(array, predicate) { let result = []; for (let item of array) { if (predicate(item)) { result.push(item); } } return result; } console.log(filter(numbers, num => num % 2 === 0)); // → [2, 4, 6] - 內建方法
javascript
const numbers = [1, 2, 3, 4, 5, 6]; console.log(numbers.filter(num => num % 2 === 0)); // → [2, 4, 6]
map(映射)
map() 方法會建立一個新的陣列,其中包含原陣列中的每個元素經過指定函式處理後的結果。簡單來說,它將每個元素都轉換成新的形式。
- 手動實現
javascript
const numbers = [1, 2, 3, 4]; function map(array, transform) { let result = []; for (let item of array) { result.push(transform(item)); } return result; } console.log(map(numbers, num => num * 2)); // → [2, 4, 6, 8] - 內建方法
javascript
const numbers = [1, 2, 3, 4]; console.log(numbers.map(num => num * 2)); // → [2, 4, 6, 8]
reduce(歸納)
reduce() 方法會將陣列中的所有元素歸納為單一值。它從左到右處理每個元素,並將處理結果傳遞給下一次的計算。
- 手動實現
javascript
const numbers = [1, 2, 3, 4]; function reduce(array, combine, start) { let current = start; for (let item of array) { current = combine(current, item); } return current; } console.log(reduce(numbers, (a, b) => a + b, 0)); // → 10 - 內建方法
javascript
const numbers = [1, 2, 3, 4]; const sum = numbers.reduce((acc, curr) => acc + curr, 0); console.log(sum); // → 10
實際應用──Unicode腳本分析
本章節將介紹如何運用前面學到的高階函數來分析 Unicode 腳本資料集。
1. Unicode 腳本資料集介紹
Unicode 腳本資料集(SCRIPTS)是一個包含了世界上各種書寫系統資訊的集合。每個腳本對象包含以下重要屬性:
const SCRIPTS = [
// ... 其他腳本
{
name: "Han",
ranges: [[11904, 11930], [11931, 12020], [12032, 12246], [12293, 12294], [12295, 12296], [12321, 12330], [12344, 12348], [13312, 19894], [19968, 40939], [63744, 64110], [64112, 64218], [131072, 173783], [173824, 177973], [177984, 178206], [178208, 183970], [183984, 191457], [194560, 195102]],
direction: "ltr",
year: -1100,
living: true,
link: "https://en.wikipedia.org/wiki/Chinese_characters"
},
// ... 其他腳本
];
- name:腳本的名稱(如:漢字、拉丁文等)
- ranges:該腳本在 Unicode 中的編碼範圍
- direction:書寫方向(ltr:左到右,rtl:右到左,ttb:上到下)
- year:該腳本最早的使用年份(負數表示西元前)
- living:表示該腳本是否仍在現代使用
- link:相關維基百科連結
2. 函數組合的實際應用
讓我們看看如何運用函數組合來分析這些資料:
2.1 查找活躍腳本
使用 filter 和 map 的組合找出現今仍在使用的腳本名稱:
console.log(SCRIPTS.filter(script => script.living).map(script => script.name));
// → ["Adlam", "Arabic", "Armenian", ...]
- 首先用
filter篩選出living為true的腳本 - 然後用
map提取這些腳本的名稱
2.2 查找字幅最多的腳本
組合 reduce 來計算和比較字符數量:
function characterCount(script) {
return script.ranges.reduce((count, [from, to]) => {
return count + (to - from);
}, 0);
}
console.log(SCRIPTS.reduce((biggest, current) => characterCount(biggest) < characterCount(current) ? current : biggest));
// → {name: "Han", …}
- 首先建立
characterCount函式,以reduce計算每個腳本的字符總數 - 再來比較由
characterCount回傳的每個腳本的字符總數 - 得出最大的字符腳本:漢字
2.3 比較腳本平均年份
要分別找到活躍及死亡腳本,並計算出平均起源年份,可以結合 filter、map 和 reduce 來計算平均值:
function average(array) {
return array.reduce((a, b) => a + b) / array.length;
}
// 活躍腳本平均年份
console.log(Math.round(average(
SCRIPTS.filter(s => s.living).map(s => s.year))));
// → 1165
// 死亡腳本平均年份
console.log(Math.round(average(
SCRIPTS.filter(s => !s.living).map(s => s.year))));
// → 204
- 首先建立
average函式,以reduce計算陣列加總的平均 - 再來分別使用
filter過濾活躍及死亡的起始年份,並使用map回傳起始年份,代入average中 - 得出平均起始年代,活躍腳本為 1165 年;死亡腳本為 204 年。顯而易見,我們發現死亡腳本平均比存活的腳本更老。
總結
本文從基礎的程式複雜性問題出發,介紹了高階函數的概念和應用,說明了抽象化的重要性。透過函數組合的方式,展示了如何優雅地組織程式邏輯,並以 Unicode 腳本分析為例,實際演示了如何運用 filter、map、reduce 等高階函數來解決複雜的數據處理問題。
關鍵要點
- 高階函數能有效降低程式的複雜度
- 合理的抽象化可以提升代碼的可讀性
- 適當的函數組合能讓程式邏輯更清晰
以上內容是基於 5. Higher-Order Functions - Eloquent JavaScript 4th edition (2024) 所整理的精簡筆記。