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 需要判斷有或沒有值,這邊提供了 isPresentisEmpty 方法,若在沒有值時呼叫 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),有哪些流程是類似,可以抽取至函式?如果 Maybevalue 不是 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')
)     

如果你有 oooxxx 函式,接受一個值,傳回 Maybe,可以一直 flatMap 下去:

print(    
    nickname('Justin')
        .flatMap(avatar)
        .flatMap(ooo)
        .flatMap(xxx)
        .orElse('default value')
)     

在這個過程中,如果 nicknameavataroooxxx 函式,有曾經傳回 Maybe(None),最後就會得到 'default value',否則就是得到最後的 xxx 函式傳回的 Maybe 之內含值,神奇吧!不!一點也不神奇,簡單來說,就是重用了 match/case 的邏輯罷了!

在一些命令式語言中,經常使用 flatMap 這種名稱,其實以上的概念,源自於純函數式語言 Monad 的概念,在 Haskell 中,如果函式 findOrderfindCustomerfindAddress 可能傳回 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 這種模式,可以用來決巢狀運算變深的問題,基本上就是,若能瞭解 MaybeArrayflatMap 方法,就是指定盒子中的值,該怎麼轉換為另一個盒子,也就是上個運算情境的結果,如何銜接至下個運算情境,那在撰寫與閱讀程式碼時,忽略掉 flatMap 這個名稱,就能比較清楚程式碼的意圖。

可以 flatMap 的來源可以有 Maybelist 等類型,flatMap 的概念,可以再抽象化為指定 a -> m b 函式,將 m a 轉換為 m b(這也是 flatMap 名稱由來,m a 會被打平為 a,再套用 a -> m b 進行映射),這種再度抽象化後的概念就是 Monad

分享到 LinkedIn 分享到 Facebook 分享到 Twitter