1. 程式人生 > 其它 >深入探索Scala的Option

深入探索Scala的Option

程式設計師最深惡痛絕併力求避免的異常是NullPointerException,很不幸,我們往往又會忽略這個錯誤。不知是誰設計了Null這樣的物件。我在文章《並非Null Object這麼簡單》中已經闡釋了這個問題。然而不僅僅是空指標異常,當程式程式碼中出現各種錯誤時,我們的處理方式該如何呢?

現在,讓我們再看看Scala語法層面的Option。Option物件並沒有從根本上解決程式錯誤的問題,但只要使用得當,就能有效地將錯誤往程式的外層推,這實際上是消除副作用的慣常做法。正如Paul Chiusano等人的著作《Scala函數語言程式設計》描述的那樣:

對函式式程式設計師而言,程式的實現,應該有一個純的核心和一層很薄的外圍來處理副作用。

REA的Scala程式設計師Ken Scambler在YOW!大會上有一個很棒的演講2 Year of Real World FP at REA。演講中提到REA選擇函數語言程式設計的三個原因:

  • 模組化(Modularity)
  • 抽象(Abstraction)
  • 可組合性(Composability)

模組化的一個重要特徵是設計沒有副作用的純函式,這樣就不會影響呼叫該純函式的上下文,也就是所謂的“引用透明(Reference Transparency)”概念,即針對任何函式都可以使用它的返回值來代替(substitution)。

Ken Scambler使用了一個解析字串的例子來闡釋這種純函式特質。如下程式碼所示:

def parseLocation(str: String): Location = {   val parts = str.split(",")   val secondStr = parts(1)   val parts2 = secondStr.split(“ “)   Location( parts(0), parts2(0), parts(1).toInt)}

這段程式碼可能存在如下錯誤:

  • 作為input的str可能為null
  • parts(0)和parts(1)可能導致索引越界
  • parts2(0)可能導致索引越界
  • parts(1)未必是整數,呼叫toInt可能導致型別轉換異常

僅僅從函式的定義來看,我們其實看不到這些潛在的負面影響。那麼,想像一下當這樣的方法被系統中許多地方直接或間接呼叫,可能會造成什麼樣的災難?!假設這樣的程式碼被放到安全要求極高的系統中,你是否會感到不寒而慄?——程式設計師,應該在道義上肩負為自己所寫每行程式碼承擔自己的責任,這是基本的職業素養。我所謂的承擔責任,並不是事後追究,而是在每次寫完程式碼後都要再三推敲,力求每行程式碼都是乾淨利落,沒有歧義,沒有潛在的錯誤。

然而,針對以上程式碼,要怎樣才能保證程式呼叫的健壯性呢?就是要對可能出現的錯誤(空物件,索引越界,型別轉換異常)進行判斷。這就需要在parseLocation函式體中加入一堆if語句,短短的六行程式碼可能會膨脹一倍,而分支語句也會讓程式的邏輯變得凌亂,正常邏輯與異常邏輯可能會像麻花一樣扭在一起。當然,我們可以運用防禦式程式設計,將可能的錯誤防禦在正常邏輯程式碼之前,但它帶來的閱讀體驗卻會非常糟糕。

即使針對這些錯誤進行判斷,仍然無法解決的一個問題是當物件真的出現錯誤時,函式實現究竟該如何處理?多數語言不支援多返回值(乃至不支援類似Scala的Pair),經典的解決辦法就是丟擲異常,可惜,異常卻存在副作用。許多程式設計師更習慣性的返回了null。這是最要命的做法,就好像是慕容復的“以彼之道,還施彼身”,典型地損人利己!

引入Option,會讓程式碼在保證健壯性的同時還保證了簡潔性,例如:

def parseLocation(str: String): Option[Location] = {  val parts = str.split(",")  for {    locality <- parts.optGet(0)    theRestStr <- parts.optGet(1)    theRest = theRestStr.split(" ")    subdivision <- theRest.optGet(0)    postcodeStr <- theRest.optGet(1)    postcode <- postcodeStr.optToInt} yield Location(locality, subdivision, postcode)}

本質上,Option是一個Monad;簡單來說,是一個定義了flatMap與map的容器,故而能夠支援Scala中可讀性更佳的for comprehension。如上程式碼簡單明瞭,你甚至可以忽略當Option為None的情形,只考慮正常的字串解析邏輯,它自然地隱含了None的語義,因為在程式碼中通過optGet與optToInt返回的值(為Option型別),只要其中一個為None,整個函式就將即刻返回一個None物件,而非一個包裹了Location的Some物件。這樣既避免了使用分支語句,還能使得函式沒有任何副作用,規避了丟擲異常的邏輯。

如上的改進仍然存在一個問題,那就是缺乏對輸入的str進行判斷。一個好的API設計者或者函式實現者,要懷著“性本惡”的悲觀主義道德觀,對任何輸入持懷疑態度,且不憚於懷疑呼叫者的惡意。對於輸入的這個str,我們仍然要避免使用條件判斷的方式,因而可以修改函式的介面為:

def parseLocation(str: Option[String]): Option[Location] = ???

如此,我們可以將對str的解析邏輯也挪動到for comprehension中:

def parseLocation(str: Option[String]): Option[Location] = {  
for {   
 val parts = str.split(",")    locality <- parts.optGet(0)    theRestStr <- parts.optGet(1)   
  theRest = theRestStr.split(" ")    subdivision <- theRest.optGet(0)    
  postcodeStr <- theRest.optGet(1)    postcode <- postcodeStr.optToInt} 
  yield Location(locality, subdivision, postcode)}

使用Option的唯一問題是:你雖然指定了Option這樣的遊戲規則,但其他API的設計者卻未必按照你設計的規則出牌。這也是如上程式碼中optGet之類函式的由來。即使是Scala的內建庫,如String的split函式,返回的也並非一個Option,而是一個普通的陣列。當我們給一個錯誤的下標值去訪問陣列時,有可能會丟擲ArrayIndexOutOfBoundsException異常。

Scala提供的解決方案是隱式轉換(implicit conversion)。split()函式返回的型別為Array[String],該型別自身是沒有optGet()函式的。但是我們可以為Array[String]定義隱式轉換:

implicit class ArrayWrapper(array: Array[String]) {  
  def optGet(index:Int): Option[String] =     if (array.length > index) Some(array(index)) else None}

optToInt方法可以如法炮製。

慣常說來,當我們在使用Option時,習慣於利用模式匹配(pattern match)以運用“分而治之”的思想來編寫程式碼。然而,多數時候我們應該使用定義在Option中的函式,這些函式可以讓程式碼變得更簡單。例如使用flatMap、map、isDefined、isEmpty、forAll、exists、orElse、getOrElse等函式。Tony Morris整理的scala.Option Cheat Sheet總結了這些函式的用法,可供參考。