Databricks Scala 编程风格指南

November 17, 2015
作者:Hawstein
出处:http://hawstein.com/posts/databricks-scala-guide.html
声明:本文采用以下协议进行授权: 自由转载-非商用-非衍生-保持署名|Creative Commons BY-NC-ND 3.0 ,转载请注明作者及出处。

声明

本文档翻译自 Databricks Scala Guide,你也可以在 Github 上阅读:Databricks Scala 编程风格指南,代码高亮支持得更好。

前言

Spark 有超过 800 位贡献者,就我们所知,应该是目前大数据领域里最大的开源项目且是最活跃的 Scala 项目。这份指南是在我们指导,或是与 Spark 贡献者及 Databricks 工程团队一起工作时总结出来的。

代码由作者__一次编写__,然后由大量工程师__多次阅读并修改__。事实上,大部分的 bug 来源于后人对代码的修改,因此我们需要长期去优化我们的代码,提升代码的可读性和可维护性。达到这个目标最好的方式就是编写简单易懂的代码。

Scala 是一种强大到令人难以置信的多范式编程语言。我们总结出了以下指南,它可以很好地应用在一个高速发展的项目。当然,这个指南并非绝对,根据团队需求的不同,可以有不同的标准。

目录

  1. 文档历史
  2. 语法风格
  3. Scala 语言特性
  4. 并发
  5. 性能
  6. 与 Java 的互操作性
  7. 其它

文档历史

语法风格

命名约定

我们主要遵循 Java 和 Scala 的标准命名约定。

一行长度

30 法则

「如果一个元素包含的子元素超过 30 个,那么极有可能出现了严重的问题」 - Refactoring in Large Software Projects

一般来说:

空格与缩进

空行

括号

大括号

即使条件语句或循环语句只有一行时,也请使用大括号。唯一的例外是,当你把 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 {
  ...
}

长整型字面量

长整型字面量使用大写的 L 作为后缀,不要使用小写,因为它和数字 1 长得很像,常常难以区分。

val longValue = 5432L  // Do this

val longValue = 5432l  // Do NOT do this

文档风格

使用 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.
  */

类内秩序

如果一个类很长,包含许多的方法,那么在逻辑上把它们分成不同的部分并加上注释头,以此组织它们。

class DataFrame {

  ///////////////////////////////////////////////////////////////////////////
  // DataFrame operations
  ///////////////////////////////////////////////////////////////////////////

  ...

  ///////////////////////////////////////////////////////////////////////////
  // RDD operations
  ///////////////////////////////////////////////////////////////////////////

  ...
}

当然,强烈不建议把一个类写得这么长,一般只有在构建某些公共 API 时才允许这么做。

Imports

模式匹配

中缀方法

避免中缀表示法,除非是符号方法(即运算符重载)。

// Correct
list.map(func)
string.contains("foo")

// Wrong
list map (func)
string contains "foo"

// 重载的运算符应该以中缀形式调用
arrayBuffer += elem

Scala 语言特性

apply 方法

避免在类里定义 apply 方法。这些方法往往会使代码的可读性变差,尤其是对于不熟悉 Scala 的人。它也难以被 IDE(或 grep)所跟踪。在最坏的情况下,它还可能影响代码的正确性,正如你在括号一节中看到的。

然而,将 apply 方法作为工厂方法定义在伴生对象中是可以接受的。在这种情况下,apply 方法应该返回其伴生类的类型。

object TreeNode {
  // 下面这种定义是 OK 的
  def apply(name: String): TreeNode = ...

  // 不要像下面那样定义,因为它没有返回其伴生类的类型:TreeNode
  def apply(name: String): String = ...
}

override 修饰符

无论是覆盖具体的方法还是实现抽象的方法,始终都为方法加上 override 修饰符。实现抽象方法时,不加 override 修饰符,Scala 编译器也不会报错。即便如此,我们也应该始终把 override 修饰符加上,以此显式地表示覆盖行为。以此避免由于方法签名不同(而你也难以发现)而导致没有覆盖到本应覆盖的方法。

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!")
  }
}

解构绑定

解构绑定(有时也叫元组提取)是一种在一个表达式中为两个变量赋值的便捷方式。

val (a, b) = (1, 2)

然而,请不要在构造函数中使用它们,尤其是当 ab 需要被标记为 transient 的时候。Scala 编译器会产生一个额外的 Tuple2 字段,而它并不是暂态的(transient)。

class MyClass {
  // 以下代码无法 work,因为编译器会产生一个非暂态的 Tuple2 指向 a 和 b
  @transient private val (a, b) = someFuncThatReturnsTuple2()
}

按名称传参

避免使用按名传参. 显式地使用 () => T

背景:Scala 允许按名称来定义方法参数,例如:以下例子是可以成功执行的:

def print(value: => Int): Unit = {
  println(value)
  println(value + 1)
}

var a = 0
def inc(): Int = {
  a + 1
  a
}

print(inc())

在上面的代码中,inc() 以闭包的形式传递给 print 函数,并且在 print 函数中被执行了两次,而不是以数值 1 传入。按名传参的一个主要问题是在方法调用处,我们无法区分是按名传参还是按值传参。因此无法确切地知道这个表达式是否会被执行(更糟糕的是它可能会被执行多次)。对于带有副作用的表达式来说,这一点是非常危险的。

多参数列表

避免使用多参数列表。它们使运算符重载变得复杂,并且会使不熟悉 Scala 的程序员感到困惑。例如:

// Avoid this!
case class Person(name: String, age: Int)(secret: String)

一个值得注意的例外是,当在定义底层库时,可以使用第二个参数列表来存放隐式(implicit)参数。尽管如此,我们应该避免使用 implicits

符号方法(运算符重载)

不要使用符号作为方法名,除非你是在定义算术运算的方法(如:+, -, *, /),否则在任何其它情况下,都不要使用。符号化的方法名让人难以理解方法的意图是什么,来看下面两个例子:

// 符号化的方法名难以理解
channel ! msg
stream1 >>= stream2

// 下面的方法意图则不言而喻
channel.send(msg)
stream1.join(stream2)

类型推导

Scala 的类型推导,尤其是左侧类型推导以及闭包推导,可以使代码变得更加简洁。尽管如此,也有一些情况我们是需要显式地声明类型的:

Return 语句

闭包中避免使用 returnreturn 会被编译器转成 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 异常,并在 __一个不同的线程中__被捕获,而这里执行的方法却没有任何影响。

然而,也有少数情况我们是推荐使用 return 的。

递归及尾递归

避免使用递归,除非问题可以非常自然地用递归来描述(比如,图和树的遍历)。

对于那些你意欲使之成为尾递归的方法,请加上 @tailrec 注解以确保编译器去检查它是否真的是尾递归(你会非常惊讶地看到,由于使用了闭包和函数变换,许多看似尾递归的代码事实并非尾递归)。

大多数的代码使用简单的循环和状态机会更容易推理,使用尾递归反而可能会使它更加繁琐且难以理解。例如,下面的例子中,命令式的代码比尾递归版本的代码要更加易读:

// 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
}

Implicits

避免使用 implicit,除非:

当使用 implicit 时,我们应该确保另一个工程师可以直接理解使用语义,而无需去阅读隐式定义本身。Implicit 有着非常复杂的解析规则,这会使代码变得极其难以理解。Twitter 的 Effective Scala 指南中写道:「如果你发现你在使用 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] = ...
}

异常处理,Try 还是 try

Options

单子链接

单子链接是 Scala 的一个强大特性。Scala 中几乎一切都是单子(如:集合,Option,Future,Try 等),对它们的操作可以链接在一起。这是一个非常强大的概念,但你应该谨慎使用,尤其是:

通过给中间结果显式地赋予一个变量名,将链接断开变成一种更加过程化的风格,能让单子链接更加易于理解。来看下面的例子:

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
  }
}

并发

Scala concurrent.Map

优先考虑使用 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

显式同步 vs 并发集合

有 3 种推荐的方法来安全地并发访问共享状态。不要混用它们,因为这会使程序变得难以推理,并且可能导致死锁。

注意,对于 case 1 和 case 2,不要让集合的视图或迭代器从保护区域逃逸。这可能会以一种不明显的方式发生,比如:返回了 Map.keySetMap.values。如果需要传递集合的视图或值,生成一份数据拷贝再传递。

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: _*) }

显式同步 vs 原子变量 vs @volatile

java.util.concurrent.atomic 包提供了对基本类型的无锁访问,比如:AtomicBoolean, AtomicIntegerAtomicReference

始终优先考虑使用原子变量而非 @volatile,它们是相关功能的严格超集并且从代码上看更加明显。原子变量的底层实现使用了 @volatile

优先考虑使用原子变量而非显式同步的情况:(1)一个对象的所有临界区更新都被限制在单个变量里并且预期会有竞争情况出现。原子变量是无锁的并且允许更为有效的竞争。(2)同步被明确地表示为 getAndSet 操作。例如:

// good: 明确又有效地表达了下面的并发代码只执行一次
val initialized = new AtomicBoolean(false)
...
if (!initialized.getAndSet(true)) {
  ...
}

// poor: 下面的同步就没那么明晰,而且会出现不必要的同步
val initialized = false
...
var wasInitialized = false
synchronized {
  wasInitialized = initialized
  initialized = true
}
if (!wasInitialized) {
  ...
}

私有字段

注意,private 字段仍然可以被相同类的其它实例所访问,所以仅仅通过 this.synchronized(或 synchronized)来保护它从技术上来说是不够的,不过你可以通过 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 }
}

隔离

一般来说,并发和同步逻辑应该尽可能地被隔离和包含起来。这实际上意味着:

性能

对于你写的绝大多数代码,性能都不应该成为一个问题。然而,对于一些性能敏感的代码,以下有一些小建议:

Microbenchmarks

由于 Scala 编译器和 JVM JIT 编译器会对你的代码做许多神奇的事情,因此要写出一个好的微基准程序(microbenchmark)是极其困难的。更多的情况往往是你的微基准程序并没有测量你想要测量的东西。

如果你要写一个微基准程序,请使用 jmh。请确保你阅读了所有的样例,这样你才理解微基准程序中「死代码」移除、常量折叠以及循环展开的效果。

Traversal 与 zipWithIndex

使用 while 循环而非 for 循环或函数变换(如:mapforeach),for 循环和函数变换非常慢(由于虚函数调用和装箱的缘故)。


val arr = // array of ints
// 偶数位置的数置零
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
}

Option 与 null

对于性能有要求的代码,优先考虑使用 null 而不是 Option,以此避免虚函数调用以及装箱操作。用 Nullable 注解明确标示出可能为 null 的值。

class Foo {
  @javax.annotation.Nullable
  private[this] var nullableField: Bar = _
}

Scala 集合库

对于性能有要求的代码,优先考虑使用 Java 集合库而非 Scala 集合库,因为一般来说,Scala 集合库要比 Java 的集合库慢。

private[this]

对于性能有要求的代码,优先考虑使用 private[this] 而非 privateprivate[this] 生成一个字段而非生成一个访问方法。根据我们的经验,JVM JIT 编译器并不总是会内联 private 字段的访问方法,因此通过使用 private[this] 来确保没有虚函数调用会更保险。

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
    }
  }
}

与 Java 的互操作性

本节内容介绍的是构建 Java 兼容 API 的准则。如果你构建的组件并不需要与 Java 有交互,那么请无视这一节。这一节的内容主要是从我们开发 Spark 的 Java API 的经历中得出的。

Scala 中缺失的 Java 特性

以下的 Java 特性在 Scala 中是没有的,如果你需要使用以下特性,请在 Java 中定义它们。然而,需要提醒一点的是,你无法为 Java 源文件生成 ScalaDoc。

Traits 与抽象类

对于允许从外部实现的接口,请记住以下几点:

// 以下默认实现无法在 Java 中使用
trait Listener {
  def onTermination(): Unit = { ... }
}

// 可以在 Java 中使用
abstract class Listener {
  def onTermination(): Unit = { ... }
}

类型别名

不要使用类型别名,它们在字节码和 Java 中是不可见的。

默认参数值

不要使用默认参数值,通过重载方法来代替。

// 打破了与 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)

多参数列表

不要使用多参数列表。

可变参数

Implicits

不要为类或方法使用 implicit,包括了不要使用 ClassTagTypeTag

class JavaFriendlyAPI {
  // 以下定义对 Java 是不友好的,因为方法中包含了一个隐式参数(ClassTag)。
  def convertTo[T: ClassTag](): T
}

伴生对象,静态方法与字段

当涉及到伴生对象和静态方法/字段时,有几件事情是需要注意的:

其它

优先使用 nanoTime 而非 currentTimeMillis

当要计算持续时间或者检查超时的时候,避免使用 System.currentTimeMillis()。请使用 System.nanoTime(),即使你对亚毫秒级的精度并不感兴趣。

System.currentTimeMillis() 返回的是当前的时钟时间,并且会跟进系统时钟的改变。因此,负的时钟调整可能会导致超时而挂起很长一段时间(直到时钟时间赶上先前的值)。这种情况可能发生在网络已经中断一段时间,ntpd 走过了一步之后。最典型的例子是,在系统启动的过程中,DHCP 花费的时间要比平常的长。这可能会导致非常难以理解且难以重现的问题。而 System.nanoTime() 则可以保证是单调递增的,与时钟变化无关。

注意事项:

优先使用 URI 而非 URL

当存储服务的 URL 时,你应当使用 URI 来表示。

URL相等性检查实际上执行了一次网络调用(这是阻塞的)来解析 IP 地址。URI 类在表示能力上是 URL 的超集,并且它执行的是字段的相等性检查。