Java 使用 interface 关键字类声明一个接口。当一个具体类实现该接口时,使用 implements 关键字。接口的存在可以用来定义规范(这一点和抽象类类似),即在体现在设计功能上。
简单回顾 Java 接口的几个特点:
- 一个类可以实现多个接口。
- Java 接口之间允许多重继承。
- 接口内的属性全都是常量。
- Java 中的接口方法原本都应该是抽象的,但在 Java 8 版本之后发生了一些变化。
- 在 Java 8 之后,如果一个接口仅有一个抽象方法,我们称之为 Simple Abstract Method,简称 SAM 。对于这类接口,我们可以使用 Lambda 函数实现。
Java 引入接口的原因其一是:它本身不允许像 C++ 语言一样进行多重继承,因为 Java 试图提供更简单的 OOP 编程。因此 Java 引入了接口的概念,在一定程度上弥补了单继承的缺陷。
原因其二是为了解耦(以下是笔者对解耦的理解):接口将声明与实现分开来。一个抽象方法的声明,允许有类 A , B , C 给出不同的实现,而它们之间不需要建立 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
复制代码
这样的特质实际指代着这样的含义:它专门用于拓展某一个类的功能。有关这一点的疑惑,我们会在后文中给出。
简单的入门
假设现在有以下情景:定义一个特质用于规范数据库连接。有 MysqlConnector 和 OracleConnector 分别给出针对不同数据库的连接实现:
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}.class | interface |
| {traitName}$class.class | abstract 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)
}
}
复制代码
假设我们在主函数中同时将 MysqlOperator 和 OracleOperator 混入到一个对象中,控制台会打印什么信息呢?
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 特质的时候,已经将 Operator 和 DBOperator 这两个父特质初始化好了,因此 OracleOperator 这里编译器偷了一个懒:既然它的父特质已经被初始化好了,那么就不会再初始化重复的父特质了。
Scala 叠加特质
在刚才的例子,我们构建对象的同时,混入了多个特质,则称这个现象为叠加特质。特质的声明和构造顺序从左到右,但是方法访问的顺序是从右到左。不妨混入几个特质并调用一次 connection 的 insert 方法来试试看,方法访问的次序是否是从右到左。
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.
复制代码
这让人百思不得其解:为什么调用的是 MysqlOperator 的 insert 方法?这就是问题所在了:特质中的 super 关键字,未必会先指向它的父特质。在 Scala 的动态混入特质过程中,特质的方法调用顺序是从右到左。如果该特质左边的特质有存在的同名方法,则这个super指代的是动态混入时,位于该特质左边的特质。
说得再具体一点,我们混入特质的时候,MysqlOperator 在左,OracleOperator在右。这样就会导致代码执行到 OracleOperator 的 insert 方法内的 super 关键字时,首先判断左边的 MysqlOperator 有无具体的 insert 方法。如果有,则调用之。
如果沿着左边一直都无法寻找到可重用的方法,这时 super 关键字才会指代其父特质的被重写方法。比如我们这次在 OracleOperator 的左边混入一个不相关的特质:
val connection = new Connection("127.0.0.1") with OtherTrait with OracleOperator
复制代码
这次 OracleOperator 的super关键字除了父特质的 insert 方法之外就别无选择了。
build a Connection.
build a Operator.
build a DBOperator.
build a OracleOperator.
insert value into Oracle Database.
insert value into database.
复制代码
如果要明确指定 OracleOperator 执行的是父特质 DBOperator 的 insert 方法,此时可以通过类型参数来表示 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)
}
}
复制代码
我们此时再去查看 OracleOperator 的 super 会指向谁:
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关键字在特质中并不一定指代父特质,它可能还指向声明在左边的特质。为了消除这个误解,我们需要在 ImpOp 的 add 方法做一些改动:
trait ImpOp extends AbstractOp{
abstract override def add(int: Int): Int =
{
println("execute add:")
super.add(int)
}
}
复制代码
笔者虽然之前曾说过 Scala 中的 abstarct 关键字只能用于修饰类,然而在这里出现了特例:现在的 add 方法是一个半实现的抽象方法:它本身实现了一部分逻辑,但也同样留下来了一部分 "空缺的" super.add方法,所有该方法同时被abstract和override两个关键字来修饰。
此时的 super 将指代动态混入时,位于其左边的特质。程序在运行前编译器会按照从右到左的顺序依次找到能与之匹配的 super 方法,否则,就会在编译前报错。我们再声明一个实现了 AbstractOp 的 add 方法的 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
复制代码
按照动态混入特质的从右到左的执行顺序,首先调用 ImpOp 的 add 方法,在打印完一句 "execute add" 之后,再通过 super 关键字来调用 LeftOp 的 add 方法来补充完整的逻辑。
此时这个程序运行起来就没有问题了。
实现特质类的构造顺序
我们分别探讨一下两种情况下的特质构造顺序:
- 在类声明时就继承特质。
- 动态混入特质。
情况一:在类声明时继承特质。
该情况遵循这样的流程:率先构建顶级的父类,然后依次向下,直到本类。如果父类也继承了特质,则先按照声明的顺序构造其父类的特质。如果这些特质也存在继承关系,则先构造其顶级特质,然后再构造当前父类的特质。当父类的特质构造完毕后,再构造父类本身,然后构造下一级子类,逻辑依旧。
给定下面的代码:
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 时,由于 traitB 和 traitA 都已经已经被构造过了,因此不会再重复构造。
在处理好 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?
复制代码
两种情况的总结
-
如果类在声明时就实现了特质,则先构造特质,再构造类实例。
-
如果类在实例化时动态混入了特质,则先构造实例,再构造特质。
-
无论哪种情况,都不会重复地构造任何一个已经被构造过的特质。
拓展类的特质
特质不仅可以继承特质,它还可以继承一个类,并且可以通过 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 A 的 trait A,那么 class C 必须要是 class A 的子类,否则,在编译时会报出错误:illegal inheritance。
用代码举个例子,某个设备正企图同时安装两个不相干的系统:
class WindowsOS{}
class Device extends WindowsOS with RedHatInstallTutorial {}
复制代码
由于 WindowsOS 和 RedHatInstallTutorial 所继承的 LinuxOS 没有直接关系,因此这是一个错误的菱形继承。而现在又有另一个系统 CentOS ,它是隶属于 LinuxOS 类的。因此 Device 同时继承 CentOS 和 RedHatInstallTutorial 就不会引发问题。
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 特质。该特质有如此功能:它可以扩大其 Computer 的 storage 容量。在需要的时候将它装载到 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
}
复制代码
它限制“只有接入了大容量硬盘的电脑才可以安装大文件”。我们再回到主程序中运行并观察效果:只有混入了 Disk 的 Computer 实例才可以混入 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。




近期评论