iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png
今天要介紹fp-ts/Tree模組。

Tree

樹是一種常見的圖形,由節點(node)和邊(edge)構成,樹會有一個根節點,根節點連著0或多棵子樹,因此樹的資料結構通常用遞迴定義,沒有子樹的節點稱為葉子(leaf)。fp-ts/Tree模組的樹是可以擁有多個子節的樹資料結構,它的介面定義如下:

export type Forest<A> = Array<Tree<A>>

export interface Tree<A> {
 readonly value: A
 readonly forest: Forest<A>
}

建構一顆樹的資料結構有以下幾個方法:

直接定義

我們可以直接按照介面的定義建構樹的物件。

// Direct construction
const numberTree: Tree<number> = {
  value: 1,
  forest: [
    { value: 2, forest: [] },
    { value: 3, forest: [
      { value: 4, forest: [] },
      { value: 5, forest: [] }
    ]}
  ]
}

make函數

fp-ts也提供了make函數建立單一節點的樹,make有二個參數,第一個參數是節點的值;第二個參數是子樹陣列,如果沒有提供第二個參數,預設為空陣列,我們也可以用make函數建構一模一樣。

const numberTree: Tree<number> = make(1, [
  make(2),
  make(3, [
    make(4),
    make(5)
  ])
])

unfoldTree函數

我們也可以透過unfoldTree函數遞迴建立一棵樹,unfoldTree函數的型別簽名如下:

unfoldTree: <A, B>(b: B, f: (b: B) => [A, Array<B>]): Tree<A>

unfoldTree函數需要二個參數,第一個參數是型別B的種子值(seed),第二個參數是節點生成函數,這個函數要回傳一個tuple,tuple的第一個值是樹節點的值,第二個值是型別B的種子值陣列,unfoldTree會根據這個陣列的種子值生成一個樹的陣列。下面的程式碼用unfoldTree生成一棵二元樹,每個節的值是所有子樹節點值的和,而每個子樹的節點值大約是父節點值的一半。這個函數的演算法必須要設立函數停止條件,也就是傳回空的種子值陣列。

const binaryTreeUnfold = T.unfoldTree(
  10, // seed value
  (n: number): [number, number[]] => {
    if (n <= 1) {
      return [n, []] // leaf node
    }
    const left = Math.floor(n / 2)
    const right = n - left
    return [n, [left, right]] // node with two children
  }
)

Tree Monad

Tree模組中當然也提供了map,ap,flatMap(chian)和of這幾個作為Monad類別必要的函數,map、flatMap和of的使用其它Monad沒有差異,就不浪費篇幅。Tree和Array一樣,存放不只一個值,因此它的ap的效果比較特別。我們先建立一個number型別的樹numberTree和一個只有根元素的數學函數樹mathFunctionsTree,看看pipe(mathFunctionsTree, ap(numbersTree))的結果。

// Tree of numbers
const numbersTree = make(3, [make(7), make(12, [make(8)])]);
// Tree of functions
const mathFunctionsTree = make(
  (x: number) => x * 2,
  [
    make((x: number) => x + 10),
    make((x: number) => x - 5, [make((x: number) => x / 2)]),
  ]
);
console.log(pipe(mathFunctionsTree, ap(numbersTree)))

mathFunctionsTree樹裏的每一個函數都會作用在numberTree樹中的每個元素而得到一棵樹,所得到便放在輸出的樹中相對應的位置。例如函數(x: number) => x * 2得到的樹放置根的位置,即等同於map((x: number) => x * 2)(numberTree),而
(x: number) => x + 10和(x: number) => x - 5得到的兩棵樹放在根節點的子樹,而(x: number) => x / 2得到的樹則放置在(x: number) => x - 5所產上的樹根節點的子樹上。因為執行結果比較冗長,這邊就不列出來,讀者可以自行將程式碼測試檢驗結果。

reduce, fold, foldMap

reduce,fold在Functional Programming互為別名(alias),因此功用是一樣的,在Array模組中只有reduce函數,在Tree模組中fold和reduce的使用方法略有不同,下面將這三個方法做說明。

reduce, reduceRight

reduce的使用彈性最大,它的使用方法和Array的reduce一樣,但是樹結構的深度優先遍歷(Depth-First Traversal DFT)有Preorder (先序), Inorder (中序), 和 Postorder (後序)三種,我們用下列程式碼可以了解fp-ts/Tree模組中reduce和reduceRight中使用遍歷的方法,numberTree2的樹狀結構如下圖所示:
https://ithelp.ithome.com.tw/upload/images/20250926/20118113HooE4RrtLg.png

const numbersTree2 = make(1, [
  make(2, [make(4), make(5)]),
  make(3, [make(6), make(7)]),
]);

console.log(reduce([], (acc, ele) => [...acc, ele])(numbersTree2)); // left preorder [1,2,4,5,3,6,7]
console.log(reduce([], (acc, ele) => [ele, ...acc])(numbersTree2)); // right postorder [7,6,3,5,4,2,1]
console.log(reduceRight([], (ele, acc) => [...acc, ele])(numbersTree2)); // right postorder [7,6,3,5,4,2,1]
console.log(reduceRight([], (ele, acc) => [ele, ...acc])(numbersTree2)); // left preorder [1,2,4,5,3,6,7]

fold

fold函數則會將先前fold好的結果放入陣列中

import { concatAll as concatAllM } from 'fp-ts/Monoid';
import { getMonoid } from 'fp-ts/Array';

type ConcatArray = (as: readonly number[][]) => number[]
const concatArray: ConcatArray = concatAllM(getMonoid<number>());

console.log(
  fold((a: number, bs: Array<Array<number>>) => [...concatArray(bs), a])(
    numbersTree2
  )
); // left postorder [4,5,2,6,7,3,1]

console.log(
  fold((a: number, bs: Array<Array<number>>) => [a, ...concatArray(bs)])(
    numbersTree2
  )
); // left preorder [1,2,4,5,3,6,7]

我們也可以不使用Monoid類別,直接用bs.reduce來處理,但是這樣就有些畫蛇添足了。

fold

fold函數則強制使用Monoid類別,但是使用上也就加簡單。

import { MonoidSum } from 'fp-ts/number';
import { Monoid as MonoidString } from 'fp-ts/string';
console.log(foldMap(getMonoid<number>())((x: number) => [x])(numbersTree2)); // left preorder [1,2,4,5,3,6,7]
console.log(foldMap(MonoidSum)(identity<number>)(numbersTree2)); // 28
console.log(foldMap(MonoidString)((n: number) => String(n))(numbersTree2)); // '1245367'

drawTree

drawTree可以Tree<string>轉換為表示樹結構的string。

console.log(
  pipe(
    numberTree,
    map((n) => String(n)),
    drawTree2
  )
);
// 1
// ├─ 2
// │  ├─ 3
// │  └─ 4
// └─ 5
//    ├─ 6
//    └─ 7

Comonad

Comonad是Monad對偶(Dual)的類別,在Haskell中,Comonad類別必須具備extract、duplicate和extend三個函數,在fp-ts中Tree和Store實作了這三個函數。

extract和of是對偶函數,of是將值封裝於型別容器中,extract則反過來將值從型別容器中取出,extract的HM型別簽名如下,其中w是Comonad類別的型別容器:

extract :: w a -> a  // 將值從型別容器中取出

duplicate是flatten的對偶函數,它的HM型別簽名如下:

duplicate :: w a -> w (w a) // 將一個型別容器嵌套變成雙重型別容器嵌套

extend則是flatMap(chain)的對偶函數,它的HM型別簽名如下:

extend :: (w a -> b) -> w a -> w b

我們可以比較flatMap的HM型別簽名,其中m是Monad類別的型別建構子,

flatMap :: (a -> m b) -> m a -> m b

flatMap要轉換的函數是輸入為一般型別,輸出為Monad類別建構的型別;而Comonad則相反,它的輸入型別是Comonad類別建構的型別,輸出為一般型別。

接下來我們看看extend的用法,先建立isLeaf、size、sizeOfLeaves和depth四個函數。

// 判斷一棵樹是否為葉子
type IsLeaf = (t: Tree<any>) => boolean;
const isLeaf = (tree: Tree<any>) => tree.forest.length === 0;

// 計算樹所有的節點數
type Size = (t: Tree<any>) => number;
const size = reduce(0, (acc, _) => acc + 1);

// 計算樹結構中葉子的數量
type SizeOfLeaves = (t: Tree<any>) => number;
const sizeOfLeaves = <A>(tree: Tree<A>): number =>
  (isLeaf(tree) ? 1 : 0) +
  tree.forest.reduce((acc, child) => acc + sizeOfLeaves(child), 0);

// 計算樹的深度
type Depth = (t: Tree<any>) => number;
const depth = <A>(tree: Tree<A>): number =>
  tree.forest.length === 0 ? 1 : 1 + Math.max(...tree.forest.map(depth));

console.log(extend(isLeaf)(numbersTree2)); // 一棵記錄numbersTree2每個子樹節點是否為葉子的樹
console.log(extend(size)(numbersTree2)); // 一棵記錄numbersTree2每個子樹節點的總節點數
console.log(extend(sizeOfLeaves)(numbersTree2)); // 一棵記錄numbersTree2每個子樹節點的總葉子數
console.log(extend(depth)(numbersTree2)); // 一棵記錄numbersTree2每個子樹節點的深度

這四個函數的輸入都是由Tree建構的型別,符合extend的函數的第一個參數要求,轉換後的函數的輸出入都是由Tree建構的型別,而且樹的結構完全一樣。

今日小結

樹是應用非常廣泛的資料結構,在檔案系統、組織圖及HTML結構都可以看見它的身影。今天介紹了fp-ts/Tree模組中一些常用的函數,包含Monad介面中的map,ap和flatMap。由於Tree是多值的型別容器,Foldable類別中的reduce,reduceRight和foldMap也就相對重要,Tree的結構比Array複雜,這幾個函數的使用情形也比Array變化多。

Comonad的使用機會比較少,若有機會再介紹另一個Comnad類別的型別容器Store,今天分享的內容就到這邊,明天再見。


上一篇
Day 25. 打造自己的Monad
系列文
數學老師學函數式程式設計 - 以fp-ts啟航26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言