挑逗 Java 程序員的那些 Scala 絕技

有個問題一直困擾着 Scala 社區,爲什麼一些 Java 開發者將 Scala 捧到了天上,認爲它是來自上帝之吻的完美語言;而另外一些 Java 開發者卻對它望而卻步,認爲它過於複雜而難以理解。同樣是 Java 開發者,爲何會出現兩種截然不同的態度,我想這其中一定有誤會。Scala 是一粒金子,但是被一些表面上看起來非常複雜的概念或語法包裹的太嚴實,以至於人們很難在短時間內搞清楚它的價值。與此同時,Java 也在不斷地摸索前進,但是由於 Java 揹負了沉重的歷史包袱,所以每向前一步都顯得異常艱難。本文主要面向 Java 開發人員,希望從解決 Java 中實際存在的問題出發,梳理最容易吸引 Java 開發者的一些 Scala 特性。希望可以幫助大家快速找到那些真正可以打動你的點。

類型推斷

挑逗指數: 四星

我們知道,Scala 一向以強大的類型推斷聞名於世。很多時候,我們無須關心 Scala 類型推斷系統的存在,因爲很多時候它推斷的結果跟直覺是一致的。 Java 在 2016 年也新增了一份提議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.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)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章