转载

从 Java 到 Scala(四):Traits

本文由 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》)

extends

举个例子

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 稍微高级一点的用法,自身类型和结构类型。

从 Java 到 Scala(四):Traits
原文  https://juejin.im/post/5bfdf45f6fb9a049dd7ffa9b
正文到此结束
Loading...