本章继续领域建模的相关内容,在Scala中,“对象”(object)一词有着双重含义。Java中将其视为类的一个实例,但在Scala中,它同时是一个关键词。本章将阐述这个词(object)的两种含义。
本章前两节,将对象视为类的一个实例,解释如何将一个对象强制转换为另一个对象,并展示了在Scala中与Java的 .class 相似的实现方式。
其余的小节则介绍了object关键字在其他场景的作用。7.3小节展示了如何使用object创建单例(Singletons)这一最基本的使用。7.4小节展示了如何使用伴生对象(companion object)在类中添加静态成员。7.5小节展示了如何在伴生对象中使用apply方法作为构造类实例的另一种方式。
随后7.6小节展示了如何使用object创建一个静态工厂方法。7.7小节展示了如何将一个或多个特质(trait)组合到一个object中,技术上也被称之为具体化(reification)。最后,模式匹配是Scala中一个重要的话题,7.8小节展示了如何在伴生对象中使用unapply方法,使得类可以在match表达式[1]中使用。
[1]:在本书之前的版本中讨论了package objects相关的内容,但是这些将在Scala 3.0之后被弃用,所以在本书中将不做讨论。
你需要将一个类的实例强制转换为另一个类型,如动态创建对象时。
下面的例子将使用开源的Sphinx-4语音识别库,其中很多属性都定义在XML文件中,它的工作方式类似于在旧版本的Spring Framework框架中创建Bean。如在例子中,lookup方法返回的对象被强制转换为Recognizer类的实例:
val recognizer = cm.lookup("recognizer").asInstanceOf[Recognizer]
上面的Scala代码等同于下面的Java代码:
Recognizer recognizer = (Recognizer)cm.lookup("recognizer");
asInstanceOf方法定义在Scala的Any类中,因此它对所有的对象有效。
动态对象编程中对象的相互转换很常见。例如,在使用 SnakeYAML 库( https://oreil.ly/7mNDf )读取 YAML 配置文件时,就需要进行类型转换:
val yaml = Yaml(new Constructor(classOf[EmailAccount]))
val emailAccount = yaml.load(text).asInstanceOf[EmailAccount]
asInstanceOf方法并不只局限于此,也可以用来转换数字类型:
val a = 10 // Int = 10
val b = a.asInstanceOf[Long] // Long = 10
val c = a.asInstanceOf[Byte] // Byte = 10
也可以在更复杂的代码中使用,如与Java代码交互时,传入Object实例数组:
val objects = Array("a", 1)
val arrayOfObject = objects.asInstanceOf[Array[Object]]
AJavaClass.sendObjects(arrayOfObject)
如果使用java.net进行编程时,可以在创建一个HTTP URL连接时进行类型转换:
import java.net.{URL, HttpURLConnection}
val connection = (new URL(url)).openConnection.asInstanceOf[HttpURLConnection]
注意,这种编码方式会导致ClassCastException,比如下面REPL中的例子:
scala> val i = 1
i: Int = 1
scala> i.asInstanceOf[String]
ClassCastException: java.lang.Integer cannot be cast to java.lang.String
和往常一样,可以使用try/catch表达式来处理这种异常情况。
当一个API要求你传入一个Class类型时,在Java中可以在对象上调用 .class,但是在Scala中不能这样做。
使用Scala的classOf方法来替代Java的 .class,下面的例子展示了如何将TargetDataLine类型的类传给DataLine.Info方法:
val info = DataLine.Info(classOf[TargetDataLine], null)
作为对比,Java中的等价方式如下:
// java
info = new DataLine.Info(TargetDataLine.class, null);
classOf方法定义在Scala的Predef对象中,因此不需要导入就可以在所有类中进行调用。
通过这种方式可以了解简单的反射技术。例如,下面的REPL例子展示了如何访问String类中的方法:
scala> val stringClass = classOf[String]
stringClass: Class[String] = class java.lang.String
scala> stringClass.getMethods
res0: Array[java.lang.reflect.Method] = Array(public boolean
java.lang.String.equals(java.lang.Object), public java.lang.String
(output goes on for a while ...)
- The Scala Predef object( https://oreil.ly/A2vWS )
你想创建一个单例对象(Singleton object),以保证只有一个类的实例存在。
在Scala中使用object关键字创建单例对象。例如,创建一个单例对象用来代表键盘、鼠标或者一个披萨店的收银机:
object CashRegister:
def open() = println("opened")
def close() = println("closed")
随着CashRegister被定义为一个对象,它只能有一个实例,而且它调用方法的方式就像是Java中类调用静态方法一样:
@main def main =
CashRegister.open()
CashRegister.close()
一个单例对象只有一个类的实例。这种模式在创建工具类方法的时候很常见,比如StringUtils对象:
object StringUtils:
def isNullOrEmpty(s: String): Boolean =
if s==null || s.trim.equals("") then true else false
def leftTrim(s: String): String = s.replaceAll("^\\s+", "")
def rightTrim(s: String): String = s.replaceAll("\\s+$", "")
def capitalizeAllWordsInString(s: String): String =
s.split(" ").map(_.capitalize).mkString(" ")
因为这些方法是定义在object中而不是类中,可以像Java中调用静态方法一样使用它们:
scala> StringUtils.isNullOrEmpty("")
val res0: Boolean = true
scala> StringUtils.capitalizeAllWordsInString("big belly burger")
val res1: String = Big Belly Burger
在使用Akka actor时,单例对象可以很好地重用消息。例如有若干的actors都可以接收开始和停止消息,可以创建如下(case)单例对象:
case object StartMessage
case object StopMessage
然后,这些对象可以被当做消费发送给actors:
inputValve ! StopMessage
outputValve ! StopMessage
-
参阅18章获取更多actors传递消息的例子。
-
除了这种方式创建对象外,还可以通过“伴生对象”的方式让一个类同时拥有静态和非静态方法。请参阅下一小节的例子。
你想创建包含实例方法和静态方法的类,但是Scala中没有static关键字。
首先创建一个含有非静态成员(实例成员)的类,然后在同一个文件里中再定义一个与类名字相同的且含有“静态”成员的对象。这个对象被称为类的伴生对象(类也被称为该对象的伴生类)。
这种方式可以在类中创建静态成员(字段和方法),如下所示:
// Pizza class
class Pizza (var crustType: String):
override def toString = s"Crust type is $crustType"
// companion object
object Pizza:
val CRUST_TYPE_THIN = "THIN" // static fields
val CRUST_TYPE_THICK = "THICK"
def getPrice = 0.0 // static method
假设Pizza类和Pizza对象都定义在名为Pizza.scala的文件中,Pizza对象可以像Java类访问静态成员一样访问自己的成员:
println(Pizza.CRUST_TYPE_THIN) // THIN
println(Pizza.getPrice) // 0.0
也可以创建一个新的Pizza实例,像往常一样使用它:
val p = Pizza(Pizza.CRUST_TYPE_THICK)
println(p) // "Crust type is THICK"
TODO(松鼠图)
在实际应用中,不要使用字符串类型的常量值,应该使用枚举替代,具体可以参阅6.12小节。
这个定义方式很直白,虽然和Java有些不同:
-
在同一个文件中定义类和对象,并赋予相同的名字。
-
在对象内定义“静态”成员。
-
在类中定义非静态成员(实例成员)。
在本节中,我用引号将静态一词括起来,是因为Scala的object中并没有静态成员的定义。但是在本文中,它们与Java中的静态成员具有相同的用途。
类和其伴生对象能互相访问对方的私有成员。在下面的代码中,伴生对象的double方法可以访问类Foo的私有成员变量secret:
class Foo:
private val secret = 42
object Foo:
// access the private class field `secret`
def doubleFoo(foo: Foo) = foo.secret * 2
@main def fooMain =
val f = Foo()
println(Foo.doubleFoo(f)) // prints 84
类似的,在下面的代码中,printObj实例成员可以访问Foo对象的私有字段obj:
class Foo:
// access the private object field `obj`
def printObj = println(s"I can see ${Foo.obj}")
object Foo:
private val obj = "Foo’s object"
@main def fooMain =
val f = Foo()
f.printObj // prints "I can see Foo’s object"
- 7.6小节可以通过这种方式实现一个工厂模式。
某些情况下,在伴生对象中创建apply方法作为类的构造函数可能更简洁、容易和方便,你期望了解这些方法。
在5.2小节和5.4小节展示了如何创建一个或多个类的构造函数。还可以通过另一种方式,在类的伴生对象中使用apply方法创建构造函数,当然这并不是真正的构造函数,更像是函数调用或者工厂方法,但它们的用途类似。
创建一个含有apply方法的伴生对象只需要以下几个步骤,假设要为Person类创建构造函数:
-
在同一个文件中定义一个Person类和Person对象。
-
将Person类的构造函数变成私有。
-
在Person对象中定义一个或多个apply方法作为类的构造器。
对于前两个步骤:
class Person private(val name: String):
// define any instance members you need here
object Person:
// define any static members you need here
最后一步:
class Person private(val name: String):
override def toString = name
object Person:
// the “constructor”
def apply(name: String): Person = new Person(name)
根据这个定义,就可以创建Person的实例,如下所示:
val Regina = Person("Regina")
val a = List(Person("Regina"), Person("Robert"))
在Scala 2中,这种方式可以消除了在类名之前使用new关键字的需要。但是,由于Scala 3中的大多数情况下都不需要使用new,所以这种技术可以在工厂方法或者其他比较罕见的情况下使用。
Scala编译器对定义在伴生对象中的apply方法进行了特殊处理。本质上是因为在这里有一点Scala语法糖,所以当编译器看到这段代码时:
val p = Person("Fred Flintstone")
Scala编译器会在伴生对象中检测是否存在apply方法,然后将上面的代码转换成下面这段代码:
val p = Person.apply("Fred Flintstone")
因此,apply方法实际上是一个工厂方法、普通函数或者构造器。从技术上来说,不是一个构造函数。
当需要使用这种方式创建多个构造函数时,可以在伴生对象中定义不同签名的apply方法:
class Person private(var name: String, var age: Int):
override def toString = s"$name is $age years old"
object Person:
// three ways to build a Person
def apply(): Person = new Person("", 0)
def apply(name: String): Person = new Person(name, 0)
def apply(name: String, age: Int): Person = new Person(name, age)
然后可以用三种不同的方式创建Person实例:
println(Person()) // is 0 years old
println(Person("Regina")) // Regina is 0 years old
println(Person("Robert", 22)) // Robert is 22 years old
由于apply只是一个函数,所以可以按照自己认为合适的方式实现它。例如,可以从一个元组,甚至是一个可变元组构造Person实例:
object Person:
def apply(t: (String, Int)) = new Person(t(0), t(1))
def apply(ts: (String, Int)*) =
for t <- ts yield new Person(t(0), t(1))
然后可以像如下使用这两个apply方法:
// create a person from a tuple
val john = Person(("John", 30))
// create multiple people using a variable number of tuples
val peeps = Person(
("Barb", 33),
("Cheryl", 31)
)
-
参阅7.6小节,如何使用apply方法创建一个静态工厂。
-
参阅5.2小节,如何创建一个私有构造函数。参阅5.4小节,如何定义辅助构造函数。
-
apply方法使用起来像一个构造函数,unapply与之相反,被称为提取器(extractor),具体参阅7.8小节。
为了将对象的创建逻辑放在统一的位置,你想在Scala中实现一个静态工厂方法。
静态工厂是工厂模式的简化版本。要创建静态工厂,可以利用Scala语法糖的优势,在对象(通常是伴生对象)中使用apply方法来创建。
例如,假设要创建Animal工厂,让其返回Cat和Dog类的实例。基于这个需求,可以在Animal类的伴生对象中定义apply方法,然后使用者可以像这样创建新的Cat和Dog实例:
val cat = Animal("cat") // creates a Cat
val dog = Animal("dog") // creates a Dog
为了实现上述逻辑,首先创建一个名为Animal.scala的文件,然后第一步创建一个父的Animal特质,第二步让类去继承这个特质,第三步在伴生对象中定义一个合适的apply方法:
package animals
sealed trait Animal:
def speak(): Unit
private class Dog extends Animal:
override def speak() = println("woof")
private class Cat extends Animal:
override def speak() = println("meow")
object Animal:
// the factory method
def apply(s: String): Animal =
if s == "dog" then Dog() else Cat()
接着,创建一个Factory.scala文件,然后定义一个 @main 方法来测试一下:
@main def test1 =
import animals.*
val cat = Animal("cat") // returns a Cat
val dog = Animal("dog") // returns a Dog
cat.speak()
dog.speak()
运行main方法,输出如下:
meow
woof
这种方式的好处是只能通过工厂方法来创建Dog和Cat的实例。直接创建将会编译失败:
val c = Cat() // compile error
val d = Dog() // compile error
实现静态工厂的方式有多种,因此,可以尝试不同的方式,尤其是以何种方式去访问Cat和Dog类。工厂方法的主旨在于确保具体的实例只能通过工厂方法创建;因此,类的构造函数应当对其他类隐藏。本节代码展示了其中一种解决问题的思路。
本节使用一个简单的静态工厂,来展示Scala object的特性。有关如何在Scala中创建一个完整工厂方法的示例,可以参阅我的博客“A Scala Factory Pattern Example” ( https://oreil.ly/hZnnR )。
你已经在特质中创建了一个或多个方法,现在想让它们变得具体化。或者,想知道下面最后一行的代码的具体含义:
trait Foo:
println("Foo")
// more code ...
object Foo extends Foo
当看到一个object继承了一个或多个特质(trait),那么这个 object 就被用来具体化这些特质。具体化(reify)表示“把抽象的概念具体化”,在这种情况下,表示object从一个或多个特质中实例化一个单例对象。
例如,给定一个特质和两个类继承它:
trait Animal
// in a world where all dogs and cats have names
case class Dog(name: String) extends Animal
case class Cat(name: String) extends Animal
在函数式编程中,还可以在特质中创建一系列方法:
// assumes that all animal have legs
trait AnimalServices:
def walk(a: Animal) = println(s"$a is walking")
def run(a: Animal) = println(s"$a is running")
def stop(a: Animal) = println(s"$a is stopped")
一旦有了这个的特质,很多开发者接下来要做的就是将AnimalServices具体化为一个对象:
object AnimalServices extends AnimalServices
然后就可以使用AnimalServices中的方法:
val zeus = Dog("Zeus")
AnimalServices.walk(zeus)
AnimalServices.run(zeus)
AnimalServices.stop(zeus)
TODO(乌鸦图)
service的命名表示其提供了一系列公共服务方法可供外部使用者调用。我发现,当假设这些方法作为一系列web服务被调用时,这种命名很有意义。例如,当使用Twitter的REST API编写Twitter的客户端时,它提供的功能可以被认为是一系列的web服务。
这种方式通常用于函数式编程中,使用样例类进行数据建模,然后将相关函数放在特质中。通常使用步骤如下:
-
使用样例类对数据进行建模。
-
在特质中定义相关函数。
-
使用object具体化特质,可以按需结合多个特质。
一个略微真实的案例如下所示。首先,定义一个简单的数据模型:
trait Animal
trait AnimalWithLegs
trait AnimalWithTail
case class Dog(name: String) extends Animal, AnimalWithLegs, AnimalWithTail
接着,创建一系列服务,也就是与特质相对应的函数:
trait TailServices:
def wagTail(a: AnimalWithTail) = println(s"$a is wagging tail")
def stopTail(a: AnimalWithTail) = println(s"$a tail is stopped")
trait AnimalWithLegsServices:
def walk(a: AnimalWithLegs) = println(s"$a is walking")
def run(a: AnimalWithLegs) = println(s"$a is running")
def stop(a: AnimalWithLegs) = println(s"$a is stopped")
trait DogServices:
def bark(d: Dog) = println(s"$d says ‘woof’")
现在可以将所有这些特质具体化为一个完整的DogServices:
object DogServices extends DogServices, AnimalWithLegsServices, TailServices
最后可以像这样使用DogServices:
import DogServices.*
val rocky = Dog("Rocky")
walk(rocky)
wagTail(rocky)
bark(rocky)
有的时候想让代码变得更加具体,让特质变得参数化,比如像这样:
trait TailServices[AnimalWithTail] ...
----------------
trait AnimalWithLegsServices[AnimalWithLegs] ...
----------------
表示“此特质中的函数只能作用于此类型。” 在大型的应用程序中,这种技术可以帮助其他开发人员更容易地理解特质的用途。这也是静态类型语言的优势之一。
将这种技术应用于上述的样例类中,可以像这样修改特质:
trait TailServices[AnimalWithTail]:
def wagTail(a: AnimalWithTail) = println(s"$a is wagging tail")
trait AnimalWithLegsServices[AnimalWithLegs]:
def walk(a: AnimalWithLegs) = println(s"$a is walking")
trait DogServices[Dog]:
def bark(d: Dog) = println(s"$d says ‘woof’")
然后像这样创建具体化的object:
object DogServices
extends DogServices[Dog], AnimalWithLegsServices[Dog], TailServices[Dog]
最后,下面的例子跟之前的使用方式一样:
import DogServices.*
val xena = Dog("Xena")
walk(xena) // Dog(Xena) is walking
wagTail(xena) // Dog(Xena) is wagging tail
bark(xena) // Dog(Xena) says ‘woof’
-
当我第一次学习reify时,我不明白为什么会在这种情况下使用它,所以我做了一些研究,并在我的博客上总结了我的发现( https://oreil.ly/fweY0 )。
-
参阅6.11小节获取更多关于创建模块的例子。
你想要在类中编写unapply方法,以便在match表达式中提取其中的字段。
在类的伴生对象中定义合适返回签名的unapply方法。这里的解决方案分为两个步骤:
-
定义一个返回String的unapply方法。
-
定义一个可以在match表达式中使用的unapply方法。
为了开始展示unapply是如何工作的,这里有一个Person类,它有一个对应的伴生对象,该对象有一个unapply方法,该方法返回一个格式化的字符串:
class Person(val name: String, val age: Int)
object Person:
def unapply(p: Person): String = s"${p.name}, ${p.age}"
使用该定义,可以像往常一样创建一个新的Person实例:
val p = Person("Lori", 33)
unapply方法的好处是,它提供了一种解构person实例的方法:
val personAsAString = Person.unapply(p) // "Lori, 33"
如上所示,将给定的Person实例解构为字符串的表示形式。在Scala中,当在一个伴生对象中放入一个unapply方法时,表示创建了一个extractor方法,这个方法可以从对象中提取字段。
虽然上述示例展示了如何将Person解构为字符串,但如果要在match表达式中提取Person的字段,unapply方法需要返回特定类型:
-
如果类中只有一个类型为A的参数时,返回一个 Option[A],也就是用Some封装一下这个参数。
-
如果类中含有多个类型为A1、A2、An的参数时,返回一个 Option[(A1, A2 ... An)],也就是用一个包含这些参数的元组,然后使用Some封装一下。
如果由于某种原因,unapply方法无法将其参数解构为正确的值,请返回None:
例如,如果用这个方法替换之前的unapply方法:
class Person(val name: String, val age: Int)
object Person:
def unapply(p: Person): Option[(String, Int)] = Some(p.name, p.age)
现在可以在match表达式中使用Person:
val p = Person("Lori", 33)
val deconstructedPerson: String = p match
case Person(n, a) => s"name: $n, age: $a"
case null => "null!"
REPL中展示了返回结果:
scala> println(deconstructedPerson)
name: Lori, age: 33
值的注意的是,样例类会自动生成unapply代码,但是如果不想使用样例类,又希望普通的类可以在match表达式中使用,那么就可以像这样在类中定义提取的unapply方法。
-
如果想知道unapply的命名由来,可能是因为在伴生对象中,这个“解构”过程基本上与编写apply方法相反。关于伴生对象中的apply方法被用做构建新实例的工厂方法,可以参阅5.15小节。
-
查看Scala官方文档( https://oreil.ly/mqDBb )获取更多关于unapply方法的细节。