FlatMap
January 23, 2022如果函式會傳回 None,為了處理 None,你必須判斷 None,例如:
def nickname(username):
return {
'Justin' : 'caterpillar',
'Monica' : 'momor',
'Irene' : 'mongshou'
}.get(username)
nick = nickname('Justin')
if nick == None:
print('Guest')
else:
print(nick)
純函式?
nickname 是純函式嗎?也就是說,它接受引數傳回結果嗎?不是!雖然 nickname 沒有對應的暱稱時會傳回 None,不過記得嗎?Python 中函式沒有指定傳回值時,就是傳回 None,也就是說,一個函式就算是明確地 return None,就相當於它沒有傳回值,也就不會是一個純函式。
無論如何,都要讓函式能傳回值的話,那就來定義一個 Maybe:
@dataclass
class Maybe:
value: Any
def isEmpty(self):
return self.value == None
def isPresent(self):
return not self.isEmpty()
def get(self):
if self.value == None:
raise ValueError('Maybe(Nothing)')
return self.value
def orElse(self, other):
if self.value == None:
return other
return self.value
Maybe 就像是個盒子,盒子裡可能有或沒有東西,如果函式沒有對應的結果,想要傳回 None 時,為了讓它能是個純函式,乾脆傳回一個盒子,這樣函式一定就有傳回值了。
Maybe 需要判斷有或沒有值,這邊提供了 isPresent、isEmpty 方法,若在沒有值時呼叫 get 會拋出例外,若想在沒有值時使用預設值,提供了 orElse 方法。
這麼一來,一開始的範例就可以改寫為:
def nickname(username):
return Maybe({
'Justin' : 'caterpillar',
'Monica' : 'momor',
'Irene' : 'mongshou'
}.get(username))
nick = nickname('Justin')
if nick.isEmpty():
print('Guest')
else:
print(nick.get())
當然,既然有 orElse,也可以寫成:
print(nickname('Justin').orElse('Guest'))
你有注意到 Maybe 是個 dataclass 嗎?嗯?它的結構是?「有或沒有值」就是它的結構,既然知道了結構,如〈Pattern matching 中談過的,可以來套用模式比對:
match nickname('Justin'):
case Maybe(None):
print('Guest')
case Maybe(value):
print(value)
巢狀的運算
這邊談到 Maybe,只不過是順便,接下來要談的,才是這篇文件的重點…
如果你進一步要用 nick 來呼叫函式,該函式也有可能傳回 None 的話,那就傳回 Maybe,例如:
def avatar(nickname):
return Maybe({
'caterpillar' : 'images/caterpillar.jpg',
'momor' : 'images/momor.jpg',
'mongshou' : 'images/mongshou.jpg'
}.get(nickname))
match nickname('Justin'):
case Maybe(None):
print('images/guest.jpg')
case Maybe(value):
match avatar(value):
case Maybe(None):
print('images/guest.jpg')
case Maybe(value):
print(value)
以上的 match/case,也能用 if/else 來實現,只不過藉由 match/case,更可以突顯出巢狀層次的問題,如果你還需要更進一步用 avatar 的結果來查詢什麼,那麼巢狀檢查的層次就會更深,造成撰寫與閱讀上的不便。
仔細看看,每一層 match/case(或 if/else),有哪些流程是類似,可以抽取至函式?如果 Maybe 的 value 不是 None,就取出 value,然後傳給某個函式(上例是 avator),因為要抽取為函式,而且要是個純函式的話,Maybe(None) 沒有能傳回的東西呢!方才說過了,沒有能傳回的東西,那就傳回 Maybe(None)。
def flatMap(maybe, mapper)
match maybe:
case Maybe(None):
return maybe
case Maybe(value):
return mapper(value)
那麼方才的巢狀判斷,就可以改寫為:
nickMaybe = nickname('Justin')
avatorMaybe = flatMap(nickMaybe, lambda nick: avatar(nick))
print(avatorMaybe.orElse('images/guest.jpg'))
流程從巢狀變成循序了!flatMap 首參數接受 Maybe,不如就將之設計為 Maybe 的方法:
@dataclass
class Maybe:
...略
def flatMap(self, mapper):
match self:
case Maybe(None):
return self
case Maybe(value):
return mapper(value)
那麼方才的範例,就可以寫為:
print(
nickname('Justin')
.flatMap(avatar)
.orElse('images/guest.jpg')
)
如果你有 ooo、xxx 函式,接受一個值,傳回 Maybe,可以一直 flatMap 下去:
print(
nickname('Justin')
.flatMap(avatar)
.flatMap(ooo)
.flatMap(xxx)
.orElse('default value')
)
在這個過程中,如果 nickname、avatar、ooo、xxx 函式,有曾經傳回 Maybe(None),最後就會得到 'default value',否則就是得到最後的 xxx 函式傳回的 Maybe 之內含值,神奇吧!不!一點也不神奇,簡單來說,就是重用了 match/case 的邏輯罷了!
在一些命令式語言中,經常使用 flatMap 這種名稱,其實以上的概念,源自於純函數式語言 Monad 的概念,在 Haskell 中,如果函式 findOrder、findCustomer、findAddress 可能傳回 Maybe,可以寫成 address = findOrder "X1234" >>= findCustomer >>= findAddress,看來就更直覺了。
來想像一下,flatMap 對目前盒子內含值進行運算,結果交給 lambda 轉換至新盒子,以便進入下個運算情境。
就 Maybe 而言,對 Maybe 內含值進行 None 判斷的運算,有值就套用 lambda 映射,以便進入下個 None 判斷的運算,因此使用者可以只指定感興趣的運算,從而突顯程式碼的意圖,又可流暢地撰寫程式碼,避免巢狀的運算流程。
Array 的 flatMap
除了 Maybe 之類的 API 之外,現代命令式語言,還有不少 API 具有 flatMap,它是個高階抽象,從目前盒子取出值(flat 是平坦化的意思,就相當於把盒子展開,看到其中的值),lambda 指定了值要怎麼轉換並封裝至新盒子。
flatMap 本身封裝了可重用的運算,就 Maybe 而言,封裝的是判斷盒子中是否有值的運算,無論封裝的流程是什麼,目的都是讓使用者,可以只指定感興趣的運算,從而突顯程式碼的意圖,又可流暢地撰寫程式碼,避免巢狀的流程出現。
例如,來看看 JavaScript 陣列,先就以下範例來說,如果你想知道最後需要的零件有哪些的話,命令式的寫法會是:
function products(order_id) {
return [
[0, 3], // 訂單 0 要的產品號碼
[1, 2], // 訂單 1 要的產品號碼
[2, 3], // 訂單 2 要的產品號碼
[0, 2], // 訂單 1 要的產品號碼
][order_id];
}
function modules(product_id) {
return [
[1, 3, 2], // 產品 0 需要的模組號碼
[0, 1, 4], // 產品 1 需要的模組號碼
[1, 2], // 產品 2 需要的模組號碼
[3, 4] // 產品 3 需要的模組號碼
][product_id];
}
function parts(module_id) {
// 有 0 到 9 個零件
return [
[10, 9, 7], // 模組 0 需要的零件號碼
[2, 9, 7], // 模組 1 需要的零件號碼
[3, 9, 7], // 模組 2 需要的零件號碼
[10, 5, 7], // 模組 3 需要的零件號碼
[3, 1, 7], // 模組 4 需要的零件號碼
][module_id];
}
const order_ids = [0, 1, 3]; // 客戶訂單 id
const collector = [];
for(let order_id of order_ids) {
const product_ids = products(order_id);
for(let product_id of product_ids) {
const module_ids = modules(product_id);
for(let module_id of module_ids) {
const part_ids = parts(module_id);
for(let part_id of part_ids) {
collector.push(part_id);
}
}
}
}
console.log(collector);
喔!四層巢狀迴圈,難寫又難讀…XD
從 ES10 以後,提供了 flatMap 方法,你可以改寫成這樣:
const order_ids = [0, 1, 3];
console.log(
order_ids.map(order_id => products(order_id)) // 從訂單 id 取得每張訂單的產品 id 清單
.flatMap(product_ids => product_ids.map(modules)) // 把產品 id 清單轉換為模組 id 清單
.flatMap(module_ids => module_ids.map(parts)) // 把模組 id 清單轉換為零件 id 清單
.flat() // 把零件 id 清單展平
);
就撰寫與閱讀上,是不是比較省事呢?這邊的 Array 本身就是盒子,flatMap 會逐一取得其中的元素,你只要指定怎麼將元素轉換為另一個盒子就可以了,也就是你只要指定怎麼將元素轉換另一組元素,也就是元素如何映射至新盒子(Array)就可以了。
至於 flatMap 怎麼迭代新的一組元素,你就不用管了,那些被封裝起來了,在後續的運算中,你只要繼續關注元素如何轉換為另一組元素;可以自行試著重構上面的四層 for 迴圈,看看能不能抽取出共用流程,實現出自己 flatMap,這篇文件已經很長了,我就不示範了…XD
flatMap 這種模式,可以用來決巢狀運算變深的問題,基本上就是,若能瞭解 Maybe、Array 的 flatMap 方法,就是指定盒子中的值,該怎麼轉換為另一個盒子,也就是上個運算情境的結果,如何銜接至下個運算情境,那在撰寫與閱讀程式碼時,忽略掉 flatMap 這個名稱,就能比較清楚程式碼的意圖。
可以 flatMap 的來源可以有 Maybe、list 等類型,flatMap 的概念,可以再抽象化為指定 a -> m b 函式,將 m a 轉換為 m b(這也是 flatMap 名稱由來,m a 會被打平為 a,再套用 a -> m b 進行映射),這種再度抽象化後的概念就是 Monad 。


