1. 程式人生 > >固執的程式設計師學習函數語言程式設計的收穫 之 二 說說monad

固執的程式設計師學習函數語言程式設計的收穫 之 二 說說monad

之前說了函數語言程式設計的收穫。比如說函式可以當作變數,然後儘量避免寫副作用的程式。
之後可以說遇到了一個超級難理解的東西–monad。

一切要從和小田君的對話說起

當我在寫java時,大概是下面的一段程式碼

List.map( item -> item.getName());
List.flatmap( item -> item.getName()); // ??

然後不知道map與flatmap的區別。於是對於一個懶人程式猿來說,答案當然不是去問谷歌,而是拉來一個同事。
本人: “小田君,flat和flatmap有什麼區別?“
小田: “啊,這個是monad啊“
本人: “莫納多?”
小田: “嗯,monad”
本人: “莫納多是什麼玩意兒?”
小田: “這個和範疇論有關哦”
本人: “那(tmd)的範疇論又是什麼啦?”
小田: “範疇論其實我也不是很懂,不過map和flatmap我覺得可以解釋清楚。”
本人: “啊,這樣啊,能跟我講講嗎?(那你喵了個咪的提範疇論幹嘛?顯得你很浮誇嗎?)”
之後小田君用了大概近10分鐘跟我講了一遍兩者區別,本人基本上就是“啊~“,“噢!“,“哦?“之類的反應。當時覺得自己聽明白了,然後過了兩天就忘了,現在想起來他大概講的還是錯的!!!
不過,當小田君教我這些知識時,我真的感覺到我好像和他完全不在一個等級上,頓時感覺自己非常的落伍,得趕緊惡補一下知識。
於是我現實谷歌了monad。然後維基百科了一下,看到的是類似於這種東西。
這裡寫圖片描述


反正基本覺得這講的不是人話。
於是就問了度娘,然後看了一些文章,裡頭出現了一些感念如”單子”之類的。可能是受面向物件思想的影響過深,和自己的耐心太差,根本無法理解裡邊的內容。於是想,是不是該學一學函式式語言了。
於是看了一本《functional programming in javascript》。因為js基本還會寫,學習成本會比較低。意料之中書裡有專門的一章講monad,不過當我看到monad那一章時,由於經過了一段時間,自己之前通過調查對monad的一些理解基本蕩然無存,最終出乎意料地沒能理解monad。
於是一怒之下之下開啟youtube,開搜monad!然後出現了這個老頭的視訊。
這裡寫圖片描述

這個叫布萊恩貝克漢姆的老頭,不用任何數學專用詞彙,很簡潔地(至少我看視訊時覺得)解釋了monad,然後說實話,我沒聽明白…
於是最終還是決定學一下Haskell,覺得可能用這個語言更容易理解monad。雖說monad這個概念肯定是不依賴於某個語言的。但是語言其實是能幫助理解的,因為你其實是在用語言在思考。
Haskell後發現對布萊恩貝克漢姆的解釋容易理解了,不過自己好事處於”這是什麼玩意兒,不過反正它好神奇”的狀態。
對於自己現在函數語言程式設計的思想還並不像面向物件那樣深入骨髓,可能能更好地以”前函數語言程式設計時代”的頭腦來說事情。想必很多未接觸過函數語言程式設計的人理解monad也會廢力一些。儘量不用函式式的專業術語,試著解釋一下monad。

什麼是monad

網上比較多的說法有兩種,名詞時自己想的,不是準確的術語。
- 容器論
- 鏈條論
簡單說一下兩者的解釋

容器論

monad像一個容器,容器裡存放著一個值。
從國外的網站盜的圖,很形象的說明。
這裡寫圖片描述
2這個數字被放在一個容器裡。
你可能會問,首先我們為什麼需要一個容器?之後會說的啦…
雖然容器存放著數字2,但容器本身不能直接進行普通的數學運算比如+ 3。容器([2])和3不是一個型別。
那要對容器裡的2進行運算該怎麼辦呢?那就把2從容器中拿出來(如圖),但是運算好之後又必須重新放進一個容器(不是2之前用的容器),或者說重新打個包。
但我們好不容易把2從容器裡拿出來進行了運算,還要把它重新打包?這有什麼意義?反正我剛看到的時候是這麼想的。正好可以引向鏈條論

鏈條論

之前的一篇文章說了,在函式式語言中,函式可以當作變數傳來傳去,還可以組合。當你把函式組合起來的時候,你的處理就像一條鏈條一樣能連一起。
再寫一下虛擬碼
假設a -> a,表示一個函式。它獲得一個a型別的引數,返回一個型別的返回值。

 func1 = a -> a
 func2 = a -> a
 func3 = func1 $ func2 // $表示呼叫函式。func3也是一種a -> a的函式。

數學上我們經常會寫這種運算吧。

2 * 3 + 2 - 7 =

四則運算都是接受數字返回數字的函式。我們把*3, +2, -7都看成函式 multiply3, plus2, minus7,然後函式式語言裡會是這樣

multiply3 & plus2 & minus7 2

回到之前提到的容器,把容器記做M。
假設有個函式接受int, 返回M[int],記做int -> M[int]。那我們可以這種型別的函式給串起來。

 func1 = int -> M[int]
 func2 = int -> M[int]
 func3 = func1 $ func2 // $表示呼叫函式。func3也是一種int -> M[int]的函式。

嗯?這是不是作弊?int -> int的函式能串起來不奇怪,int -> M[int]怎麼串?第一個函式的返回值和第二個函式的引數不一樣啊?
所以如果光用容器論來解釋這個問題就會比較難懂。我的見解就是Monad還包含了一個行為,M[int]定義瞭如何把裡邊的int取出然後扔給後一個int -> M[int]的函式。
這是不是很抽象。舉個例子。java中有Optional這個類吧。

   /**
     * ex: "Michael Fu" -> "MICHAEL" 
     * @param maybeName
     */
    public void givenNameInUpperCase(Optional<String> maybeName){
       Optional<String> mayGivenNameInUpperCase = maybeName.flatMap(name -> Optional.of(name.substring(0, name.indexOf(" "))))
               .flatMap(name -> Optional.of(name.toUpperCase()));

    }

上面的程式碼用flatMap把兩個String -> Optional的函式串起來了。flatMap會負責把Optional中的String解包,然後把String作為引數扔給函式處理。flatMap這個行為是由Optional定義的。

這樣的monad有什麼意義?

為什麼除了a -> a之外我們還需要a -> M[a]。而且是M[a]是來解決具體問題的。
從上面的程式碼例子,我們可以直接地體會到,如果沒有Optional這個東西,我們的處理會有這樣的語句

if(str == null){}

但是人們會很在意個一個if語句嗎?但是寫函式式語言時,會盡量寫成鏈條的樣子,而且寫太多if容易程式設計指令式程式設計(imperative programming)的風格。你是不是曾經寫過類似下面的程式碼很多遍?

List<Integer> numbersGreaterThan3 = new List<>()
for(int num : nums){
  if(num > 3) {
    numbersGreaterThan3.add(num);
  }
}

而如果用Stream的話,就很簡潔啦。

 List<Integer> numbersGreaterThan3 = nums.stream().filter(num -> num > 3).collect(Collectors.toList());

還有另外一個比較典型的例子就是Promise。如果你寫過js,你可能掉入過回撥地獄(callback hell)。

const verifyUser = function(username, password, callback){
   dataBase.verifyUser(username, password, (error, userInfo) => {
       if (error) {
           callback(error)
       }else{
           dataBase.getRoles(username, (error, roles) => {
               if (error){
                   callback(error)
               }else {
                   dataBase.logAccess(username, (error) => {
                       if (error){
                           callback(error);
                       }else{
                           callback(null, userInfo, roles);
                       }
                   })
               }
           })
       }
   })
};

一個解決方案便是promise(現在還有加強版的aysnc await),然後程式碼就能寫成鏈式的了。

const verifyUser = function(username, password) {
   database.verifyUser(username, password)
       .then(userInfo => dataBase.getRoles(userInfo))
       .then(rolesInfo => dataBase.logAccess(rolesInfo))
       .then(finalResult => {
           //do whatever the 'callback' would do
       })
       .catch((err) => {
           //do whatever the error handler needs
       });
};

Optional, Stream, Promise都是利用了這個monad概念。另外容器的解包不是所有monad都一樣的。容器會有自己的解包方式,有興趣大家可以看實現。Optional的話是比較簡單的

    public<U> Optional<U> flatMap(Function<? super T, Optional<U>> mapper) {
        Objects.requireNonNull(mapper);
        if (!isPresent())
            return empty();
        else {
            return Objects.requireNonNull(mapper.apply(value));
        }
    }

這樣的monad有什麼意義 * 2

好像這些東西很神奇(至少我第一次理解monad時是這麼覺得的),很多的問題都用一種概念或者思想給解決的。但它真的只是為了程式碼的優美而存在的嗎?
前邊提到的布萊恩貝克漢姆解釋說其實monad是為了限制副作用。不過很遺憾,我們能很好地理解到那個層面。如果用我的話來說的話,monad把不確定的因素從處理的主流程中分開來了,不需要很多的分支,能把處理寫成一條鏈。
比如當你寫一個向資料庫查詢一個人,結果可能有資料活沒有,甚至資料庫沒連上。我們可以這樣定義函式。用Optional來表示返回值。當找不到的情況下,我們可以返回Empty。

  Optional<Person> findPerson(PersonId personId);

然後獲得一個人的姓名的處理就會變成

  public Option<String> getPersonName(PersonId personId){
    return findPerson(personId).flatMap(person -> Optional.of(person -> person.getName()));
  }

而Promise則幫我們回吊函式何時呼叫的不去定型給排除了。

一點小注意

一直在說到容器的事情。在函式式語言中,放進容器的東西失去不出來的。

  Optional<Integer> mayNum = Optional.of(10);
  num = mayNum.get();  // <- 從函數語言程式設計的角度,這樣做是不好的

這可能違揹我們的直覺?取不出來那還有什麼用?
而答案必須經由容器來操作容器中的值,因為返回的也是容器,所以一切的操作都在容器內。
那Optional的例子來說感覺上就是
Optional -> Optional -> Optional這樣一路下去。
容器幫我們隱蔽了不確定性。但不確定性還是存在的。
比如Optional<Integer> 中可能有數字也可能沒有,所以理所當然它沒有辦法返回一個確定的數字。所以建議大家在寫非純函式式語言的時候注意這個細節,儘量把所有的處理寫在和容器互動的函式內,而不是把容器中的東西拿出來。
我有的時候突然覺得這有點像面向物件程式設計。物件分裝了資料,你不能直接去操作資料,必須通過物件開放的介面來進行處理。

總結

說了一下自己的monad的理解。
- 可以把monad當作一種容器
- monad用來控制副作用(本人尚未理解)
- 放入容器後的東西,無法取出,只有容器才能對它操作,處理後還是以容器的形式返回
不能說有多深入或者獨到的理解,只能當作自己學習筆記吧,如果今後對monad有了更好的理解,希望能再寫一寫。

map和flatMap

其實看java的程式碼還是能知道區別的。
map接受的函式,函式的返回值就是普通的任何型別。 a -> b
flatMap接受的函式,函式的返回值必須是Stream(容器) a -> M[b]

參考文章