本文将讨论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个条件:
用俩匹配的值必须是一个已知的整数
每个匹配表达式必须足够简单:不能包含类型检查、if语句和extractors。如果是表达式需要在编译时就是可用的:它保持不变,不能在运行时求值。
至少有两个以上分支,否则优化是没有必要的。
我们继续看个例子:
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个条件:
方法必须声明为final或private,不能是多态的。
方法必须有返回值注解。
方法必须在其一个返回分支上最后调用自己。
我们有一个普通递归和一个尾递归:
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】尾递归指的是函数最后一句调用自身的函数。尾递归优化可以减少递归需要的栈空间。一般将其展开为循环,或者重复使用当前栈空间。尾递归会增加理解的难度,并且不少编译器是不提供尾递归优化的。