容器的妙用
學習 Functional Programming 時,你是否總是對 Functor、Monad 這兩個詞產生困惑,你不是第一個也不會是最後一個,這確實是大部分人學習 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)
似乎乾淨許多,但好像還不夠,每次都要自己解開實在是太繁瑣了,不如我們把 flatten
跟 map
組合在一起叫做 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 去哪了?
因為 flatMap
由 map
構成,其實可以不用特別寫出來,因為由於有 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
應用一個接收值返回容器函式結果所造成的容器疊層問題。 -
透過
map
跟flatMap
,可以降低因各種繁複的判斷式而影響程式主流程的複雜度。
更多不同種類的容器
除了 Option / Maybe、Either,還有許多不同用途的容器
-
Array / List
用於儲存一個有序列表
-
IO / Task
處理與外界溝通的副作用(在 fp-ts IO 是指同步,Task 是指非同步) -
Reader
用於共享資訊讀取值的一個容器 -
Writer
紀錄計算過程的細節 -
State
管理共用資源
結論
透過容器的抽象化,我們將特定的共通問題與程式的主要流程進行隔離。這種方式不僅讓你能夠最大程度地維持原始的商業邏輯,避免被不相關的問題對程式邏輯造成干擾。我們只需在最適合的時機來處理這些問題即可。
Functional Programming 的核心精神在問題的分解和組合上。開發者需要學習如何有效地拆解問題,同時,FP 提供了讓問題組合更為靈巧的方法。這兩者是開發過程中不可或缺的元素。