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

文字版本
第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}78function 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}
這個函式接受三個參數:
object
:要操作的目標物件key
:要更新的屬性名稱modify
:一個回呼函式,接收屬性的舊值並回傳新值
14.4 以 update() 修改物件屬性
實際應用範例
假設某公司將員工資訊存放在物件中:
1var employee = {2 name: "Kim",3 salary: 120000,4};
人事部門想為該員工加薪10%,可以這樣實作:
1function raise10Percent(salary) {2 return salary * 1.1;3}45var updatedEmployee = update(employee, "salary", raise10Percent);
不可變性的重要性
update()
不會修改原始物件,而是回傳一個新的物件。這體現了函數式程式設計中「不可變性」的重要原則。
如果需要儲存變更,可以這樣做:
1employee = update(employee, "salary", raise10Percent);
14.5 重構:以 update() 取代「取得、修改、設定」
重構步驟
- 辨識「取得、修改、設定」程式段落
- 利用
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};67update(shoes, "quantity", function (value) {8 return value * 2; // 數值加倍9});
執行步驟:
- 取得鍵(key)所指定的物件屬性值
- 呼叫回呼函式以處理前面取得的屬性值
- 產生具有屬性新值的物件複本
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}45function updateAuthor(post, modifyUser) {6 return update(post, "author", modifyUser);7}89function 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};67// 解答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 元件
- 處理樹狀結構的資料
透過這些函數式工具,我們可以寫出更簡潔、更可預測的程式碼。