1. 程式人生 > >挑逗 Java 程式設計師的那些 Scala 絕技

挑逗 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,則會導致左側的型別丟失,從而導致整個表示式的型別丟失:

var 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 函式已經演示了鏈式呼叫。下面我們演示一下如何使用集合操作實現 SQL 的關聯查詢功能,待實現的 SQL 語句如下:

SELECT p.name, p.company, c.country FROM people p JOIN companies c ON p.companyId = c.id
WHERE p.age == 20

上面 SQL 語句實現的功能是關聯查詢 people 和 companies 兩張表,返回年齡為20歲的所有員工名稱、年齡以及其所在公司名稱。

對應的 Scala 實現程式碼如下:

// Entity
case class People(name: String, age: Int, companyId: String)
case class Company(id: String, name: String)

// Entity List
val people    = List(People("jack", 20, "0"))
val companies = List(Company("0", "lightbend"))

// 實現關聯查詢
people
  .filter(p => p.age == 20)
  .flatMap{ p =>
    companies
      .filter(c => c.id == p.companyId)
      .map(c => (p.name, p.age, c.name))
}
//結果:List((jack,20,lightbend))

其實使用 for 表示式看起來更加簡潔:

for {
  p <- people if p.age == 20
  c <- companies if p.companyId == c.id
} yield (p.name, p.age, c.name)

非典型集合操作

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 用於啟動一個非同步任務並且儲存執行結果,我們可以用 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)。

文章轉自:https://my.oschina.net/joymufeng/blog/2251038