天天看點

泛函程式設計(32)-泛函IO:IO Monad

 由于泛函程式設計非常重視函數組合(function composition),任何帶有副作用(side effect)的函數都無法實作函數組合,是以必須把包含外界影響(effectful)副作用不純代碼(impure code)函數中的純代碼部分(pure code)抽離出來形成獨立的另一個純函數。我們通過代碼抽離把不純代碼逐漸抽離向外推并在程式裡形成一個純代碼核心(pure core)。這樣我們就可以順利地在這個純代碼核心中實作函數組合。io monad就是泛函程式設計處理副作用代碼的一種手段。我們先用個例子來示範副作用抽離:

很明顯,declarewinner是個包含副作用的函數。它做了兩件事:先對比兩個player的分數然後列印分數較大的player。這裡列印可以說是一項帶有外作用的函數,我們試着把它分離出來:

我們把分數比較代碼winner分離了出來。我們還可以繼續分解:printwinner也可以被認為做了兩件事:先合成了一條資訊,然後列印資訊:

這個例子看起來好像有些幼稚,但它示範了泛函程式設計的函數分解原理:我們并沒有改變程式的功能,隻是對程式代碼進行了分解。把程式分解成更細的函數。實際上任何一個包含副作用的函數内都會有純函數等待我們去分解。用一種代數關系表達就是:任何一個函數 a => b 都可以被分解成:

1、一個純函數:a => d, 這裡d隻是一個功能描述表達式

2、一個帶副作用的非純函數: d => b, 它可以被視為d的解譯器(interpreter),把描述解譯成有副作用的指令

在泛函程式設計中我們會持續進行這種函數分解(factoring),把含有副作用的代碼分解提取出來向外推形成一個副作用代碼層。這個非純函數形成的代碼層内部則是經分解形成的純代碼核心。最後我們到達了那些表面上已經無可分解的非純函數如:println,它的類型是string => unit, 接下去我們應該怎麼辦呢?

實際上通過增加一個新的資料類型io我們甚至可以對println進行分解:

現在函數printwinner已經變成了純函數,它傳回了一個io值:這個io值隻是對一個副作用的描述但并沒有運作它。隻有這個io類型的解譯器(interpreter)在運算這個io值時才會真正産生相應的副作用。

這裡涉及到一些大的概念:編寫io程式和運算io值是互相分離的過程(separation of concern)。我們的io程式用一系清單達式描述了要求的io功能,而io interpreter實作這些功能的方式可以是多樣的,包括:外設讀寫,檔案、資料庫讀寫及并行讀取等等。如何實作這些io功能與io程式編寫無任何關系。

現在,有了這個io類型,我們可以放心地用函數組合的泛函程式設計方式圍繞着這個io類型來編寫io程式,因為我們知道通過這個io類型我們把副作用的産生推延到io程式之外的io解譯器裡,而io程式設計與解譯器是兩個各自獨立的程式。

泛函模式的io程式設計就是把io功能表達和io副作用産生分開設計:io功能描述使用基于io monad的monadic程式設計語言,充分利用函數組合進行。而産生副作用的io實作則推延到獨立的interpreter部分。當然,interpreter也有可能繼續分解,把産生副作用代碼再抽離向外推延,這樣我們還可以對interpreter的純代碼核心進行函數組合。

我們上面的簡版io類型隻代表輸出類型(output type)。input類型需要一個存放輸入值的變量。在泛函程式設計模式裡變量是用類型參數代表的:

我們用run來對io值進行計算。在上面我們已經實作了map和flatmap函數,是以這個io類型就是個monad。看下面:

既然io類型是個monad類型,那麼我們就可以使用monadic語言程式設計了:

我們再來看看這個io類型:io[a] { def run: a },從類型款式來看我們隻知道io[a]類型值是個延後值,因為a值是通過調用函數run取得的。實際情況是run在運算a值時run函數裡的純代碼向程式外的環境提請一些運算要求如輸入(readline),然後把結果傳遞到另外一些純代碼;然後這些純代碼有可能又向外提請。我們根本無法确定副作用是在那個環節産生的。如此可以确定,這個io類型無法完整地表達io運算。我們必需對io類型進行重新定義:

這個io類型把純代碼與副作用代碼分開兩種io運算狀态:io運算可以是一個純函數值,或者是一個外部副作用運算請求。這個external類型定義了外部副作用運算方式,它決定了我們程式能獲得什麼樣的外部副作用運算。這個external[i]就像一個表達式,但隻能用外部運算io的程式來運算它。cont函數是個接續函數,它決定了擷取external[i]運算結果後接着該做些什麼。

現在我們可以明确分辨一個運算中的純函數和副作用函數。但是我們還無法控制external類型的行為。external[i]可以代表一個簡單的推延值,如下:

如上所示,任何副作用都可以被放入delay。如果我們希望更好控制使用外界影響,可以把external的選項作為io的類參數:

我們隻是把external換成了f,然後把它放進io類型參數。現在我們可以通過定義不同的f類型來擷取不同的副作用,例如:

現在通過io[console,a]我們獲得了隻能對鍵盤顯示屏進行讀寫的副作用。當然,我們還可以定義檔案、資料庫、網絡讀寫這些io能力的f類型。是以我們通過定義f來規範使用副作用。注意,即使在console類型我們也無法獲知副作用是否的确産生,這部分是由f類型的interpreter在運算io程式時才确定的。這不又是free monad分開獨立考慮(separation of concern)的interpreter部分嘛。這是我們可以把這部分延後分開考慮。

我們先看看如何計算這個io類型的值:

可以看出這個run函數是個遞歸算法:先計算f值然後再遞歸調用run運算所産生的io值。

我們現在可以建立一個f類型的執行個體然後運算io:

實際上這個io類型是個monad,因為我們可以實作它的unit和flatmap函數:

 它的monad執行個體如下:

我們可以把這個io類型的運算方式再概括一點:隻要f類型是個monad,那麼我們就可以運算io值:

有了f類型的monad執行個體,函數runm現在能運算io類型的值了。

在以上的讨論過程中我們得出了這樣的結論:f類型代表了io類型的interpreter,我們不需要知道它到底産生副作用與否或者怎樣産生。我們用f類型來把副作用的使用推延到f類型的執行個體。我們的io程式隻是對io算法的描述。如何運算io值包括如何使用副作用則在運算interpreter時才展現。

作為io算法,首先必須注意防止的就是遞歸算法産生的堆棧溢出問題。運算io值的runm是個遞歸算法,那我們必須保證至少它是一個尾遞歸算法。當然,我們前面讨論的trampoline類型是最佳選擇。我們可以比較一下io和trampoline類型結構:

它們有許多相似點。最主要的是它們都是循環遞歸結構,能實作以heap換stack目的。我們可以把trampoline類型的算法引進到io類型中,這樣就可以有效防止stackoverflow問題。實際上io類型與trampoline類型的深度抽象類型free monad更為相似:

free類型的flatmap結構和io類型的request結構極其相像。我們在前面的讨論中已經介紹了free類型:首先它是一個為支援尾遞歸算法而設計的結構,是由一個functor f産生的monad。free的功能由monad和interpreter兩部分組成:monad部分使我們可以使用monadic程式設計語言來描述一些算法,interpreter就是f類型,必須是個functor,它負責描述副作用行為。隻有在運算算法時才真正産生副作用。我們可以直接使用free類型代表io運算:用free的monadic程式設計語言來描述io算法,用interpreter來描述io效果,用free的trampoline運算機制實作尾遞歸運算。現在我們先看看完整的free類型:

我們先用free monadic程式設計語言來描述io算法:

現在我們用monadic程式設計語言描述了一個io程式,下一步就是運算這個io程式進而獲得它的值。如何運算io值是interpreter的功能。這個過程可能會産生副作用。至于如何産生副作用,産生什麼樣的副作用則由interpreter程式描述。io值運算過程就是一個由monadic io功能描述到io影響産生方式interpret語句的語言轉換(interpret,翻譯)。我們可以來看看這個運算函數:

這個foldmap就是一個io程式運算函數。由于它是一個循環計算,是以通過resume函數引入trampoline尾遞歸計算方式來保證避免stackoverflow問題發生。foldmap函數将io描述語言f解譯成可能産生副作用的g語言。在解譯的過程中逐漸用flatmap運作非純代碼。

我們可以用free monad的結構替代io類型結構,這樣我們就可以用monadic程式設計語言來描述io程式。至于實際的io副作用如何,我們隻知道産生副作用的interpret程式是個monad,其它一無所知。

現在我們可以進入interpreter程式設計了:

我們說過:運算io值就是把io程式語言逐句翻譯成産生副作用的interpreter語言這個過程。在以上例子裡我們采用了id monad作為interpreter語言。id monad的flatmap不做任何事情,是以io程式被直接對應到基本io函數readline, println上了。

我們也可以把副作用變成對list進行讀寫:

隻要我們在interpret程式裡把getline,putline對應到inlog,outlog兩個list的讀寫。

如果我們需要采用無獨占(non-blocking)讀寫副作用的話可以這樣改寫interpreter:

從以上的讨論我們得出:io類型可以用free類型代替,這樣我們可以充分利用free monad的monadic程式設計語言編寫io程式,我們又可以分開考慮編寫可能産生io副作用的interpreter。