深入理解Scala-编码规范

本文将讨论Scala中的一些编码规范,它们有助于减少编译和运行时的错误。

避免直接借用其他语言的编码规范

的确,有一些编码规范是在各种语言通用的,比如良好的注释。但Scala与我们经常碰到的Java,C,C++还是有很大的不同,在深入理解Scala之前,最好谨慎使用其他语言相关的编码规范。

编码规范在团队开发中必须的。它帮助团队避免一些曾经出现的错误,提供代码层面交流的一致性语言。我们也许没有去看自己公司、团队的编码规范,但可以从代码中略知一二。

建立团队的编码规范的步骤有:

  • 首先建立预防错误的一些规则。这些规则可能是来自使用相同语言的其他项目。然后自己添加一些从过去出错项目中总结的一些规则。比如C++中析构函数应该声明为虚函数。

  • 然后根据团队自己的开发环境,来发现、定义一些新的编码规范。比如包的命名方式。

  • 坚持执行前面的制定的规则。最好有一个自动化工具,能够检测我们的编码是否符合规范,然后可以自动做一些重构工作。

大家肯定见过关于左大括号应该换行写,还是同行写的争论。当然这个争论无关痛痒,对编译器来说没有什么影响。我们看一个Scala例子:

class FooHolder
{
  def foo1()
  {
    println("foo1 was called")
  }
  def foo2(): Unit =
  {
    println("foo2 was called")
  }
  def foo3() =
    println("foo3 was called")
}

foo1、foo2、foo3都是正确的。都是定义一个函数,然后输出一个字符串。不同的是书写风格。foo1类似于C语言风格,但是没有指定返回值;foo2是标准完整的Scala函数定义,有返回值,有表达式;foo3虽然没有返回值,但是有表达式赋值。

需要注意的是,对于foo3,如果没有=。那么在实例化一个FooHolder的时候编译器会报错:class FooHolder needs to be abstract, since method foo3 is not defined。因为编译器会把它认为是一个抽象函数,这样的类去实例化是不允许的。虽然Scala给大家提供了一个非常宽松的环境,但为了避免类似的错误,也从人们理解上考虑,避免歧义,避免猜测,建议大家使用第二种foo2的方式。

悬垂操作符

dangling operator怎么翻译,没有找到一个现成的答案,那就自己定义一个名字:悬垂操作符。它指的是位于每行最后的操作符,比如+、-都可以作为悬垂操作符,它告诉Scala编译器本行还没有结束。

在Java中我们连接一个字符串可以随便写,同行也可以,分行也可以。但是Scala中,操作符需要考虑它们所在的位置。比如下面的代码:

val x = 5  
def foo2 = "HAI"
    + x
    + "ZOMG"
    + "\n"

这个函数编译是会出错的:value unary_+ is not a member of String。String没有一元操作符+(Scala直接使用的Java的String)。但是x(类型为Int,Scala自己的一个类型)却是有的,所以+x没有报错。

为了解决这个编译错误,我们有两种办法:

一是告诉编译器+表示的是一行尚未完,即使用悬垂操作符:

  val x = 5
  def foo1 = "HAI" +
    x +
    "ZOMG" +
    "\n"

二是加上括号:

  def foo2 = ("HAI"
    + x
    + "ZOMG"
    + "\n")

使用有意义的变量名

一般的语言标识符只能是字母、数字和下划线,外加一些限制。相比之下,Scala提供非常灵活的命名方式。Scala有三种方法可以构造一个标识符:

第一,首字符是字母,后续字符是任意字母和数字。这种标识符还可后接下划线‟_‟,然后是任意字母和数字。

第二,首字符是算符字符,后续字符是任意算符字符。这两种形式是普通标识符。

最后,标识符可以是由反引号‟`‟括起来的任意字符串(宿主系统可能会对字符串和合法性有些限制)。这种标识符可以由除了反引号的任意字符构成。

第二条规则的存在,很容易让人回想起C++的操作符重载。Scala直接将其当作标识符来处理,应该是更进来一步。

避免在标示符中使用$。因为编译器内部为内部类、闭包等生成的内部标示符使用了$。如果出现同名,将会导致代码奇怪的行为。有兴趣的话,可以看看编译生成的汇编代码。

在Scala2.8引入命名参数和默认参数。命名参数会作为API的一部分,名字的改变会使客户端代码出错。因此请使用有意义的名字来对参数进行命名。这就是核心规则6。

class Foo {
  def foo(one: Int = 1,
          two: String = "two",
          three: Double = 2.5): String =
    two + one + three
}
object Test extends scala.App{
  val x = new Foo
  println(x.foo())
  println(x.foo(two = "not two"))
  println(x.foo(0,"zero",0.1))
  println(x.foo(4, three = 0.4))
  println(x.foo(three = 0.4, one = 3, two = "two here"))
}

运行结果:

two12.5

not two12.5

zero00.1

two40.4

two here30.4

C++也有默认参数。但是没有参数命名。这样C++就有一些限制,需要默认参数放到右边。Scala参数有了名字,调用的时候,它们的顺序就可以任意存放。但如果调用的时候,有的参数直接传值,有的使用参数名字,比如上面的x.foo(4, three = 0.4),我们就需要注意没有使用名字的参数的顺序。

Scala使用变量的静态类型来绑定参数名字,但是缺省值是由运行时类型决定的。一句话:名字是静态的、值是动态。看一个例子:

class Parent {
  def foo(bar: Int = 1, baz: Int = 2): Int =
    bar + baz
}
class Child extends Parent {
  override def foo(baz: Int = 3, bar: Int = 4): Int =
    super.foo(baz,bar)
}
object Test extends scala.App{
  val p = new Parent
  println(p.foo())
  val y = new Child
  println(y.foo())
  val z: Parent = new Child
  println(z.foo())
  println(y.foo(bar = 1))
  println(z.foo(bar = 1))
  println(z.foo(baz = 4))
}

输出如下:

3

7

7

4

5

7

Parent定义了foo,子类Child覆盖了foo。z.foo()使用的是缺省值,缺省值是由z的运行时类型Child提供的,所以baz=3,bar=4,输出为7。y.foo(bar = 1)运行时类型和静态类型都是Child,所以baz=3,bar=1,输出为4。z.foo(bar = 1)的运行时类型是Child,静态类型是Parent,函数使用静态类型的,即Parent的def foo(bar: Int = 1, baz: Int = 2): Int。但要注意的是Child和Parent的命名参数位置是反的。缺省值使用静态类型的,所以baz=4(Parent的baz对应Child的bar),bar=1,输出为5。同样的,可以得到z.foo(baz = 4)的结果是7。

可以看到对于交换了命名参数位置的重载,从理解上看,不直观,比较困难。所有大家应该保持重载的命名参数是一致的。不要随意交换它们的位置。

重载带有命名参数的函数,可以修改函数的默认值。

告诉大家这是重载函数

核心规则7:Scala中,虽然有的时候override是可选的,但是坚持使用override是安全的。

Trait与Java的interface类似,但是可以拥有方法体,并且可以在类实例化的时候混入。我们编写下面的代码:

trait UserService {
  def login(credentials: Credentials): UserSession
  def logout(session: UserSession): Unit
  def isLoggedIn(session: UserSession): Boolean
  def changePassword(new_credentials: Credentials,
                     old_credentials: Credentials): Boolean
}
class UserServiceImpl extends UserService {
  def login(credentials: Credentials): UserSession =
    new UserSession {}
  def logout(session: UserSession): Unit //class UserServiceImpl needs to be abstract, since method logout is not defined
  def isLoggedIn(session: UserSession): Boolean = true
  def changePassword(session: UserSession,
                     credentials: Credentials): Boolean = true
}

编译失败。因为编译器发现UserServiceImpl的logout还是一个抽象函数,没有实现,这在class中是不允许的。还有一个错误是UserService的ChangePassword作为一个抽象函数也没有实现。

如果我们给UserServiceImp加上override关键字,也可以通过编译:

trait UserServiceImpl extends UserService {
  override def login(credentials: Credentials): UserSession =
    new UserSession {}
  override def logout(session: UserSession): Unit
  override def isLoggedIn(session: UserSession): Boolean = true
  override def changePassword(session: UserSession,
                     credentials: Credentials): Boolean = true
}

这是因为Scala在实现一个抽象方法的时候,不需要override关键字。这样的设置是为了解决多重继承的菱形继承问题。举个例子:

trait Animal {
  def talk: String
}
trait Cat extends Animal {
  override def talk: String = "Meow"
}
trait Dog extends Animal {
  override def talk: String = "Woof"
}
object Test extends scala.App
{
  val kittydoggy = new Cat with Dog
  println(kittydoggy.talk) //Woof
  val kittydoggy2 = new Dog with Cat
  println(kittydoggy2.talk) //Meow
}

大家可能不理解为什么输出是这样?这是Scala的类线性化(Class linearization)决定的。当存在多个函数的多个重载实现的时候,Scala从类声明的最右边开始,在每个类中寻找函数,如果找到,就停止查找。new Cat with Dog会先看Dog有没有定义talk,Dog定义了talk,并返回Woof,所以输出是Woof。类似地,new Dog with Cat会在Cat里面找talk函数。

如果我们去掉Dog和Cat里面的override。编译器会报下面的错误:

anonymous class $anon inherits conflicting members:

  method talk in trait Cat of type => String and

  method talk in trait Dog of type => String

(Note: this can be resolved by declaring an override in anonymous class $anon.)

  val kittydoggy = new Cat with Dog

                       ^

anonymous class $anon inherits conflicting members:

  method talk in trait Dog of type => String and

  method talk in trait Cat of type => String

(Note: this can be resolved by declaring an override in anonymous class $anon.)

  val kittydoggy2 = new Dog with Cat

                        ^

如果我们只是去掉Dog的override,编译器会报:

anonymous class $anon inherits conflicting members:

  method talk in trait Cat of type => String and

  method talk in trait Dog of type => String

(Note: this can be resolved by declaring an override in anonymous class $anon.)

  val kittydoggy = new Cat with Dog

                       ^

在Cat中混入Dog是不允许的,因为Dog没有说它的talk方法可以被重载。反过来,Dog中混入Cat是可以的,因为Cat说明了自己的talk方法可以被重载。

为了重载某个方法,我们可以在类实例化的时候混入trait,而不需要定义一个新的类。

对期待的优化使用注解

Scala编译器在生成字节码的时候做一些优化操作:

  • 优化尾递归【注1】

  • 优化模式匹配

Scala通过将模式匹配当做switch来处理,来优化模式匹配的效率。模式匹配优化时,编译器编译生成分支表,而不是决策树。这意味着,我们不是在拿值做比较。而是用匹配的值来直接定位分支表。通过JVM的tableswitch操作码可以直接完成。

Scala使用tableswitch进行优化,必须满足3个条件:

  1. 用俩匹配的值必须是一个已知的整数

  2. 每个匹配表达式必须足够简单:不能包含类型检查、if语句和extractors。如果是表达式需要在编译时就是可用的:它保持不变,不能在运行时求值。

  3. 至少有两个以上分支,否则优化是没有必要的。

我们继续看个例子:

  def unannotated(x: Int) = x match {
    case 1 => "One"
    case 2 => "Two!"
    case z => z + "?"
  }

使用javap -c得到汇编代码:

public java.lang.String unannotated(int);

  Code:

   0: iload_1

   1: istore_2

   2: iload_2

   3: tableswitch{ //1 to 2

                1: 51;

                2: 46;

                default: 24 }

   24: new #12; //class scala/collection/mutable/StringBuilder

   27: dup

   28: invokespecial #16; //Method scala/collection/mutable/StringBuilder."<init>":()V

   31: iload_2

   32: invokevirtual #20; //Method scala/collection/mutable/StringBuilder.append:(I)Lscala/collection/mutable/StringBuilder;

   35: ldc #22; //String ?

   37: invokevirtual #25; //Method scala/collection/mutable/StringBuilder.append:(Ljava/lang/Object;)Lscala/collection/mutable/StringBuilder;

   40: invokevirtual #29; //Method scala/collection/mutable/StringBuilder.toString:()Ljava/lang/String;

   43: goto 53

   46: ldc #31; //String Two!

   48: goto 53

   51: ldc #33; //String One

   53: areturn

在得到参数后,放到临时变量,然后调用tableswith指令,通过索引访问跳转表,并跳转。

我们修改一下上面的代码:

def notOptimised(x: Int) = x match {
case 1 => "One"
case 2 => "Two!"
case i: Int => "Other"
}

在最后一个分支上,加上了类型检查。这样就不满足前面的条件2,Scala不会进行优化了。

public java.lang.String notOptimised(int);

  Code:

   0: iload_1

   1: istore_2

   2: iconst_1

   3: iload_2

   4: if_icmpne 13

   7: ldc #12; //String One

   9: astore_3

   10: goto 27

   13: iconst_2

   14: iload_2

   15: if_icmpne 24

   18: ldc #14; //String Two!

   20: astore_3

   21: goto 27

   24: ldc #16; //String Other

   26: astore_3

   27: aload_3

   28: areturn

使用if_icmpne指令来判断两个int是否相等,进而决定是不是需要进行跳转。

我们如何知道编译器是否做了模式匹配的优化呢?Scala可以给类型表达式使用注解。@switch告诉编译器我想做tableswitch优化。如果编译器发现它做不了优化就会报错。比如下面的代码:

import annotation.switch
class Tableswitch{
  def annotated(x: Int @switch) = x match {
    case 1 => "One"
    case 2 => "Two!"
    case z => z + "?"
  }
  def notOptimised(x: Int) =
    (x: @switch) match {
      case 1 => "One"
      case 2 => "Two!"
      case i: Int => "Other"
    }
}

在第11行会报错:

could not emit switch for @switch annotated match

    (x: @switch) match {

         ^

@tailrec注解用来告诉编译器,请对尾递归进行优化。

核心规则8:对需要优化的尾递归,加上@tailrec注解,保证我们得到期望的性能优化。

Scala编译器进行尾递归优化也需要满足3个条件:

  1. 方法必须声明为final或private,不能是多态的。

  2. 方法必须有返回值注解。

  3. 方法必须在其一个返回分支上最后调用自己。

我们有一个普通递归和一个尾递归:

import annotation.tailrec
object TailRecursion{
  def FibonacciRecursive(n: Int): Int = {
    if(n < 2)
      n
    else
      FibonacciRecursive(n-1)+FibonacciRecursive(n-2)
  }
  @tailrec
  def FibonacciTailRecursive(n: Int, ret1: Int, ret2: Int): Int = {
    if(n < 2)
      ret1
    else
      FibonacciTailRecursive(n-1, ret2, ret1 + ret2)
  }
}

它们的汇编代码:

public int FibonacciRecursive(int);

  Code:

   0: iload_1

   1: iconst_2

   2: if_icmpge 9

   5: iload_1

   6: goto 24

   9: aload_0

   10: iload_1

   11: iconst_1

   12: isub

   13: invokevirtual #16; //Method FibonacciRecursive:(I)I

   16: aload_0

   17: iload_1

   18: iconst_2

   19: isub

   20: invokevirtual #16; //Method FibonacciRecursive:(I)I

   23: iadd

   24: ireturn

public int FibonacciTailRecursive(int, int, int);

  Code:

   0: iload_1

   1: iconst_2

   2: if_icmpge 7

   5: iload_2

   6: ireturn

   7: iload_1

   8: iconst_1

   9: isub

   10: iload_3

   11: iload_2

   12: iload_3

   13: iadd

   14: istore_3

   15: istore_2

   16: istore_1

   17: goto 0

可见Scala对后者进行了优化。使用的是goto循环。但是如果我们在FibonacciRecursive加上@tailrec注解,编译器就会报错:

could not optimize @tailrec annotated method FibonacciRecursive: it contains a recursive call not in tail position

      FibonacciRecursive(n-1)+FibonacciRecursive(n-2)

                             ^

优化注解并不是要求编译器去做优化,而是要求编译或发出警告。

【注1】尾递归指的是函数最后一句调用自身的函数。尾递归优化可以减少递归需要的栈空间。一般将其展开为循环,或者重复使用当前栈空间。尾递归会增加理解的难度,并且不少编译器是不提供尾递归优化的。

你可能感兴趣的:(深入理解Scala-编码规范)