前言
要問我為什么不從頭開始翻譯而偏偏從第四部分開始的話,是因為前三部分已經有大神翻過了。而原文我也是從那里提供的鏈接跳過去的,所以就接著開始讀第四部分了。原文和前三部分鏈接在這里。
原文
http://james-iry.blogspot.com/search/label/monads
前面部分的翻譯
http://hongjiang.info/monads-are-elephants-part1-chinese
http://hongjiang.info/monads-are-elephants-part2-chinese
http://hongjiang.info/monads-are-elephants-part3-chinese
渣翻,輕噴。
Monads are Elephants Part 4
在你第一次摸到成年大象之前,你可不知道大象到底能有多大。我在這系列文章中把Monads比作大象,可是從開始直到現在我僅僅展示了一些諸如List, Option之類的嬰兒級別的大象呢。不過現在是時候了,讓我們來一起看下到底這頭成年巨獸是什么樣子的。有趣的是,他甚至會表演一點雜技給你看。
Functional Programming and IO
函數式編程中有一個常見的概念,叫做引用透明。引用透明意味著不論你什么時候在哪里調用一個特定的函數,只要調用的參數一致,返回的結果就一定會一樣。顯然的,引用透明的程序相比擁有一大堆狀態的代碼來說更加容易使用和調試。
但是有一件事情貌似對引用透明來說是不可能的:IO。想象下,當用戶把自己吃的早餐作為字符串輸入到命令行的時候,每個readLine函數顯然會返回不同的字符串。類似的道理,發送網絡包的函數也同時有成功和失敗的可能。
但我們并不能為了程序的引用透明性而放棄使用IO。一個沒有IO交互的程序充其量也只是別出心裁的消耗CPU的計算資源而已。
你可能猜到了,既然這個系列文章都在談論Monads,那他應該能夠提供一個解決方案。的確是這樣,我接下來會從一些基本的概念開始介紹。雖然我只會用命令行讀取和打印的例子來演示如何解決這個問題。但你顯然可以舉一反三的把這個方法用于任何其他的例如網絡通信和文件讀寫的IO。
當然啦,你可能不覺得引用透明的IO在Scala中是不可缺少的。我也并不是在這里試圖鼓吹任何純凈函數式引用透明的真理。我在這里僅僅是希望討論Monads而事實上恰巧IO Monads能夠非常形象深刻的幫助你理解其他Monads是怎么工作的。
The World In a Cup
從命令行讀取字符串之所以不是引用透明的,是因為readLine函數的返回值是由用戶的狀態決定的,而用戶卻并不是調用readLine函數的參數。同樣一個讀取文件的函數讀到的內容取決于文件系統的狀態。讀取指定web頁面的函數則會收到目標web服務器,因特網,甚至本地網絡的狀態。同樣,輸出型的IO函數也會有類似的依賴關系。
所有這些都可以被歸囊括在一個我們創建的叫做WorldState的類然后把它作為所有IO函數的輸入和返回參數。不幸的是,這個世界貌似太大啦。我的第一次嘗試最終以因內存耗盡而導致的編譯器崩潰而失敗告終。因此我決定選擇一個和模擬整個世界相比不那么龐大的方法。在這里我們會需要引入一點馬戲團魔術。
這個技巧就是僅僅用我們需要的那部分構造出整個世界,就是然后假裝世界會對其他部分了若指掌。以下有一些我們需要考慮到的。
- 世界的狀態在IO函數之間變化。
- 整個世界的狀態是一個單例,你不能因為需要就隨時隨地的使用new關鍵詞來創建新的世界。
- 整個世界在任何一個時刻都只會處在一個特定的狀態。
第三點有一點微妙所以我們現在考慮前兩點來看看。
針對第一點,這里有一個粗糙的版本。
//file RTConsole.scala
object RTConsole_v1 {
def getString(state: WorldState) =
(state.nextState, Console.readLine)
def putString(state: WorldState, s: String) =
(state.nextState, Console.print(s) )
}
getString和putString函數直接使用了在scala.Console中定義的原生函數。并且他們將世界的狀態傳入,然后將一個包含了世界狀態和原生IO函數返回結果的元組傳出。
接著我們來看第二點怎么實現。
//file RTIO.scala
sealed trait WorldState{def nextState:WorldState}
abstract class IOApplication_v1 {
private class WorldStateImpl(id:BigInt)
extends WorldState {
def nextState = new WorldStateImpl(id + 1)
}
final def main(args:Array[String]):Unit = {
iomain(args, new WorldStateImpl(0))
}
def iomain(
args:Array[String],
startState:WorldState):(WorldState, _)
}
我們將WorldState定義成一個密封的特質,因此它只能夠在同一個源代碼文件中被繼承。然后我們在IOApplication中,以private的方式,定義了WorldState得唯一實現所以現在沒有其他人可以繼承它了。IOApplication還定義了一個不可被覆蓋的main方法并且在里面調用了另一個需要在子類中實現的抽象方法iomain。注意我們在這里做的一切都是為了阻止那些使用IO庫的程序員們直接訪問到WorldState。
有了上面的鋪墊,我們來一個HelloWorld的例子。
// file HelloWorld.scala
class HelloWorld_v1 extends IOApplication_v1 {
import RTConsole_v1._
def iomain(
args:Array[String],
startState:WorldState) =
putString(startState, "Hello world")
}
That Darn Property 3
之前我們曾經提出過要求,整個世界在任何一個時刻都只會處在一個特定的狀態。可惜,這點至今我尚未能解決,我們來看看這是為什么呢。
class Evil_v1 extends IOApplication_v1 {
import RTConsole_v1._
def iomain(
args:Array[String],
startState:WorldState) = {
val (stateA, a) = getString(startState)
val (stateB, b) = getString(startState)
assert(a == b)
(startState, b)
}
}
在這里,我連續兩次調用getString函數并且他們的輸入參數一致。如果我們的代碼是引用透明的話,那么兩次函數的返回值a和b,肯定是一致的。然后這顯然并不可能,因為這兩個返回值完全取決于用戶分別兩次輸入命令行的內容。這里的問題是,在程序的執行的過程中的任一時刻,startState和其他的狀態stateA,stateB一樣,對程序員來說都是可見的。
Inside Out
作為解決這個問題的第一步,我決定把整個解決方案徹底推翻。與之前通過iomain函數來將WorldState進行轉化的方式不同,這次的iomain將會直接返回這樣一個供main方法調用的函數。代碼是這樣的。
//file RTConsole.scala
object RTConsole_v2 {
def getString = {state:WorldState =>
(state.nextState, Console.readLine)}
def putString(s: String) = {state: WorldState =>
(state.nextState, Console.print(s))}
}
getString和putString函數不再直接對參數進行操作 - 這次他們將會返回一個新的等待WorldState傳入然后才被執行的函數。
//file RTIO.scala
sealed trait WorldState{def nextState:WorldState}
abstract class IOApplication_v2 {
private class WorldStateImpl(id:BigInt)
extends WorldState {
def nextState = new WorldStateImpl(id + 1)
}
final def main(args:Array[String]):Unit = {
val ioAction = iomain(args)
ioAction(new WorldStateImpl(0));
}
def iomain(args:Array[String]):
WorldState => (WorldState, _)
}
現在IOApplication的main方法會調用iomain函數來獲得它接下來將會執行的函數,接下來它將一個最初的世界狀態傳遞給這個函數從執行。我們的HelloWorld看上去并沒什么改變,除了他不再需要WorldState對象了。
//file HelloWorld.scala
class HelloWorld_v2 extends IOApplication_v2 {
import RTConsole_v2._
def iomain(args:Array[String]) =
putString("Hello world")
}
起初看來,我們好像已經解決這個問題了,因為WorldState不在出現在我們的程序中了。但事實是怎樣的呢,讓我們接著看下去。
Oh That Darn Property 3
class Evil_v2 extends IOApplication_v2 {
import RTConsole_v2._
def iomain(args:Array[String]) = {
{startState:WorldState =>
val (statea, a) = getString(startState)
val (stateb, b) = getString(startState)
assert(a == b)
(startState, b)
}
}
}
但是只要我們如法炮制,就能通過一個貌似完全符合規范的函數讓我們之前建立的規則再度悲劇了。看來只要程序員不受到限制而能夠隨意創建IO函數,那么他就能夠看穿這個WorldState把戲。
Property 3 Squashed For Good
看上去我們需要防止程序員隨意的創建簽名符合要求的函數。恩。。。那現在該怎么做呢?
顯而易見,在處理WorldState時我們已經能夠做到防止程序員來實現它的子類了。所以接下來讓我們把我們的整個函數也變成特質的形式。
sealed trait IOAction[+A] extends
Function1[WorldState, (WorldState, A)]
private class SimpleAction[+A](
expression: => A) extends IOAction[A]...
與WorldState不同,我們是需要創建IOAction的實例的。舉例來說,我們接下來在另一個文件中將會定義的getString和putString函數就需要創建新的IOAction。我們這樣做僅僅是讓他們更加安全。這會讓你有點摸不著頭腦,直到意識到我們現在的getString和putString函數都具有兩個不相干的部分組成:一部分調用原生IO,另一部分則將世界轉化到下一個狀態。讓我們借助一點工廠方法來讓代碼變得更清楚。
//file RTIO.scala
sealed trait IOAction_v3[+A] extends
Function1[WorldState, (WorldState, A)]
object IOAction_v3 {
def apply[A](expression: => A):IOAction_v3[A] =
new SimpleAction(expression)
private class SimpleAction [+A](
expression: => A) extends IOAction_v3[A] {
def apply(state:WorldState) =
(state.nextState, expression)
}
}
sealed trait WorldState{def nextState:WorldState}
abstract class IOApplication_v3 {
private class WorldStateImpl(id:BigInt)
extends WorldState {
def nextState = new WorldStateImpl(id + 1)
}
final def main(args:Array[String]):Unit = {
val ioAction = iomain(args)
ioAction(new WorldStateImpl(0));
}
def iomain(args:Array[String]):IOAction_v3[_]
}
IOAction對象是一個創建SimpleAction的工廠而已,通過使用“=> A”的注解,我們讓它的構造函數需要傳入一個會被延遲計算的表達式作為參數。這個表達式不會被計算直到SimpleAction的apply方法被調用。而為了調用這個apply方法,一個WorldState需要被傳入。而返回的結果將是一個包含了新的WorldState和表達式結果的元組。
現在我們的IO方法看起來是這樣子的。
//file RTConsole.scala
object RTConsole_v3 {
def getString = IOAction_v3(Console.readLine)
def putString(s: String) =
IOAction_v3(Console.print(s))
}
最終我們的HelloWorld還和之前保持一樣。
class HelloWorld_v3 extends IOApplication_v3 {
import RTConsole_v3._
def iomain(args:Array[String]) =
putString("Hello world")
}
現在我們似乎可以保證悲劇不再發生了。程序員不再能夠接觸到WorldState了。它被完全的密封起來了,main方法現在僅僅是傳第一個WorldState給IOAction的apply方法,而我們沒法通過定制IOAction子類的apply方法的方式來進行隨意的IO操作了。
不幸的是,我們遇到了一點組合的問題。我們沒法將多個IO操作進行組合了。因此我們不再能夠簡單的進行這樣的對話了。“你叫什么名字?”,Bob, “你好Bob。”
額。IO操作是一個表達式的容器而Monads是容器。IO操作需要被組合而Monads是能夠被組合的。也許。。。讓我們來看下。
Ladies and Gentleman I Present the Mighty IO Monad
我們給IOAction的apply方法傳入一個類型為A的參數,然后返回一個IOAction[A]的對象。這看起來很像“unit”。雖然它不是,但是對于現在來說,它已經足夠像了。接下來只要我們知道對于這樣的Monad我們的flatMap是什么,Monad法則就能告訴我們它對應的map函數應該是怎么樣的。但我們需要怎樣的flatMap呢。它的函數簽名應該是這樣的
def flatMap[B](f: A=>IOAction[B]):IOAction[B]。
所以這有什么用?
在這里我們希望它能夠將一個操作鏈接到另一個返回IOAction的函數,并且在這個函數激活的時候能夠順序的調用這兩個操作。換句話說,getString.flatMap{y => putString(y)}應該會返回一個IOAction的Monad,在執行的時候能夠首先調用getString操作然后執行putString函數所返回的操作。試試看。
//file RTIO.scala
sealed abstract class IOAction_v4[+A] extends
Function1[WorldState, (WorldState, A)] {
def map[B](f:A => B):IOAction_v4[B] =
flatMap {x => IOAction_v4(f(x))}
def flatMap[B](f:A => IOAction_v4[B]):IOAction_v4[B]=
new ChainedAction(this, f)
private class ChainedAction[+A, B](
action1: IOAction_v4[B],
f: B => IOAction_v4[A]) extends IOAction_v4[A] {
def apply(state1:WorldState) = {
val (state2, intermediateResult) =
action1(state1);
val action2 = f(intermediateResult)
action2(state2)
}
}
}
object IOAction_v4 {
def apply[A](expression: => A):IOAction_v4[A] =
new SimpleAction(expression)
private class SimpleAction[+A](expression: => A)
extends IOAction_v4[A] {
def apply(state:WorldState) =
(state.nextState, expression)
}
}
// the rest remains the same
sealed trait WorldState{def nextState:WorldState}
abstract class IOApplication_v4 {
private class WorldStateImpl(id:BigInt) ...
IOAction的工廠和SimpleAction依然保持不變。IOAction類現在擁有了我們的Monad方法。按照Monad法則,map的行為由flatMap和我們選取的unit決定。看樣子,對于我們的新IO行為ChainedAction來說,flatMap的實現是我們新方案的關鍵。
我們來看下ChainedAction的apply方法。首先它將最初的世界狀態傳遞給action1來執行,得到了第二個世界狀態以及一個中間的執行結果。它鏈接到的函數會需要這個執行結果并且由這個函數產生另一個操作:action2。action2通過第二個世界狀態調用并且最終講包含了所有結果的元組返回出來。記住,所有這一切都是延遲執行的,直到我們的main方法將最初的世界狀態傳遞進來才會執行。
A Test Drive
你也許會有疑問,getString和putString函數既然僅僅是返回對應的IO動作而不是真正執行他們的話,為什么不干脆就直接叫做createGetStringAction和createPutStringAction呢。想要知道原因的話,我們來接著看看當把它們和我們的老朋友“for”糅雜之后會發生些什么呢。
object HelloWorld_v4 extends IOApplication_v4 {
import RTConsole_v4._
def iomain(args:Array[String]) = {
for{
_ <- putString(
"This is an example of the IO monad.");
_ <- putString("What's your name?");
name <- getString;
_ <- putString("Hello " + name)
} yield ()
}
}
看上去好像“for”和getString/putString一起組成了一種新的表達復雜IO行為的迷你語言。
Take a Deep Breath
現在讓我們來總結一下。IOApplication建立了一個純粹的體系。用戶創建它的子類并且實現一個會被main方法調用的叫做iomain的方法。而這個方法會返回一個IOAction,它可能是一個單獨的IO操作,也可能是一組鏈接起來的操作。這個操作不會立刻執行直到有人傳遞給它一個WorldState對象。這里ChainedAction類保證了WorldState在每個動作之間的變化和傳遞。
getString和putString并不像它們名字宣稱的那樣能夠傳入或輸出字符串。事實上,它們返回IOActions。但是作為Monad,IOAction能夠被嵌入for表達式,從而讓它們看起來像是真的做了它們號稱做的事情。
這是一個好的開始,我們已經幾乎完成了一個完美的Monad。但這里還有兩個問題。首先,因為unit會改變整個世界的狀態,所以我們似乎稍微打破了一點Monad法則(e.g. m flatMap unit === m)。不過這點問題不大,因為在這里這個變化是不可見的。不過我們最好還是想辦法修復它。
第二個問題則是。眾所周知,IO是有失敗的可能的,而我們目前還沒有考慮怎么樣去處理它們。
IO Errors
對Monad來說,失敗是用Zero來表示的。因此我們只要將這里的失敗的定義(異常)對應到我們Monad的概念中就可以了。這里我要換一個套路:我會給出一個最終版的程序,我會在期間對它們一一說明來幫助你理解。
IOAction對象保持了帶有若干個工廠方法和私有實現類的方便的模塊的形式(也許把它們寫成匿名類更好,不過帶有名字的話比較便于我進行說明)。SimpleAction保持原樣而IOAction的apply方法是它的工廠方法。
//file RTIO.scala
object IOAction {
private class SimpleAction[+A](expression: => A)
extends IOAction[A] {
def apply(state:WorldState) =
(state.nextState, expression)
}
def apply[A](expression: => A):IOAction[A] =
new SimpleAction(expression)
UnitAction是“unit”的操作 - 一個返回特定值但不改變世界狀態的操作。unit方法是它的一個工廠方法。把它從SimpleAction那里分離開來看上去有點奇怪,不過我們最好還是養成良好的習慣,按照Monad的規則來處理它們。
private class UnitAction[+A](value: A)
extends IOAction[A] {
def apply(state:WorldState) =
(state, value)
}
def unit[A](value:A):IOAction[A] =
new UnitAction(value)
FailureAction是我們用來表示Zero的類。這是一個總是會拋出異常的IO操作。我們自定義的UserException是從其中拋出的異常之一。這里的fail和ioError方法都是我們用來創建Zero的工廠方法。fail方法讀入一個字符串然后返回一個會拋出UserException的action,ioError則將一個任意異常作為參數,返回一個會拋出該異常的action。
private class FailureAction(e:Exception)
extends IOAction[Nothing] {
def apply(state:WorldState) = throw e
}
private class UserException(msg:String)
extends Exception(msg)
def fail(msg:String) =
ioError(new UserException(msg))
def ioError[A](e:Exception):IOAction[A] =
new FailureAction(e)
}
IOAction的flatMap方法和ChainedAction保持原樣。map函數現在會直接調用unit方法所以它也是符合Monad法則的。除此之外,我還另外添加了兩個操作符>>和<<。如同flatMap會將一個返回另一個action的函數拼接到這個action之后,>>和<<將另一個action直接拼接到這個action之后。這僅僅是一個返回值的問題,>>讀作“then”,有了它我們就可以做到創建一個返回第二個操作結果的動作。因此putString "What's your name" >> getString 就能創建一個首先輸出提示符然后讀入用戶輸入的操作。而相反,<<讀作“before”,則會創建一個會返回第一個操作結果的動作。
sealed abstract class IOAction[+A]
extends Function1[WorldState, (WorldState, A)] {
def map[B](f:A => B):IOAction[B] =
flatMap {x => IOAction.unit(f(x))}
def flatMap[B](f:A => IOAction[B]):IOAction[B]=
new ChainedAction(this, f)
private class ChainedAction[+A, B](
action1: IOAction[B],
f: B => IOAction[A]) extends IOAction[A] {
def apply(state1:WorldState) = {
val (state2, intermediateResult) =
action1(state1);
val action2 = f(intermediateResult)
action2(state2)
}
}
def >>[B](next: => IOAction[B]):IOAction[B] =
for {
_ <- this;
second <- next
} yield second
def <<[B](next: => IOAction[B]):IOAction[A] =
for {
first <- this;
_ <- next
} yield first
由于我們現在定義好Zero了,我們很容易就能夠遵循Monad法則來添加一個filter方法。這里我創建了兩種形式的filter方法。第一種允許傳入一個用戶自定義的消息來提示為什么filter不兼容而第二種則延續Scala的標準規范,使用通用的錯誤消息。
def filter(
p: A => Boolean,
msg:String):IOAction[A] =
flatMap{x =>
if (p(x)) IOAction.unit(x)
else IOAction.fail(msg)}
def filter(p: A => Boolean):IOAction[A] =
filter(p, "Filter mismatch")
Zero還意味著我們能夠實現Monad的加法了。為了實現他,我們需要做一點準備工作。HandlingAction能夠包裹住另一個action類,并且在那個action拋出異常的時候將異常傳遞給一個特定的處理函數。onError則是一個專門創建HandlingAction的工廠方法。最后,“or”是我們的Monad加法。簡單說,它會在一個action失敗的時候嘗試運行另外那個可選的action。
private class HandlingAction[+A](
action:IOAction[A],
handler: Exception => IOAction[A])
extends IOAction[A] {
def apply(state:WorldState) = {
try {
action(state)
} catch {
case e:Exception => handler(e)(state)
}
}
}
def onError[B >: A](
handler: Exception => IOAction[B]):
IOAction[B] =
new HandlingAction(this, handler)
def or[B >: A](
alternative:IOAction[B]):IOAction[B] =
this onError {ex => alternative}
最終版本的IOApplication保持原樣
sealed trait WorldState{def nextState:WorldState}
abstract class IOApplication {
private class WorldStateImpl(id:BigInt)
extends WorldState {
def nextState = new WorldStateImpl(id + 1)
}
final def main(args:Array[String]):Unit = {
val ioaction = iomain(args)
ioaction(new WorldStateImpl(0));
}
def iomain(args:Array[String]):IOAction[_]
}
RTConsole同樣也幾乎沒變,不過我給它添加了一個類似于println的putLine?方法。我還把getString方法設置為了val變量。為什么不呢?它并不會改變。
//file RTConsole.scala
object RTConsole {
val getString = IOAction(Console.readLine)
def putString(s: String) =
IOAction(Console.print(s))
def putLine(s: String) =
IOAction(Console.println(s))
}
現在是時候給我們的HelloWorld程序添加點新功能了。sayHello方法通過一個字符串返回了一個action,如果它認得出這個名字那么就會給他打個招呼,不然就會返回一個會導致失敗的action。
Ask是一個方便的創建提示符然后讀入字符串的方法,我們的>>操作符保證了這個action的結果會是getString返回的字符串。
processsString方法讀入一個任意字符串,如果讀到的是“quit”的話,它就會返回一個會向你告別的action然后退出。其他情況下,它會再次調用sayHello方法并將你輸入的字符串傳入,然后為了防止失敗還將sayHello返回的action和另一action用or進行了組合。最后它會再次調用一個叫做loop的action。
Loop是很有趣的方法。它被定義成了一個val,當然用def也沒什么不好。它其實是一個遞歸的函數而并不是一個普通的循環。它的定義中用到processString方法而恰巧processString方法也是基于loop來定義的。
最后是iomain方法,它僅僅是創建了一個會打印自我介紹語句然后會再次調用loop的action。
警告:由于這個庫中loop方法的實現問題,最終這些代碼可能會導致棧溢出。不要在任何生產環境中用這段代碼,你能從上面的注解里看到原因。
object HelloWorld extends IOApplication {
import IOAction._
import RTConsole._
def sayHello(n:String) = n match {
case "Bob" => putLine("Hello, Bob")
case "Chuck" => putLine("Hey, Chuck")
case "Sarah" => putLine("Helloooo, Sarah")
case _ => fail("match exception")
}
def ask(q:String) =
putString(q) >> getString
def processString(s:String) = s match {
case "quit" => putLine("Catch ya later")
case _ => (sayHello(s) or
putLine(s + ", I don't know you.")) >>
loop
}
val loop:IOAction[Unit] =
for {
name <- ask("What's your name? ");
_ <- processString(name)
} yield ()
def iomain(args:Array[String]) = {
putLine(
"This is an example of the IO monad.") >>
putLine("Enter a name or 'quit'") >>
loop
}
}
結論
在這篇文章中,我把IO Monad稱為IO Action來讓大家更好的理解它們是等待被執行的動作。也許有的人會認為在Scala中,IO Monad并沒有太大的實際意義。沒關系,我的本意也并不是在這里鼓吹函數引用透明性。IO Monad只是作為我們目前遇到的Monad中唯一從各種意義上來說都不是容器類型的簡單例子來介紹。
事實上,IO Monad依然可以被看做是容器,只是與普通包含值的容器不同,它包含的是表達式。它通過flatMap和map函數依次將嵌套的表達式來組成更加復雜的表達式。
也許從更深層次的角度可以把IO Monad看做一個函數或是抽象的計算。而flatMap的作用可以看做是把函數應用于計算而創建更加復雜計算的。
在這個系列的最后部分,我會介紹一種將容器和計算模型統一的抽象方法。但首先我要通過展示一個稍微復雜一點的運用了大量Monad的應用來向你們展示一下到底它有多重要。