从 Java 到 Scala(四):Traits

3,358 阅读10分钟

本文由 Rhyme 发表在 ScalaCool 团队博客。

Traits特质,一个我们既熟悉又陌生的特性。熟悉是因为你会发现它和你平时在Java中使用的interface接口有着很大的相似之处,而陌生又是因为Traits的新玩法会让你打破对原有接口的认知,进入一个更具有挑战性,玩法更高级的领域。所以,在一开始,我们可以对Traits有一个初步的认识:它是一个加强版的interface。之后,随着你对它了解的深入,你就会发现相比Java接口,Traits跟类更为相似。再之后,你或许会觉察到,Traits在尝试着将抽象更好地融为一个整体。

Traits 入门

在Java中为了避免多重继承所带来的昂贵代价(方法或字段冲突、菱形继承等问题),Java的设计者们使用了interface接口。而为了解决Java接口无法进行stackable modifications(即无法使用对象状态进行迭代)、无法提供字段等局限,在Scala中,我们使用Traits特质而非接口。

定义一个trait

trait Animal {
  val typeOf: String = "哺乳动物" //  带有默认值的字段

  def move(): Unit = {  // 带有默认实现的方法
    println("walk")
  }

  def eat() //未实现的抽象方法
}

以上代码类似于以下的Java代码

public interface Animal {
    String typeOf = "哺乳动物";

    default void move() {
        System.out.println("walk");
    }

    void eat();
}

在Scala中使用关键字trait而不interface,和Java接口一样,trait也可以有默认方法的实现。也就是说Java接口有的,trait基本上也都有,而且实现起来要优雅许多。 之所以要说类似于以上的Java代码,原因在于trait拥有的是字段typeOf,而interface拥有的是静态属性typeOf。这是interfacetrait的一点区别。但是再仔细观察思考这一点区别,更好更灵活的字段设计,是否使得trait更好地组织了抽象,使得它们成为了一个更好的整体。

mix in trait

和Java一样,Scala只支持单继承,但却可以有任意数量的特质。在Scala中,我们不称接口被implements实现了,而是traits被mix in混入了类中。

class Bird extends Animal {
  override val typeOf: String = "蛋生动物"

  override def eat(): Unit = {
    println("eat bugs")
  }

  override def move(): Unit = {
    println("fly")
  }
}

以上代码中,Bird类混入了特质Animal。当类混入了多个特质时,需要使用with关键字

trait Egg

class Bird extends Animal with Egg{
  override val typeOf: String = "蛋生动物"

  override def eat(): Unit = {
    println("eat bugs")
  }

  override def move(): Unit = {
    println("fly")
  }
}

在Scala中,我们将extends with的这种语法解读为一个整体,例如在以上代码中,我们将extends Animal with Egg看做一个整体,然后被Bird类混入。从这里你是否也能够感受到 trait在尝试着将抽象更好地融为一个整体。

到这里,你或许能够发现,相比Java interface,trait和类更加相似。而事实也确实如此,trait可以具备类的所有特性,除了缺少构造器参数。这一点trait可以使用构造器字段来达到同样的效果。也就是说你不能想给类传入构造器参数那样给特质传入参数。具体代码这里就不再演示。

其实在这里我们可以简单地思考一番,为什么要把trait设计得这么像一个class,是设计者们有意为之,还是无意间的巧合。其实,不管怎么样,个人认为,但从设计层面来讲,class类的设计就比trait更加具备一致性,class产生的对象就可以被很好的管理,为什么我们不像管理对象一样来管理我们的抽象呢?

Traits的两大基本应用

Traits最常见的两种使用方式:一种是和Java接口类似,用于设计富接口,另一种是Traits独有的stackable modifications。这里就说到了interfacetrait的第二个区别,Traits支持stackable modificatio,使它能够使用对象状态,可以对对象状态进行灵活地迭代。

rich interface

富接口的应用要归功于interface中对默认方法这一特性的支持,一方面松绑了类和接口之间实现与被实现之间的强关系,另一方面为程序的可扩展性代入了很大的灵活性。trait在这一方面的应用和Java的没有很大的区别。而trait中的默认方法的实现背后采用的也是interface中的default默认方法。

trait Hello {
  def hello(): Unit = {println("hello")
  }
}
interface Hello2 {
    default void hello() {...}
}

stackable modifications

关于stackable modifications,顾名思义,我们将modification保存在了一个stack栈中。也就是说我们可以对运算的结果进行不断的迭代处理,已达到我们想要的结果。这对于想要分布处理并得到某一结果的需求来说是非常有用的。

这里我们借用一下programming in scala中的例子

abstract class IntQueue {
  def get(): Int

  def put(x: Int)
}

import scala.collection.mutable.ArrayBuffer

class BasicIntQueue extends IntQueue {
  private val buf = new ArrayBuffer[Int]

  def get() = buf.remove(0)

  def put(x: Int) {
    buf += x
  }
}

trait Doubling extends IntQueue {
  abstract override def put(x: Int) {
    super.put(2 * x)
  }
}

trait Incrementing extends IntQueue {
  abstract override def put(x: Int) {
    super.put(x + 1)
  }
}

trait Filtering extends IntQueue {
  abstract override def put(x: Int) {
    if (x >= 0) super.put(x)
  }
}

在以上代码中我们定义了一个抽象的队列,有putget方法,在类BasicIntQueue中提供了相应的实现方法。同时又定义了三个特质DoublingIncrementingFiltering,它们都继承了IntQueue抽象类(还记得之前讲过的,trait可以具备类的所有特性),并重写了其中的方法。Doubling将处理结果*2,Incrementing特质将处理结果做了+1处理,Filtering将过滤掉<0的值。

我们在来看以下的运行结果

scala> val queue = (new BasicIntQueue with Incrementing with Filtering)
queue: BasicIntQueue with Incrementing with Filtering...
scala> queue.put(-1); queue.put(0); queue.put(1)
scala> queue.get()
res15: Int = 1
scala> queue.get()
res16: Int = 2
scala> val queue = (new BasicIntQueue with Filtering with Incrementing)
queue: BasicIntQueue with Filtering with Incrementing...
scala> queue.put(-1); queue.put(0); queue.put(1)
scala> queue.get()
res17: Int = 0
scala> queue.get()
res18: Int = 1
scala> queue.get()
res19: Int = 2

仔细观察以上的代码,了解了上面的代码,你基本也就了解了stackable modifications

首先,你可以观察到,以上的两段代码整体相似,却得到不同的运行结果,原因只是因为特质FilteringIncrementing混入的顺序不同。我们仔细查看一下特质中的方法实现,可以发现在特质中都通过super关键字调用了父类的方法。而以上情况的产生原因就在于此。trait中的super是支持stackable modifications的根本关键。

trait中的super是动态绑定的,并且super调用的是另一个特质中的方法,具体哪个特质中的方法被调用需要取决于特质被混入的顺序。对于一般的序列,我们可以采用"从后往前"的顺序来推断super的调用顺序。

就拿以上的代码而言。

new BasicIntQueue with Incrementing with Filtering

代码的super的执行顺序按照从后往前的规则依次是

Filtering -> Incrementing -> BasicIntQueue 

举个具体的例子

例如这个时候我执行了put(1)的代码,那么按照上面的执行顺序,

先执行Filteringput方法判断值是否大于1,发现合法,将值1传给Incrementing中的put方法,Incrementing中的put方法将值加1之后传给BasicIntQueue然后将最终的值2放入队列中。

以上代码的执行过程就是stackable modifications的核心。因此到这里,你或许也能理解以上因为混入顺序不同而出现的不同结果了吧。

另外,说到动态性,我们在这里也可以简单地聊几句。在Java中,super的静态性与traitsuper的动态性形成了鲜明的对比。而动态性所带来的种种优势与强大,我们也已经在这一小节的内容中见识了一二。其实动态性抽离出来是一种设计思想,而它也早已在我们的身边大展拳脚。例如我们熟知的IOC依赖注入,AOP面向切面编程,以及前端的动态压缩技术等等,能够列举的还有很多,而它们的背后就是动态性的思想,你越是灵活,能够做的事也就越多。

Traits 探索

Traits构造顺序

trait Test {
    val name:String = "hello" //特质构造器的一部分
    println(name);  // 特质构造器的一部分
}

正如你在以上代码中所见的,在特质大括号中包裹的执行语句均属于特质构造器的一部分。

特质构造器的顺序如下:(参考自《快学Scala》)

  1. 首先执行超类的构造器(也就是跟在extends之后的类)
  2. 特质构造器在超类构造器之后、类构造器之前执行
  3. 特质由左到右构造
  4. 父特质先构造
  5. 类构造器

举个例子

class SavingAccount extends Account with FileLogger with ShortLogger

trait ShortLogger extends Logger

trait FileLogger extends Logger

以上构造器将按如下顺序执行

  1. Account(超类)
  2. Logger(第一个特质的父特质)
  3. FileLogger(第一个特质)
  4. ShortLogger(从左往右第二个特质,它的父特质Logger已经被构造,不再重复构造)
  5. SavingAccount(类构造器)

线性化

其实以上构造器顺序实现的背后使用的是一种叫"线性化"的技术。

拿以上的代码作为例子

class SavingAccount extends Account with FileLogger with ShortLogger

以上的代码将被线性化解析为

>>的意思是右侧将先被构造

lin(SavingsAccount) = SavingsAccount >> lin(ShortLogger) >> lin(FileLogger) >> lin(Account)

= SavingsAccount >> (ShortLogger >> Logger) >> (FileLogger >> Logger) >> Account

= SavingsAccount >> ShortLogger >> FileLogger >> Logger >> Account

仔细观察以下线性化的结果,你会发现,以上的顺序就是构造器执行的顺序。同时,线性化也给出了super的执行顺序,举例来说,在ShortLogger中调用super将调用右侧的FileLogger中的方法,而FileLogger中的super将调用右侧Logger中的方法,依次类推。

特质字段初始化

因此由于特质构造器的执行时间要早于类构造器的执行,因此在初始化特质中的字段时要额外注意字段的执行时间,避免出现空指针的情况。例如以下代码就会出现错误

trait Hello {
  val name:String
  val out = new PrintStream(name)
}

val test = new Test with Hello {
    val name = "Rhyme" // Error 类构造器晚于特质构造器
}

解决方法有提前定义或者懒值

采用提前定义的代码如下所示

val test = new { 
    val name = "Rhyme" //先于所有的构造器执行
}Test with Hello 

采用提前定义的方式使得代码不太雅观,我们还可以使用懒值的方式

采用懒值的方式如下

trait Hello {
  val name:String
  lazy val out = new PrintStream(name) // 使用懒值,延迟name的初始化
}

懒值在每次使用前都回去检查字段是否已经初始化,存在一定的使用开销。使用前需要仔细考虑

由于篇幅限制,关于trait的探索,我们就到此为止。希望本文能够对你学习和了解trait提供一点帮助。在下一章我们将介绍trait稍微高级一点的用法,自身类型和结构类型。