Databricks Scala 編程風格指南
本文轉(zhuǎn)載自 https://github.com/databricks/scala-style-guide/blob/master/README-ZH.md
聲明 (Disclaimer)
The Chinese version of the Databricks Scala Guide is contributed and maintained by community member Hawstein. We do not guarantee that it will always be kept up-to-date.
本文檔翻譯自 Databricks Scala Guide,目前由 Hawstein 進行維護。由于是利用業(yè)余時間進行翻譯并維護,因此該中文文檔并不保證總是與原文檔一樣處于最新版本,不過我會盡可能及時地去更新它。
前言
Spark 有超過 1000 位貢獻者,就我們所知,應該是目前大數(shù)據(jù)領(lǐng)域里最大的開源項目且是最活躍的 Scala 項目。這份指南是在我們指導,或是與 Spark 貢獻者及 Databricks 工程團隊一起工作時總結(jié)出來的。
代碼由作者 一次編寫 ,然后由大量工程師 多次閱讀并修改 。事實上,大部分的 bug 來源于后人對代碼的修改,因此我們需要長期去優(yōu)化我們的代碼,提升代碼的可讀性和可維護性。達到這個目標最好的方式就是編寫簡單易懂的代碼。
Scala 是一種強大到令人難以置信的多范式編程語言。我們總結(jié)出了以下指南,它可以很好地應用在一個高速發(fā)展的項目。當然,這個指南并非絕對,根據(jù)團隊需求的不同,可以有不同的標準。
<a rel="license" ><img alt="Creative Commons License" style="border-width:0" src="https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png" /></a><br />This work is licensed under a <a rel="license" >Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License</a>.
<a name='TOC'>目錄</a>
<a name='history'>文檔歷史</a>
- 2015-03-16: 最初版本。
- 2015-05-25: 增加 override 修飾符 一節(jié)。
- 2015-08-23: 把一些規(guī)則的嚴重程度從「不要」降級到「避免」。
- 2015-11-17: 更新 apply 方法 一節(jié):伴生對象中的 apply 方法應該返回其伴生類。
- 2015-11-17: 該指南被翻譯成中文,由 Hawstein 進行維護,中文文檔并不保證總是與原文檔一樣處于最新版本。
- 2015-12-14: 該指南被翻譯成韓文, 韓文版本由 Hyukjin Kwon 進行翻譯并且由 Yun Park, Kevin (Sangwoo) Kim, Hyunje Jo 和 Woochel Choi 進行校對。韓文版本并不保證總是與原文檔一樣處于最新版本。
- 2016-06-15: 增加 匿名方法 一節(jié)。
- 2016-06-21: 增加 變量命名約定 一節(jié)。
- 2016-12-24: 增加 樣例類與不可變性 一節(jié)。
- 2017-02-23: 增加 測試 一節(jié)。
- 2017-04-18: 增加 優(yōu)先使用現(xiàn)存的經(jīng)過良好測試的方法而非重新發(fā)明輪子 一節(jié)。
<a name='syntactic'>語法風格</a>
<a name='naming'>命名約定</a>
我們主要遵循 Java 和 Scala 的標準命名約定。
-
類,trait, 對象應該遵循 Java 中類的命名約定,即 PascalCase 風格。
class ClusterManager trait Expression
-
包名應該遵循 Java 中包名的命名約定,即使用全小寫的 ASCII 字母。
package com.databricks.resourcemanager
方法/函數(shù)應當使用駝峰式風格命名。
-
常量命名使用全大寫字母,并將它們放在伴生對象中。
object Configuration { val DEFAULT_PORT = 10000 }
枚舉命名與類命名一致,使用 PascalCase 風格。
-
注解也應遵循 Java 中的約定,即使用 PascalCase 風格。注意,這一點與 Scala 的官方指南不同。
final class MyAnnotation extends StaticAnnotation
<a name='variable-naming'>變量命名約定</a>
-
變量命名應當遵循駝峰式命名方法,并且變量名應當是不言而喻的,即變量名可以直觀地反應它的涵義。
val serverPort = 1000 val clientPort = 2000
可以在小段的局部代碼中使用單字符的變量名,比如在小段的循環(huán)體中(例如 10 行以內(nèi)的代碼),“i” 常常被用作循環(huán)索引。然而,即使在小段的代碼中,也不要使用 “l(fā)” (Larry 中的 l)作為標識符,因為它看起來和 “1”,“|”,“I” 很像,難以區(qū)分,容易搞錯。
<a name='linelength'>一行長度</a>
- 一行長度的上限是 100 個字符。
- 唯一的例外是 import 語句和 URL (即便如此,也盡量將它們保持在 100 個字符以下)。
<a name='rule_of_30'>30 法則</a>
「如果一個元素包含的子元素超過 30 個,那么極有可能出現(xiàn)了嚴重的問題」 - Refactoring in Large Software Projects。
一般來說:
- 一個方法包含的代碼行數(shù)不宜超過 30 行。
- 一個類包含的方法數(shù)量不宜超過 30 個。
<a name='indent'>空格與縮進</a>
-
運算符前后保留一個空格,包括賦值運算符。
def add(int1: Int, int2: Int): Int = int1 + int2
-
逗號后保留一個空格。
Seq("a", "b", "c") // 使用這種方式 Seq("a","b","c") // 不要忽略逗號后的空格
-
冒號后保留一個空格。
// 使用這種方式 def getConf(key: String, defaultValue: String): String = { // some code } // 冒號前不需要使用空格 def calculateHeaderPortionInBytes(count: Int) : Int = { // some code } // 不要忽略冒號后的空格 def multiply(int1:Int, int2:Int): Int = int1 * int2
-
一般情況下,使用兩個空格的縮進。
if (true) { println("Wow!") }
-
對于方法聲明,如果一行無法容納下所有的參數(shù),那么使用 4 個空格來縮進它們。返回類型可以與最后一個參數(shù)在同一行,也可以放在下一行,使用兩個空格縮進。
def newAPIHadoopFile[K, V, F <: NewInputFormat[K, V]]( path: String, fClass: Class[F], kClass: Class[K], vClass: Class[V], conf: Configuration = hadoopConfiguration): RDD[(K, V)] = { // method body } def newAPIHadoopFile[K, V, F <: NewInputFormat[K, V]]( path: String, fClass: Class[F], kClass: Class[K], vClass: Class[V], conf: Configuration = hadoopConfiguration) : RDD[(K, V)] = { // method body }
-
如果一行無法容納下類頭(即 extends 后面那部分),則把它們放到新的一行,用兩個空格縮進,然后在類內(nèi)空一行再開始函數(shù)或字段的定義(或是包的導入)。
class Foo( val param1: String, // 4 space indent for parameters val param2: String, val param3: Array[Byte]) extends FooInterface // 2 space here with Logging { def firstMethod(): Unit = { ... } // blank line above }
-
不要使用垂直對齊。它使你的注意力放在代碼的錯誤部分并增大了后人修改代碼的難度。
// Don't align vertically val plus = "+" val minus = "-" val multiply = "*" // Do the following val plus = "+" val minus = "-" val multiply = "*"
<a name='blanklines'>空行</a>
- 一個空行可以出現(xiàn)在:
- 連續(xù)的類成員或初始化器(initializers)之間:字段,構(gòu)造函數(shù),方法,嵌套類,靜態(tài)初始化器及實例初始化器。
- 例外:連續(xù)的兩個字段之間的空行是可選的(前提是它們之間沒有其它代碼),這一類空行主要為這些字段做邏輯上的分組。
- 在方法體內(nèi),根據(jù)需要,使用空行來為語句創(chuàng)建邏輯上的分組。
- 在類的第一個成員之前或最后一個成員之后,空行都是可選的(既不鼓勵也不阻止)。
- 連續(xù)的類成員或初始化器(initializers)之間:字段,構(gòu)造函數(shù),方法,嵌套類,靜態(tài)初始化器及實例初始化器。
- 使用一個或兩個空行來分隔不同類的定義。
- 不鼓勵使用過多的空行。
<a name='parentheses'>括號</a>
-
方法聲明應該加括號(即使沒有參數(shù)列表),除非它們是沒有副作用(狀態(tài)改變,IO 操作都認為是有副作用的)的訪問器(accessor)。
class Job { // Wrong: killJob changes state. Should have (). def killJob: Unit // Correct: def killJob(): Unit }
-
函數(shù)調(diào)用應該與函數(shù)聲明在形式上保持一致,也就是說,如果一個方法聲明時帶了括號,那調(diào)用時也要把括號帶上。注意這不僅僅是語法層面的人為約定,當返回對象中定義了
apply
方法時,這一點還會影響正確性。class Foo { def apply(args: String*): Int } class Bar { def foo: Foo } new Bar().foo // This returns a Foo new Bar().foo() // This returns an Int!
<a name='curly'>大括號</a>
即使條件語句或循環(huán)語句只有一行時,也請使用大括號。唯一的例外是,當你把 if/else 作為一個單行的三元操作符來使用并且沒有副作用時,這時你可以不加大括號。
// Correct:
if (true) {
println("Wow!")
}
// Correct:
if (true) statement1 else statement2
// Correct:
try {
foo()
} catch {
...
}
// Wrong:
if (true)
println("Wow!")
// Wrong:
try foo() catch {
...
}
<a name='long_literal'>長整型字面量</a>
長整型字面量使用大寫的 L
作為后綴,不要使用小寫,因為它和數(shù)字 1
長得很像,常常難以區(qū)分。
val longValue = 5432L // Do this
val longValue = 5432l // Do NOT do this
<a name='doc'>文檔風格</a>
使用 Java Doc 風格,而非 Scala Doc 風格。
/** This is a correct one-liner, short description. */
/**
* This is correct multi-line JavaDoc comment. And
* this is my second line, and if I keep typing, this would be
* my third line.
*/
/** In Spark, we don't use the ScalaDoc style so this
* is not correct.
*/
<a name='ordering_class'>類內(nèi)秩序</a>
如果一個類很長,包含許多的方法,那么在邏輯上把它們分成不同的部分并加上注釋頭,以此組織它們。
class DataFrame {
///////////////////////////////////////////////////////////////////////////
// DataFrame operations
///////////////////////////////////////////////////////////////////////////
...
///////////////////////////////////////////////////////////////////////////
// RDD operations
///////////////////////////////////////////////////////////////////////////
...
}
當然,強烈不建議把一個類寫得這么長,一般只有在構(gòu)建某些公共 API 時才允許這么做。
<a name='imports'>Imports</a>
導入時避免使用通配符, 除非你需要導入超過 6 個實體或者隱式方法。通配符導入會使代碼在面對外部變化時不夠健壯。
始終使用絕對路徑來導入包 (如:
scala.util.Random
) ,而不是相對路徑 (如:util.Random
)。-
此外,導入語句按照以下順序排序:
-
java.*
和javax.*
scala.*
- 第三方庫 (
org.*
,com.*
, 等) - 項目中的類 (對于 Spark 項目,即
com.databricks.*
或org.apache.spark
)
-
在每一組導入語句內(nèi),按照字母序進行排序。
-
你可以使用 IntelliJ 的「import organizer」來自動處理,請使用以下配置:
java javax _______ blank line _______ scala _______ blank line _______ all other imports _______ blank line _______ com.databricks // or org.apache.spark if you are working on spark
<a name='pattern-matching'>模式匹配</a>
-
如果整個方法就是一個模式匹配表達式,可能的話,可以把 match 關(guān)鍵詞與方法聲明放在同一行,以此減少一級縮進。
def test(msg: Message): Unit = msg match { case ... }
-
當以閉包形式調(diào)用一個函數(shù)時,如果只有一個 case 語句,那么把 case 語句與函數(shù)調(diào)用放在同一行。
list.zipWithIndex.map { case (elem, i) => // ... }
如果有多個 case 語句,把它們縮進并且包起來。
list.map { case a: Foo => ... case b: Bar => ... }
如果唯一的目的就是想匹配某個對象的類型,那么不要展開所有的參數(shù)來做模式匹配,這樣會使得重構(gòu)變得更加困難,代碼更容易出錯。
case class Pokemon(name: String, weight: Int, hp: Int, attack: Int, defense: Int)
case class Human(name: String, hp: Int)
// 不要像下面那樣做,因為
// 1. 當 pokemon 加入一個新的字段,我們需要改變下面的模式匹配代碼
// 2. 非常容易發(fā)生誤匹配,尤其是當所有字段的類型都一樣的時候
targets.foreach {
case target @ Pokemon(_, _, hp, _, defense) =>
val loss = sys.min(0, myAttack - defense)
target.copy(hp = hp - loss)
case target @ Human(_, hp) =>
target.copy(hp = hp - myAttack)
}
// 像下面這樣做就好多了:
targets.foreach {
case target: Pokemon =>
val loss = sys.min(0, myAttack - target.defense)
target.copy(hp = target.hp - loss)
case target: Human =>
target.copy(hp = target.hp - myAttack)
}
<a name='infix'>中綴方法</a>
避免中綴表示法,除非是符號方法(即運算符重載)。
// Correct
list.map(func)
string.contains("foo")
// Wrong
list map (func)
string contains "foo"
// 重載的運算符應該以中綴形式調(diào)用
arrayBuffer += elem
<a name='anonymous'>匿名方法</a>
對于匿名方法,避免使用過多的小括號和花括號。
// Correct
list.map { item =>
...
}
// Correct
list.map(item => ...)
// Wrong
list.map(item => {
...
})
// Wrong
list.map { item => {
...
}}
// Wrong
list.map({ item => ... })
<a name='lang'>Scala 語言特性</a>
<a name='case_class_immutability'>樣例類與不可變性</a>
樣例類(case class)本質(zhì)也是普通的類,編譯器會自動地為它加上以下支持:
- 構(gòu)造器參數(shù)的公有 getter 方法
- 拷貝構(gòu)造函數(shù)
- 構(gòu)造器參數(shù)的模式匹配
- 默認的 toString/hash/equals 實現(xiàn)
對于樣例類來說,構(gòu)造器參數(shù)不應設為可變的,可以使用拷貝構(gòu)造函數(shù)達到同樣的效果。使用可變的樣例類容易出錯,例如,哈希表中,對象根據(jù)舊的哈希值被放在錯誤的位置上。
// This is OK
case class Person(name: String, age: Int)
// This is NOT OK
case class Person(name: String, var age: Int)
// 通過拷貝構(gòu)造函數(shù)創(chuàng)建一個新的實例來改變其中的值
val p1 = Person("Peter", 15)
val p2 = p2.copy(age = 16)
<a name='apply_method'>apply 方法</a>
避免在類里定義 apply 方法。這些方法往往會使代碼的可讀性變差,尤其是對于不熟悉 Scala 的人。它也難以被 IDE(或 grep)所跟蹤。在最壞的情況下,它還可能影響代碼的正確性,正如你在括號一節(jié)中看到的。
然而,將 apply 方法作為工廠方法定義在伴生對象中是可以接受的。在這種情況下,apply 方法應該返回其伴生類的類型。
object TreeNode {
// 下面這種定義是 OK 的
def apply(name: String): TreeNode = ...
// 不要像下面那樣定義,因為它沒有返回其伴生類的類型:TreeNode
def apply(name: String): String = ...
}
<A name='override_modifier'>override 修飾符</a>
無論是覆蓋具體的方法還是實現(xiàn)抽象的方法,始終都為方法加上 override 修飾符。實現(xiàn)抽象方法時,不加 override 修飾符,Scala 編譯器也不會報錯。即便如此,我們也應該始終把 override 修飾符加上,以此顯式地表示覆蓋行為。以此避免由于方法簽名不同(而你也難以發(fā)現(xiàn))而導致沒有覆蓋到本應覆蓋的方法。
trait Parent {
def hello(data: Map[String, String]): Unit = {
print(data)
}
}
class Child extends Parent {
import scala.collection.Map
// 下面的方法沒有覆蓋 Parent.hello,
// 因為兩個 Map 的類型是不同的。
// 如果我們加上 override 修飾符,編譯器就會幫你找出問題并報錯。
def hello(data: Map[String, String]): Unit = {
print("This is supposed to override the parent method, but it is actually not!")
}
}
<a name='destruct_bind'>解構(gòu)綁定</a>
解構(gòu)綁定(有時也叫元組提?。┦且环N在一個表達式中為兩個變量賦值的便捷方式。
val (a, b) = (1, 2)
然而,請不要在構(gòu)造函數(shù)中使用它們,尤其是當 a
和 b
需要被標記為 transient
的時候。Scala 編譯器會產(chǎn)生一個額外的 Tuple2 字段,而它并不是暫態(tài)的(transient)。
class MyClass {
// 以下代碼無法 work,因為編譯器會產(chǎn)生一個非暫態(tài)的 Tuple2 指向 a 和 b
@transient private val (a, b) = someFuncThatReturnsTuple2()
}
<a name='call_by_name'>按名稱傳參</a>
避免使用按名傳參. 顯式地使用 () => T
。
背景:Scala 允許按名稱來定義方法參數(shù),例如:以下例子是可以成功執(zhí)行的:
def print(value: => Int): Unit = {
println(value)
println(value + 1)
}
var a = 0
def inc(): Int = {
a += 1
a
}
print(inc())
在上面的代碼中,inc()
以閉包的形式傳遞給 print
函數(shù),并且在 print
函數(shù)中被執(zhí)行了兩次,而不是以數(shù)值 1
傳入。按名傳參的一個主要問題是在方法調(diào)用處,我們無法區(qū)分是按名傳參還是按值傳參。因此無法確切地知道這個表達式是否會被執(zhí)行(更糟糕的是它可能會被執(zhí)行多次)。對于帶有副作用的表達式來說,這一點是非常危險的。
<A name='multi-param-list'>多參數(shù)列表</a>
避免使用多參數(shù)列表。它們使運算符重載變得復雜,并且會使不熟悉 Scala 的程序員感到困惑。例如:
// Avoid this!
case class Person(name: String, age: Int)(secret: String)
一個值得注意的例外是,當在定義底層庫時,可以使用第二個參數(shù)列表來存放隱式(implicit)參數(shù)。盡管如此,我們應該避免使用 implicits!
<a name='symbolic_methods'>符號方法(運算符重載)</a>
不要使用符號作為方法名,除非你是在定義算術(shù)運算的方法(如:+
, -
, *
, /
),否則在任何其它情況下,都不要使用。符號化的方法名讓人難以理解方法的意圖是什么,來看下面兩個例子:
// 符號化的方法名難以理解
channel ! msg
stream1 >>= stream2
// 下面的方法意圖則不言而喻
channel.send(msg)
stream1.join(stream2)
<a name='type_inference'>類型推導</a>
Scala 的類型推導,尤其是左側(cè)類型推導以及閉包推導,可以使代碼變得更加簡潔。盡管如此,也有一些情況我們是需要顯式地聲明類型的:
- 公有方法應該顯式地聲明類型,編譯器推導出來的類型往往會使你大吃一驚。
- 隱式方法應該顯式地聲明類型,否則在增量編譯時,它會使 Scala 編譯器崩潰。
- 如果變量或閉包的類型并非顯而易見,請顯式聲明類型。一個不錯的判斷準則是,如果評審代碼的人無法在 3 秒內(nèi)確定相應實體的類型,那么你就應該顯式地聲明類型。
<a name='return'>Return 語句</a>
閉包中避免使用 return。return
會被編譯器轉(zhuǎn)成 scala.runtime.NonLocalReturnControl
異常的 try/catch
語句,這可能會導致意外行為。請看下面的例子:
def receive(rpc: WebSocketRPC): Option[Response] = {
tableFut.onComplete { table =>
if (table.isFailure) {
return None // Do not do that!
} else { ... }
}
}
.onComplete
方法接收一個匿名閉包并把它傳遞到一個不同的線程中。這個閉包最終會拋出一個 NonLocalReturnControl
異常,并在 一個不同的線程中被捕獲,而這里執(zhí)行的方法卻沒有任何影響。
然而,也有少數(shù)情況我們是推薦使用 return
的。
-
使用
return
來簡化控制流,避免增加一級縮進。def doSomething(obj: Any): Any = { if (obj eq null) { return null } // do something ... }
-
使用
return
來提前終止循環(huán),這樣就不用額外構(gòu)造狀態(tài)標志。while (true) { if (cond) { return } }
<a name='recursion'>遞歸及尾遞歸</a>
避免使用遞歸,除非問題可以非常自然地用遞歸來描述(比如,圖和樹的遍歷)。
對于那些你意欲使之成為尾遞歸的方法,請加上 @tailrec
注解以確保編譯器去檢查它是否真的是尾遞歸(你會非常驚訝地看到,由于使用了閉包和函數(shù)變換,許多看似尾遞歸的代碼事實并非尾遞歸)。
大多數(shù)的代碼使用簡單的循環(huán)和狀態(tài)機會更容易推理,使用尾遞歸反而可能會使它更加繁瑣且難以理解。例如,下面的例子中,命令式的代碼比尾遞歸版本的代碼要更加易讀:
// Tail recursive version.
def max(data: Array[Int]): Int = {
@tailrec
def max0(data: Array[Int], pos: Int, max: Int): Int = {
if (pos == data.length) {
max
} else {
max0(data, pos + 1, if (data(pos) > max) data(pos) else max)
}
}
max0(data, 0, Int.MinValue)
}
// Explicit loop version
def max(data: Array[Int]): Int = {
var max = Int.MinValue
for (v <- data) {
if (v > max) {
max = v
}
}
max
}
<a name='implicits'>Implicits</a>
避免使用 implicit,除非:
- 你在構(gòu)建領(lǐng)域特定的語言(DSL)
- 你在隱式類型參數(shù)中使用它(如:
ClassTag
,TypeTag
) - 你在你自己的類中使用它(意指不要污染外部空間),以此減少類型轉(zhuǎn)換的冗余度(如:Scala 閉包到 Java 閉包的轉(zhuǎn)換)。
當使用 implicit 時,我們應該確保另一個工程師可以直接理解使用語義,而無需去閱讀隱式定義本身。Implicit 有著非常復雜的解析規(guī)則,這會使代碼變得極其難以理解。Twitter 的 Effective Scala 指南中寫道:「如果你發(fā)現(xiàn)你在使用 implicit,始終停下來問一下你自己,是否可以在不使用 implicit 的條件下達到相同的效果」。
如果你必需使用它們(比如:豐富 DSL),那么不要重載隱式方法,即確保每個隱式方法有著不同的名字,這樣使用者就可以選擇性地導入它們。
// 別這么做,這樣使用者無法選擇性地只導入其中一個方法。
object ImplicitHolder {
def toRdd(seq: Seq[Int]): RDD[Int] = ...
def toRdd(seq: Seq[Long]): RDD[Long] = ...
}
// 應該將它們定義為不同的名字:
object ImplicitHolder {
def intSeqToRdd(seq: Seq[Int]): RDD[Int] = ...
def longSeqToRdd(seq: Seq[Long]): RDD[Long] = ...
}
<a name='exception'>異常處理 (Try 還是 try)</a>
-
不要捕獲 Throwable 或 Exception 類型的異常。請使用
scala.util.control.NonFatal
:try { ... } catch { case NonFatal(e) => // 異常處理;注意 NonFatal 無法匹配 InterruptedException 類型的異常 case e: InterruptedException => // 處理 InterruptedException }
這能保證我們不會去捕獲
NonLocalReturnControl
異常(正如在Return 語句中所解釋的)。 -
不要在 API 中使用
Try
,即,不要在任何方法中返回 Try。對于異常執(zhí)行,請顯式地拋出異常,并使用 Java 風格的 try/catch 做異常處理。背景資料:Scala 提供了單子(monadic)錯誤處理(通過
Try
,Success
和Failure
),這樣便于做鏈式處理。然而,根據(jù)我們的經(jīng)驗,發(fā)現(xiàn)使用它通常會帶來更多的嵌套層級,使得代碼難以閱讀。此外,對于預期錯誤還是異常,在語義上常常是不明晰的。因此,我們不鼓勵使用Try
來做錯誤處理,尤其是以下情況:一個人為的例子:
class UserService { /** Look up a user's profile in the user database. */ def get(userId: Int): Try[User] }
以下的寫法會更好:
class UserService { /** * Look up a user's profile in the user database. * @return None if the user is not found. * @throws DatabaseConnectionException when we have trouble connecting to the database/ */ @throws(DatabaseConnectionException) def get(userId: Int): Option[User] }
第二種寫法非常明顯地能讓調(diào)用者知道需要處理哪些錯誤情況。
<a name='option'>Options</a>
如果一個值可能為空,那么請使用
Option
。相對于null
,Option
顯式地表明了一個 API 的返回值可能為空。-
構(gòu)造
Option
值時,請使用Option
而非Some
,以防那個值為null
。def myMethod1(input: String): Option[String] = Option(transform(input)) // This is not as robust because transform can return null, and then // myMethod2 will return Some(null). def myMethod2(input: String): Option[String] = Some(transform(input))
不要使用 None 來表示異常,有異常時請顯式拋出。
不要在一個
Option
值上直接調(diào)用get
方法,除非你百分百確定那個Option
值不是None
。
<a name='chaining'>單子鏈接</a>
單子鏈接是 Scala 的一個強大特性。Scala 中幾乎一切都是單子(如:集合,Option,F(xiàn)uture,Try 等),對它們的操作可以鏈接在一起。這是一個非常強大的概念,但你應該謹慎使用,尤其是:
- 避免鏈接(或嵌套)超過 3 個操作。
- 如果需要花超過 5 秒鐘來理解其中的邏輯,那么你應該盡量去想想有沒什么辦法在不使用單子鏈接的條件下來達到相同的效果。一般來說,你需要注意的是:不要濫用
flatMap
和fold
。 - 鏈接應該在 flatMap 之后斷開(因為類型發(fā)生了變化)。
通過給中間結(jié)果顯式地賦予一個變量名,將鏈接斷開變成一種更加過程化的風格,能讓單子鏈接更加易于理解。來看下面的例子:
class Person(val data: Map[String, String])
val database = Map[String, Person]
// Sometimes the client can store "null" value in the store "address"
// A monadic chaining approach
def getAddress(name: String): Option[String] = {
database.get(name).flatMap { elem =>
elem.data.get("address")
.flatMap(Option.apply) // handle null value
}
}
// 盡管代碼會長一些,但以下方法可讀性更高
def getAddress(name: String): Option[String] = {
if (!database.contains(name)) {
return None
}
database(name).data.get("address") match {
case Some(null) => None // handle null value
case Some(addr) => Option(addr)
case None => None
}
}
<a name='concurrency'>并發(fā)</a>
<a name='concurrency-scala-collection'>Scala concurrent.Map</a>
優(yōu)先考慮使用 java.util.concurrent.ConcurrentHashMap
而非 scala.collection.concurrent.Map
。尤其是 scala.collection.concurrent.Map
中的 getOrElseUpdate
方法要慎用,它并非原子操作(這個問題在 Scala 2.11.16 中 fix 了:SI-7943)。由于我們做的所有項目都需要在 Scala 2.10 和 Scala 2.11 上使用,因此要避免使用 scala.collection.concurrent.Map
。
<a name='concurrency-sync-vs-map'>顯式同步 vs 并發(fā)集合</a>
有 3 種推薦的方法來安全地并發(fā)訪問共享狀態(tài)。不要混用它們,因為這會使程序變得難以推理,并且可能導致死鎖。
-
java.util.concurrent.ConcurrentHashMap
:當所有的狀態(tài)都存儲在一個 map 中,并且有高程度的競爭時使用。private[this] val map = new java.util.concurrent.ConcurrentHashMap[String, String]
-
java.util.Collections.synchronizedMap
:使用情景:當所有狀態(tài)都存儲在一個 map 中,并且預期不存在競爭情況,但你仍想確保代碼在并發(fā)下是安全的。如果沒有競爭出現(xiàn),JVM 的 JIT 編譯器能夠通過偏置鎖(biased locking)移除同步開銷。private[this] val map = java.util.Collections.synchronizedMap(new java.util.HashMap[String, String])
-
通過同步所有臨界區(qū)進行顯式同步,可用于監(jiān)視多個變量。與 2 相似,JVM 的 JIT 編譯器能夠通過偏置鎖(biased locking)移除同步開銷。
class Manager { private[this] var count = 0 private[this] val map = new java.util.HashMap[String, String] def update(key: String, value: String): Unit = synchronized { map.put(key, value) count += 1 } def getCount: Int = synchronized { count } }
注意,對于 case 1 和 case 2,不要讓集合的視圖或迭代器從保護區(qū)域逃逸。這可能會以一種不明顯的方式發(fā)生,比如:返回了 Map.keySet
或 Map.values
。如果需要傳遞集合的視圖或值,生成一份數(shù)據(jù)拷貝再傳遞。
val map = java.util.Collections.synchronizedMap(new java.util.HashMap[String, String])
// This is broken!
def values: Iterable[String] = map.values
// Instead, copy the elements
def values: Iterable[String] = map.synchronized { Seq(map.values: _*) }
<a name='concurrency-sync-vs-atomic'>顯式同步 vs 原子變量 vs @volatile</a>
java.util.concurrent.atomic
包提供了對基本類型的無鎖訪問,比如:AtomicBoolean
, AtomicInteger
和 AtomicReference
。
始終優(yōu)先考慮使用原子變量而非 @volatile
,它們是相關(guān)功能的嚴格超集并且從代碼上看更加明顯。原子變量的底層實現(xiàn)使用了 @volatile
。
優(yōu)先考慮使用原子變量而非顯式同步的情況:(1)一個對象的所有臨界區(qū)更新都被限制在單個變量里并且預期會有競爭情況出現(xiàn)。原子變量是無鎖的并且允許更為有效的競爭。(2)同步被明確地表示為 getAndSet
操作。例如:
// good: 明確又有效地表達了下面的并發(fā)代碼只執(zhí)行一次
val initialized = new AtomicBoolean(false)
...
if (!initialized.getAndSet(true)) {
...
}
// poor: 下面的同步就沒那么明晰,而且會出現(xiàn)不必要的同步
val initialized = false
...
var wasInitialized = false
synchronized {
wasInitialized = initialized
initialized = true
}
if (!wasInitialized) {
...
}
<a name='concurrency-private-this'>私有字段</a>
注意,private
字段仍然可以被相同類的其它實例所訪問,所以僅僅通過 this.synchronized
(或 synchronized
)來保護它從技術(shù)上來說是不夠的,不過你可以通過 private[this]
修飾私有字段來達到目的。
// 以下代碼仍然是不安全的。
class Foo {
private var count: Int = 0
def inc(): Unit = synchronized { count += 1 }
}
// 以下代碼是安全的。
class Foo {
private[this] var count: Int = 0
def inc(): Unit = synchronized { count += 1 }
}
<a name='concurrency-isolation'>隔離</a>
一般來說,并發(fā)和同步邏輯應該盡可能地被隔離和包含起來。這實際上意味著:
- 避免在 API 層面、面向用戶的方法以及回調(diào)中暴露同步原語。
- 對于復雜模塊,創(chuàng)建一個小的內(nèi)部模塊來包含并發(fā)原語。
<a name='perf'>性能</a>
對于你寫的絕大多數(shù)代碼,性能都不應該成為一個問題。然而,對于一些性能敏感的代碼,以下有一些小建議:
<a name='perf-microbenchmarks'>Microbenchmarks</a>
由于 Scala 編譯器和 JVM JIT 編譯器會對你的代碼做許多神奇的事情,因此要寫出一個好的微基準程序(microbenchmark)是極其困難的。更多的情況往往是你的微基準程序并沒有測量你想要測量的東西。
如果你要寫一個微基準程序,請使用 jmh。請確保你閱讀了所有的樣例,這樣你才理解微基準程序中「死代碼」移除、常量折疊以及循環(huán)展開的效果。
<a name='perf-whileloops'>Traversal 與 zipWithIndex</a>
使用 while
循環(huán)而非 for
循環(huán)或函數(shù)變換(如:map
、foreach
),for 循環(huán)和函數(shù)變換非常慢(由于虛函數(shù)調(diào)用和裝箱的緣故)。
val arr = // array of ints
// 偶數(shù)位置的數(shù)置零
val newArr = list.zipWithIndex.map { case (elem, i) =>
if (i % 2 == 0) 0 else elem
}
// 這是上面代碼的高性能版本
val newArr = new Array[Int](arr.length)
var i = 0
val len = newArr.length
while (i < len) {
newArr(i) = if (i % 2 == 0) 0 else arr(i)
i += 1
}
<a name='perf-option'>Option 與 null</a>
對于性能有要求的代碼,優(yōu)先考慮使用 null
而不是 Option
,以此避免虛函數(shù)調(diào)用以及裝箱操作。用 Nullable 注解明確標示出可能為 null
的值。
class Foo {
@javax.annotation.Nullable
private[this] var nullableField: Bar = _
}
<a name='perf-collection'>Scala 集合庫</a>
對于性能有要求的代碼,優(yōu)先考慮使用 Java 集合庫而非 Scala 集合庫,因為一般來說,Scala 集合庫要比 Java 的集合庫慢。
<a name='perf-private'>private[this]</a>
對于性能有要求的代碼,優(yōu)先考慮使用 private[this]
而非 private
。private[this]
生成一個字段而非生成一個訪問方法。根據(jù)我們的經(jīng)驗,JVM JIT 編譯器并不總是會內(nèi)聯(lián) private
字段的訪問方法,因此通過使用
private[this]
來確保沒有虛函數(shù)調(diào)用會更保險。
class MyClass {
private val field1 = ...
private[this] val field2 = ...
def perfSensitiveMethod(): Unit = {
var i = 0
while (i < 1000000) {
field1 // This might invoke a virtual method call
field2 // This is just a field access
i += 1
}
}
}
<a name='java'>與 Java 的互操作性</a>
本節(jié)內(nèi)容介紹的是構(gòu)建 Java 兼容 API 的準則。如果你構(gòu)建的組件并不需要與 Java 有交互,那么請無視這一節(jié)。這一節(jié)的內(nèi)容主要是從我們開發(fā) Spark 的 Java API 的經(jīng)歷中得出的。
<a name='java-missing-features'>Scala 中缺失的 Java 特性</a>
以下的 Java 特性在 Scala 中是沒有的,如果你需要使用以下特性,請在 Java 中定義它們。然而,需要提醒一點的是,你無法為 Java 源文件生成 ScalaDoc。
- 靜態(tài)字段
- 靜態(tài)內(nèi)部類
- Java 枚舉
- 注解
<a name='java-traits'>Traits 與抽象類</a>
對于允許從外部實現(xiàn)的接口,請記住以下幾點:
- 包含了默認方法實現(xiàn)的 trait 是無法在 Java 中使用的,請使用抽象類來代替。
- 一般情況下,請避免使用 trait,除非你百分百確定這個接口即使在未來也不會有默認的方法實現(xiàn)。
// 以下默認實現(xiàn)無法在 Java 中使用
trait Listener {
def onTermination(): Unit = { ... }
}
// 可以在 Java 中使用
abstract class Listener {
def onTermination(): Unit = { ... }
}
<a name='java-type-alias'>類型別名</a>
不要使用類型別名,它們在字節(jié)碼和 Java 中是不可見的。
<a name='java-default-param-values'>默認參數(shù)值</a>
不要使用默認參數(shù)值,通過重載方法來代替。
// 打破了與 Java 的互操作性
def sample(ratio: Double, withReplacement: Boolean = false): RDD[T] = { ... }
// 以下方法是 work 的
def sample(ratio: Double, withReplacement: Boolean): RDD[T] = { ... }
def sample(ratio: Double): RDD[T] = sample(ratio, withReplacement = false)
<a name='java-multi-param-list'>多參數(shù)列表</a>
不要使用多參數(shù)列表。
<a name='java-varargs'>可變參數(shù)</a>
-
為可變參數(shù)方法添加
@scala.annotation.varargs
注解,以確保它能在 Java 中使用。Scala 編譯器會生成兩個方法,一個給 Scala 使用(字節(jié)碼參數(shù)是一個 Seq),另一個給 Java 使用(字節(jié)碼參數(shù)是一個數(shù)組)。@scala.annotation.varargs def select(exprs: Expression*): DataFrame = { ... }
需要注意的一點是,由于 Scala 編譯器的一個 bug(SI-1459,SI-9013),抽象的變參方法是無法在 Java 中使用的。
-
重載變參方法時要小心,用另一個類型去重載變參方法會破壞源碼的兼容性。
class Database { @scala.annotation.varargs def remove(elems: String*): Unit = ... // 當調(diào)用無參的 remove 方法時會出問題。 @scala.annotation.varargs def remove(elems: People*): Unit = ... } // remove 方法有歧義,因此編譯不過。 new Database().remove()
一種解決方法是,在可變參數(shù)前顯式地定義第一個參數(shù):
class Database { @scala.annotation.varargs def remove(elems: String*): Unit = ... // 以下重載是 OK 的。 @scala.annotation.varargs def remove(elem: People, elems: People*): Unit = ... }
<a name='java-implicits'>Implicits</a>
不要為類或方法使用 implicit,包括了不要使用 ClassTag
和 TypeTag
。
class JavaFriendlyAPI {
// 以下定義對 Java 是不友好的,因為方法中包含了一個隱式參數(shù)(ClassTag)。
def convertTo[T: ClassTag](): T
}
<a name='java-companion-object'>伴生對象,靜態(tài)方法與字段</a>
當涉及到伴生對象和靜態(tài)方法/字段時,有幾件事情是需要注意的:
-
伴生對象在 Java 中的使用是非常別扭的(伴生對象
Foo
會被定義為Foo$
類內(nèi)的一個類型為Foo$
的靜態(tài)字段MODULE$
)。object Foo // 等價于以下的 Java 代碼 public class Foo$ { Foo$ MODULE$ = // 對象的實例化 }
如果非要使用伴生對象,可以在一個單獨的類中創(chuàng)建一個 Java 靜態(tài)字段。
不幸的是,沒有辦法在 Scala 中定義一個 JVM 靜態(tài)字段。請創(chuàng)建一個 Java 文件來定義它。
-
伴生對象里的方法會被自動轉(zhuǎn)成伴生類里的靜態(tài)方法,除非方法名有沖突。確保靜態(tài)方法正確生成的最好方式是用 Java 寫一個測試文件,然后調(diào)用生成的靜態(tài)方法。
class Foo { def method2(): Unit = { ... } } object Foo { def method1(): Unit = { ... } // 靜態(tài)方法 Foo.method1 會被創(chuàng)建(字節(jié)碼) def method2(): Unit = { ... } // 靜態(tài)方法 Foo.method2 不會被創(chuàng)建 } // FooJavaTest.java (in test/scala/com/databricks/...) public class FooJavaTest { public static void compileTest() { Foo.method1(); // 正常編譯 Foo.method2(); // 編譯失敗,因為 method2 并沒有生成 } }
-
樣例對象(case object) MyClass 的類型并不是 MyClass。
case object MyClass // Test.java if (MyClass$.MODULE instanceof MyClass) { // 上述條件始終為 false }
要實現(xiàn)正確的類型層級結(jié)構(gòu),請定義一個伴生類,然后用一個樣例對象去繼承它:
class MyClass case object MyClass extends MyClass
<a name='testing'>測試</a>
<a name='testing-intercepting'>異常攔截</a>
當測試某個操作(比如用無效的參數(shù)調(diào)用一個函數(shù))是否會拋出異常時,對于拋出的異常類型指定得越具體越好。你不應該簡單地使用 intercept[Exception]
或 intercept[Throwable]
(ScalaTest 語法),這能攔截任意異常,只能斷言有異常拋出,而不能確定是什么異常。這樣做在測試中能捕獲到代碼中的異常并且通過測試,然而卻沒真正檢驗你想驗證的行為。
// 不要使用下面這種方式
intercept[Exception] {
thingThatThrowsException()
}
// 這才是推薦的做法
intercept[MySpecificTypeOfException] {
thingThatThrowsException()
}
如果你無法指定代碼會拋出的異常的具體類型,說明你這段代碼可能寫得不好,需要重構(gòu)。這種情況下,你要么測試更底層的代碼,要么改寫代碼令其拋出類型更加具體的異常。
<a name='misc'>其它</a>
<a name='misc_currentTimeMillis_vs_nanoTime'>優(yōu)先使用 nanoTime 而非 currentTimeMillis</a>
當要計算持續(xù)時間或者檢查超時的時候,避免使用 System.currentTimeMillis()
。請使用 System.nanoTime()
,即使你對亞毫秒級的精度并不感興趣。
System.currentTimeMillis()
返回的是當前的時鐘時間,并且會跟進系統(tǒng)時鐘的改變。因此,負的時鐘調(diào)整可能會導致超時而掛起很長一段時間(直到時鐘時間趕上先前的值)。這種情況可能發(fā)生在網(wǎng)絡已經(jīng)中斷一段時間,ntpd 走過了一步之后。最典型的例子是,在系統(tǒng)啟動的過程中,DHCP 花費的時間要比平常的長。這可能會導致非常難以理解且難以重現(xiàn)的問題。而 System.nanoTime()
則可以保證是單調(diào)遞增的,與時鐘變化無關(guān)。
注意事項:
- 永遠不要序列化一個絕對的
nanoTime()
值或是把它傳遞給另一個系統(tǒng)。絕對的nanoTime()
值是無意義的、與系統(tǒng)相關(guān)的,并且在系統(tǒng)重啟時會重置。 - 絕對的
nanoTime()
值并不保證總是正數(shù)(但t2 - t1
能確??偸钱a(chǎn)生正確的值)。 -
nanoTime()
每 292 年就會重新計算起。所以,如果你的 Spark 任務需要花非常非常非常長的時間,你可能需要別的東西來處理了:)
<a name='misc_uri_url'>優(yōu)先使用 URI 而非 URL</a>
當存儲服務的 URL 時,你應當使用 URI
來表示。
URL
的相等性檢查實際上執(zhí)行了一次網(wǎng)絡調(diào)用(這是阻塞的)來解析 IP 地址。URI
類在表示能力上是 URL
的超集,并且它執(zhí)行的是字段的相等性檢查。
<a name='misc_well_tested_method'>優(yōu)先使用現(xiàn)存的經(jīng)過良好測試的方法而非重新發(fā)明輪子</a>
當存在一個已經(jīng)經(jīng)過良好測試的方法,并且不會存在性能問題,那么優(yōu)先使用這個方法。重新實現(xiàn)它可能會引入Bug,同時也需要花費時間來進行測試(也可能我們甚至忘記去測試這個方法?。?。
val beginNs = System.nanoTime()
// Do something
Thread.sleep(1000)
val elapsedNs = System.nanoTime() - beginNs
// 不要使用下面這種方式。這種方法容易出錯
val elapsedMs = elapsedNs / 1000 / 1000
// 推薦方法:使用Java TimeUnit API
import java.util.concurrent.TimeUnit
val elapsedMs2 = TimeUnit.NANOSECONDS.toMillis(elapsedNs)
// 推薦方法:使用Scala Duration API
import scala.concurrent.duration._
val elapsedMs3 = elapsedNs.nanos.toMillis
例外:
- 使用現(xiàn)存的方法需要引入新的依賴。如果一個方法特別簡單,比起引入一個新依賴,重新實現(xiàn)它通常更好。但是記得進行測試。
- 現(xiàn)存的方法沒有針對我們的用法進行優(yōu)化,性能達不到要求。但是首先做一下benchmark, 避免過早優(yōu)化。