阅读 160

Scala之:支持动态混入的特质 Trait

Java 使用 interface 关键字类声明一个接口。当一个具体类实现该接口时,使用 implements 关键字。接口的存在可以用来定义规范(这一点和抽象类类似),即在体现在设计功能上。

简单回顾 Java 接口的几个特点:

  1. 一个类可以实现多个接口。
  2. Java 接口之间允许多重继承
  3. 接口内的属性全都是常量。
  4. Java 中的接口方法原本都应该是抽象的,但在 Java 8 版本之后发生了一些变化。
  5. 在 Java 8 之后,如果一个接口仅有一个抽象方法,我们称之为 Simple Abstract Method,简称 SAM 。对于这类接口,我们可以使用 Lambda 函数实现。

Java 引入接口的原因其一是:它本身不允许像 C++ 语言一样进行多重继承,因为 Java 试图提供更简单的 OOP 编程。因此 Java 引入了接口的概念,在一定程度上弥补了单继承的缺陷。

原因其二是为了解耦(以下是笔者对解耦的理解):接口将声明与实现分开来。一个抽象方法的声明,允许有类 ABC 给出不同的实现,而它们之间不需要建立 is-a 的强依赖关系。

然而,Java 中的解耦有一定的局限性:子类会继承来自父类所有方法,无论它是否真的需要用到它们。这有点违背了接口的解耦初衷:一旦父类实现了接口方法,这个方法就会被继承下去,而不管子类是否真正需要它。由于继承机制的限制,Java 中的接口并没有真正的实现 "热部署" 。Scala 为了弥补这点遗憾,引入了动态混入的概念。

特质:trait

Scala 没有保留 Java 的接口概念,而是引入了:trait 。当多个类拥有共同的 "特征" 时,就可以把这个特质提取出来。采取trait关键字去声明。trait 在底层等价于 Java 语言的 interface (+ abstract class)。所以它具备 Java 接口和抽象类的所有特性。

Java 中的所有接口 interface 都可以使用 Scala 的 trait 来代替。比如 Scala 的序列化特质就是对原 Java 接口的简单包装:

package scala

/**
 * Classes extending this trait are serializable across platforms (Java, .NET).
 */
trait Serializable extends Any with java.io.Serializable
复制代码

使用方法

一旦类具备某个特质,就意味着这个类满足了这个特质的所有要素,因此在使用时也采用 extends 关键字(Scala 不使用 implements 关键字)。下面是几种不同的声明情况:

当一个类,或者一个特质继承了多个特质时,使用 with 关键字连接:

class Clazz extends trait1 with trait2 with ...
复制代码

当一个类同时继承了一个父类和多个特质时,将父类声明在 extends 关键字后面,再用 with 关键字链接其它的特质。

class Clazz extends Parent with trait1 with ...
复制代码

特质可以存在伴生对象。

trait Calculator {

  def add(a1: Int, a2: Int): Int = a1 + a2
}

object Calculator {
   final val PI : Double = 3.1415
}
复制代码

特质之间也存在着继承关系,甚至特质可以去继承一个类 class 。

trait Trait extends Object
复制代码

这样的特质实际指代着这样的含义:它专门用于拓展某一个类的功能。有关这一点的疑惑,我们会在后文中给出。

简单的入门

假设现在有以下情景:定义一个特质用于规范数据库连接。有 MysqlConnectorOracleConnector 分别给出针对不同数据库的连接实现:

trait DBConnector {
  //声明抽象方法时不需要带函数体
  def Connect(): Unit
}

class MySQLConnector extends DBConnector {
  override def Connect(): Unit = {
    println("Connect to Mysql")
  }
}

class OracleConnector extends DBConnector {
  override def Connect(): Unit = {
    println("Connect to Oracle")
  }
}
复制代码

从底层观察特质的编译结果

当 trait 内仅声明了抽象方法时,在底层仅编译一个如下代码所示的 .class 文件。此时的 trait 等价于 interface 。

//from jd-gui.exe
//忽略无关代码
import scala.reflect.ScalaSignature;

public abstract interface DBConnector
{
  public abstract void Connect();
}
复制代码

同时存在抽象方法和具体方法的情况

我们这次在 DBConnector 里同时加入具体方法,再去观察编译器是如何编译的:

trait DBConnector {
  //抽象方法
  def Connect(): Unit

  //具体方法
  def getURL(): String = "127.0.0.1"
}
复制代码

在 Java 8 版本之前,其接口内不可以直接声明具体方法,所以马丁为了做兼容,它将特质内的具体方法放到了一个另一个抽象类内去保存,此时的特质等价于interface + abstract class。另外同时具备抽象方法和具体方法的特质称为富接口

在当前情况下,特质生成了两个文件:

文件名类型
{traitName}.classinterface
{traitName}$class.classabstract class

动态混入特质*

一句话回顾 OCP 原则:对修改限制,对拓展开放。

除了在类声明时继承特质以外,还可以在构造对象时动态混入特质。它可以在不在修改类的声明时拓展类的功能,耦合性低。动态混入在为类拓展功能的同时,没有影响到原有的继承关系。

举个简单例子:声明一个普通的计算器 Calculator ,它的 add 功能被 “外挂” 在了 Calculate 的 trait 中。

trait Calculate{
  //在特质声明了具体方法,供装载该接口的类去使用。
  def add(a:Int,b:Int) :Int= a+b
}
class Calculator {}
复制代码

我们在实例化 Calculator 的时候,将这个特质混入进去:

//在类的实例化时,直接使用with关键字动态接入一个trait。
val c= new Calculator with Calculate

//如果没有混入trait,则不能使用该方法
c.add(2,3)
复制代码

可能有同学好奇,这个被混入了特质的 Calculator 实例还是单纯的 Calculator 类吗?此时,它的类型就是 Calculator with Calculate ,既不是纯粹的 Calculator,也不是纯粹的 Calculate

val c: Calculate with Calculator = new Calculate with Calculator
复制代码

特质的继承关系

我们先构造一个如上继承关系的三个特质,为了观察构造顺序,我们再其内部的构造器加上一行 println 语句:

trait Operator{

  def insert(value : Int) : Boolean
}

trait DBOperator extends Operator {

  println("build a DBOperator.")

  override def insert(value: Int): Boolean = {
    println("insert value into database.")
    true
  }

}

trait MysqlOperator extends DBOperator{

    println("build a MysqlOperator.")

    override def insert(value: Int): Boolean ={
        println("insert value into Mysql Database.")
        true
    }   
}
复制代码

我们再声明一个用于装载特质的工具类:

class Connection(thisURL: String){

  val URL : String = thisURL

  println("build a Connection.")
  
}
复制代码

动态混入特质,然后令其在主函数中运行:

def main(args : Array[String]): Unit = {

  val connection = new Connection("127.0.0.1") with MysqlOperator

  connection.insert(100)
}
复制代码

控制台中的打印顺序是是什么样的呢?特质的构造顺序和类的构造顺序是一样的,会从父类开始逐步向下进行构建,因此控制台会依次打印出:

build a Connection.
build a Operator.
build a DBOperator.
build a MysqlOperator.
insert value into Mysql Database.
复制代码

我们可以看出是从 Operator 特质开始沿着继承关系逐渐向下层初始化的。假设继承关系变成了这个结构:

下面给出 OracleOperator 的声明:

trait OracleOperator extends DBOperator{

  println("build a MysqlOperator.")

  override def insert(value: Int): Boolean ={

    println("insert value into Oracle Database.")
    //留意一下这个super关键字!等会提及它。
    super.insert(value)
  }
}
复制代码

假设我们在主函数中同时将 MysqlOperatorOracleOperator 混入到一个对象中,控制台会打印什么信息呢?

val connection = new Connection("127.0.0.1") with MysqlOperator with OracleOperator
复制代码

控制台中打印出的是这样的信息:

build a Connection.
build a Operator.
build a DBOperator.
build a MysqlOperator.
build a OracleOperator.
复制代码

这是笔者要说明的一个现象:在构造 MysqlOperator 特质的时候,已经将 OperatorDBOperator 这两个父特质初始化好了,因此 OracleOperator 这里编译器偷了一个懒:既然它的父特质已经被初始化好了,那么就不会再初始化重复的父特质了

Scala 叠加特质

在刚才的例子,我们构建对象的同时,混入了多个特质,则称这个现象为叠加特质。特质的声明和构造顺序从左到右,但是方法访问的顺序是从右到左。不妨混入几个特质并调用一次 connectioninsert 方法来试试看,方法访问的次序是否是从右到左。

val connection = new Connection("127.0.0.1") with MysqlOperator with OracleOperator
复制代码

控制台打印了如下的内容:

build a Connection.
build a Operator.
build a DBOperator.
build a MysqlOperator.
build a OracleOperator.
insert value into Oracle Database.
insert value into Mysql Database.
复制代码

这让人百思不得其解:为什么调用的是 MysqlOperatorinsert 方法?这就是问题所在了:特质中的 super 关键字,未必会先指向它的父特质。在 Scala 的动态混入特质过程中,特质的方法调用顺序是从右到左。如果该特质左边的特质有存在的同名方法,则这个super指代的是动态混入时,位于该特质左边的特质。

说得再具体一点,我们混入特质的时候,MysqlOperator 在左,OracleOperator在右。这样就会导致代码执行到 OracleOperatorinsert 方法内的 super 关键字时,首先判断左边的 MysqlOperator 有无具体的 insert 方法。如果有,则调用之。

如果沿着左边一直都无法寻找到可重用的方法,这时 super 关键字才会指代其父特质的被重写方法。比如我们这次在 OracleOperator 的左边混入一个不相关的特质:

val connection = new Connection("127.0.0.1") with OtherTrait with OracleOperator
复制代码

这次 OracleOperatorsuper关键字除了父特质的 insert 方法之外就别无选择了。

build a Connection.
build a Operator.
build a DBOperator.
build a OracleOperator.
insert value into Oracle Database.
insert value into database.
复制代码

如果要明确指定 OracleOperator 执行的是父特质 DBOperatorinsert 方法,此时可以通过类型参数来表示 super 指代直接继承关系的父特质,不再考虑其左边是否有同名的方法。

trait OracleOperator extends DBOperator{

  println("build a OracleOperator.")

  override def insert(value: Int): Boolean ={

    println("insert value into Oracle Database.")
    //无视从右到左的访问顺序,指明super为DBOperator.
    super[DBOperator].insert(value)
  }
}
复制代码

我们此时再去查看 OracleOperatorsuper 会指向谁:

insert value into Oracle Database.
insert value into database.
复制代码

abstract 关键字在特质中的特殊用法

首先用一段代码来说明问题:

trait AbstractOp{

  def add(int: Int) : Int

}

trait ImpOp extends AbstractOp{

  override def add(int: Int): Int =
  {
    println("execute add:")
    super.add(int)
  }

}
复制代码

注意 ImpOp 代码中的 add 方法。编译器认为,我们似乎是想要调用父类的抽象 add 方法,而这种做法在逻辑上是不成立的:虽然我们在之前已经提及过,super关键字在特质中并不一定指代父特质,它可能还指向声明在左边的特质。为了消除这个误解,我们需要在 ImpOpadd 方法做一些改动:

trait ImpOp extends AbstractOp{

  abstract override def add(int: Int): Int =
  {
    println("execute add:")
    super.add(int)
  }

}
复制代码

笔者虽然之前曾说过 Scala 中的 abstarct 关键字只能用于修饰类,然而在这里出现了特例:现在的 add 方法是一个半实现的抽象方法:它本身实现了一部分逻辑,但也同样留下来了一部分 "空缺的" super.add方法,所有该方法同时被abstractoverride两个关键字来修饰。

此时的 super 将指代动态混入时,位于其左边的特质。程序在运行前编译器会按照从右到左的顺序依次找到能与之匹配的 super 方法,否则,就会在编译前报错。我们再声明一个实现了 AbstractOpadd 方法的 LeftOp 特质:

trait LeftOp extends AbstractOp{
  override def add(int: Int): Int =
  {
    int + 3
  }
}
复制代码

其它的逻辑不动,在主函数中创建一个 InstanceOp 类(这个类只用于外挂特质,它没有其它的任何内容),按照次序进行特质的动态混入:

 val instanceOp = new InstanceOp with LeftOp with ImpOp

 println(instanceOp.add(4))
复制代码

执行主函数:

execute add:
7
复制代码

按照动态混入特质的从右到左的执行顺序,首先调用 ImpOpadd 方法,在打印完一句 "execute add" 之后,再通过 super 关键字来调用 LeftOpadd 方法来补充完整的逻辑。

此时这个程序运行起来就没有问题了。

实现特质类的构造顺序

我们分别探讨一下两种情况下的特质构造顺序:

  1. 在类声明时就继承特质。
  2. 动态混入特质。

情况一:在类声明时继承特质。

该情况遵循这样的流程:率先构建顶级的父类,然后依次向下,直到本类。如果父类也继承了特质,则先按照声明的顺序构造其父类的特质如果这些特质也存在继承关系,则先构造其顶级特质,然后再构造当前父类的特质。当父类的特质构造完毕后,再构造父类本身,然后构造下一级子类,逻辑依旧。

给定下面的代码:

trait traitA{

  println("construct traitA")
}

trait traitB extends traitA{
  println("construct traitB")

}

trait traitC extends traitB{
  println("construct traitC")

}

trait traitD{
  println("construct traitD")
}

trait traitE extends traitD{
  println("construct traitE")
}

class ObjA {
  println("construct ObjA")

}

class ObjB extends ObjA with traitB with traitC{
  println("construct ObjB")
}

class ObjC extends ObjB with traitE {
  println("construct ObjC")
}
复制代码

用图示来描述这个关系:

在主函数中构造一个 ObjC 实例时,先去构造顶级父类 ObjA,随后再去尝试构造下一级父类 ObjB

由于 ObjB 还继承了一些特质,因此要率先把 ObjB 的特质构造出来。按照 ObjB 的声明顺序,首先构造 traitB 。由于 traitB 继承自 traitA ,因此首先将traitA 构造出来。当构造 traitC 时,由于 traitBtraitA 都已经已经被构造过了,因此不会再重复构造。

在处理好 ObjB 的特质之后,再去构造 ObjB 本身,然后再构造最终的 ObjC ,按照相同的逻辑顺序,先处理好 traitD ,再处理 traitE ,最后再构造 ObjC 。当在主函数中构造一个 ObjC 的实例时,屏幕会打印:

construct ObjA
construct traitA
construct traitB
construct traitC
construct ObjB
construct traitD
construct traitE
construct ObjC
复制代码

情况二:动态混入特质。

在上述代码中,我们删掉 ObjC 额外实现的特质,改为在主程序中进行动态混入:

new ObjC with traitE with traitD
复制代码

用图来展示流程,其中动态混入的依赖被虚线标注:

和情况一的区别发生在构造 ObjC 的过程:在动态混入特质时,先将类构造出来,再去构造特质

运行主函数,观察结果:

construct ObjA
construct traitA
construct traitB
construct traitC
construct ObjB
construct ObjC
construct traitD  //why?
construct traitE  //why?
复制代码

两种情况的总结

  1. 如果类在声明时就实现了特质,则先构造特质,再构造类实例。

  2. 如果类在实例化时动态混入了特质,则先构造实例,再构造特质。

  3. 无论哪种情况,都不会重复地构造任何一个已经被构造过的特质。

拓展类的特质

特质不仅可以继承特质,它还可以继承一个类,并且可以通过 super 关键字来调用父类方法(但是 super 在动态混入情况下并不总是如此)。

如图所示,特质 traitA 继承了 class A 。它的作用就像是一个粘合剂:任何声明继承了该特质的类,也相当于继承了类class A

或者应该这样理解:class A 想要获取 trait A 的一些特性,而 trait A 却是一个专门用于拓展 class A 的一个特质。所以,理所应当地,class B 肯定也需要和 class A 保持着继承关系。举个例子,有一台设备 Device ,和一个 RedHatInstallTutorial 特质,它继承了 LinuxOS 类。 Device 通过继承这个特质来间接地实现继承 LinuxOS

class LinuxOS

trait RedHatInstallTutorial extends LinuxOS

//通过继承 RedHatInstallTutorial 来间接实现继承 LinuxOS.
class Device extends RedHatInstallTutorial
复制代码

不可实现多重继承

乍一看,利用这个特性或许可以通过 “曲线救国” 的方式来实现 Scala 的多重继承,但其实并不能行得通。

如果 class B 也继承了一个类 class C,同时还要混入继承了 class Atrait A,那么 class C 必须要是 class A 的子类,否则,在编译时会报出错误:illegal inheritance

用代码举个例子,某个设备正企图同时安装两个不相干的系统:

class WindowsOS{}
class Device extends WindowsOS with RedHatInstallTutorial {}
复制代码

由于 WindowsOSRedHatInstallTutorial 所继承的 LinuxOS 没有直接关系,因此这是一个错误的菱形继承。而现在又有另一个系统 CentOS ,它是隶属于 LinuxOS 类的。因此 Device 同时继承 CentOSRedHatInstallTutorial 就不会引发问题。

class CentOS extends LinuxOS {}
class Device extends CentOS with RedHatInstallTutorial {}
复制代码

目前为止,支持多继承的主流语言只有 C++ 语言。似乎目前的广大编程语言并不认可多继承,而是转而使用其它的方法来弥补单继承的限制。

避免动态混入时的菱形继承问题

在动态混入中,我们同样要避免菱形继承的现象,对于动态混入时特质 trait A,必须要满足两个条件之一:

如果 class B 在声明时没有任何继承关系,则动态混入的特质 trait A 要么没有声明继承关系,要么只能继承 class B 本身。

trait traitA extends classB {}
class classB {}
//-----main--------//
//动态混入
val clazzB = new classB with traitA
复制代码

如果 class B 在声明时继承了一个类 class A ,则动态混入的特质必须也直接或间接地继承自 class A

class classA{}
trait traitA extends classA {}
class classB extends classA {}
//---main--------//
//动态混入
 val clazzB = new classB with traitA
复制代码

无论哪种做法,都是为了避免在动态混入时导致的菱形继承问题。

为什么不直接建立一个 B→C→A 的继承关系?

该如何理解奇怪的做法呢?我们需要站在 class A 的程序设计者的角度,并结合动态混入去思考:有些情况,class A 想为它的子类提供可选的服务,它的子类只有在必要的情况下才通过混入特质来调用功能。下面,我们代入场景来说明问题:

场景一

现在有一台电脑 Computer,它内部有一个 Int 型的属性 storage ,另有一个 Computer 专用的 Disk 特质。该特质有如此功能:它可以扩大其 Computerstorage 容量。在需要的时候将它装载到 Computer 实例上,就会生效。

class Computer(protected var storage: Int) {

  println("build a Computer.")
  protected def setStorage(storage: Int): Unit = this.storage = this.storage + storage
  //外部可以查看该电脑容量。
  def getStorage: Int = storage
}

trait Disk extends Computer {
  println("This computer's capability has been extended.")
  setStorage(1024)
}
复制代码

对于任何混入了 Disk 特质的 Computer 实例,它的storage属性都会扩充 1024 个单位。这个步骤在 Disk 被初始化时执行 setStorage方法来进行。我们在主程序中同时声明两个 Computer 实例,一个混入了 Disk ,另一个则没有。

val computer = new Computer(512) with Disk
println(s"computer's storage: ${computer.getStorage}")

val computer2 = new Computer(512)
println(s"computer2's storage: ${computer2.getStorage}")
复制代码

运行主程序,观察屏幕的打印次序:

build a Computer.
This computer's capability has been extended.
computer's storage: 1536
build a Computer.
computer2's storage: 512
复制代码

场景二

定义一个华为电脑 HuaWeiComputer 类,该类还有一个子类 MateBook

//-------HuaWeiComputer------------//
class HuaWeiComputer{
  //假定华为的电脑都具备以下两个功能:
  def powerOn():Unit ={
    print("电脑启动")
  }

  def powerOff():Unit ={
    print("电脑关闭")
  }
}
//----------MateBoook------------//
class MateBook extends HuaWeiComputer {}
复制代码

华为电脑 HuaWeiComputer 承诺,任何华为品牌的电脑,发生损坏时,都可以提供额外的维修服务。

这里提到了两个条件:一:该类属于 HuaWeiComputer ,二:维修服务是可选的。

因此 HuaWeiComputer 额外提供了一个这样的"售后服务"特质:

trait afterSaleService extends HuaWeiComputer
{
  def post(info: String):Unit ={
    println(s"收到您的型号$info,我们将为您提供额外的售后服务。")
  }
}
复制代码

当某个华为品牌的电脑出现了故障,它便可以动态混入的方式接入此特质。而不需要维修功能的华为电脑就不要混入特质,当然,它就没有对应的 post 方法。而其它不属于 HuaWeiComputer 的电脑,不能使用该功能。

//需要维修功能的华为电脑可以混入特质
val huaWei100 = new MateBook with afterSaleService
huaWei100.post("100")

//不需要维修功能的华为电脑不用混入特质
val huaWei101 = new MateBook

//不属于HuaWeiComputer的电脑不能使用该服务(会报错)
val Apple100 = new MacBook with afterSaleSerive
复制代码

特质的自身类型 (self type) 引用

现在又存在一个特质 Install ,它要限制只有实现(或混入)了 Disk 特质的 Computer 才可以使用它。这时应该怎么做呢?

trait Install extends Computer with Disk
{
    //....
}
复制代码

这样的写法只能表示 Install 特质同时继承了 Computer 类和 Disk 特质,而不是继承了混入 Disk 特质的 Computer 类。此时,我们就需要特质的自身类型引用了:我们按照下面的格式对想要接入 Install 的类进行限制,写法为this : xxxx =>

trait Install{
  this : Computer with Disk=>
  def bigData : Int = getStorage - 1300
}
复制代码

它限制“只有接入了大容量硬盘的电脑才可以安装大文件”。我们再回到主程序中运行并观察效果:只有混入了 DiskComputer 实例才可以混入 Install 特质,并调用其功能,否则,就会报出编译错误:

val computer = new Computer(512) with Disk with Install
println(s"computer's storage: ${computer.getStorage}")
println(computer.bigData)

//由于它没有混入Disk特质,因此它不能再混入Install。
val computer2 = new Computer(512) 
println(s"computer2's storage: ${computer2.getStorage}")
复制代码

如果用 Java 的角度来重写这个 Install 特质,则它的声明应该是类似这个样子:

public interface Install<Computer extends Disk> {//...}
复制代码

梳理目前为止创建对象的4种方式

  • new 方法:最常用的构建实例方法。
  • apply 方法:其伴生对象将类的构造器封装为一个 apply 方法,通过调用该方法来获取一个实例。
  • 匿名子类:常用于直接对抽象类或者特质进行重写并使用的场合。
  • 动态混入:根据实际需要,在 new 对象的过程中额外混入其它 trait