Functional Programming 處理巢狀資料的函數式工具章節導讀

2025年7月26日16 分鐘閱讀

本文為簡約的軟體開發思維:用 Functional Programming 重構程式 以 Javascript 為例多條這本書中的第14章處理巢狀資料的函數式工具導讀:

讀書會導讀簡報

Functional Tools for Nested Data

讀書會導讀簡報

文字版本

第14章:處理巢狀資料的函數式工具

導讀摘要

本章介紹了如何運用函數式程式設計的工具來處理巢狀資料結構。從基本的物件屬性操作,到深度巢狀資料的處理,以及遞迴的應用,本章提供了一套完整的解決方案。

14.1 用高階函式處理物件內的值

在函數式程式設計中,我們不僅要處理陣列,更要學會處理物件(hashmap)中的值。本章將建立高階函式來操作物件中的數值,讓程式碼更加簡潔和可維護。

14.2 讓屬性名稱變顯性

程式碼異味的識別

考慮以下兩個函式:

1function incrementQuantity(item) {
2 var quantity = item["quantity"];
3 var newQuantity = quantity + 1;
4 var newItem = objectSet(item, "quantity", newQuantity);
5 return newItem;
6}
7
8function incrementSize(item) {
9 var size = item["size"];
10 var newSize = size + 1;
11 var newItem = objectSet(item, "size", newSize);
12 return newItem;
13}

這兩個函式幾乎完全相同,唯一的差別在於它們操作的屬性名稱。這種重複的程式碼模式就是所謂的「程式碼異味」。

重構:將隱性引數轉為顯性

我們可以將屬性名稱從函式名稱中提取出來,變成一個參數:

1function incrementField(item, field) {
2 var value = item[field];
3 var newValue = value + 1;
4 var newItem = objectSet(item, field, newValue);
5 return newItem;
6}

這樣的重構讓函式變得更加通用,可以處理任何我們傳入的 field 屬性。

14.3 實作更新物件內屬性值的 update()

當我們對多個操作(increment、decrement、double、halve)進行相同重構後,又會出現新的重複模式。這時我們需要一個更強大的工具:update() 函式。

1function update(object, key, modify) {
2 var value = object[key]; // 取得屬性值
3 var newValue = modify(value); // 修改屬性值
4 var newObject = objectSet(object, key, newValue); // 設定新物件
5 return newObject;
6}

這個函式接受三個參數:

  1. object:要操作的目標物件
  2. key:要更新的屬性名稱
  3. modify:一個回呼函式,接收屬性的舊值並回傳新值

14.4 以 update() 修改物件屬性

實際應用範例

假設某公司將員工資訊存放在物件中:

1var employee = {
2 name: "Kim",
3 salary: 120000,
4};

人事部門想為該員工加薪10%,可以這樣實作:

1function raise10Percent(salary) {
2 return salary * 1.1;
3}
4
5var updatedEmployee = update(employee, "salary", raise10Percent);

不可變性的重要性

update() 不會修改原始物件,而是回傳一個新的物件。這體現了函數式程式設計中「不可變性」的重要原則。

如果需要儲存變更,可以這樣做:

1employee = update(employee, "salary", raise10Percent);

14.5 重構:以 update() 取代「取得、修改、設定」

重構步驟

  1. 辨識「取得、修改、設定」程式段落
  2. 利用 update() 取代以上三個段落,其中「修改」的部分為回呼

重構前後比較

重構前:

1function incrementField(item, field) {
2 var value = item[field]; // 取得
3 var newValue = value + 1; // 修改
4 var newItem = objectSet(item, field, newValue); // 設定
5 return newItem;
6}

重構後:

1function incrementField(item, field) {
2 return update(item, field, function (value) {
3 return value + 1;
4 });
5}

14.6 函數式工具 update()

update() 是另一個重要的函數式工具,專門用於操作物件(視為 hash map)。它將「如何更新」的邏輯從「更新哪個物件」中抽離出來,讓我們可以專注在資料的轉換上。

14.7 將 update() 的行為視覺化

讓我們透過一個範例來理解 update() 的運作過程:

1var shoes = {
2 name: "shoes",
3 price: 7,
4 quantity: 2,
5};
6
7update(shoes, "quantity", function (value) {
8 return value * 2; // 數值加倍
9});

執行步驟:

  1. 取得鍵(key)所指定的物件屬性值
  2. 呼叫回呼函式以處理前面取得的屬性值
  3. 產生具有屬性新值的物件複本

14.8 將巢狀資料的 update() 視覺化

當遇到巢狀資料時,情況變得複雜:

1var shirt = {
2 name: "shirt",
3 price: 13,
4 options: {
5 color: "blue",
6 size: 3,
7 },
8};

要修改 size 屬性,需要手動處理每一層:

1function incrementSize(item) {
2 var options = item.options; // 取得
3 var size = options.size; // 取得
4 var newSize = size + 1; // 修改
5 var newOptions = objectSet(options, "size", newSize); // 設定
6 var newItem = objectSet(item, "options", newOptions); // 設定
7 return newItem;
8}

14.9 用 update() 處理巢狀資料

我們可以用 update() 來重構巢狀資料的處理:

1function incrementSize(item) {
2 return update(item, "options", function (options) {
3 return update(options, "size", increment);
4 });
5}

14.10 實作成普適化的 updateOption()

進一步重構,將隱性引數轉為顯性:

1function updateOption(item, option, modify) {
2 return update(item, "options", function (options) {
3 return update(options, option, modify);
4 });
5}

14.11 實作兩層巢狀結構的 update2()

為了處理兩層巢狀結構,我們可以建立 update2()

1function update2(object, key1, key2, modify) {
2 return update(object, key1, function (value1) {
3 return update(value1, key2, modify);
4 });
5}

比較重構前後:

原始程式:

1function incrementSize(item) {
2 var options = item.options;
3 var size = options.size;
4 var newSize = size + 1;
5 var newOptions = objectSet(options, "size", newSize);
6 var newItem = objectSet(item, "options", newOptions);
7 return newItem;
8}

重構後:

1function incrementSize(item) {
2 return update2(item, "options", "size", function (size) {
3 return size + 1;
4 });
5}

14.12 視覺化說明 update2() 如何操作巢狀物件

路徑(Path)概念

用來定位巢狀物件中之屬性值的鍵序列,其中每一個鍵對應一個巢狀層。

例如:'options', 'size' 就是通往目標屬性的路徑。

14.13 函式 incrementSizeByName() 的4種實作方法

對於三層巢狀結構:

1var cart = {
2 shirt: {
3 name: "shirt",
4 price: 13,
5 options: {
6 color: "blue",
7 size: 3,
8 },
9 },
10};

有四種不同的實作方法:

方法1:使用 update()incrementSize()

1function incrementSizeByName(cart, name) {
2 return update(cart, name, incrementSize);
3}

方法2:使用 update()update2()

1function incrementSizeByName(cart, name) {
2 return update(cart, name, function (item) {
3 return update2(item, "options", "size", function (size) {
4 return size + 1;
5 });
6 });
7}

方法3:只用 update()

1function incrementSizeByName(cart, name) {
2 return update(cart, name, function (item) {
3 return update(item, "options", function (options) {
4 return update(options, "size", function (size) {
5 return size + 1;
6 });
7 });
8 });
9}

方法4:自行實作所有「取得、修改、設定」步驟

1function incrementSizeByName(cart, name) {
2 var item = cart[name];
3 var options = item.options;
4 var size = options.size;
5 var newSize = size + 1;
6 var newOptions = objectSet(options, "size", newSize);
7 var newItem = objectSet(item, "options", newOptions);
8 var newCart = objectSet(cart, name, newItem);
9 return newCart;
10}

14.14 實作三層巢狀結構的 update3()

1function update3(object, key1, key2, key3, modify) {
2 return update(object, key1, function (object2) {
3 return update2(object2, key2, key3, modify);
4 });
5}

14.15 實作任意巢狀深度的 nestedUpdate()

觀察規律後,我們可以實作一個通用的 nestedUpdate() 函式:

1function nestedUpdate(object, keys, modify) {
2 if (keys.length === 0) return modify(object);
3 var key1 = keys[0];
4 var restOfKeys = drop_first(keys);
5 return update(object, key1, function (value1) {
6 return nestedUpdate(value1, restOfKeys, modify);
7 });
8}

14.16 安全的遞迴需要具備什麼?

1. 一定要有基本條件

要防止遞迴無限循環下去,就一定要定義不含任何遞迴呼叫的基本條件,並以此當做終點。

2. 弄清楚遞迴的條件

遞迴函式的定義中至少要包含一次遞迴條件,也就是包含了遞迴呼叫的敘述。

3. 確定函式呼叫有朝著基本條件前進

必須確保其中至少有一個引數正在「變小」,並且使得呼叫條件越來越接近基本條件。

14.17 將 nestedUpdate() 的行為視覺化

遞迴函式的呼叫堆疊結構能自然地對應巢狀資料的層級結構,讓我們可以先逐層深入(取得),處理完最內層的資料後,再逐層返回(設定)。

14.18 比較遞迴和迴圈

迴圈的處理方式

迴圈走訪陣列時,程式會從索引0開始,一邊處理其中的元素,一邊將生成的結果加到傳回陣列末端。

巢狀資料的處理方式

巢狀資料的操作方式則不一樣:我們需要先一層一層進行「取得」,接著「修改」目標屬性值,最後再循環相反方向完成每一層的「設定」。

事實上,「取得、修改、設定」的巢狀執行方式恰好反映巢狀結構,而這樣的構造很難不使用遞迴和呼叫堆疊實現。

14.19 遇到深度巢狀資料時的設計考量

nestedUpdate() 處理深度巢狀資料時,可能會遇到以下問題:

需要傳入一長串鍵作為路徑,但我們很難記得這些鍵到底指的是什麼?

例如:

1httpGet("http://my-blog.com/api/category/blog", function (blogCategory) {
2 renderCategory(
3 nestedUpdate(blogCategory, ["posts", "12", "author", "name"], capitalize),
4 );
5});

14.20 為巢狀資料建立抽象屏障

抽象屏障(Abstraction Barrier)

是有效隱藏實作細節的函式層,有了它,使用屏障中的函式時,完全不需要了解函式的底層實作。

實作範例

1function updatePostById(category, id, modifyPost) {
2 return nestedUpdate(category, ["posts", id], modifyPost);
3}
4
5function updateAuthor(post, modifyUser) {
6 return update(post, "author", modifyUser);
7}
8
9function capitalizeName(user) {
10 return update(user, "name", capitalize);
11}

結合以上,就可以得到:

1updatePostById(blogCategory, "12", function (post) {
2 return updateAuthor(post, capitalizeUserName);
3});

使用抽象屏障優點

  • 函式的名稱簡單易懂
  • 不知道鍵的名稱也能夠順利修改目標屬性

14.21 總結高階函式的應用

在走訪陣列時取代for迴圈

forEach()map()filter()、與reduce(),皆是能操作陣列的高階函式。你可以將它們串連成鏈,以實現更複雜的計算。

有效處理巢狀資料

要改變巢狀資料中的值非常麻煩,不僅需進行多次「取得」,還得對每一層的物件做「寫入時複製」。為此,我們實作了 update()nestedUpdate() — 無論巢狀結構有多深,這兩個高階函式都能精準更改指定屬性值。

套用「寫入時複製」

「寫入時複製」的步驟是固定的(即:產生複本、修改複本、傳回複本),故實作時可能產生重複程式碼。但只要使用 withArrayCopy()withObjectCopy(),就能把任意操作協調成「寫入時複製」版本。

將 try/catch 敘述標準化

我們曾寫過名為 wrapLogging() 的高階函式:其可接受任意函式f,並傳回功能和「相同,但多了 try/catch 錯誤捕捉能力的新函式。

重點整理

  • 函數式工具 update() 能修改物件中的指定屬性值,為我們免去手動取得屬性值、修改,然後再將結果設定回物件中的麻煩。

  • nestedUpdate() 能處理深度巢狀資料。當知道目標屬性值在巢狀物件中的路徑(由一系列的「鍵」構成)時,就能用此函數式工具輕鬆修改該值。

  • 一般來說,迴圈比遞迴容易理解。但面對巢狀資料時,遞迴則比較好用。

  • 函式在呼叫自己之前,遞迴會利用函式呼叫堆疊來追蹤目前進度,這使得遞迴函式的構造能反映巢狀資料結構。

  • 深度巢狀結構會造成理解困難上的不便。要操作此類資料,你必須記得每一巢狀層的資料結構為何。

  • 可以為關鍵資料結構設計抽象屏障,藉此降低需要記憶的細節。

練習題

練習 14-1

使用 update()email 屬性的值全部轉換為小寫。

1var user = {
2 firstName: "Joe",
3 lastName: "Nash",
4 email: "JOE@EXAMPLE.COM",
5};
6
7// 解答
8update(user, "email", lowercase);

練習 14-2

實作 tenXQuantity() 函式,將商品物件中的數量屬性值乘以10。

1function tenXQuantity(item) {
2 return update(item, "quantity", function (quantity) {
3 return quantity * 10;
4 });
5}

練習 14-5

使用 nestedUpdate() 實作 incrementSizeByName()

1function incrementSizeByName(cart, name) {
2 return nestedUpdate(cart, [name, "options", "size"], function (size) {
3 return size + 1;
4 });
5}

結論

本章介紹了處理巢狀資料的完整工具集,從基本的 update() 到通用的 nestedUpdate(),以及如何運用遞迴和抽象屏障來處理複雜的資料結構。這些工具讓我們能夠以函數式的方式優雅地處理各種巢狀資料,同時保持程式碼的可讀性和可維護性。

在實際的前端開發中,這些概念特別有用,例如:

  • 處理複雜的 API 回應資料
  • 管理應用程式狀態
  • 渲染巢狀的 UI 元件
  • 處理樹狀結構的資料

透過這些函數式工具,我們可以寫出更簡潔、更可預測的程式碼。