6. 特质和枚举

因为特质和枚举是大型Scala应用程序的基本构建块,在这第二章领域建模中会涉及到它们。

特质可以用来定义细化的行为单元,然后这些细化的单元可以被组合起来以构建更大的组件。如6.1小节所示,最基本用途是,它们可以像Java 8之前的 接口(interface) 一样使用,使用它们的主要原因是为扩展类必须实现的抽象方法声明签名。

然而,Scala特质比这更强大、更灵活,它们可以用来定义具体的方法和字段,而不是抽象的成员。类和对象可以混合使用多个特质。我在6.2、6.3和6.4小节中展示了这些特性。

作为这种方法的一个快速演示,与其试图在一个 Dog 类中定义狗的所有功能,Scala允许你为更小的功能单元定义特质,如尾巴、腿、眼睛、耳朵、鼻子和嘴巴。这些小单元更容易思考、创建、测试和使用,而且它们以后还可以组合在一起,以创建一个完整的狗:

    class Dog extends Tail, Legs, Ears, Mouth, RubberyNose

这只是对Scala特质功能的一个非常有限的介绍。其他功能包括:

  • 抽象和具体的字段(6.2小节)

  • 抽象和具体的方法(6.3小节)

  • 可以混合多个特质的类,如6.4和6.5小节中所示

  • 能够限制你的特质可以混入的类(6.6、6.7和6.8小节)

  • 特质可以被参数化,以限制它们可以与哪些类一起使用(6.9小节)

  • 可以有构造函数参数的特质,如6.10小节所示

  • 使用特质构建模块的能力,如6.11小节所示。这是一种组织和简化大型应用程序的极好方法

本章将展示所有这些特性。

特质的简述

作为一个关于如何使用特质的简单例子,下面是一个名为 Pet 的特质的源代码,它有一个具体方法和一个抽象方法:

    trait Pet:
        def speak() = println("Yo") // 具体方法
        def comeToMaster(): Unit    // 抽象方法

如示例所示,具体方法是一个有实现的方法 —— 方法体,而 抽象方法是一个没有方法体的方法。

接下来,这里有一个名为 HasLegs 的特质,它有一个内置的具体的 run 方法:

    trait HasLegs:
        def run() = println("I’m running!")

最后,这里有一个 Dog 类,它混合了 PetHasLegs 特质,同时提供了 comeToMaster 方法的具体实现:

    class Dog extends Pet, HasLegs:
        def comeToMaster() = println("I'm coming!")

现在,当你创建一个新的Dog的实例并调用它的方法时,会看到如下输出:

    val d = Dog()
    d.speak()           // yo
    d.comeToMaster()    // I’m coming!
    d.run()             // I’m running

这是对一些基本的特质作为接口功能的一个小的瞥见。这也是混入多个特质来创建一个类的一种方式。

特质的构造顺序

有一点在下面的例子中没有涉及,那就是当一个类混合了几个特质时,特质的构建顺序。例如,给定这些特质:

    trait First:
        println("First is constructed")
    trait Second:
        println("Second is constructed")
    trait Third:
        println("Third is constructed")

和这个混入了这些特质的类:

    class MyClass extends First, Second, Third:
        println("MyClass is constructed")

当MyClass的一个新实例被创建时:

    val c = MyClass()

它的输出是这样的:

    First is constructed
    Second is constructed
    Third is constructed
    MyClass is constructed

这表明,在构建类本身之前,特质的构建是按照从左到右的顺序。

在介绍完特质之后,最后两节将介绍枚举结构,这是Scala 3的新结构。枚举是enumeration的缩写 —— 定义(a)一个密封的类或特质以及(b)值被定义为该类伴生对象的成员。

枚举是一种强大、简洁的快捷方式。它们可以被用来创建常量的命名值的集合,也可以用来实现代数数据类型(ADTs)。在6.12小节中展示了它们用于定义一组常量,在6.13小节中展示了它们用于定义ADTs。

6.1 特质用作接口

问题

你已经在其他语言(如Java)中习惯了创建纯粹的接口 —— 声明方法签名而没有实现,并希望在Scala中使用类似的东西,然后用具体的类来实现这些接口。

解决方案

简单而言,Scala特质用起来像Java 8之前的接口那样,可以定义方法签名,但不提供实现。

想象一下,假设你在写一些代码来模拟任何有尾巴的动物,例如狗或猫。你可能首先想到的是尾巴可以摇摆,所以你定义了一个如下的特质,它里面有两个方法签名,但没有方法体:

    trait HasTail:
        def startTail(): Unit
        def stopTail(): Unit

这两个方法不接受任何参数。如果你想定义的方法需要带参数,像往常一样声明它们即可:

    trait HasLegs:
        def startRunning(speed: Double): Unit
        def runForNSeconds(speed: Double, numSeconds: Int): Unit

继承特质

接下来,如果想创建一个继承以上特质的类时,请使用 extends 关键字:

    class Dog extends HasTail

如果一个类需要继承多个特质时,对第一个特质使用 extends,并以逗号分隔后续的特质:

    class Dog extends HasTail, HasLegs, HasRubberyNose

如果一个类继承了一个特质,但没有实现其它的抽象方法,那么这个类必须被声明为抽象类:

    // Dog类没有实现HasTail和HasLegs的抽象方法,所以必须定义为abstract
    abstract class Dog extends HasTail, HasLegs:

但是如果这个类为它继承的特质的所有抽象方法都提供了实现,那么这个类就可以被声明为一个普通的类:

    class Dog extends HasTail, HasLegs:
        def startTail(): Unit = println("Tail is wagging")
        def stopTail(): Unit = println("Tail is stopped")
        def startRunning(speed: Double): Unit =
            println(s"Running at $speed miles/hour")
        def runForNSeconds(speed: Double, numSeconds: Int): Unit =
            println(s"Running at $speed miles/hour for $numSeconds seconds")

讨论

如这些例子所示,简单而言,特质可以作为简单的接口使用。类使用 extends 关键字来继承特质,遵从以下这些规则:

  • 如果一个类继承一个特质,使用 extends 关键字。

  • 如果一个类继承多个特质,第一个特质使用 extends ,其余的用逗号分开。

  • 如果一个类继承一个类(或抽象类)和一个特质,总是先列出类的名称 —— 在类的名称前使用 extends ,然后在额外的特质名称前使用逗号。

正如你将在下面的一些示例中看到的那样,特质也可以继承其他特质:

    trait SentientBeing:
        def imAlive_!(): Unit = println("I’m alive!")
    trait Furry
    trait Dog extends SentientBeing, Furry

另见

  • 对象也可以继承特质来创建模块,在本书6.11小节的示例中展示了这种技术。

6.2 在特质中定义抽象字段

问题

你想声明一个特质并拥有一个字段,但你不想给这个字段赋初始值,也就是说,你希望它是抽象的。

解决方案

随着时间的推移,Scala开发者已经认识到,在特质中定义抽象字段的最简单和最灵活的方法是使用 def

    trait PizzaTrait:
        def maxNumToppings: Int

这让你可以在继承你的特质的类(和特质)中以各种方式重写该字段,包括作为一个 val

    class SmallPizza extends PizzaTrait:
        val maxNumToppings = 4

作为一个 lazy val

    class SmallPizza extends PizzaTrait:
        lazy val maxNumToppings =
            // some long-running operation
            Thread.sleep(1_000)
            4

作为一个 var

    class MediumPizza extends PizzaTrait:
        var maxNumToppings = 6

或者作为一个 def

    class LargePizza extends PizzaTrait:
        def maxNumToppings: Int =
            // some algorithm here
            42

讨论

特质中的字段可以是具体的或抽象的:

  • 如果你给它赋了一个值,它就是具体的。

  • 如果你不给它赋值,它就是抽象的。

从实现的角度看,这就很简单了:

    trait Foo:
        def bar: Int // 抽象方法
        val a = 1 // 具体 val 值
        var b = 2 // 具体 var 值

虽然有这些选择,但随着时间的推移,Scala开发者了解到,在特质中定义字段最灵活、最抽象的方法是将它们声明为 def。如解决方案所示,这为你在继承特质的类中实现该字段提供了多种方式。换句话说,如果你把一个抽象字段定义为 varval,就会大大限制你在继承类中的选择。

根据我的经验,“当你的特质中需要包含抽象字段时,如何更灵活地去实现它?”根据定义,在Scala中,当你定义一个含有抽象成员的特质时,声明该字段最抽象的方式是将其声明为def。这表示你不想束缚子类的实现方式;并希望子类用自己最佳的方式去实现它,以满足需求。

特质中的具体字段

如果你确实想在特质中定义一个具体的 valvar 字段,像IntelliJ IDEA或VS Code这样的IDE可以帮助你确定在继承特质的类中可以做什么和不可以做什么。例如,如果你在特质中指定了一个具体的 var 字段,你会发现你在继承这个特质的类中可以重写这个值:

    trait SentientBeing:
        var uuid = 0 // 具体字段
    class Person extends SentientBeing:
        uuid = 1

同样地,如果你将一个特质字段定义为一个具体的值,你需要使用 override 修饰符来改变继承类中的值:

    trait Cat:
        val numLives = 9 // 具体的
    class BetterCat extends Cat:
        override val numLives = 10

在这两种情况下,你都 不能 在你的类中把这些字段实现为 deflazy val 值。

另见

Scala开发者在一段时间内了解了 def 方式的原理。使用这种方式的部分原因与JVM的工作方式有关,因此也与Scala如何编译特质以适应JVM有关。这是一个很漫长的讨论,如果你对细节感兴趣,我在我的博文“Scala Traits中的def、val和var字段在编译后是什么样子(包括继承它们的类)”中详细描述了这一点。( https://oreil.ly/ID6C3

6.3 像抽象类一样使用特质

你想把特质作为类似于Java中的抽象类的东西来使用,同时定义抽象和具体的方法。

问题

根据需要在你的特质中定义具体和抽象方法。在继承特质的类中,你可以重写这两种类型的方法,或者对于具体方法,你可以继承特质中定义的默认行为。

在下面的例子中,特质 Pet 中的 speak 方法有一个默认的、具体的实现,所以实现类不需要重写它。类 Dog 选择不重写它,而类 Cat 则选择重写。两个类都必须实现 comeToMaster 方法,因为它在特质 Pet 中没有默认的实现:

    trait Pet:
        def speak() = println("Yo") // 具体实现
        def comeToMaster(): Unit // 抽象方法
    class Dog extends Pet:
        // 可以不用重写 `speak`
        def comeToMaster() = println("I'm coming!")
    class Cat extends Pet:
        override def speak() = println("meow")
        def comeToMaster() = println("That’s not gonna happen.")

如果一个类继承了一个特质而没有实现其抽象方法,它必须被声明为抽象的。因为 FlyingPet 并没有实现 comeToMaster,所以它必须被声明为抽象的:

    abstract class FlyingPet extends Pet:
        def fly() = println("Woo-hoo, I’m flying!")

讨论

虽然Scala有抽象类,但使用特质比抽象类实现基础行为要 普遍 得多。一个类只能继承一个抽象类,但它可以实现(继承)多个特质,所以使用特质更加灵活。因为Scala 3还允许特质拥有构造器参数,这使得它将能被用于更多的场合。

另见

  • 关于在Scala 3中使用特质参数的细节,请参阅本书6.9小节。

  • 关于何时使用抽象类而不是特质的信息,请参阅本书5.1小节中的“还有一件事:何时使用抽象类”。

6.4 特质用作混入

问题

你想设计一个具备鲁棒性的解决方案,其中一个或多个特质可以被混入(mixin)到一个类中。

解决方案

要使用特质作为混入,只需要像往常一样,将特质中的方法定义为抽象或具体的方法,然后使用 extends 将特质混入你的类。这至少可以通过两种不同的方式实现:

  • 用特质构建一个类

  • 在构建变量时混入特质

用特质构建一个类

第一种方法是创建一个类,同时继承一个或多个特质。例如,设想你有这两个特质:

    trait HasTail:
        def wagTail() = println("Tail is wagging")
        def stopTail() = println("Tail is stopped")
    trait Pet:
        def speak() = println("Yo")
        def comeToMaster(): Unit // 抽象的

HasTail 中的方法都是具体的,而 Pet 中的 comeToMaster 方法是抽象的,因为该方法没有方法体。现在你可以通过混入这些特质并实现 comeToMaster 来创建一个具体的 Dog 类:

    class Dog(val name: String) extends Pet, HasTail:
        def comeToMaster() = println("Woo-hoo, I'm coming!")

    val d = Dog("Zeus")

使用同样的方法,你可以创建一个 Cat 类,以不同的方式实现 comeToMaster ,同时也重写 speak 方法:

    class Cat(val name: String) extends Pet, HasTail:
        def comeToMaster() = println("That’s not gonna happen.")
        override def speak() = println("meow")

    val c = Cat("Morris")

在构建变量时混入特质

另一种混入方法是在创建一个变量的同时,将特质添加到一个类中。想象一下,你现在有这三个特质(它们没有方法)和一个 Pet 类:

    trait HasLegs
    trait HasTail
    trait MansBestFriend
    class Pet(val name: String)

现在你可以创建一个新的 Pet 实例,同时也可以为这个特定的变量混入你想要的特特质:

    val zeus = new Pet("Zeus") with MansBestFriend with HasTail with HasLegs

然后你可以通过混入有意义的特质来创建其他变量:

    val cat = new Pet("Morris") with HasTail with HasLegs

讨论

我展示了两种方法,因为不同的人对 mixin 的含义有不同定义。当我第一次了解到混入时,主要的用例是第二个例子,它展示如何在创建变量时混入一个特质。

但是现在,当多个特质被用来组成一个类时,都可以使用 mixin 这个术语。这是因为这些特质并不是类的唯一父类,而是它们被混入到类中。例如,维基百科上的mixin页面( https://oreil.ly/YpON2 )提供了一个很好的方法来思考这个问题,它指出混入是“被描述为‘包含’而不是‘继承’”。

这是特质的一个关键好处:它们让你通过将大问题分解成小问题来构建模块化的行为单元。 例如,与其试图设计一个大型的 Dog 类,理解构成狗的较小 组件 更加容易 ,并将问题分解为与拥有尾巴、腿、毛发、耳朵等有关的特质,然后将这些特质混入到一起,创建一只狗。通过这样做,你创建了小的、细化的模块,这使得设计更容易理解和测试,而且这些模块也可以用来创建其他东西,如猫、马等。

将特质作为混入元素使用的几个关键是:

  • 创建范围和功能集中的小单元。

  • 实现你能实现的方法,并将其他方法声明为抽象方法。

  • 因为特质有一个集中的责任区域,它们通常会实施不相关的行为(也称为正交行为)。

可叠加的特质模式 -- TODO 耗子栏

要想看到混入的强大力量的一个很好的实例,请阅读Bill Venners关于可堆叠特质模式的Artima短文( https://oreil.ly/U8WF1 )。通过将特质和类定义为 基础核心可堆叠 的,该文章展示了如何通过堆叠从三个特质中派生出16个不同的类。

关于混入的最后一点说明,Cay S. Horstmann的 Scala for the Impatient(Addison-Wesley Professional)一书中提出了一个观点:从哲学上讲,这段代码:

    class Pet(val name: String) extends HasLegs, HasTail, MansBestFriend

不是读作“class Pet extends HasLegs ‘with HasTail and MansBestFriend’”,而是读作“class Pet extends ‘HasLegs, HasTail, and MansBestFriend.’”,这是一个微妙的观点,说明一个类平等地混入了所有这些特质,而不是以任何特殊的方式偏爱第一个特质。

另见

当你开发特质时,你可能想限制它们可以混入的类。这可以通过以下技术来实现:

  • 本书6.6小节展示了如何标记特质,以便它们只能被某一类型的子类使用。

  • 本书6.7小节展示了用来确保一个特质只能被混入有特定方法的类中。

  • 本书6.8小节展示了如何通过声明来限制哪些类可以使用特质的继承性。

  • 本书7.7小节,“将特质具体化为对象”,展示了如何创建一个混合了多个特质的 object

  • Bill Venners的Artima短文“可叠加的特质模式”( https://oreil.ly/U8WF1 )展示了如何通过将特质堆叠在一起而衍生出许多不同的类。

6.5 解决方法名称冲突和理解super

问题

你试图创建一个混入了多个特质的类,但这些特质有相同的方法名称和参数列表,导致编译器错误。

解决方案

当混入的两个或更多的特质共享相同的方法名时,解决方案是,手动解决这个冲突。这可能需要理解在提到混入在一起的特质时 super 的含义。

作为一个例子,设想你有两个特质都有一个 greet 方法:

    trait Hello:
        def greet = "hello"
    trait Hi:
        def greet = "hi"

现在,如果你试图创建一个混入了这两种特质的 Greeter 类:

    class Greeter extends Hello, Hi

你会看到类似这样的错误:

        class Greeter extends Hello, Hi
              ^
    class Greeter inherits conflicting members:
           |method greet in trait Hello of type |=> String and
           |method greet in trait Hi of type |=> String
    (Note: this can be resolved by declaring an override in class Greeter.)

错误信息告诉你解决方案 —— 你可以重写 Greeter 类中的 greet 方法。但它并没有给出如何做的细节。

有三个主要的解决方案,所有这些都需要你在以下内容中覆写 Greeter 类中的 greet 方法:

  • 用自定义行为重写 greet

  • 告诉 Greeter 中的 greet 要从 super 中调用 greet 方法,这就提出了一个问题:“当你混入多个特征时,super 指的是什么?”

  • 告诉 Greeter 中的 greet 使用混入其中的指定特质的 greet方法。

下面几节将详细介绍每种解决方案。

用自定义行为重写greet

第一个解决方案是忽略特质中定义的方法,通过重写方法实现一些自定义行为:

    // 通过在类上重写greet方法来解决冲突
    class Greeter extends Hello, Hi:
        override def greet = "I greet thee!"
    // greet方法按预期工作
    val g = Greeter()
    g.greet == "I greet thee!" // true

这是一个简单明了的解决方案,适用于你不关心特质如何实现这种方法的情况。

用super调用greet

第二个解决方案是按照你的直属父级,即 super 实例中的定义来调用该方法。在这段代码中,Speaker 类中的 speak 方法调用了 super.speak

    trait Parent:
        def speak = "make your bed"
    trait Granddad:
        def speak = "get off my lawn"
        // 通过调用super.speak解决冲突
    class Speaker extends Parent, Granddad:
        override def speak = super.speak
    @main def callSuperSpeak =
        println(Speaker().speak)

问题是,super.speak 打印的是什么?

答案是 super.speak 会打印出“get off my lawn”。在这样一个例子中,一个类混入了多个特质 —— 而且这些特质之间没有混入或继承关系 —— super 将总是指 最后一个混入的特质 。这被称为 从后向前的线性化 顺序。

控制你所调用的super

在第三种解决方案中,你要指定你想调用的混入特质的方法,并使用 super[classname].methodName 语法来指定你要调用哪个被混入的特质的方法。例如,给定这三个特质:

    trait Hello:
        def greet = "hello"
    trait Hi:
        def greet = "hi"
    trait Yo:
        def greet = "yo"

你可以创建一个 Greeter 类,它混入了这些特质,然后定义了一系列的 greet 方法,调用这些特质的 greet 方法:

    class Greeter extends Hello, Hi, Yo:
        override def greet = super.greet
        def greetHello = super[Hello].greet
        def greetHi = super[Hi].greet
        def greetYo = super[Yo].greet
    end Greeter

你可以在REPL中用这段代码测试该配置:

    val g = Greeter()
    g.greet // yo
    g.greetHello // hello
    g.greetHi // hi
    g.greetYo // yo

这个解决方案的关键是 super[Hello].greet 语法给了你一种方法来引用 Hello 特质的 hello 方法,以此类推,HiYo 特质也是如此。注意在 g.greet 的例子中,super 再次引用了混入的最后一个特质。

讨论

命名冲突只发生在方法名称相同且方法参数列表相同的情况下。方法的返回类型并不影响是否会发生冲突。例如,这段代码导致了名称冲突,因为 f 函数的两个版本的参数类型都是 (Int, Int)

    trait A:
        def f(a: Int, b: Int): Int = 1
    trait B:
        def f(a: Int, b: Int): Long = 2

    //不能编译,error: "class C inherits conflicting members."
    class C extends A, B

但这段代码并 没有 导致冲突的出现,因为参数列表有不同的类型:

    trait A:
        def f(a: Int, b: Int): Int = 1 // (Int, Int)
    trait B:
        def f(a: Int, b: Long): Int = 2 // (Int, Long)

    // 可以编译,因为 A.f 和 B.f 有不同的参数列表
    class C extends A, B

另见

特质可以通过一种被称为 可叠加修改 的技术进行组合。

  • Scala编程 第一版的第12章详细介绍了基本技术,该书可在Artima网站上免费获得。参阅该在线章节中的“作为可堆叠修改的特质”部分( https://oreil.ly/KWmu0 )。

  • knoldus.com 的这篇文章( https://oreil.ly/Eaiy8 )对混入类中的特质的线性化如何运作有很好的讨论。

6.6 限定特质只可用于指定类型的子类

问题

你想标记你的特质,使它只能被继承了给定基类型的类型所使用。

解决方案

为了确保一个名为 MyTrait 的特质只能被混入一个名为 BaseType 的类型的子类中,用这个语法开始你的特质:

    trait MyTrait:
        this: BaseType =>

例如,为了确保 StarfleetWarpCore 只能被混入一个同时混入 FederationStarship 的类中,可以这样编写 StarfleetWarpCore 特质:

    trait StarfleetWarpCore:
        this: FederationStarship =>
        // the rest of the trait here ...

鉴于这个声明,这段代码可以编译:

    // 如预期那样,编译成功
    trait FederationStarship
    class Enterprise extends FederationStarship, StarfleetWarpCore

但其他像这样的尝试将会失败:

    class RomulanShip

    // 这将编译失败
    class Warbird extends RomulanShip, StarfleetWarpCore
          ^

    illegal inheritance: self type
    Warbird of class Warbird does not conform to self type
    FederationStarship of parent trait StarfleetWarpCore

    Explanation: You mixed in trait StarfleetWarpCore which requires self
    type FederationStarship

下面的讨论中展示了如何使用这种技术来要求多个其他类型的存在。

讨论

如错误信息所示,这种方法被称为自我类型(或self-type)。Scala词汇表( https://oreil.ly/v7FEp )将此语句作为其对自我类型描述的一部分:

一个特质的 自我类型this 的假设类型,即接受的是使用该特质的假定类型。任何混入特质中的具体类必须确保其类型符合特质的自我类型。

思考这句话的一个方法是,当使用混入来组成一个类时,评估 this 意味着什么。例如,给定一个名为 HasLegs 的特质:

    trait HasLegs

你可以定义一个名为 CanRun 的特质,该特质要求每当 CanRun 被混入到一个具体的类中时都要有 HasLegs 存在:

    trait CanRun:
        this: HasLegs =>

因此,当你通过混入 HasLegsCanRun 创建一个 Dog 类时,你可以在该类中测试 this 意味着什么:

    class Dog extends HasLegs, CanRun:
        def whatAmI(): Unit =
            if this.isInstanceOf[Dog] then println("Dog")
            if this.isInstanceOf[HasLegs] then println("HasLegs")
            if this.isInstanceOf[CanRun] then println("CanRun")

现在,当你创建一个 Dog 实例并运行 whatAmI 方法:

    val d = Dog()
    d.whatAmI()

你会看到它打印出以下结果,因为在 Dog 里面的 this 是所有这些类型的一个实例:

    Dog
    HasLegs
    CanRun

重要的是要记住,当你像这样定义一个自我类型时:

    trait CanRun:
        this: HasLegs =>

关键是 CanRun 知道,当它的一个具体实例最终被创建时,该具体实例中的 this 可以作出反应:“是的,我也是 HasLegs 的一个实例。”

一个特质可以调用所需类型中的方法

这种方法的一个很大的特点是,因为特质知道另一个类型必须存在,它可以调用另一个类型中定义的方法。例如,如果你有一个名为 HasLegs 的类型,有一个名为 numLegs 的方法:

    trait HasLegs:
        def numLegs = 0

你可能想创建一个新的特质,名为 CanRunCanRun 需要有 HasLegs 的存在,所以你用一个自我类型将其作为一个契约要求:

    trait CanRun:
        this: HasLegs =>

现在你可以再往前走一步。因为 CanRun 知道当 CanRun 被混入时 HasLegs 必须存在,所以它可以安全地调用 numLegs 方法:

    trait CanRun:
        this: HasLegs =>
        def run() = println(s"I have $numLegs legs and I’m running!")

现在,当你创建一个带有 HasLegsCanRunDog 类时:

    class Dog extends HasLegs, CanRun:
        override val numLegs = 4

    @main def selfTypes =
        val d = Dog()
        d.run()

你将看到这样的输出:

    I have 4 legs and I’m running!

这是一种强大而安全(编译器强制实施)的技术。

要求有多个其他类型的存在

一个特质也可以要求任何希望混入它的类型必须同时继承多个其他类型。下面的 WarpCore 定义要求任何希望混入它的类型必须继承 WarpCoreEjectorFireExtinguisherFederationStarship

    trait WarpCore:
        this: FederationStarship & WarpCoreEjector & FireExtinguisher =>
        // more trait code here ...

因为下面的 Enterprise 定义符合该签名,所以这段代码可以编译:

    class FederationStarship
    trait WarpCoreEjector
    trait FireExtinguisher

    // this works
    class Enterprise extends FederationStarship, WarpCore, WarpCoreEjector, ↵
          FireExtinguisher

另见

  • 关于 def numLegs 代码,本书6.2小节解释了为什么特质中的抽象字段最好被声明为 def 字段。

6.7 保证特质只能被添加到具有特定方法的类型中

问题

你只想让一个特质混入一个有特定方法签名的类型(类、抽象类或特质)中。

解决方案

使用自我类型语法的一个变体是让你声明任何试图混入特质的类必须实现你描述的方法。

在下面的例子中,WarpCore 特质要求任何试图混入它的类都必须有一个带有下面所示签名的 ejectWarpCore 方法,该方法使用一个 String 参数并返回一个 Boolean 值:

    trait WarpCore:
        this: { def ejectWarpCore(password: String): Boolean } =>
        // more trait code here ...

下面这个 Enterprise 类的定义符合这些要求,因此可以编译:

    class Starship:
    // code here ...

    class Enterprise extends Starship, WarpCore:
        def ejectWarpCore(password: String): Boolean =
            if password == "password" then
                println("ejecting core!")
                true
            else
                false
            end if

讨论

这种方法被称为 结构化类型 ,因为你通过说明该类必须具有某种结构,即你所指定的方法签名,来限制该特质可以混入哪些类。

特质也可以要求一个实现类有多个方法。要要求多个方法,请在代码块中添加额外的方法签名。下面是一个完整的例子:

    trait WarpCore:
        this: {
         // 一个实现类必须有具有 ejectWarpCore 和 startWarpCore 方法,并且签名相同
            def ejectWarpCore(password: String): Boolean
            def startWarpCore(): Unit
        } =>
        // more trait code here ...

    class Starship

    class Enterprise extends Starship, WarpCore:
         def ejectWarpCore(password: String): Boolean =
            if password == "password" then
                println("core ejected")
                true
            else
                false
            end if
        end ejectWarpCore
        def startWarpCore() = println("core started")

在这个例子中,因为 Enterprise 包含 WarpCore 特质所要求的 ejectWarpCorestartWarpCore 方法,所以 Enterprise 能够混入 WarpCore 特质。

6.8 通过继承来限定特质的使用范围

问题

你想要限制一个特质,这样它只能被添加到继承了特定超类的类中。

解决方案

使用下面的语法来声明一个名为 TraitName 的特质,TraitName 只能被混入继承了名为 SuperClass 的类中,其中 SuperClass 可以是一个类或抽象类:

    trait TraitName extends SuperClass

例如,在为一家拥有公司办公室和许多小型零售店的大型披萨连锁店建模时,法律部门创建了一条规则,规定为顾客运载披萨的人必须是 StoreEmployee 的子类,而不能是 CorporateEmployee 的子类。为了执行这个规定,首先要定义你的基类:

    trait Employee
    class CorporateEmployee extends Employee
    class StoreEmployee extends Employee

因为运送食物的人只能是 StoreEmployee,所以你要在 DeliversFood 特质中执行这一要求:

    trait DeliversFood extends StoreEmployee
                       ---------------------

现在你可以像这样成功地定义一个 DeliveryPerson 类:

    // this is allowed
    class DeliveryPerson extends StoreEmployee, DeliversFood

但由于 DeliversFood 特质只能被混入继承了 StoreEmployee 的类中,下面这行代码就不能编译了:

    // won’t compile
    class Receptionist extends CorporateEmployee, DeliversFood

编译器的错误信息看起来是这样的:

    illegal trait inheritance: superclass CorporateEmployee
    does not derive from trait DeliversFood's super class StoreEmployee

这让法律部门的人感到很高兴。

讨论

我并不经常使用这种技术,但是当你需要通过要求特定的超类来限制一个特质可以混入哪些类时,这是一种有效的技术。

注意,当 CorporateEmployeeStoreEmployee 是特质而不是类时,这种方法不起作用。当你需要对特质使用这种方法时,请参阅本书6.6小节。

6.9 使用参数化特质

问题

随着你在处理类型方面的进步,你想写一个特质,它的方法可以应用于泛型类型,或限于其他特定类型。

解决方案

根据你的需要,你可以使用类型参数或带有类型成员的特质。这个例子展示了一个泛型特质的 类型参数 是什么样子:

    trait Stringify[A]:
        def string(a: A): String

这个例子展示了一个 类型成员 是什么样子:

    trait Stringify:
        type A
        def string(a: A): String

这里有一个完整的类型参数的例子:

    trait Stringify[A]:
        def string(a: A): String = s"value: ${a.toString}"

    @main def typeParameter =
        object StringifyInt extends Stringify[Int]
        println(StringifyInt.string(100))

下面是使用类型成员编写的同样的例子:

    trait Stringify:
        type A
        def string(a: A): String

    object StringifyInt extends Stringify:
        type A = Int
        def string(i: Int): String = s"value: ${i.toString}"

    @main def typeMember =
        println(StringifyInt.string(42))

依赖类型 -- TODO 耗子栏

Dave Gurnell(Underscore)的免费书籍 The Type Astronaut’s Guide to Shapelesshttps://oreil.ly/Mx4bc ),展示了一个例子,其中类型参数和类型成员被结合使用来创建一个被称为依赖类型的东西( https://oreil.ly/rcApf )。

讨论

通过使用类型参数,你可以指定多种类型。例如,这是一个 Java Pair 接口的Scala实现,它显示在关于泛型类型的Java文档页面上( https://oreil.ly/ZGBX0 ):

    trait Pair[A, B]:
        def getKey: A
        def getValue: B

这展示了两个泛型参数在一个小特质例子中的使用。

使用这两种技术对特质进行参数化的一个好处是,你可以防止那些不应该发生的事情发生。例如,鉴于这个特质和类的层次结构:

    sealed trait Dog
    class LittleDog extends Dog
    class BigDog extends Dog

你可以用一个类型成员来定义另一个特质,比如这样:

    trait Barker:
        type D <: Dog //type member
        def bark(d: D): Unit

现在你可以为小狗定义一个带有 bark 方法的对象:

    object LittleBarker extends Barker:
        type D = LittleDog
        def bark(d: D) = println("wuf")

而你可以为大狗定义另一个带有 bark 方法的对象:

    object BigBarker extends Barker:
        type D = BigDog
        def bark(d: D) = println("WOOF!")

现在,当你创建这些实例的时候:

    val terrier = LittleDog()
    val husky = BigDog()

这段代码可以编译:

    LittleBarker.bark(terrier)
    BigBarker.bark(husky)

如期望的那样,这段代码则无法编译:

    BigBarker.bark(terrier)

这展示了一个类型成员如何在初始特质中声明一个基类型,以及如何在继承该基类型的特质、类和对象中应用更具体的类型。

6.10 使用特质参数

问题

在Scala 3中,你想创建一个需要一个或多个参数的特质,就像类或抽象类需要构造函数参数一样。

解决方案

在Scala 3中,特质可以有参数,就像类或抽象类一样。例如,这里有一个接受参数的特质的例子:

    trait Pet(val name: String)

然而,根据Scala 3的特质参数规范( https://oreil.ly/loZU3 ),这个功能的使用是有限制的:

  • 一个特质T可以有一个或多个参数。

  • 一个特质T1可以继承T,只要它不向T传递参数。

  • 如果一个类C继承了T,而它的超类没有,那么C必须向T传递参数。

  • 如果一个类C继承了T,而它的超类也继承了T,那么C可能不会向T传递参数。

回到这个例子,一旦你有一个接受参数的特质,一个类就可以像这样继承它:

    trait Pet(val name: String)

    // 一个类可以继承一个用参数的特质
    class Dog(override val name: String) extends Pet(name):
        override def toString = s"dog name: $name"

    // use the Dog class
    val d = Dog("Fido")

在你的代码的后面,另一个类也可以继承 Dog 类:

    class SiberianHusky(override val name: String) extends Dog(name)

在一个所有cats都叫“Morris”的世界里,一个类可以用这样的参数继承一个特质:

    class Cat extends Pet("Morris"):
        override def toString = s"Cat: $name"

    // use the Cat class
    val c = Cat()

这些例子展示了特质是如何在前面列举的第一、第三和第四个要点中使用的。

一个特质可以继承另一个特质,但有一定的限制

接下来,如前所述,一个特质可以继承另一个需要一个或多个参数的特质,只要它不向它传递参数。因此,这个尝试失败了:

    // 无法编译
    trait Echidna(override val name: String) extends Pet(name)
                                                     ^^^^^^^^^
                           trait Echidna may not call constructor of trait Pet

而这个没有试图向 Pet 传递参数的尝试成功了:

    trait FeatheredPet extends Pet

然后,当一个类继承 FeatheredPet 时,正确的做法是这样写你的代码:

    class Bird(override val name: String) extends Pet(name), FeatheredPet:
        override def toString = s"bird name: $name"

    // create a new Bird
    val b = Bird("Tweety")

讨论

在这个解决方案中,这两种方法之间有一个微妙的区别:

trait Pet(val name: String) // 上面例子所用的
trait Pet(name: String)

不使用 val时,name是一个简单的参数,但它没有提供getter方法。当 使用 val时,它为name提供了一个getter,解决方案中的一切都如这里介绍的那样正常工作。

当你在 Petname 字段中不使用 val 时,下面所有的代码都像以前一样工作。除了 Cat 类,它将无法编译:

    trait Pet(name: String):
        override def toString = s"Pet: $name"
    trait FeatheredPet extends Pet

    // `override` is not needed on these parameters
    class Bird(val name: String) extends Pet(name), FeatheredPet:
        override def toString = s"Bird: $name"
    class Dog(val name: String) extends Pet(name):
        override def toString = s"Dog: $name"
    class SiberianHusky(override val name: String) extends Dog(name)

    // this will not compile
    class Cat extends Pet("Morris"):
        override def toString = s"Cat: $name"

Cat 的方法不能编译,因为 Pet 类中的 name 参数没有定义为 val;因此没有getter方法。同样,这是一个微妙的问题,你如何定义初始字段取决于你将来要如何访问 name

特质参数被添加到Scala 3中,至少在一定程度上是为了帮助消除Scala 2中的一个功能,即 early initializers or early definitions。在Scala 2的某个地方,有人发现你可以这样写代码:

    // 这是Scala 2的代码。从一个普通的特质开始。
    trait Pet {
     def name: String
     val nameLength = name.length // note: this is based on `name`
    }

    // 注意到在extends之后和with之前初始化一个变量的不寻常做法。这就是Scala 2的“早期初始化器”技术。
    class Dog extends {
     val name = "Xena, the Princess Warrior"
    } with Pet

    val d = new Dog
    d.name // Xena, the Princess Warrior
    d.nameLength // 26

这种方法的目的是确保 name 被提前初始化,这样 nameLength 表达式就不会抛出一个 NullPointerException。相反,如果你这样写代码,当你试图创建一个新的 Dog 时,它将抛出一个 NullPointerException

    // 这也是Scala 2的代码
    trait Pet {
     def name: String
     val nameLength = name.length
    }
    class Dog extends Pet {
     val name = "Xena, the Princess Warrior"
    }
    val d = new Dog //java.lang.NullPointerException

在Scala 2中,我从未使用过这个早期初始化器的功能,但众所周知,它很难正确实现,所以在Scala 3中取消了它,取而代之的是特质参数。

还要注意,特质参数对特质的初始化方式没有影响。鉴于这些特征:

    trait A(val a: String):
        println(s"A: $a")
    trait B extends A:
        println(s"B: $a")
    trait C:
        println(s"C")

下面的 D 类和 E 类表明,当这些特质被混入在一起时,可以以任何顺序指定:

    class D(override val a: String) extends A(a), B, C
    class E(override val a: String) extends C, B, A(a)

创建 DE 的新实例的输出在REPL中显示:

scala> D("d") A: d B: d C

scala> E("e") C A: e B: e

如上所示,特质可以按任何顺序排列。

6.11 使用特质创建模块

问题

你听说特质是在Scala中实现模块的一种 方式 ,你想了解如何以这种方式使用它们。

解决方案

在详细的层面上,有几种方法可以解决这个问题,但解决方案中的一个共同主题是,你在Scala中使用对象(object)来创建 模块

本示例所展示的技术一般用于组成大型系统,所以我将从一个小例子开始演示。想象一下,你已经定义了一个特质来实现一个将两个整数相加的方法:

    trait AddService:
        def add(a: Int, b: Int) = a + b

创建一个模块的基本技术是通过该特质创建一个单例对象,语法是:

    object AddService extends AddService

在这种情况下,你从 AddService 特质中创建一个名为 AddService 的单例对象。你可以不在对象中实现方法,因为特质中的 add 方法是具体的。

具体化一个特质 -- TODO 鸽子栏

有些人把这称为对特质的 具体化(reifying),其中 具体化 一词意思是“把抽象的概念变得具体”。我发现记住这个意思的方法是把它看成是 real-ify,比如说“让它成为现实”。

你在其余的代码中使用 AddService 模块的方式 —— 一个单例对象,看起来像这样:

    import AddService.*
    println(add(1,1)) // prints 2

试图让事情变得简单,这里是该技术的第二个例子,我通过混入两个特质来创建另一个模块:

    trait AddService:
        def add(a: Int, b: Int) = a + b

    trait MultiplyService:
        def multiply(a: Int, b: Int) = a * b

    object MathService extends AddService, MultiplyService

你的应用程序的其他部分以同样的方式使用这个模块:

    import MathService.*
    println(add(1,1)) // 2
    println(multiply(2,2)) // 4

虽然这些例子很简单,但它们展示了该技术的本质:

  • 创建特质来模拟业务领域中小的、逻辑上分组的区域。

  • 这些特质的公共接口只包含纯函数。

  • 如果有意义,可以将这些特质混入在一起,形成更大的逻辑组,比如 MathService

  • 从这些特质中建立单例对象(具体化它们)。

  • 使用这些对象的纯函数来解决问题。

这就是两个小例子中解决方案的本质。但由于特质具有本章所述的所有其他功能,在实际应用中的实现可能会非常复杂。

讨论

Scala的名字来自于 可扩展(scalable) 一词,Scala的目的是为了扩展:轻松解决小问题,也可以扩展到解决世界上最大的计算挑战。Scala的模块和模块化的概念使得去解决这些大问题成为可能的。

Scala语言的创建者Martin Odersky在撰写的 Scala编程 一书中指出,任何在编程语言中实现模块化的技术都必须提供几个基本要领:

  • 首先,一种语言需要一个模块结构来提供接口和实现之间的分离。在Scala中,特质提供了这种功能。

  • 其次,必须有一种方法将一个模块替换为另一个具有相同接口的模块,而无需更改或重新编译依赖该接口的模块。

  • 第三,应该有一种方法可以将模块连接起来。这项缝合任务可以被认为是配置系统。

Scala编程一书中,特别建议将程序划分为单例对象,你也可以将其视为模块。

一个更大的例子:一个订单账目系统

为了更大程度地展示这种技术的工作原理 —— 同时结合本章中的其他功能 —— 让我们看看为一家披萨店开发一个订单账目系统。

俗话说得好,有时从结尾开始是有帮助的,遵循这条建议,下面是我将在本节中创建的 @main 方法的源代码:

    @main def pizzaModuleDemo =
         import CrustSize.*
         import CrustType.*
         import Topping.*

         // 创建一些用于测试的mock对象
         object MockOrderDao extends MockOrderDao
         object MockOrderController extends OrderController, ConsoleLogger:
             // 指定OrderDao的具体实例,为这个MockOrderController指定一个MockOrderDao
             val orderDao = MockOrderDao

         val smallThinCheesePizza = Pizza(
            Small, Thin, Seq(Cheese)
         )

         val largeThickWorks = Pizza(
            Large, Thick, Seq(Cheese, Pepperoni, Olives)
         )

         MockOrderController.addItemToOrder(smallThinCheesePizza)
         MockOrderController.addItemToOrder(largeThickWorks)
         MockOrderController.printReceipt()

你会看到,当这段代码运行时,它会将这个输出打印到STDOUT:

    YOUR ORDER
    ----------
    Pizza(Small,Thin,List(Cheese))
    Pizza(Large,Thick,List(Cheese, Pepperoni, Olives))

    LOG:
    YOUR ORDER
    ----------
    Pizza(Small,Thin,List(Cheese))
    Pizza(Large,Thick,List(Cheese, Pepperoni, Olives))

为了知道这段代码是如何工作的,让我们深入了解用于构建它的代码。首先,我使用Scala 3的枚举创建了一些与披萨相关的ADTs:

    enum CrustSize:
        case Small, Medium, Large

    enum CrustType:
        case Thin, Thick, Regular

    enum Topping:
        case Cheese, Pepperoni, Olives

接下来,以函数式风格创建一个 Pizza 类,也就是说,它是一个具有不可变字段的样例类:

    case class Pizza(
        crustSize: CrustSize,
        crustType: CrustType,
        toppings: Seq[Topping]
    )

这种方法类似于C、Rust和Go等其他语言中使用的 struct

接下来,我将对订单的概念进行简单介绍。在实际应用中,一个订单会有行项目,可能是披萨、面包条、奶酪条、软饮料等等,但在这个例子中,它只保存一个披萨的列表:

    case class Order(items: Seq[Pizza])

这个例子也处理了数据库的概念,所以我创建了一个数据库 接口,看起来像这样:

    trait OrderDao:
        def addItem(p: Pizza): Unit
        def getItems: Seq[Pizza]

一个接口的好处是,你可以创建它的多个实现,然后用这些实现构建你的模块。例如,创建一个模拟数据库用于测试,然后其他代码用于在生产环境中连接到真正的数据库服务器,这是很常见的。这里有一个用于测试的数据访问对象( DAO ),它只是把数据存储在内存的 ArrayBuffer 中:

    trait MockOrderDao extends OrderDao:
        import scala.collection.mutable.ArrayBuffer
        private val items = ArrayBuffer[Pizza]()

        def addItem(p: Pizza) = items += p
        def getItems: Seq[Pizza] = items.toSeq

为了使事情变得更复杂,我们假设披萨店的法律部门要求我们每次创建收据时都要写到一个单独的日志中。为了支持这个要求,我遵循同样的模式,首先创建一个接口:

    trait Logger:
        def log(s: String): Unit

然后创建该接口的一个实现:

    trait ConsoleLogger extends Logger:
        def log(s: String) = println(s"LOG: $s")

其他的实现可能包括 FileLoggerDatabaseLogger 等,但我在这个例子中只使用 ConsoleLogger

在这一点上,唯一剩下的就是创建一个 OrderController。注意在这段代码中,Logger 被声明为一个自我类型,orderDao 是一个抽象字段:

    trait OrderController:
        this: Logger => // declares a self-type
        def orderDao: OrderDao // abstract

        def addItemToOrder(p: Pizza) = orderDao.addItem(p)
        def printReceipt(): Unit =
            val receipt = generateReceipt
            println(receipt)
            log(receipt) // from Logger

        // 这是一个有关特质中私有方法的例子
        private def generateReceipt: String =
            val items: Seq[Pizza] = for p <- orderDao.getItems yield p
            s"""
            |YOUR ORDER
            |----------
            |${items.mkString("\n")}""".stripMargin

请注意,在 printReceipt 方法中调用了这个控制器所混入的任何 Logger 实例的 log 方法。该代码还调用了 OrderDao 实例的 addItem 方法,该实例可以是 MockOrderDaoOrderDao 接口的任何其他实现。

当你回头看源代码时,你会发现这个例子展示了几个有关特质的技术,包括:

  • 如何将特质具体化为对象(模块)?

  • 如何使用接口(如 OrderDao )和抽象字段( orderDao )来创建一个依赖注入的形式

  • 如何使用自我类型,在这里我声明 OrderController 特质的子类必须同时混入 Logger

有很多方法可以扩展这个例子,我在 Functional Programming, Simplifiedhttps://oreil.ly/9hfV6 )一书中描述了它的一个更大版本。例如,OrderDao可以这样发展:

    trait OrderDao:
        def addItem(p: Pizza): Unit
        def removeItem(p: Pizza): Unit
        def removeAllItems: Unit
        def getItems: Seq[Pizza]

然后 PizzaService 提供了更新 Pizza 所需的所有纯函数:

    trait PizzaService:
        def addTopping(p: Pizza, t: Topping): Pizza
        def removeTopping(p: Pizza, t: Topping): Pizza
        def removeAllToppings(p: Pizza): Pizza
        def setCrustSize(p: Pizza, cs: CrustSize): Pizza
        def setCrustType(p: Pizza, ct: CrustType): Pizza

还需要一个函数来计算披萨的价格。根据你的设计思路,你可能希望将这段代码放在 PizzaService 中,或者你可能希望有一个单独的名为 PizzaPricingService 与定价有关的特质:

    trait PizzaPricingService:
        def pizzaDao: PizzaDao
        def toppingDao: ToppingDao

        def calculatePizzaPrice(
            p: Pizza,
            toppingsPrices: Map[Topping, Money],
            crustSizePrices: Map[CrustSize, Money],
            crustTypePrices: Map[CrustType, Money]
        ): Money

如前两行所示,PizzaPricingService 需要引用其他的 DAO 实例来从数据库中获取价格。

在所有这些例子中,我都使用“服务”一词作为特质名称的一部分。我发现这是一个很好的名字,因为你可以把这些特质看作是一个提供了一系列相关纯函数或服务的集合,例如在web服务或微服务中。另一个好词是 模块,在这种情况下,你可能会命名为 PizzaModulePizzaPricingModule。(你可以随意使用任何有意义的名字)。

另见

  • 参阅本书7.3小节,“用 object 创建单例对象”,了解关于单例对象的更多细节。

  • 我在博客上写过关于具体化的文章,“The Meaning of the Word Reify in Programming” ( https://oreil.ly/T8mtv )。

  • 在本书7.7小节,讨论了“将特质具体化为对象”的过程。

  • 关于ADTs的细节,请参阅本书6.13小节。

  • 第10章的示例展示了创建和使用ADTs和纯函数的其他方法。

6.12 如何使用枚举创建命名值的集合

问题

你想创建一组常量来模拟世界上的某些东西,比如方向(北、南、东、西),显示器上的位置(上、下、左、右),披萨上的配料,以及其他有限的值的集合。

解决办法

使用Scala 3枚举定义常量命名值的集合。本例展示了在为一个披萨店应用程序建模时,如何定义饼皮大小、饼皮类型和配料的值:

    enum CrustSize:
        case Small, Medium, Large

    enum CrustType:
        case Thin, Thick, Regular

    enum Topping:
        case Cheese, Pepperoni, Mushrooms, GreenPeppers, Olives

创建了一个枚举后,首先导入它的实例,然后在表达式和参数中使用它们,就像类、特质或其他类型一样:

    import CrustSize.*

    if currentCrustSize == Small then ...

    currentCrustSize match
        case Small => ...
        case Medium => ...
        case Large => ...

    case class Pizza(
        crustSize: CrustSize,
        crustType: CrustType,
        toppings: ArrayBuffer[Topping]
    )

像类和特质一样,枚举也可以接受参数并拥有成员,如字段和方法。下面的例子展示了一个名为 code 的参数是如何在枚举中使用的:

    enum HttpResponse(val code: Int):
        case Ok extends HttpResponse(200)
        case MovedPermanently extends HttpResponse(301)
        case InternalServerError extends HttpResponse(500)

正如讨论中所描述的,枚举的实例类似于样例对象(case object),所以就像其他对象一样,可以直接访问对象上的 code 字段(就像Java中的静态成员):

    import HttpResponse.*
    Ok.code // 200
    MovedPermanently.code // 301
    InternalServerError.code // 500

我们将在接下来的讨论中展示成员。

枚举包含一组值 -- TODO 鸽子栏

在这个地方,我们有意用 set 这个词来描述枚举。就像 Set 类一样,枚举中的所有值必须是唯一的。

讨论

枚举是一种快捷方式 —— 用于定义(a)一个密封的类或特征,以及(b)定义为类的伴生对象的成员的值。例如,此枚举:

    enum CrustSize:
        case Small, Medium, Large

下面是使用更冗长的代码来编写枚举的例子:

    sealed class CrustSize
    object CrustSize:
        case object Small extends CrustSize
        case object Medium extends CrustSize
        case object Large extends CrustSize

在这段较长的代码中,需要注意的是枚举实例( Small、Medium、Large)是如何在伴生对象( CrustSize )中作为样例对象(case object)被枚举的。这是Scala 2中创建枚举的常见方法。

枚举可以有成员

正如这个Scala 3枚举页面上的 Planet 例子所展示的那样( https://oreil.ly/iKtTy ),枚举也可以有成员,即字段和方法:

    enum Planet(mass: Double, radius: Double):
        private final val G = 6.67300E-11
        def surfaceGravity = G * mass / (radius * radius)
        def surfaceWeight(otherMass: Double) = otherMass * surfaceGravity

        case Mercury extends Planet(3.303e+23, 2.4397e6)
        case Earth extends Planet(5.976e+24, 6.37814e6)
    // more planets here ...

注意在这个例子中,massradius 参数没有被定义为 valvar 字段。因此,它们是 Planet 枚举的私有参数。这意味着它们可以在内部方法中被访问,比如 surfaceGravitysurfaceWeight,但不能在枚举之外被访问。这与类和特质的私有参数具体相同的行为。

何时使用枚举

关于何时使用特质、类和枚举的界限似乎很模糊,但关于枚举要记住的一点是,它们通常被用来模拟一个小的、有限的可能值的集合。例如,在 Planet 的例子中,我们的太阳系中只有八颗(ornine)行星(取决于谁在计数)。因为这是一个小的、有限的常量值的集合,所以使用枚举来为行星建模是一个不错的选择。

与Java的兼容

如果你想把Scala枚举定义为Java枚举,请继承默认已经导入的 java.lang.Enumhttps://oreil.ly/OXczK ):

    enum CrustSize extends Enum[CrustSize]:
        case Small, Medium, Large

如上所示,需要用Scala枚举类型 CrustSize 作为 java.lang.Enum 的泛型参数。

另见

  • 关于枚举功能的更多细节,请参阅Scala 3枚举文档( https://oreil.ly/iKtTy )。

  • Scala 3书中的 “领域建模”一章( https://oreil.ly/cjhkt )提供了关于枚举的更多细节。

  • 本书6.13小节展示了枚举的其他用途。

  • Scala Planet的例子最初来自the enum types Java Tutorial( https://oreil.ly/Ob6M2 )。

6.13 用枚举为代数数据类型建模

问题

当以函数式编程风格进行编程时,你想用Scala 3来为代数数据类型建模。

解决方案

有两种主要的ADTs类型:

  • sum类型

  • 乘积类型

下面的例子中展示了这两种方法。

sum类型

Sum type 也被称为枚举类型,因为你只需枚举该类型的所有可能实例。在Scala 3中,这是用 enum 来完成的。例如,要创建你自己的Bool数据类型,首先要定义一个sum类型,像这样:

    enum Bool:
        case True, False

这可以理解为“Bool是一个有两个可能值的类型,即 TrueFalse ”。同样地,Position 是一个有四个可能值的类型:

    enum Position:
        case Top, Right, Bottom, Left

乘积类型

一个 Product type 是通过一个类的构造函数创建的。乘积 的名字来自于这样一个事实:该类可能的具体实例的数量是由其所有构造函数字段的可能性数量相乘决定的。

例如,这个名为 DoubleBoo 的类有两个 Bool 构造函数参数:

    case class DoubleBoo(b1: Bool, b2: Bool)

在这样一个小例子中,你可以列举出从这个构造函数中可以创建的可能值:

    DoubleBoo(True, True)
    DoubleBoo(True, False)
    DoubleBoo(False, True)
    DoubleBoo(False, False)

如上所示,有四个可能的值。正如 Product 这个名字所暗示的那样,你也可以通过数学的方式得出这个答案。我们将在下面的讨论中涉及这个。

讨论

非正式地讲,一个 代数 可以被认为是由两件事组成的:

  • 一组对象

  • 可以应用于这些对象以创建新对象的操作

严格来说,一个代数还包括第三件事 —— 支配代数的法则 —— 但这是一本关于函数式编程的大书的主题。

Bool 的例子中,对象的集合是 TrueFalse。操作由你为这些对象定义的方法组成。例如,你可以定义 andor 操作来处理 Bool,像这样:

    enum Bool:
        case True, False

    import Bool.*
    def and(a: Bool, b: Bool): Bool = (a,b) match
        case (True, True) => True
        case (False, False) => False
        case (True, False) => False
        case (False, True) => False

    def or(a: Bool, b: Bool): Bool = (a,b) match
        case (True, _) => True
        case (_, True) => True
        case (_, _) => False

这些例子说明了这些操作是如何进行的:

    and(True,True) // True
    and(True,False) // False
    or(True,False) // True
    or(False,False) // False

sum类型

关于sum类型的几个要点:

  • 在Scala 3中,它们被创建为枚举类型的 case

  • 列出的枚举类型的数量是该类型的唯一可能的实例。在前面的例子中,Bool 是这个类型,它有两个可能的值,TrueFalse

  • 在谈论sum类型时,使用短语 is aor a。例如,True 是一个 BoolBool 是一个 True 或一个 False

sum类型实例的替代名称 -- TODO 鸽子栏

人们用不同的名字来表示一个sum类型的具体实例类型,包括value constructors、alternates和 cases。

乘积类型

如前所述,Product 类型的名称来自于这样一个事实:你可以通过乘以一个类型所有构造器字段的可能性数量来确定该类型的可能实例数量。在解决方案中,我列举了四个可能的 Bool 值,但你可以从数学上这样确定可能的实例数:

  1. b1有两种可能性。

  2. b2有两种可能性。

  3. 因为有两个参数,而且每个参数有两种可能性,所以 DoubleBoo 的可能实例数是 2 乘以 2,即 4

同样地,在下一个例子中,TripleBoo 有八个可能的值,因为 2 乘以 2 乘以 28

    case class TripleBoo(b1: Bool, b2: Bool, b3: Bool)

按照这个逻辑,这个 Pair 类可以有多少个值呢?

    case class Pair(a: Int, b: Int)

如果你的回答是“很多”,那已经很接近了。一个 Int 有232个可能的值,所以如果你把可能的 Int 值的数量乘以它自己,你会得到一个非常大的数字。

与Scala 2相比,差别很大

枚举类型是在Scala 3中引入的,而在Scala 2中,你必须使用这种较长的语法来定义sum类型:

    sealed trait Bool
    case object True extends Bool
    case object False extends Bool

幸运的是,新的语法更加简洁,在列举较大的sum类型时,你可以体会到这一点:

    enum Topping:
        case Cheese, BlackOlives, GreenOlives, GreenPeppers, Onions, Pepperoni,
            Mushrooms, Sausage

为什么程序员不使用String或Int作为常量 -- 特殊模块栏

本示例的乘积类型部分有助于解释为什么程序员不喜欢使用字符串或数字作为常量。为了证明这一点,让我们假设你把所有与披萨有关的数值集定义为字符串,像这样开始:

    val Small = "SMALL"
    val Medium = "MEDIUM"
    val Large = "LARGE"

如果你继续这样做,你会得到这个 Pizza 类构造函数:

    case class Pizza(
        crustSize: String,
        crustType: String,
        toppings: ArrayBuffer[String]
    )

这种方法有两个问题:

  • Scala是一种静态类型语言,所以这里应该使用强类型,这样就可以获得类型安全的所有好处。参数应该使用 CrustSizeCrustTypeTopping 等类型,但却使用了字符串。

  • 知道这个构造函数创建了一个乘积类型,你也知道它可能无限数量的值:它有三个构造函数参数,每个参数都有无限的可能性(“无限的3次幂”就是无穷大)。

相反,当你使用枚举来模拟 CrustSizeCrustTypeTopping 时,一个有三种可能的饼皮尺寸、三种可能的饼皮类型和10种可能的配料的 Pizza 只有90种可能(3×3×10)。


另见

  • 除了sum和乘积类型外,还有其他类型的ADTs,非正式地称为复合类型。我在“Appendix: Algebraic Data Types in Scala”( https://oreil.ly/Kp9Hc )中讨论了这个。

  • Scala 3书 中的“代数数据类型”一章( https://oreil.ly/wgGrU )提供了关于Scala中ADTs和广义ADTs的更多细节。

译者注 -- TODO

  • 线性化

  • super

  • Scala 2 枚举

  • ADTs

最后更新于

这有帮助吗?