原 薦 挑逗 Java 程式設計師的那些 Scala 絕技
有個問題一直困擾著 Scala 社群,為什麼一些 Java 開發者將 Scala 捧到了天上,認為它是來自上帝之吻的完美語言;而另外一些 Java 開發者卻對它望而卻步,認為它過於複雜而難以理解。同樣是 Java 開發者,為何會出現兩種截然不同的態度,我想這其中一定有誤會。Scala 是一粒金子,但是被一些表面上看起來非常複雜的概念或語法包裹的太嚴實,以至於人們很難在短時間內搞清楚它的價值。與此同時,Java 也在不斷地摸索前進,但是由於 Java 揹負了沉重的歷史包袱,所以每向前一步都顯得異常艱難。本文主要面向 Java 開發人員,希望從解決 Java 中實際存在的問題出發,梳理最容易吸引 Java 開發者的一些 Scala 特性。希望可以幫助大家快速找到那些真正可以打動你的點。
型別推斷
挑逗指數: 四星
我們知道,Scala 一向以強大的型別推斷聞名於世。很多時候,我們無須關心 Scala 型別推斷系統的存在,因為很多時候它推斷的結果跟直覺是一致的。 Java 在 2016 年也新增了一份提議ofollow,noindex" target="_blank">JEP 286 ,計劃為 Java 10 引入區域性變數型別推斷(Local-Variable Type Inference)。利用這個特性,我們可以使用 var 定義變數而無需顯式宣告其型別。很多人認為這是一項激動人心的特性,但是高興之前我們要先看看它會為我們帶來哪些問題。
與 Java 7 的鑽石操作符衝突
Java 7 引進了鑽石操作符,使得我們可以降低表示式右側的冗餘型別資訊,例如:
List<Integer> numbers = new ArrayList<>();
如果引入了 var,則會導致左側的型別丟失,從而導致整個表示式的型別丟失:
val numbers = new ArrayList<>();
所以 var 和 鑽石操作符必須二選一,魚與熊掌不可兼得。
容易導致錯誤的程式碼
下面是一段檢查使用者是否存在的 Java 程式碼:
public boolean userExistsIn(Set<Long> userIds) { var userId = getCurrentUserId(); return userIds.contains(userId); }
請仔細觀察上述程式碼,你能一眼看出問題所在嗎? userId 的型別被 var 隱去了,如果 getCurrentUserId() 返回的是 String 型別,上述程式碼仍然可以正常通過編譯,卻無形中埋下了隱患,這個方法將會永遠返回 false, 因為 Set<Long>.contains 方法接受的引數型別是 Object。可能有人會說,就算顯式聲明瞭型別,不也是於事無補嗎?
public boolean userExistsIn(Set<Long> userIds) { String userId = getCurrentUserId(); return userIds.contains(userId); }
Java 的優勢在於它的型別可讀性,如果顯式聲明瞭 userId 的型別,雖然還是可以正常通過編譯,但是在程式碼審查時,這個錯誤將會更容易被發現。 這種型別的錯誤在 Java 中非常容易發生,因為 getCurrentUserId() 方法很可能因為重構而改變了返回型別,而 Java 編譯器卻在關鍵時刻背叛了你,沒有報告任何的編譯錯誤。 雖然這是由於 Java 的歷史原因導致的,但是由於 var 的引入,會導致這個錯誤不斷的蔓延。
很顯然,在 Scala 中,這種低階錯誤是無法逃過編譯器法眼的:
def userExistsIn(userIds: Set[Long]): Boolean = { val userId = getCurrentUserId() userIds.contains(userId) }
如果 userId 不是 Long 型別,則上面的程式無法通過編譯。
字串增強
挑逗指數: 四星
常用操作
Scala 針對字元作進行了增強,提供了更多的使用操作:
//字串去重 "aabbcc".distinct // "abc" //取前n個字元,如果n大於字串長度返回原字串 "abcd".take(10) // "abcd" //字串排序 "bcad".sorted // "abcd" //過濾特定字元 "bcad".filter(_ != 'a') // "bcd" //型別轉換 "true".toBoolean "123".toInt "123.0".toDouble
其實你完全可以把 String 當做 Seq[Char] 使用,利用 Scala 強大的集合操作,你可以隨心所欲地操作字串。
原生字串
在 Scala 中,我們可以直接書寫原生字串而不用進行轉義,將字串內容放入一對三引號內即可:
//包含換行的字串 val s1= """Welcome here. Type "HELP" for help!""" //包含正則表示式的字串 val regex = """\d+"""
字串插值
通過 s 表示式,我們可以很方便地在字串內插值:
val name = "world" val msg = s"hello, ${name}" // hello, world
集合操作
挑逗指數: 五星
Scala 的集合設計是最容易讓人著迷的地方,就像毒品一樣,一沾上便讓人深陷其中難以自拔。通過 Scala 提供的集合操作,我們基本上可以實現 SQL 的全部功能,這也是為什麼 Scala 能夠在大資料領域獨領風騷的重要原因之一。
簡潔的初始化方式
在 Scala 中,我們可以這樣初始化一個列表:
val list1 = List(1, 2, 3)
可以這樣初始化一個 Map:
val map = Map("a" -> 1, "b" -> 2)
所有的集合型別均可以用類似的方式完成初始化,簡潔而富有表達力。
便捷的 Tuple 型別
有時方法的返回值可能不止一個,Scala 提供了 Tuple (元組)型別用於臨時存放多個不同型別的值,同時能夠保證型別安全性。千萬不要認為使用 Java 的 Array 型別也可以同樣實現 Tuple 型別的功能,它們之間有著本質的區別。Tuple 會顯式宣告所有元素的各自型別,而不是像 Java Array 那樣,元素型別會被向上轉型為所有元素的父型別。
我們可以這樣初始化一個 Tuple:
val t = ("abc", 123, true) val s: String= t._1 // 取第1個元素 val i: Int= t._2 // 取第2個元素 val b: Boolean = t._3 // 取第3個元素
需要注意的是 Tuple 的元素索引從1開始。
下面的示例程式碼是在一個長整型列表中尋找最大值,並返回這個最大值以及它所在的位置:
def max(list: List[Long]): (Long, Int) = list.zipWithIndex.sorted.reverse.head
我們通過 zipWithIndex 方法獲取每個元素的索引號,從而將 List[Long] 轉換成了 List[(Long, Int)],然後對其依次進行排序、倒序和取首元素,最終返回最大值及其所在位置。
鏈式呼叫
通過鏈式呼叫,我們可以將關注點放在資料的處理和轉換上,而無需考慮如何儲存和傳遞資料,同時也避免了建立大量無意義的中間變數,大大增強程式的可讀性。其實上面的 max 函式已經演示了鏈式呼叫。下面這段程式碼演示瞭如果在一個整型列表中尋找大於3的最小奇數:
val list = List(3, 6, 4, 1, 7, 8) list.filter(i => i % 2 == 1).filter(i => i > 3).sorted.head
非典型集合操作
Scala 的集合操作非常豐富,如果要詳細說明足夠寫一本書了。這裡僅列出一些不那麼常用但卻非常好用的操作。
去重:
List(1, 2, 2, 3).distinct // List(1, 2, 3)
交集:
Set(1, 2) & Set(2, 3)// Set(2)
並集:
Set(1, 2) | Set(2, 3) // Set(1, 2, 3)
差集:
Set(1, 2) &~ Set(2, 3) // Set(1)
排列:
List(1, 2, 3).permutations.toList //List(List(1, 2, 3), List(1, 3, 2), List(2, 1, 3), List(2, 3, 1), List(3, 1, 2), List(3, 2, 1))
組合:
List(1, 2, 3).combinations(2).toList // List(List(1, 2), List(1, 3), List(2, 3))
並行集合
Scala 的並行集合可以利用多核優勢加速計算過程,通過集合上的 par 方法,我們可以將原集合轉換成並行集合。並行集合利用分治演算法將計算任務分解成很多子任務,然後交給不同的執行緒執行,最後將計算結果進行彙總。下面是一個簡單的示例:
(1 to 10000).par.filter(i => i % 2 == 1).sum
優雅的值物件
挑逗指數: 五星
Case Class
Scala 標準庫包含了一個特殊的 Class 叫做 Case Class,專門用於領域層值物件的建模。它的好處是所有的預設行為都經過了合理的設計,開箱即用。下面我們使用 Case Class 定義了一個 User 值物件:
case class User(name: String, role: String = "user", addTime: Instant = Instant.now())
僅僅一行程式碼便完成了 User 類的定義,請腦補一下 Java 的實現。
簡潔的例項化方式
我們為 role 和 addTime 兩個屬性定義了預設值,所以我們可以只使用 name 建立一個 User 例項:
val u = User("jack")
在建立例項時,我們也可以命名引數(named parameter)語法改變預設值:
val u = User("jack", role = "admin")
在實際開發中,一個模型類或值物件可能擁有很多屬性,其實很多屬性都可以設定一個合理的預設值。利用預設值和命名引數,我們可以非常方便地建立模型類和值物件的例項。 所以在 Scala 中基本上不需要使用工廠模式或構造器模式建立物件,如果物件的建立過程確實非常複雜,則可以放在伴生物件中建立,例如:
object User { def apply(name: String): User = User(name, "user", Instant.now()) }
在使用伴生物件方法建立例項時可以省略方法名 apply,例如:
User("jack") // 等價於 User.apply("jack")
在這個例子裡,使用伴生物件方法例項化物件的程式碼,與上面使用類構造器的程式碼完全一樣,編譯器會優先選擇伴生物件的 apply 方法。
不可變性
Case Class 的例項是不可變的,意味著它可以被任意共享,併發訪問時也無需同步,大大地節省了寶貴的記憶體空間。而在 Java 中,物件被共享時需要進行深拷貝,否則一個地方的修改會影響到其它地方。例如在 Java 中定義了一個 Role 物件:
public class Role { public String id = ""; public String name = "user"; public Role(String id, String name) { this.id = id; this.name = name; } }
如果在兩個 User 之間共享 Role 例項就會出現問題,就像下面這樣:
u1.role = new Role("user", "user"); u2.role = u1.role;
當我們修改 u1.role 時,u2 就會受到影響,Java 的解決方式是要麼基於 u1.role 深度克隆一個新物件出來,要麼新建立一個 Role 物件賦值給 u2。
物件拷貝
在 Scala 中,既然 Case Class 是不可變的,那麼如果想改變它的值該怎麼辦呢?其實很簡單,利用命名引數可以很容易拷貝一個新的不可變物件出來:
val u1 = User("jack") val u2 = u1.copy(name = "role", role = "admin")
清晰的除錯資訊
我們不需要編寫額外的程式碼便可以得到清晰的除錯資訊,例如:
val users = List(User("jack"), User("rose")) println(users)
輸出內容如下:
List(User(jack,user,2018-10-20T13:03:16.170Z), User(rose,user,2018-10-20T13:03:16.170Z))
預設使用值比較相等性
在 Scala 中,預設採用值比較而非引用比較,使用起來更加符合直覺:
User("jack") == User("jack") // true
上面的值比較是開箱即用的,無需重寫 hashCode 和 equals 方法。
模式匹配
挑逗指數: 五星
更強的可讀性
當你的程式碼中存在多個 if 分支並且 if 之間還會有巢狀,那麼程式碼的可讀性將會大大降低。而在 Scala 中使用模式匹配可以很容易地解決這個問題,下面的程式碼演示貨幣型別的匹配:
sealed trait Currency case class Dollar(value: Double) extends Currency case class Euro(value: Double) extends Currency val Currency = ... currency match { case Dollar(v) => "$" + v case Euro(v) => "€" + v case _ => "unknown" }
我們也可以進行一些複雜的匹配,並且在匹配時可以增加 if 判斷:
use match { case User("jack", _, _) => ... case User(_, _, addTime) if addTime.isAfter(time) => ... case _ => ... }
變數賦值
利用模式匹配,我們可以快速提取特定部分的值並完成變數定義。 我們可以將 Tuple 中的值直接賦值給變數:
val tuple = ("jack", "user", Instant.now()) val (name, role, addTime) = tuple // 變數 name, role, addTime 在當前作用域內可以直接使用
對於 Case Class 也是一樣:
val User(name, role, addTime) = User("jack") // 變數 name, role, addTime 在當前作用域內可以直接使用
併發程式設計
挑逗指數: 五星
在 Scala 中,我們在編寫併發程式碼時只需要關心業務邏輯即可,而並不需要關注底層的執行緒池如何分配。Future 用於啟動一個非同步任務並且儲存執行結果,每個 Future 都在獨立的執行緒中執行。我們可以用 for 表示式收集多個 Future 的執行結果,從而避免回撥地獄:
val f1 = Future{ 1 + 2 } val f2 = Future{ 3 + 4 } for { v1 <- f1 v2 <- f2 }{ println(v1 + v2) // 10 }
使用 Future 開發爬蟲程式將會讓你事半功倍,假如你想同時抓取 100 個頁面資料,一行程式碼就可以了:
Future.sequence(urls.map(url => http.get(url))).forEach{ contents => ...}
Future.sequence 方法用於收集所有 Future 的執行結果,通過 forEach 方法我們可以取出收集結果並進行後續處理。
當我們要實現完全非同步的請求限流時,就需要精細地控制每個 Future 的執行時機。也就是說我們需要一個控制Future的開關,沒錯,這個開關就是Promise。每個Promise例項都會有一個唯一的Future與之相關聯:
val p = Promise[Int]() val f = p.future for (v <- f) { println(v) } // 3秒後才會執行列印操作 //3秒鐘之後返回3 Thread.sleep(3000) p.success(3)
跨執行緒錯誤處理
Java 通過異常機制處理錯誤,但是問題在於 Java 程式碼只能捕獲當前執行緒的異常,而無法跨執行緒捕獲異常。而在 Scala 中,我們可以通過 Future 捕獲任意執行緒中發生的異常。
非同步任務可能成功也可能失敗,所以我們需要一種既可以表示成功,也可以表示失敗的資料型別,在 Scala 中它就是 Try[T]。Try[T] 有兩個子型別,Success[T]表示成功,Failure[T]表示失敗。就像量子物理學中薛定諤的貓,在非同步任務執行之前,你根本無法預知返回的結果是 Success[T] 還是 Failure[T],只有當非同步任務完成執行以後結果才能確定下來。
val f = Future{ /*非同步任務*/ } // 當非同步任務執行完成時 f.value.get match { case Success(v) => // 處理成功情況 case Failure(t) => // 處理失敗情況 }
我們也可以讓一個 Future 從錯誤中恢復:
val f = Future{ /*非同步任務*/ } for{ result <- f.recover{ case t => /*處理錯誤*/ } } yield { // 處理結果 }
宣告式程式設計
挑逗指數: 四星
Scala 鼓勵宣告式程式設計,採用宣告式編寫的程式碼可讀性更強。與傳統的程序式程式設計相比,宣告式程式設計更關注我想做什麼而不是怎麼去做。例如我們經常要實現分頁操作,每頁返回 10 條資料:
val allUsers = List(User("jack"), User("rose")) val pageList = allUsers .sortBy(u => (u.role, u.name, u.addTime)) // 依次按 role, name, addTime 進行排序 .drop(page * 10) // 跳過之前頁資料 .take(10) // 取當前頁資料,如不足10個則全部返回
你只需要告訴 Scala 要做什麼,比如說先按 role 排序,如果 role 相同則按 name 排序,如果 role 和 name 都相同,再按 addTime 排序。底層具體的排序實現已經封裝好了,開發者無需實現。
面向表示式程式設計
挑逗指數: 四星
在 Scala 中,一切都是表示式,包括 if, for, while 等常見的控制結構均是表示式。表示式和語句的不同之處在於每個表示式都有明確的返回值。
val i = if(true){ 1 } else { 0 } // i = 1 val list1 = List(1, 2, 3) val list2 = for(i <- list1) yield { i + 1 }
不同的表示式可以組合在一起形成一個更大的表示式,再結合上模式匹配將會發揮巨大的威力。下面我們以一個計算加法的直譯器來做說明。
一個整數加法直譯器
我們首先定義基本的表示式型別:
abstract class Expr case class Number(num: Int) extends Expr case class PlusExpr(left: Expr, right: Expr) extends Expr
上面定義了兩個表示式型別,Number 表示一個整數表示式, PlusExpr 表示一個加法表示式。
下面我們基於模式匹配實現表示式的求值運算:
def evalExpr(expr: Expr): Int = { expr match { case Number(n) => n case PlusExpr(left, right) => evalExpr(left) + evalExpr(right) } }
我們來嘗試針對一個較大的表示式進行求值:
evalExpr(PlusExpr(PlusExpr(Number(1), Number(2)), PlusExpr(Number(3), Number(4)))) // 10
隱式引數和隱式轉換
挑逗指數: 五星
隱式引數
如果每當要執行非同步任務時,都需要顯式傳入執行緒池引數,你會不會覺得很煩?Scala 通過隱式引數為你解除這個煩惱。例如 Future 在建立非同步任務時就聲明瞭一個 ExecutionContext 型別的隱式引數,編譯器會自動在當前作用域內尋找合適的 ExecutionContext,如果找不到則會報編譯錯誤:
implicit val ec: ExecutionContext = ??? val f = Future { /*非同步任務*/ }
當然我們也可以顯式傳遞 ExecutionContext 引數,明確指定使用的執行緒池:
implicit val ec: ExecutionContext = ??? val f = Future { /*非同步任務*/ }(ec)
隱式轉換
隱式轉換相比較於隱式引數,使用起來更來靈活。如果 Scala 在編譯時發現了錯誤,在報錯之前,會先對錯誤程式碼應用隱式轉換規則,如果在應用規則之後可以使得其通過編譯,則表示成功地完成了一次隱式轉換。
在不同的庫間實現無縫對接
當傳入的引數型別和目標型別不匹配時,編譯器會嘗試隱式轉換。利用這個功能,我們將已有的資料型別無縫對接到三方庫上。例如我們想在 Scala 專案中使用 MongoDB 的官方 Java 驅動執行資料庫查詢操作,但是查詢介面接受的引數型別是 BsonDocument,由於使用 BsonDocument 構建查詢比較笨拙,我們希望能夠使用 Scala 的 JSON 庫構建一個查詢物件,然後直接傳遞給官方驅動的查詢介面,而無需改變官方驅動的任何程式碼,利用隱式轉換可以非常輕鬆地實現這個功能:
implicit def toBson(json: JsObject): BsonDocument =... val json: JsObject = Json.obj("_id" -> "0") jCollection.find(json) // 編譯器會自動呼叫 toBson(json)
利用隱式轉換,我們可以在不改動三方庫程式碼的情況下,將我們的資料型別與其進行無縫對接。例如我們通過實現一個隱式轉換,將 Scala 的 JsObject 型別無縫地對接到了 MongoDB 的官方 Java 驅動的查詢介面中,看起就像是 MongoDB 官方驅動真的提供了這個介面一樣。
同時我們也可以將來自三方庫的資料型別無縫整合到現有的介面中,也只需要實現一個隱式轉換方法即可。
擴充套件已有類的功能
例如我們定義了一個美元貨幣型別 Dollar:
class Dollar(value: Double) { def + (that: Dollar): Dollar = ... def + (that: Int): Dollar = ... }
於是我們可以執行如下操作:
val halfDollar = new Dollar(0.5) halfDollar + halfDollar // 1 dollar halfDollar + 0.5 // 1 dollar
但是我們卻無法執行像 0.5 + halfDollar 這樣的運算,因為在 Double 型別上無法找到一個合適的 + 方法。
在 Scala 中,為了實現上面的運算,我們只需要實現一個簡單的隱式轉換就可以了:
implicit def doubleToDollar(d: Double) = new Dollar(d) 0.5 + halfDollar // 等價於 doubleToDollar(0.5) + halfDollar
更好的執行時效能
在日常開發中,我們通常需要將值物件轉換成 Json 格式以方便資料傳輸。Java 的通常做法是使用反射,但是我們知道使用反射是要付出代價的,要承受執行時的效能開銷。而 Scala 則可以在編譯時為值物件生成隱式的 Json 編解碼器,這些編解碼器只不過是普通的函式呼叫而已,不涉及任何反射操作,在很大程度上提升了系統的執行時效能。
小結
如果你堅持讀到了這裡,我會覺得非常欣慰,很大可能上 Scala 的某些特性已經吸引了你。但是 Scala 的魅力遠不止如此,以上列舉的僅僅是一些最容易抓住你眼球的一些特性。如果你願意推開 Scala 這扇大門,你將會看到一個完全不一樣的程式設計世界。本文歡迎轉載,請註明作者沐風(joymufeng)。