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 。