从 Java 到 Scala(四):Traits
Traits特质,一个我们既熟悉又陌生的特性。熟悉是因为你会发现它和你平时在 Java 中使用的 interface 接口有着很大的相似之处,而陌生又是因为Traits的新玩法会让你打破对原有接口的认知,进入一个更具有挑战性,玩法更高级的领域。所以,在一开始,我们可以对 Traits 有一个初步的认识:它是一个加强版的interface
。之后,随着你对它了解的深入,你就会发现相比 Java 接口,Traits 跟类更为相似。再之后,你或许会觉察到,Traits在尝试着将抽象更好地融为一个整体。
Traits 入门
在 Java 中为了避免多重继承所带来的昂贵代价(方法或字段冲突、菱形继承等问题),Java 的设计者们使用了 interface 接口。而为了解决 Java 接口无法进行stackable modifications
(即无法使用对象状态进行迭代)、无法提供字段等局限,在 Scala 中,我们使用 Traits 特质而非接口。
定义一个trait
1 | trait Animal { |
以上代码类似于以下的Java代码
1 | public interface Animal { |
在 Scala 中使用关键字trait
而不interface
,和 Java 接口一样,trait
也可以有默认方法的实现。也就是说 Java 接口有的,trait
基本上也都有,而且实现起来要优雅许多。
之所以要说类似于以上的 Java 代码,原因在于trait
拥有的是字段typeOf
,而interface
拥有的是静态属性typeOf
。这是interface
和trait
的一点区别。但是再仔细观察思考这一点区别,更好更灵活的字段设计,是否使得trait
更好地组织了抽象,使得它们成为了一个更好的整体。
mix in trait
和 Java 一样,Scala 只支持单继承,但却可以有任意数量的特质。在 Scala 中,我们不称接口被implements
实现了,而是traits
被 mix in 混入了类中。
1 | class Bird extends Animal { |
以上代码中,Bird
类混入了特质Animal
。当类混入了多个特质时,需要使用with
关键字。
1 | trait Egg |
在 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
。这里就说到了interface
和trait
的第二个区别,Traits 支持stackable modifications
,使它能够使用对象状态,可以对对象状态进行灵活地迭代。
rich interface
富接口的应用要归功于interface
中对默认方法这一特性的支持,一方面松绑了类和接口之间实现与被实现之间的强关系,另一方面为程序的可扩展性代入了很大的灵活性。trait
在这一方面的应用和Java的没有很大的区别。而trait
中的默认方法的实现背后采用的也是interface
中的default
默认方法。
1 | trait Hello { |
1 | interface Hello2 { |
stackable modifications
关于stackable modifications
,顾名思义,我们将modification
保存在了一个stack
栈中。也就是说我们可以对运算的结果进行不断的迭代处理,已达到我们想要的结果。这对于想要分布处理并得到某一结果的需求来说是非常有用的。
这里我们借用一下programming in scala
中的例子:
1 | abstract class IntQueue { |
在以上代码中我们定义了一个抽象的队列,有put
和get
方法,在类BasicIntQueue中提供了相应的实现方法。同时又定义了三个特质Doubling
、Incrementing
、Filtering
,它们都继承了IntQueue抽象类(还记得之前讲过的,trait
可以具备类的所有特性),并重写了其中的方法。Doubling
将处理结果*2,Incrementing
特质将处理结果做了+1处理,Filtering
将过滤掉<0的值。
我们在来看以下的运行结果:
1 | scala> val queue = (new BasicIntQueue with Incrementing with Filtering) |
1 | scala> val queue = (new BasicIntQueue with Filtering with Incrementing) |
仔细观察以上的代码,了解了上面的代码,你基本也就了解了stackable modifications
。
首先,你可以观察到,以上的两段代码整体相似,却得到不同的运行结果,原因只是因为特质Filtering
和Incrementing
混入的顺序不同。我们仔细查看一下特质中的方法实现,可以发现在特质中都通过super
关键字调用了父类的方法。而以上情况的产生原因就在于此。trait
中的super
是支持stackable modifications
的根本关键。
在trait
中的super
是动态绑定的,并且super
调用的是另一个特质中的方法,具体哪个特质中的方法被调用需要取决于特质被混入的顺序。对于一般的序列,我们可以采用”从后往前”的顺序来推断super
的调用顺序。
就拿以上的代码而言。
1 | new BasicIntQueue with Incrementing with Filtering |
代码的super的执行顺序按照从后往前的规则依次是:
1 | Filtering -> Incrementing -> BasicIntQueue |
举个具体的例子:
例如这个时候我执行了put(1)
的代码,那么按照上面的执行顺序,
先执行Filtering
的put
方法判断值是否大于1,发现合法,将值1传给Incrementing
中的put
方法,Incrementing
中的put
方法将值加1之后传给BasicIntQueue
然后将最终的值2放入队列中。
以上代码的执行过程就是stackable modifications
的核心。因此到这里,你或许也能理解以上因为混入顺序不同而出现的不同结果了吧。
另外,说到动态性,我们在这里也可以简单地聊几句。在Java中,super
的静态性与trait
中super
的动态性形成了鲜明的对比。而动态性所带来的种种优势与强大,我们也已经在这一小节的内容中见识了一二。其实动态性抽离出来是一种设计思想,而它也早已在我们的身边大展拳脚。例如我们熟知的IOC依赖注入,AOP面向切面编程,以及前端的动态压缩技术等等,能够列举的还有很多,而它们的背后就是动态性的思想,你越是灵活,能够做的事也就越多。
Traits 探索
Traits构造顺序
1 | trait Test { |
正如你在以上代码中所见的,在特质大括号中包裹的执行语句均属于特质构造器的一部分。
特质构造器的顺序如下:(参考自《快学Scala》)
- 首先执行超类的构造器(也就是跟在
extends
之后的类) - 特质构造器在超类构造器之后、类构造器之前执行
- 特质由左到右构造
- 父特质先构造
- 类构造器
举个例子:
1 | class SavingAccount extends Account with FileLogger with ShortLogger |
以上构造器将按如下顺序执行:
Account
(超类)Logger
(第一个特质的父特质)FileLogger
(第一个特质)ShortLogger
(从左往右第二个特质,它的父特质Logger
已经被构造,不再重复构造)SavingAccount
(类构造器)
线性化
其实以上构造器顺序实现的背后使用的是一种叫”线性化”的技术。
拿以上的代码作为例子:
1 | class SavingAccount extends Account with FileLogger with ShortLogger |
以上的代码将被线性化解析为:
>>
的意思是右侧将先被构造
1 | lin(SavingsAccount) = SavingsAccount >> lin(ShortLogger) >> lin(FileLogger) >> lin(Account) |
仔细观察以下线性化的结果,你会发现,以上的顺序就是构造器执行的顺序。同时,线性化也给出了super
的执行顺序,举例来说,在ShortLogger
中调用super
将调用右侧的FileLogger
中的方法,而FileLogger
中的super
将调用右侧Logger
中的方法,依次类推。
特质字段初始化
因此由于特质构造器的执行时间要早于类构造器的执行,因此在初始化特质中的字段时要额外注意字段的执行时间,避免出现空指针的情况。例如以下代码就会出现错误
1 | trait Hello { |
解决方法有提前定义
或者懒值
。
采用提前定义的代码如下所示:
1 | val test = new { |
采用提前定义的方式使得代码不太雅观,我们还可以使用懒值的方式。
采用懒值的方式如下:
1 | trait Hello { |
懒值在每次使用前都回去检查字段是否已经初始化,存在一定的使用开销,使用前需要仔细考虑。
由于篇幅限制,关于 Traits 的探索,我们就到此为止。希望本文能够对你学习和了解 Traits 提供一点帮助。在下一章我们将介绍 Traits 稍微高级一点的用法,自身类型和结构类型。