容器的妙用

Typescript Functional Programming fp-ts

hero image

學習 Functional Programming 時,你是否總是對 FunctorMonad 這兩個詞產生困惑,你不是第一個也不會是最後一個,這確實是大部分人學習 FP 的一道檻,但是如果從數學角度切入對我們一般人而言實在是太痛苦了,所以本篇從最簡單的容器帶你走過整個演變過程,並且去識別開發中最讓人頭痛的部份,以及一個特殊容器如何解決這些問題。

容器

在識別一個 Functor / Monad 之前,首先我們來談一個最單純的容器型別,這裡我們叫他 Container

type Container<T> = { value: T }

of

這個容器包裹著一個值,這個值是什麼取決於用的人,但既然是容器我們肯定還需要有一個把他放進去的動作,不然其實沒什麼用,所以我們會有一個 of 方法:

function of<T>(value: T): Container<T> {
  return {
    value: value
  }
}

map

一切看起來很美好,但我們會遇到一個問題,如果我有一個 function 只接受一個純值也回傳一個純值,我遇到 Container<T> 我該怎麼辦 ?

const containerX: Container<number> = of(1);

const increase = (x: number) => x + 1

increase(containerX) // 被包裹住無法計算 

我必須得把值從容器內拿出來做計算,並且因為他原本就在容器內所以我們還要把他放回去

const containerX: Container<number> = of(1);

const increase = (x: number) => x + 1;

const value = containerX.value; // 拿出來

const result = increase(value); // 執行函式

const containerY = of(result); // 裝回去

或者修改你的 function

const containerX: Container<number> = of(1);

// 被迫更改為容器版本
const increase = (containerX: Container<number>): Container<number> => {
  return of(containerX.value + 1);
}

const result = increase(containerX)

好了我們解決了因為在容器內無法計算的問題,但你會發現你一直在做這重複又無聊的動作,甚至他把你原本純淨無暇的 code 搞的髒髒的,這當然不能忍!程式必須維持優雅!

我們剛剛說這是一個 拿出來 → 計算 → 放回去 的重複的動作,那我們就可以把他做成一個通用的 function 叫做 map

function map<T, U>(container: Container<T>, fn: (a: T) => U): Container<U> {
    return of(fn(container.value));
}

現在你可以把他寫成這樣

const containerX: Container<number> = of(1);

const increase = (x: number) => x + 1;

const result = map(containerX, increase);

好,到目前為止我們有了三樣東西,Container, of, map ,這三樣東西的組成我們可以將他粗略的視為 Functor

為什麼說是粗略呢?因為還有一些的條件要達成

  • Identity : map(fa, a ⇒ a) = fa

    const fa: Container<number> = of(1);
    
    const id = (v) => v; 
    
    // 兩個動作的結果皆相等
    map(fa, id) // Container<1>
    id(fa) // Container<1>
    
  • Composition:map(fa, (a) ⇒ g(h(a))) = map(map(fa, h), g)

    const fa: Container<number> = of(1);
    
    // h
    const double = (v) => v * 2;
    
    // g
    const square = (v) => v * v; 
    
    // 兩個動作的結果皆相等
    map(fa, (a) => square(double(a))) // Container<4>
    map(map(fa, double), square) // Container<4>
    

相關連結

flatMap

現在 map 處理了一般 function 無法對容器做計算的問題,但另一個問題來了,如果我的function 是接收一個值返回一個容器呢?

const double = (x: number): Container<number> => of(x * 2) 

我們嘗試用 map 執行看看,你會發現你會得到一個多疊一層容器的結果 Container<Container<number>>

const containerX = of(1)

// Container<Container<number>>
const result = map(containerX, double)

如果你又想做第二次 double,你不僅要多套一層 map 上去,而且結果變成三層的容器!如果想做更多次就會越套愈多,最後就是無止盡的套層。

const containerX = of(1)

// Container<Container<number>>
const result = map(containerX, double)

// Container<Container<Container<number>>>
const result2 = map(result1, (c) => map(c, double));

現在我們的程式又被這套層弄的亂糟糟的,那我們要優雅的繼續 map 下去我們該怎麼做?

那就是在 map 結束後把他解開永遠維持一層,所以我們製作一個叫 flatten 的 function 幫我們做這件事

function flatten<T>(container: Container<Container<T>>): Container<T> {
  return container.value;
}

現在你可以

const containerX = of(1)

// Container<Container<number>>
const result = map(containerX, double)

// Container<number>
const result2 = flatten(result)

// Container<Container<number>>
const result3 = map(result2, double);

// Container<number>
const result4 = flatten(result3)

似乎乾淨許多,但好像還不夠,每次都要自己解開實在是太繁瑣了,不如我們把 flattenmap 組合在一起叫做 flatMap

function flatMap<T, U>(container: Container<T>, fn: (a: T) => Container<U>): Container<U> {
  return flatten(map(container, fn));
}

現在你只要遇到 function 是傳入一個值返回一個容器,你就可以使用 flatMap 操作。

我們除了 Container, of, map,現在又多了一個函式叫 flatMap

所以我們可以將 Container, of, flatMap 三樣東西的組合粗略的視為 Monad

等等,為什麼是三個? map 去哪了?為什麼又是粗略呢?

map 去哪了?

因為 flatMapmap 構成,其實可以不用特別寫出來,因為由於有 map 關係,所以今天一個 Monad 會同時擁有 Functor 特性

為什麼又是粗略呢?

除了 Functor 嚴格上還有一些條件要達成,Monad 也有,你可以 follow Haskell 的 Monad laws

  • Left identity:flatMap(of(a), f) = f(a)

    const fn = (x: number) => of(x * 2);
    
    // 兩個動作的結果皆相等
    flatMap(of(1), fn) // Container<2>
    fn(1) // Container<2>
    
  • Right identity:flatMap(fa, of) = fa

    const fa: Container<number> = of(1);
    
    // 兩個動作的結果皆相等
    flatMap(fa, of) // Container<1>
    fa // Container<1>
    
  • Associativity:flatMap(flatMap(fa, g), h) = flatMap(fa, (a) ⇒ flatMap(g(a), h))

    // g
    const double = (x: number) => of(x * 2)
    
    // h
    const square = (x: number) => of(x * x)
    
    const fa = of(1);
    
    // 兩個動作的結果皆相等
    flatMap(flatMap(fa, double), square)
    flatMap(fa, (a) => flatMap(double(a), square))
    

相關連結


是什麼真正讓你的程式變髒

在 FP 的理想中,我們希望所有事情都是正確無負擔跑完所有流程,那當一個程式是完全 pure 的時候就能達成這個理想,並且你的程式也會乾乾淨淨的(除非連 pure function 都的寫亂七八糟 🤡),但是一個完全 pure 的程式其實沒什麼作用,你必須跟外界溝通,跟外界溝通就會有溝通不良的問題(現實也是如此)。

溝通不良會有什麼問題,不是沒值就是錯誤這兩種,這時你就必須將這些狀況寫在你潔淨無暇的程式裡,這裡判斷一下空值,那裡處理一下錯誤,不知不覺你的程式變得越來越糟糕,你也越來越看不懂你的程式。

容器?

回到一開始提到的容器,它有用嘛?我很直接的告訴你它沒什麼用,但是今天我們製作一種特殊用途的容器它就會變得有意義,所有容器都會固定有個 拿出來 → 計算 → 放回去 的動作,所以一個容器的可以在 拿出來 → 計算 → 放回去 的過程中根據它的用途做不同的特殊操作。

空值處裡

回想一下我們在 function 中處理空值都怎麼處理,要嘛判斷完再丟進來,要嘛直接改寫function 讓它判斷空值:

判斷完再丟進去

const value: number | undefined = undefined;

const double = (x: number) => x * 2;

if(value !== undefined){
  const result = double(value);

  // ...
}

//...

直接改寫 function

const value: number | undefined = undefined;

const double = (x: number | undefined) => {
  return x !== undefined ? x * 2 : undefined
};

const result = double(value)

// ...

你發現不管哪一種都避免不了這不是很優雅的判斷式,某種程度上也造成了閱讀上的困難(你可能也是?),那有沒有什麼招能讓這東西變得好看一點 ?

Option / Maybe

Option 跟 Maybe 在表示一個可能有可能無的容器,通常我們都是有值的時候 function 才有執行的意義,沒值會維持原樣提早離開,而 Option / Maybe 就是在 拿出來 → 計算 → 放回去 的途中做這些判別

以下是一個簡易 Option 實做

type Some<T> = { _tag: 'some'; value: T };
type None<T> = { _tag: 'none' };
type Option<T> = Some<T> | None<T>;

function none<T>(): None<T> {
  return {
    _tag: 'none',
  };
}

function of<T>(value: T): Option<T> {
  return {
    _tag: 'some',
    value,
  };
}

function map<T, U>(option: Option<T>, fn: (v: T) => U): Option<U> {
  if (option._tag === 'some') {
    return of(fn(option.value));
  }

  return option;
}

function flatten<T>(option: Option<Option<T>>): Option<T> {
  if (option._tag === 'some') {
    return option.value;
  }
  return option;
}

function flatMap<T, U>(option: Option<T>, fn: (v: T) => Option<U>): Option<U> {
  if (option._tag === 'some') {
    return flatten(map(option, fn));
  }

  return option;
}

你可比較優雅的做完你要的操作

const value: Option<number> = of(2);

const double = (x: number) => x * 2;

const result = map(value, double);

// ...

甚至串接多個操作也不必理會容器裡有沒有值

const value: Option<number> = of(2);

const double = (x: number) => x * 2;

const square = (x: number) => x * x;

const evenNumber = (x: number) => x % 2 === 0 ? of(x) : none() 

const result = map(value, double);

const result2 = map(result, square);

const result3 = flatMap(result2, evenNumber);

// ...

錯誤處理

除了空值我們還有錯誤處裡,我們平常都會用 try catch 來處理錯誤

const divide = (a: number, b: number) => {
  if (b === 0) {
    throw new Error('Cannot divide by zero');
  }
  return a / b;
};

function fn(){

  try {
    const result = divide(10, 0);
    // ... 
  } catch(e) {
    console.log(e);
    // ...
  }
}

那這方法有什麼問題嘛?

沒問題,他是對的,但他有兩個缺點

  • 你怎麼知道什麼時候要 try catch 什麼時候不用,今天我們用 try catch 是建立在我們知道我所使用的 function 會丟出錯誤,那每個人都必須到每個 function 原始碼看看到底有沒有機會丟出錯誤,並且 typescript 也不會在你使用的時候告訴你這件事。

  • 這種方法也使我們的 code 變得髒亂,造成閱讀上的困難,typescript 也不會告訴你錯誤的形式到底是什麼,你只能一個一個對照。

Either

Either 用於表示可能對也可能錯的一種容器,對就是 Right 錯就是 Left,但他的運作方式跟Option / Maybe 相似,只是多了個用於表示錯誤的型別,但在型別處理上要複雜許多。

以下是簡易 Either 實作

type Right<T> = { _tag: 'right'; value: T };
type Left<T> = { _tag: 'left'; error: T };
type Either<E, T> = Right<T> | Left<E>;

function left<T>(error: T): Left<T> {
  return {
    _tag: 'left',
    error,
  };
}

function of<T>(value: T): Right<T> {
  return {
    _tag: 'right',
    value,
  };
}

function map<E, T, U>(either: Either<E, T>, fn: (a: T) => U): Either<E, U> {
  if (either._tag === 'right') {
    return of(fn(either.value));
  }
  return either;
}

function flatten<E, E2, T>(either: Either<E, Either<E2, T>>): Either<E | E2, T> {
  if (either._tag === 'right') {
    return either.value;
  }
  return either;
}

function flatMap<E, T, E2, U>(
  either: Either<E, T>,
  fn: (a: T) => Either<E2, U>
): Either<E | E2, U> {
  if (either._tag === 'right') {
    return flatten(map(either, fn));
  }

  return either;
}

那 Either 如何解決了上述問題?

  • 明確表明了這是一個有可能會出錯的結果,迫使開發者去處裡。

  • 不會過度破壞主流程,使閱讀上更容易,並且明確表示錯誤型別是什麼

const divide = (a: number, b: number): Either<string, number> => {
  if (b === 0) {
    return left('Cannot divide by zero');
  }
  return of(a / b);
};

function fn(){

  // 可以從 result 中得知這是個可能錯誤的結果,並且明確表明錯誤型別
  const result: Either<string, number> = divide(10, 0);

  // 減少對主流程的破壞,使閱讀更加容易
  const result2 = map(result, (x: number) => x * 2)
}

經過上述解說我們可以得知幾件事

  • 單純的容器本身沒什麼用處,但透過設計一個特殊容器能解決平時開發上繁瑣但你沒察覺的事。

  • 一個特定用途的容器可以促使開發者意識到該結果可能發生的狀況,並且在適當的時機處理它。

  • map 用於解決容器無法直接使用一般函式的問題。

  • flatMap 解決了當 map 應用一個接收值返回容器函式結果所造成的容器疊層問題。

  • 透過 mapflatMap,可以降低因各種繁複的判斷式而影響程式主流程的複雜度。

更多不同種類的容器

除了 Option / Maybe、Either,還有許多不同用途的容器

  • Array / List

    用於儲存一個有序列表

  • IO / Task
    處理與外界溝通的副作用(在 fp-ts IO 是指同步,Task 是指非同步)

  • Reader
    用於共享資訊讀取值的一個容器

  • Writer
    紀錄計算過程的細節

  • State
    管理共用資源

結論

透過容器的抽象化,我們將特定的共通問題與程式的主要流程進行隔離。這種方式不僅讓你能夠最大程度地維持原始的商業邏輯,避免被不相關的問題對程式邏輯造成干擾。我們只需在最適合的時機來處理這些問題即可。

Functional Programming 的核心精神在問題的分解組合上。開發者需要學習如何有效地拆解問題,同時,FP 提供了讓問題組合更為靈巧的方法。這兩者是開發過程中不可或缺的元素。