CH02-特质与混入组合
在专研一些实际的设计模式之前,我们需要确保读者对 Scala 中的一些概念都很了解。这些概念中的很多一部分都会在后续用于实现实际的设计模式,同时能够意识到这些概念的能力、限制以及陷阱也是编写正确、高效代码的关键因素。尽管有些概念并不被认为是“官方上”的设计模式,但仍然可以使用它们来编写好的软件。甚至有些场景中,基于 Scala 的丰富特性,一些概念甚至可以使用语言特性来代替一种设计模式。最后,向我们前面提到的,设计模式的存在是因为一种语言的特性缺失,因为特性的不够丰富而不能完成一些需要的任务。
在第一个主题中我们将会了解特质与混入(mixin)组合。他们为开发者提供了一种可能来共享已实现的功能或在应用中为类定义接口。很多由特质与混入组合为开发者带来的可能性对于本书后续要实现的一些设计模式都是很有帮助的。本章将主要关注一下几个主题:
- 特质
- 混入组合
- 多重继承
- 线性化
- 测试特质
- 特质与类
特质
大家或许对 Scala 中的特质持有不同的看法。他们不仅可以被视作类似其他语言中的接口,同时可以被当做一个拥有无参构造器的类。
特质参数
Scala 编程语言充满活力,其发展很快。其中一个将会在 2.12 版本中探讨的趋势是特质参数。更多信息请查看 http://www.scala-lang.org/news/roadmap-next/。
在下面的几个小节中,我们将会以不同的视角来了解特质并为你提供一些如何使用它们的想法。
特质作为接口
特质可以看做是其他语言中的接口,比如 Java。但是支持开发者实现其中的一部分或全部方法。每当特质中拥有一些代码,该特质则被称为 mixin(混入、混合)。让我们看一下下面这个例子:
trait Alarm {
def trigger(): String
}
这里Alarm
是一个接口。仅拥有一个trigger
方法,不拥有任何实现,如果它被混入一个非抽象的类则必须提供一个该方法的实现。
让我们看一下另一个特质的例子:
trait Notifier {
val notificationMessage: String
def printNotification(): Unit = {
println(notificationMessage)
}
def clear(): Unit
}
这个Notifier
特质拥有一个已实现的方法,而notificationMessage
的值和clear
则需要由混入它的类来提供实现。此外,特质可以要求一个类必须拥有一个指定的变量,这类似于其他语言中的抽象类。
混入带有变量的特质
像上面提到的,特质可以要求类拥有一个指定的变量。一个有趣的用法是我们可以传递一个变量到类的构造器中。这样则可以满足该特质的需求:
class NotifierImpl(val notificationMessage:String) extends Notifier {
override def clear(): Unit = println(""cleared)
}
这里唯一的必要是在类的定义中该变量必须使用相同的名字并且前面要使用val
关键字来修饰。如果上面的代码中我们不适用val
关键字作为前缀,编译器则仍然会要求我们实现该特质。这种情况下,我们不得不为类的参数使用一个不同的名字,并在类体中拥有一个override val notificationMessage
赋值。
这种行为的原因很简单:如果我们显式的使用val
(或 var,根据特质要求),编译器会创建一个字段,并拥有与参数相同作用域的 getter。如果我们仅拥有参数,仅当该参数被用作构造器的作用域之外时(比如用在该类的方法中)才会创建一个字段和内部的 getter。更完备的是,样例类(case class)会自动在参数之前追加val
关键字。因此,如果我们使用这个val
关键字,我们将会自动获得一个与该参数同名的字段、相同的作用域,同时将会自动覆写特质中对我们的要求。
特质作为类
特质同样可以以类的视角来看待。这种情况下,他们需要实现其所有的方法,同时仅拥有一个不接收任何参数的构造器。考虑下面的代码:
trait Beeper {
def beep(times: Int): Unit = {
assert(times >= 0)
1 to times foreach(i => println(s"Beep number: $i"))
}
}
现在我们实际上可以直接实例化Beeper
并调用其方法。比如下面的例子:
object BeeperRunner extends App{
val TIMES = 10
val beeper = new Beeper{} // <= 实例化一个特质
beeper.beep(TIMES)
}
像预期一样,运行代码后我们将看到如下输出:
Beep number: 1
Beep number: 2
Beep number: 3
....
扩展类
特质同样可以用来扩展类。让我们看一下下面的例子:
abstract class Connector {
def connect()
def close()
}
trait ConnectorWithHelper extends Connector {
def findDriver(): Unit = {
println("Find driver called.")
}
}
class PgSqlConnector extends ConnectorWithHelper {
override def connect(): Unit = {
println("Connected...")
}
override def close(): Unit = {
println("Closed...")
}
}
和预期的一样,PgSqlConnector
会被抽象类约束来实现其抽象方法。你可能会推测,我们可以用一些别的特质来扩展这些类然后再将他们(同时)进行混入。在 Scala 中,我们会对一些情况稍加限制,在后续章节中我们研究组合的时候会来看一下这么做会对我们产生哪些影响。
扩展特质
特质可以被互相扩展。看一下如下例子:
trait Ping {
def ping():Unit = {
println("ping")
}
}
tring Pong {
def pong():Unit = {
println("pong")
}
}
trait PingPong extends Ping with Pong {
def pingPong():Unit = {
ping()
pong()
}
}
object Runner extends App with PingPong {
pingPong()
}
上面这个例子是比较简单的,实际上也可以让
Runner
分别来扩展两个特质。扩展特质可以很好的用来实现特质堆叠这种设计模式,本书的后续部分中我们将会探讨这个话题。
混入组合
Scala 支持开发者在一个类中扩展多个特质。这相对于那些不允许同时扩展多个类的语言增加了多重继承的可能性并且能剩下很多编写代码的精力。在这个子主题中,我们将会展示如果将多个特质混入到一个指定的类,或者在编写代码的时候如何创建一个带有某些指定功能的匿名类。
混入特质
首先,我们先修改一下上个例子的代码。这个改动很小,它将会展示特质是如何被混入的:
object MixinRunner extends App with Ping with Pong {
ping()
pong()
}
从上面的代码中可以发现,我们可以将多个特质添加到同一个类中。上面代码中扩展的App
特质主要是为了使用其自带的main
方法。这会和创建无参类有点类似。
如何混入特质?
将特质混入到类可以使用如下语法:
extends with T1 with T2 with T3 with ... Tn
。如果一个类已经扩展了别的类,我们仅需要通过
with
关键字来添加特质。如果特质方法在特质体内并没有被实现,同时我们混入该特质的类也没有声明为抽象类,则该类必须实现特质中的方法。否则,将会出现编译错误。
组合
创建时的组合给我们提供了一个无需显式定义就能创建匿名类的机会。同样,如果我们想要组合多个不同的特质,创建所有的可能则要花费很多的工作。
组合简单特质
让我们看一个组合简单特质的例子,这里不会扩展其他类或特质:
class watch(brand:String, initialTime: Long){
def getTime(): Long = System.currentTimeMillis() - initialTime
}
object WatchUser extends App{
val expensiveWatch = new Watch("expensive brand", 1000L) with Alarm with Notifier {
override def trigger(): String = "The alarm was triggered."
override def clear(): Unit = println("Alarm cleared.")
override val notificationMessage:String = "Alarm is running!"
}
val cheapWatch = new Watch("cheap brand", 1000L) with Alarm {
override def trigger(): String = "The alarm was triggered."
}
// show some watch usage.
println(expensiveWatch.trigger())
expensiveWatch.printNotification()
println(s"The time is ${expensiveWatch.getTime()}.")
expensiveWatch.clear()
println(cheapWatch.trigger())
println("Cheap watches cannot manually stop the alarm...")
}
在这个例子中我们使用了之前定义的Alarm
和Notifier
特质。我们创建了两个手表实例——一个是贵的,它拥有更多有用的功能;另一个是便宜的,它的功能则会少很多。本质上,他们都是匿名类,在初始化的时候被定义。另外要注意的是,和预期一样,我们需要实现那些我们扩展的特质中的抽象方法。希望这个例子能为你在拥有很多特质及多种可能的组合时带来一些想法。
只是为了完整,下面是上个程序的输出:
The alarm was triggered.
Alarm is running!
The time is 1234567890562.
Alarm cleared.
The alarm was triggered.
Cheap watches cannot manually stop the alarm...
和预期一样,第三行的时间会在每次运行的时候都有所不同。
组合复杂特质
在有些可能的情况下,我们需要组合更加复杂的特质,这些特质可能扩展了一些别的类或特质。如果在继承链的上层没有一个特质或别的特质已经显式扩展了一个指定的类,事情则会变得很简单,他们也不会改变太多。这种情况下,我们可以简单的访问超特质的方法。然而,让我们看一下如果继承链中的特质已经扩展了一个指定的类会发生什么。在这个例子中,我们将会使用之前定义的ConnectorWithHelper
特质。该特质扩展了名为Connector
的抽象类。加入我们想拥有另一个非常昂贵的手表,比如它可以连接到数据库:
object ReallyExpensiveWatchUser extends App{
val reallyExpensiveWatch = new Watch("really expensive brand", 1000L) with ConnectorWithHelper {
override def connect(): Unit = println("Connected with another connector.")
override def close(): Unit = println("Closed with another connector.")
}
println("Using the really expensive watch.")
reallyExpensiveWatch.findDriver()
reallyExpensiveWatch.connect()
reallyExpensiveWatch.close()
}
看起来都很好,但是当我们编译的时候,会得到如下错误信息:
Error:(36, 80) illegal inheritance; superclass Watch
is not a subclass of the superclass Connector of the mixin trait ConnectorWithHelper
val reallyExpensiveWatch = new Watch("really expensive brand",
1000L) with ConnectorWithHelper {
^
该错误消息告诉我们,由于ConnectorWithHelper
特质已经扩展了Connector
类,所有使用该特质进行组合的类必须是Connector
的子类。现在让我们假如需要混入另一个同样已经扩展了一个类的特质,但被扩展的这个类与之前不同。根据之前的逻辑,会需要Watch
同样需要是该类的子类。但这是不可能出现的,因为我们同时只能扩展一个类,这也就是 Scala 如何来限制多重继承以避免危险错误的发生。
如果我们想要修复这个例子的编译错误,我们不得不去修改原有的Watch
类的实现以确保其是Connector
的子类。然而这可能并非我们所原本期望的,或许这种情况下需要一些重构。
组合自类型(self-type)
在上一节中,我们看到了如何在Watch
类中扩展Connector
以便能够编译我们的代码。有些场景中我们或许真的需要强制一个类来混入一个特质,或者同时有其他的特质或多个特质需要混入。让我们加入需要一个闹钟同时带有提醒功能:
trait AlarmNotifier {
this: Notifier =>
def trigger(): String
}
这里我们展示了什么是自类型。第二行代码将Notifier
的所有方法引入到了新特质的当前作用域,它同时要求所有混入了该特质的类必须同时混入Notifier
。否则将会出现编译错误。如果使用self
来代替this
,我们则可以使用手动的方式来在AlarmNotifier
中引用Notifier
的方法,比如self.printNotification()
。
下面的代码展示了如何来使用这个新的特质:
object SelfTypeWatchUser extends App {
AlarmNotifier {
val watch = new Watch("alarm with notification", 1000l) with AlarmNotifier with Notifier {
override def trigger():String = "Alarm triggered."
override def clear(): Unit = println("Alarm cleared.")
override val notificationMessage:String = "The notification."
}
}
println(watch.trigger())
watch.printNotification()
println(s"The time is ${watch.getTime()}.")
watch.clear()
}
如果在上面的代码中去掉同时扩展Notifier
的部分则会出现编译错误。
在这个小节中,我们展示了子类型的简单用法。一个特质可以要求在被混入的同时混入其他一个或多个特质,多个的时候仅需要使用with
关键字分割即可。子类型是实现“蛋糕模式”的关键,该模式用于依赖注入。本书后续部分我们会看到更多有趣的用法。
特质冲突
你的脑海中可能已经出现了一个问题——如果我们混入的特质中拥有相同签名的方法会怎样?下面的几个小节我们将会探讨该问题。
相同签名和返回类型
考虑一个例子,我们想要混入的两个特质拥有相同的方法:
trait FormalGreeting {
def hello():String
}
trait InformalGreeting {
def hello():String
}
class Greeter extends FormalGreeting with InformalGreeting {
override def hello():String = "Good morning, ser/madam!"
}
object GreeterUser extends App {
val greeter = new Greeter()
println(greetrt.hello())
}
在这个例子中,接待员总是会很有礼貌并且同时混入正式的和非正式的问候。在实现时仅需要实现该方法一次。
相同签名和不同返回类型
如果我们的问候特质拥有更多方法,签名相同但返回类型不同呢?我们将下面的声明添加到FormalGreeting
中:
def getTime():String
同时向InformalGreeting
中添加:
def getTime():Int
这种情况下我们需要在Greeter
中实现同时实现这两个方法。然而,编译器不允许我们定义getTime
两次,这表示 Scala 中会避免发生这样的事。
相同签名和返回类型的混入
在继续之前,快速回忆一下混入只是一个带有一些代码实现的特质。这意味着在下面的例子中,我们不需要在使用它们的类中实现这些方法:
trait A {
def hello(): String = "Hello, I am trait A!"
}
trait B {
def hello(): String = "Hello, I am trait B!"
}
object Clashing extends App with A with B {
println(hello())
}
可能和预期一样,我们会得到一个编译错误信息:
Error:(11, 8) object Clashing inherits conflicting members:
method hello in trait A of type ()String and
method hello in trait B of type ()String
(Note: this can be resolved by declaring an override in object Clashing.)
object Clashing extends A with B {
^
该信息很有用,它甚至为我们提供了一个如何修复问题的提示。方法冲突在多重继承中是一个问题,但是和你看到的一样,我们致力于选择一个可用的方法。在Clashing
对象中我们或许可以这样修改:
override def hello():String = super[A].hello()
然而,如果处于某些原因我们相同时使用两个方法呢?这种情况下,我们可以创建另外一个名字不同的方法来调用另一个指定特质中的方法。我们同样可以直接通过super
符号直接引用这些方法而不是将他们包装在另一个方法中。然而我个人更倾向于包装在另一个方法内,否则代码将会变得很乱。
super 符号
如果在上面的例子中,我们直接使用
override def hello(): String = super.hello()
而不是super[A]. hello()
,真正被选择的又是那个特质中的方法呢?这种情况下将会选择 B 中的方法。这取决于 Scala 中的线性化特性,我们将在后面的章节中详细讨论。
相同签名和不同返回类型的混入
和预期一样,如果方法的传入参数在类型或数量上有所不同则上面的问题就不再存在,因为这成了一个新的签名。但如果特质中有如下两个方法问题则仍然存在:
def value(a: Int): Int = a // in trait A
def value(a: Int): String = a.toString // in trait B
我用用过的方式在这里不再有效,你可能会对此感到吃惊。如果我们决定仅覆写特质 A 中的方法,将会得到如下编译错误:
Error:(19, 16) overriding method value in trait B of type (a: Int): String;
method value has incompatible type
override def value(a: Int): Int = super[A].value(a)
^
如果重写 B 中的方法,错误也会随之改变。而如果两个都覆写,则会得到如下错误:
Error:(20, 16) method value is defined twice
conflicting symbols both originated in file '/path/to/traits/src/main/ scala/com/ivan/nikolov/composition/Clashing.scala'
override def value(a: Int): String = super[B].value(a)
这展示了 Scala 会避免我们在多重继承中进行这样危险的操作。为了完整,如果你遇到类似的问题,仍然存在变通的方式,比如像下面的例子一样,牺牲掉混入的功能:
trait D {
def value(a:Int):String = a.toString
}
object Example extends App{
val c = new C{}
val d = new D{}
println(s"c.value: ${c.value(10)}")
println(s"d.value: ${d.value(10)}")
}
这段代码中把特质当做合作者使用,但这也丢掉了混入这些特质的类的实例同样也拥有这些特质的类型这一事实(即扩展了特质的类,其实例同时拥有特质的类型),这一性质在某些操作中会很有用。
多重继承
因为可以同时混入多个特质,而且这些特质都可以拥有各自不同的方法实现,因此我们已经在前面的章节中多次提到了多重继承。多重继承不仅是一个强大的技术,同时也很危险,甚至有些语言中决定不进行支持,比如 Java。向我们看到的一样,Scacla 对此进行了支持,不过带有一些限制。本节中我们会接收多重继承的问题及 Scala 是如何处理这些问题的。
菱形问题
多重继承忍受着菱形问题的痛苦。
让我们看一下下面的图示:
如图,B 和 C 同时扩展了 A,然后 D 同时扩展了 B 和 C。这看起来可能不是很清晰。比如我们有个方法一开始定义在 A,但是 B 和 C 同时覆写了它。当在 D 中调用该方法时会发生什么呢?实际上调用的是哪个方法呢?
因为上面的问题有点模糊或将引起错误。让我们使用 Scala 的特质来重新描述一下该问题:
trait A {
def hello(): String = "Hello from A"
}
trait B extends A {
override def hello(): String = "Hello from B"
}
trait C extends A {
override def hello(): String = "Hello from C"
}
trait D extends B with C {
}
object Diamond extends App with D {
println(hello())
}
运行后会得到如下结果:
Hello from C
如果我们把特质 D 修改为:
trait D extends C with B {
}
则会得到结果为:
Hello from B
你会发现,尽管例子仍然会有点模糊甚至易于出错,我们可以告诉你实际上被调用的是哪个方法。这是通过**线性化(linearization)**实现的,在下一节中会深入介绍。
限制
在我们关注线性化之前,让我们指出 Scala 所支持的多重继承拥有的限制。我们之前已经见过他们很多次,这里会概括描述。
Scala 多重继承限制
Scala 中的多重继承由特质实现并遵循线性化规则。
在多重继承中,如果一个特质已经显式扩展了一个类,则混入该特质的类必须是之前特质混入的类的子类。这意味着当混入一个已扩展了别的类的特质时,他们必须拥有相同的父类。
如果特质中定义或声明了相同签名但返回类型不同的方法,则无法混入这些特质。
需要特别小心的是特质中定义了相同签名和返回类型的方法。若果方式仅是声明而被要求实现,这不会带来问题而且只需要提供一个实现即可。
测试特质
测试实际上是软件开发中很重要的一部分。它确保了代码中变更的部分不再产生错误,无论是方法的改变还是其他部分。
我们可以使用多种不同的测试框架,这完全取决于个人喜好。本书中我们将使用 ScalaTest,这也是我在项目中使用的框架;它很容易理解,可读且易于使用。
有些情况下,如果一个特质被混入到了类中,我们则可以直接测试这个类。然而,我们或许仅需要测试一个指定的特质。测试一个没有任何方法实现的特质也没有什么意义,因此这么我会探讨那些拥有代码实现的特质。同时,我们这里展示的单元测实际上是很简单的,他们也仅作为示例。我们会在本书的后续章节讨论更加复杂和有意义的测试。
使用一个类
让我们看一下前面见到的DoubledMultiplierIdentity
将会被如何测试。一种方式是将这个特质混入一个测试类来测试它的方法:
class DoubledMultiplierIdentityTest extends Flatspec with ShouldMatchers with DoubledMultiplierIdentity
然而这会编译失败并显示一个错误信息:
Error:(5, 79) illegal inheritance; superclass FlatSpec
is not a subclass of the superclass MultiplierIdentity
of the mixin trait DoubledMultiplierIdentity
class DoubledMultiplierIdentityTest extends FlatSpec with ShouldMatchers
with DoubledMultiplierIdentity {
^
我们在前面已经谈论过这个问题,事实上特质只能被混入到一个与该特质拥有相同基类的类。这意味着为了测试这个特质,我们需要在我们的测试类中创建一个虚拟的类然后再使用它:
package com.ivan.nikolov.linearization
import org.scalatest.{ ShouldMatchers, FlatSpec}
class DoubledMultiplierIdentityTest extends FlatSpec with ShouldMatchers {
class DoubledMultipliersIdentityClass extends DoubledMultiplierIdentity
val instance = new DoubledMultiplierIdentityClass
"identity" should "return 2 * 1" in {
instance.identity should equals(2)
}
}
混入特质
我们可以将特质混入来对他进行测试。有几个地方我们可以这么做:混入到一个测试类或者一个单独的测试用例。
混入到测试类
只有当该特质确定没有扩展任何其他类时,才可以将该特质混入到一个测试类,因此特质、测试类的超类必须是相同的。除了这样,其他任何方式都和我们前面做的一样。
让我们测试一个本节出现过的特质 A,他拥有一个hello
方法。同时我们添加了一个pass
方法,现在该特质看起来会像下面这样:
trait A {
def hello(): String = "Hello, I am trait A!"
def pass(a: Int): String = s"Trait A said: 'You passed $a.'"
}
然后是测试类:
package com.ivan.nikolov.composition
import org.scalatest.{ShouldMatchers, FlatSpec}
class TraitTest extends FlatSpec with ShouldMatchers with A {
"hello" should "greet properly." in {
hello() should equal("Hello, I am trait A!")
}
"pass" should "return the right string with the number." in {
pass(10) should equal("Trait A said: 'You passed 10.'")
}
it should "be correct also for negative values." in {
pass(-10) should equal("Trait A said: 'You passed -10.'")
}
}
混入到测试用例
我们同样可以将特质分别混入到个别测试案例中。这样可以支持我们单独为这些测试用例设置自定义。下面是对上面那个单元测试的另一种表示:
package com.ivan.nikolov.composition
import org.scalatest.{ ShouldMatchers, FlatSpec }
class TraitCaseScopeTest extends FlatSpec with ShouldMatchers {
"hello" should "greet properly." in new A {
hello() should equal("hello, I am trait A!")
}
"pass" should "return the right string with the number." in new A {
pass(10) should equal("Trait A said: 'You passed 10.'")
}
it should "be correct also for negative values." in new A {
pass(-10) should equal("Trait A said: 'You passed -10.'")
}
}
在上面的代码中你可以看到,这些测试用例与前面的例子一样。但是是在单独的用例中混入 A。这支持我们对不同的用例设置自定义,比如一个特质需要一个方法的实现或者一个变量的初始化。这种方式也可以让我们仅专注于要测试的特质,而不需要创建它的一些实际的实例。
运行测试
在测试编写完成后,运行并观察一切是否符合预期是很有用的。仅需要在项目的根目录执行以下命令将会运行所有测试:mvn clean test
。
如果你需要,可以将 Maven 项目转换为 SBT 项目,然后通过sbt test
来触发所有测试。
特质与类
特质或许与类很相似,但同时又有很大的不同。对于开发者来说或许很难在不同的场景中进行选择,不过我们会尝试提供一个通用的指导方针以帮助开发者:
使用类:
- 当一个行为根本不会被复用或在多个地方出现
- 如果你计划在其他语言中使用 Scala 代码,比如创建一个将会在 Java 中使用的库
使用特质:
- 当一个行为将会被复用到多个不相关的类中
- 当你需要定义一个接口并在 Scala 之外使用,比如 Java 中。因为那些没有任何代码实现的特质被编译后与接口类似
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.