Databricks Scala 编程风格指南 [转载 gith

2018-09-12  本文已影响0人  雪轩辕

Databricks Scala 编程风格指南

本文转载自 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 进行维护。由于是利用业余时间进行翻译并维护,因此该中文文档并不保证总是与原文档一样处于最新版本,不过我会尽可能及时地去更新它。

前言

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

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

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

<a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/"><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" href="http://creativecommons.org/licenses/by-nc-sa/4.0/">Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License</a>.

<a name='TOC'>目录</a>

  1. 文档历史

  2. 语法风格

  3. Scala 语言特性

  4. 并发

  5. 性能

  6. 与 Java 的互操作性

  7. 测试

  8. 其它

<a name='history'>文档历史</a>

<a name='syntactic'>语法风格</a>

<a name='naming'>命名约定</a>

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

<a name='variable-naming'>变量命名约定</a>

<a name='linelength'>一行长度</a>

<a name='rule_of_30'>30 法则</a>

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

一般来说:

<a name='indent'>空格与缩进</a>

<a name='blanklines'>空行</a>

<a name='parentheses'>括号</a>

<a name='curly'>大括号</a>

即使条件语句或循环语句只有一行时,也请使用大括号。唯一的例外是,当你把 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 作为后缀,不要使用小写,因为它和数字 1 长得很像,常常难以区分。

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'>类内秩序</a>

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

class DataFrame {

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

  ...

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

  ...
}

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

<a name='imports'>Imports</a>

<a name='pattern-matching'>模式匹配</a>

case class Pokemon(name: String, weight: Int, hp: Int, attack: Int, defense: Int)
case class Human(name: String, hp: Int)

// 不要像下面那样做,因为
// 1. 当 pokemon 加入一个新的字段,我们需要改变下面的模式匹配代码
// 2. 非常容易发生误匹配,尤其是当所有字段的类型都一样的时候
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"

// 重载的运算符应该以中缀形式调用
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)本质也是普通的类,编译器会自动地为它加上以下支持:

对于样例类来说,构造器参数不应设为可变的,可以使用拷贝构造函数达到同样的效果。使用可变的样例类容易出错,例如,哈希表中,对象根据旧的哈希值被放在错误的位置上。

// This is OK
case class Person(name: String, age: Int)

// This is NOT OK
case class Person(name: String, var age: Int)

// 通过拷贝构造函数创建一个新的实例来改变其中的值
val p1 = Person("Peter", 15)
val p2 = p2.copy(age = 16)

<a name='apply_method'>apply 方法</a>

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

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

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

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

<A name='override_modifier'>override 修饰符</a>

无论是覆盖具体的方法还是实现抽象的方法,始终都为方法加上 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!")
  }
}

<a name='destruct_bind'>解构绑定</a>

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

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

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

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

<a name='call_by_name'>按名称传参</a>

避免使用按名传参. 显式地使用 () => 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 传入。按名传参的一个主要问题是在方法调用处,我们无法区分是按名传参还是按值传参。因此无法确切地知道这个表达式是否会被执行(更糟糕的是它可能会被执行多次)。对于带有副作用的表达式来说,这一点是非常危险的。

<A name='multi-param-list'>多参数列表</a>

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

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

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

<a name='symbolic_methods'>符号方法(运算符重载)</a>

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

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

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

<a name='type_inference'>类型推导</a>

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

<a name='return'>Return 语句</a>

闭包中避免使用 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 的。

<a name='recursion'>递归及尾递归</a>

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

对于那些你意欲使之成为尾递归的方法,请加上 @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
}

<a name='implicits'>Implicits</a>

避免使用 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] = ...
}

<a name='exception'>异常处理 (Try 还是 try)</a>

<a name='option'>Options</a>

<a name='chaining'>单子链接</a>

单子链接是 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
  }
}

<a name='concurrency'>并发</a>

<a name='concurrency-scala-collection'>Scala concurrent.Map</a>

优先考虑使用 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 并发集合</a>

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

<a name='concurrency-sync-vs-atomic'>显式同步 vs 原子变量 vs @volatile</a>

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) {
  ...
}

<a name='concurrency-private-this'>私有字段</a>

注意,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 }
}

<a name='concurrency-isolation'>隔离</a>

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

<a name='perf'>性能</a>

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

<a name='perf-microbenchmarks'>Microbenchmarks</a>

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

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

<a name='perf-whileloops'>Traversal 与 zipWithIndex</a>

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

<a name='perf-option'>Option 与 null</a>

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

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

<a name='perf-collection'>Scala 集合库</a>

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

<a name='perf-private'>private[this]</a>

对于性能有要求的代码,优先考虑使用 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
    }
  }
}

<a name='java'>与 Java 的互操作性</a>

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

<a name='java-missing-features'>Scala 中缺失的 Java 特性</a>

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

<a name='java-traits'>Traits 与抽象类</a>

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

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

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

<a name='java-type-alias'>类型别名</a>

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

<a name='java-default-param-values'>默认参数值</a>

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

// 打破了与 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'>多参数列表</a>

不要使用多参数列表。

<a name='java-varargs'>可变参数</a>

<a name='java-implicits'>Implicits</a>

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

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

<a name='java-companion-object'>伴生对象,静态方法与字段</a>

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

<a name='testing'>测试</a>

<a name='testing-intercepting'>异常拦截</a>

当测试某个操作(比如用无效的参数调用一个函数)是否会抛出异常时,对于抛出的异常类型指定得越具体越好。你不应该简单地使用 intercept[Exception]intercept[Throwable](ScalaTest 语法),这能拦截任意异常,只能断言有异常抛出,而不能确定是什么异常。这样做在测试中能捕获到代码中的异常并且通过测试,然而却没真正检验你想验证的行为。

// 不要使用下面这种方式
intercept[Exception] {
  thingThatThrowsException()
}

// 这才是推荐的做法
intercept[MySpecificTypeOfException] {
  thingThatThrowsException()
}

如果你无法指定代码会抛出的异常的具体类型,说明你这段代码可能写得不好,需要重构。这种情况下,你要么测试更底层的代码,要么改写代码令其抛出类型更加具体的异常。

<a name='misc'>其它</a>

<a name='misc_currentTimeMillis_vs_nanoTime'>优先使用 nanoTime 而非 currentTimeMillis</a>

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

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

注意事项:

<a name='misc_uri_url'>优先使用 URI 而非 URL</a>

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

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

<a name='misc_well_tested_method'>优先使用现存的经过良好测试的方法而非重新发明轮子</a>

当存在一个已经经过良好测试的方法,并且不会存在性能问题,那么优先使用这个方法。重新实现它可能会引入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

例外:

上一篇 下一篇

猜你喜欢

热点阅读