Skip to content

Latest commit

 

History

History
711 lines (469 loc) · 23.9 KB

7.对象.md

File metadata and controls

711 lines (469 loc) · 23.9 KB

7. 对象

本章继续领域建模的相关内容,在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之后被弃用,所以在本书中将不做讨论。


7.1 对象的强制转换

问题

你需要将一个类的实例强制转换为另一个类型,如动态创建对象时。

解决方案

下面的例子将使用开源的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表达式来处理这种异常情况。

7.2 用classOf方法获取类的class实例

问题

当一个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 ...)

另见

7.3 用object创建单例

问题

你想创建一个单例对象(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传递消息的例子。

  • 除了这种方式创建对象外,还可以通过“伴生对象”的方式让一个类同时拥有静态和非静态方法。请参阅下一小节的例子。

7.4 用伴生对象创建静态成员

问题

你想创建包含实例方法静态方法的类,但是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小节可以通过这种方式实现一个工厂模式。

7.5 使用对象的apply方法创建实例

问题

某些情况下,在伴生对象中创建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小节。

7.6 使用apply实现静态工厂方法

问题

为了将对象的创建逻辑放在统一的位置,你想在Scala中实现一个静态工厂方法。

解决方案

静态工厂是工厂模式的简化版本。要创建静态工厂,可以利用Scala语法糖的优势,在对象(通常是伴生对象)中使用apply方法来创建。

例如,假设要创建Animal工厂,让其返回CatDog类的实例。基于这个需求,可以在Animal类的伴生对象中定义apply方法,然后使用者可以像这样创建新的CatDog实例:

    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

这种方式的好处是只能通过工厂方法来创建DogCat的实例。直接创建将会编译失败:

    val c = Cat() // compile error
    val d = Dog() // compile error

讨论

实现静态工厂的方式有多种,因此,可以尝试不同的方式,尤其是以何种方式去访问Cat和Dog类。工厂方法的主旨在于确保具体的实例只能通过工厂方法创建;因此,类的构造函数应当对其他类隐藏。本节代码展示了其中一种解决问题的思路。

另见

本节使用一个简单的静态工厂,来展示Scala object的特性。有关如何在Scala中创建一个完整工厂方法的示例,可以参阅我的博客“A Scala Factory Pattern Example” ( https://oreil.ly/hZnnR )。

7.7 将特质具体化成对象

问题

你已经在特质中创建了一个或多个方法,现在想让它们变得具体化。或者,想知道下面最后一行的代码的具体含义:

    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”命名

service的命名表示其提供了一系列公共服务方法可供外部使用者调用。我发现,当假设这些方法作为一系列web服务被调用时,这种命名很有意义。例如,当使用Twitter的REST API编写Twitter的客户端时,它提供的功能可以被认为是一系列的web服务。

讨论

这种方式通常用于函数式编程中,使用样例类进行数据建模,然后将相关函数放在特质中。通常使用步骤如下:

  1. 使用样例类对数据进行建模。

  2. 在特质中定义相关函数。

  3. 使用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小节获取更多关于创建模块的例子。

7.8 使用unapply实现模式匹配

问题

你想要在类中编写unapply方法,以便在match表达式中提取其中的字段。

解决方案

在类的伴生对象中定义合适返回签名的unapply方法。这里的解决方案分为两个步骤:

  1. 定义一个返回Stringunapply方法。

  2. 定义一个可以在match表达式中使用的unapply方法。

定义一个返回Stringunapply方法

为了开始展示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方法,这个方法可以从对象中提取字段。

定义一个可以在match表达式中使用的unapply方法

虽然上述示例展示了如何将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方法的细节。