Skip to content

Latest commit

 

History

History
1938 lines (1323 loc) · 80.8 KB

23.类型.md

File metadata and controls

1938 lines (1323 loc) · 80.8 KB

23. 类型

看一眼集合类的Scaladoc就能知道,Scala有一个强大的类型系统。然而,只要你不是一个类库的创建者,你都可以在使用Scala作为开发语言的很长一段时间里,不必深入地去了解Scala类型的复杂性。但是,一旦你开始准备为其他用户创建类库,便无法避免地需要深度了解并学习Scala的类型系统。

本章为你会遇到的最常见的类型相关问题提供了示例,但当你需要深入了解时,我强烈推荐 Programming in Scala(Artima)这本书。这本书的作者之一 Martin Odersky 是Scala编程语言的创建者,我认为这本书是Scala的“参考指南”。

Scala的类型系统使用一组符号(symbols)来表达不同的泛型(generic)类型概念,包括 边界(bounds)型变(variance)约束(constraints) 等概念。在进入本章正文之前,我在下面几个小节总结了最常见的这些符号。

关于编程水平和类型的说明 -- TODO 鸽子栏

    早在2011年1月,Martin Odersky就定义了不同类型的Scala程序员需要的六个知识层级( https://oreil.ly/FGTc2 )。他将A1-A3级别用于应用程序员,L1-L3级别用于类库设计者。本章所展示的类型相关技术与他的L1至L3级别相对应。

泛型类型参数(泛型参数)

当你刚开始写Scala代码时,你会使用 IntString等类型,以及你创建的自定义类型,如PersonEmployeePizza。然后,你会创建特质、类和使用这些类型的方法。下面是一个使用 Int 类型以及 Seq[Int] 的例子:

    // 忽略可能发生的错误
    def first(xs: Seq[Int]): Int = xs(0)

Seq[Int] 是一种容器类型,它可以作为另一种类型的容器。同样地,List[String]Option[Int] 也是容器类型。

随着你在处理类型方面的经验越来越丰富,当你看 first 方法时,你会发现它的返回类型与 Seq 容器中的内容完全没有关系。Seq 可以包含 IntStringFishBird 等类型,而方法的主体不会改变。因此,你可以用一个泛型类型参数重写这个方法,就像这样:

    def first[A](xs: Seq[A]): A = xs(0) 
             ___         _    _

代码中的下划线部分显示了如何指定一个泛型类型参数。在代码中从右向左阅读:

  • 如前所述,方法主体中没有引用该类型;只有 xs(0)
  • A 被用作方法的返回类型,而不是 Int
  • Seq 中使用了 A,而不是 Int
  • 在方法声明之前的括号中指定了 A

关于最后一点,在方法签名前的括号中指定泛型类型参数,是告诉编译器和代码的读者,泛型类型参数可以用在(a)方法签名,(b)返回类型,或(c)方法主体,或这三个地方的任何组合。

这样编写泛型代码能使你的代码对更多的人有用。这个方法不再仅仅适用于 Seq[Int],而是适用于 Seq[Fish]Seq[Bird],以及一般来说 —— 也就是 generic 这个词 —— 任何类型的 Seq

按照惯例,当你在Scala中声明泛型时,第一个被指定的泛型使用字母 A,第二个泛型是 B,以此类推。例如,如果Scala没有内置Tuple(元组)类型,而你想自己声明一个包含两种不同类型的二元组类型,此时可以这样声明它:

    class Pair[A,B](val a: A, val b: B)

这里有几个例子说明如何使用该类:

    Pair(1, 2)        // A and B are both Int
    Pair(1, "1")      // A is Int, B is String
    Pair("1", 2.2)    // A is String, B is Double

在第一个例子中,AB 刚好有相同的类型,而在后两个例子中,AB 是不同的类型。

最后,为了完善我们的第一个泛型例子,让我们创建一个使用泛型参数的特质,然后创建一个实现该特质的类。首先,让我们创建两个简单的类,这个例子将需要这些简单的类,还有我们之前的 Pair 类:

    class Cat
    class Dog
    class Pair[A,B](val a: A, val b: B)

在这个的背景下,你可以使用两个泛型类型参数来创建参数化特质:

    trait Foo[A,B]:
        def pair(): Pair[A, B]

请注意,你在特质名称后面声明了你需要的类型,然后在特质中引用了这些类型。

接下来,这是一个为狗和猫实现特质的类:

    class Bar extends Foo[Cat, Dog]:
        def pair(): Pair[Cat, Dog] = Pair(Cat(), Dog())

这第一行代码声明 Bar 适用于 CatDog 类型,CatA 的特定替换,DogB 的替换:

    class Bar extends Foo[Cat, Dog]:

如果你想创建另一个继承 Foo 的类,并对 StringInt 进行操作。你可以这样写:

    class Baz extends Foo[String, Int]:
        def pair(): Pair[String, Int] = Pair("1", 2)

这些例子展示了在不同情况下如何使用泛型类型参数。

随着你越来越多的使用泛型类型参数,你可能想对这些类型定义某些期望和限制。为了处理这些情况,你将使用边界、型变和类型约束,这些将在接下来讨论。

边界

边界让你能对类型参数进行限制。例如,想象一下,你想写一个方法来返回一个类型的 name 字段的大写版本:

    // 这段代码无法编译
    def upperName[A](a: A) = a.name.toUpperCase

这段代码与你想要的差不多,但他无法通过编译,因为不能保证类型 A 有一个 name 字段。作为这个问题的解决方案,如果你有一个像 SentientBeing 这样的类型,它声明了一个 name 字段:

    trait SentientBeing:
        def name: String

你可以通过使用一个边界来正确实现 upperName 方法,如下面的代码所示:

    def upperName[A <: SentientBeing](a: A) = a.name.toUpperCase
                  ------------------

这告诉编译器,无论 A 类型是什么,它必须是 SentientBeing 的一个子类,它被保证有一个 name 字段。因此,如果你有这样的类,它们是 SentientBeing 的子类:

    case class Dog(name: String) extends SentientBeing
    case class Person(name: String, age: Int) extends SentientBeing
    case class Snake(name: String) extends SentientBeing

upperName 方法在下列使用情景都能做到使命必达:

    upperName(Dog("rover"))       // "ROVER"
    upperName(Person("joe", 25))  // "JOE"
    upperName(Snake("Noodles"))   // "NOODLES"

这就是使用边界工作的本质。它们给你提供了一种方法来定义限制 —— 边界,或者说是泛型类型的可能性的边界。表23-1提供了常见边界符号的描述。

表23-1. Scala的边界符号的描述

Bound Description
A <: B 上界 A必须是B的一个子类型,见23.5小节。
A >: B 下界 A必须是B的一个超类型(父类型)。
A <: 上界 >: 下界 下界和上界一起使用 类型A既有上界又有下界。

下界在集合类的一些方法中有示范。要找到它们的例子,可以在 Listhttps://oreil.ly/Q5pMd )等类的Scaladoc中搜索 >: 符号。

型变

顾名思义,型变是一个概念,它关系到创建类型的子类时,泛型类型参数是如何变化的。Scala使用了所谓的 声明端型变,这意味着 —— 你作为库的创建者 —— 在创建新类型时,在泛型类型参数上声明了型变注释,例如特质和类。(这与Java相反,Java使用的是 使用端型变,这意味着库的使用者要负责理解这些注释)。

我发现,当在创建的新类型类似我们一直使用的 ListArrayBuffer 的集合时,最容易展示型变。所以作为一个例子,我将创建一个名为 Container 的新类型,它包含一个元素。当我定义 Container 时,型变与我是否将其泛型类型参数 A 定义为 A+A-A 有关:

    class Container[A](a: A) ...  // invariant
    class Container[+A](a: A) ... // covariant
    class Container[-A](a: A) ... // contravariant

现在 如何声明 A 会影响到 以后 如何使用 Container 实例。例如,在这样的讨论中,型变将会发挥作用:

     当我使用这些注释之一定义一个新的 Container 类型时,如果我还定义了一个 Dog 类,它是 Animal 的一个子类型,那么 Container[Dog]Container[Animal] 的一个子类型吗?

具体来说,这意味着如果你有这样一个方法,它被定义为接受 Container[Animal] 类型的参数:

    def foo(c: Container[Animal]) = ???

你能把一个 Container[Dog] 传给 foo 吗?

简化型变的两种方法

解释型变需要几个步骤,因为你必须同时关注这两个方面:(a)泛型参数最初是如何声明的,以及(b)你的container的实例后来是如何使用的,但我发现有两种方法可以简化这个话题。

1. 如果所有东西都是不可变的。 简化型变的第一个方法是,如果Scala中的所有东西都是不可变的,那么就没有必要进行型变了。具体来说,在一个完全不可变的世界里,所有的字段都是 val,所有的集合都是不可变的(比如 List ),如果 DogAnimal 的子类,Container[Dog] 肯定是 Container[Animal] 的子类。

在一个不可改变的世界中,不需要型变 -- TODO 鸽子栏

     在下面的讨论中,在一个完全不可改变的世界中,对型变的需求就消失了。

这在下面的代码中得到了证明。首先,我创建了一个 Animal 特质,然后创建了一个继承自动物的 Dog 样例类:

    sealed trait Animal:
        def name: String
    case class Dog(name: String) extends Animal

现在我定义了我的 Container 类,将其泛型类型参数声明为 +A,使其成为 协变 的。虽然这是一个花哨的数学术语,但它只是意味着当一个方法被声明为接受一个 Container[Animal] 时,你可以把一个 Container[Dog] 传给它。因为类型是协变的,所以它是灵活的,允许在这个方向上变化(即,允许接受一个子类型):

    class Container[+A](a: A):
        def get: A = a

然后我创建了一个 Dog 的实例以及一个 Container[Dog] ,然后验证 Container 中的 get 方法是否如愿以偿:

    val d = Dog("Fido")
    val h = Container[Dog](d)
    h.get   // Dog(Fido)

为了完成这个例子,我定义了一个方法,它接受一个 Container[Animal] 参数:

    def printName(c: Container[Animal]) = println(c.get.name)

最后,我向该方法传递了一个 Container[Dog] 变量,该方法如愿以偿:

    printName(h)   // "Fido"

简而言之,所有这些代码都是有效的,因为所有东西都是不可变的,我用泛型参数 +A 定义了 Container

请注意,如果我把该参数只定义为 A 或定义为 -A ,该代码就无法编译。(关于这方面的更多信息,请继续阅读)。

2. 型变与类型的“输入”和“输出”的位置有关。 还有第二种方法来简化型变的概念,我将其总结为以下三段话:

正如你刚才看到的,Container 类中的 get 方法只使用 A 类型作为其返回类型。这并不是巧合:只要你把一个参数声明为 +A ,它就永远只能作为 Container 方法的返回类型。你可以认为这是一个 输出(out) 位置,可以说你的容器是一个 生产者:像 get 这样的方法会产生 A 类型值。除了刚才展示的 Container[+A] 类,其他生产者的例子是Scala的 List[+A]Vector[+A] 类。对于这些类,一旦它们的实例被创建,你就不能再向它们添加更多的 A 值。相反,它们是不可变的和只读的,你只能通过内置的方法访问它们的 A 值。你可以把 ListVector 看作是 A 类型元素(以及 A 的派生)的生产者。

相反,如果你指定的泛型类型参数只用作容器中的方法的输入参数,那么就用 -A 声明该参数是 逆变(contravariant) 的。这个声明告诉编译器,A 类型的值将只被传递到消费者(consumer)的方法中 —— “输入”的位置 —— 并且它们永远不会被当作返回值。因此,你的容器被说成是一个 消费者 。(请注意,与其他两种可能性相比,这种情况很罕见,但在生产者/消费者的讨论中,最容易提到它。)

最后,如果泛型参数既被用于方法的返回类型位置,又被用作容器内的方法参数,那么通过用符号 A 来声明该类型是 不变的(invariant)。当你使用这种类型来声明类的泛型参数时,类既是 A 类型的生产者也是消费者,作为这种灵活性的副作用,该类型是不变的(invariant) —— 这意味着它不能变化。当一个方法被声明为接受 Container[Dog] 时,它只能接受一个 Container[Dog] 。这种类型在定义可变容器时使用,比如 ArrayBuffer[A] 类,你可以在其中添加新元素、编辑元素和访问元素。

下面是这三种生产者/消费者情况的例子。

在第一种情况下,当泛型只被用作方法的返回类型时,容器是生产者,用 +A 标记该类型为协变:

    // 协变:A只在“输出”的位置使用。
    trait Producer[+A]:
        def get: A

请注意,对于这个用例,C#和Kotlin语言 —— 它们也使用声明端型变 —— 在定义 A 时使用了关键字 out。如果Scala使用 out 而不是 +,代码会是这样的:

    trait Producer[out A]:   // 如果Scala改为使用'out'
        def get: A

对于第二种情况,如果泛型参数只被用作容器方法的输入参数,那么可以把容器看作是一个 消费者。使用 -A 将泛型标记为逆变:

    // 逆变:A只在“输入”的位置使用。
    trait Consumer[-A]:
        def consume(a: A): Unit

在这种时候,C#和Kotlin使用关键字 in 来表示 A 只作为方法的输入参数(“输入”的位置)。如果Scala有这个关键字,你的代码会是这样的:

    trait Consumer[in A]:   // 如果Scala改为使用'out'
        def consume(a: A): Unit

最后,当一个泛型类型参数既被用作方法的输入参数又被用作方法的返回参数时,它被认为是不变的 —— 不允许变化,并被设计为 A

    // 不变:A用于“输入”和“输出”的位置
    trait ProducerConsumer[A]:
        def consume(a: A): Unit
        def produce(): A

一种记住型变符号的方法 -- TODO 耗子栏

    虽然我一般喜欢用关键字 outin 来声明泛型参数的变化 —— 至少在简单的、单参数的声明中,我发现这样可以记住Scala的符号:

  • + 表示允许在正(子类型)方向上有变化。
  • - 表示允许在负数(超类型)方向上有变化。
  • 没有附加符号意味着不允许有变化。

因为子类型方向比超类型方向更常见,所以很容易将其视为“积极”方向。

表23-2提供了这些术语的摘要,包括Scala标准库中的每个例子。

表23-2. Scala类型变化(型变)的描述和例子

Variance Symbol In or Out Producer/Consumer Examples
Covariant +A Out Producer List[+A]Vector[+A]
Contravariant -A In Consumer Function1[-T1,+R] 中的 T1 参数
Invariant A Both Both Array[A]ArrayBuffer[A]mutable.Set[A]

实际上很难找到这些型变术语的一致定义,但微软的这个“通用术语中的协变和逆变”页面( https://oreil.ly/uZj51 )提供了很好的定义,我在这里稍作重新表述:

协变(Scala中的+A)
让你使用一个比指定类型更“派生”的类型。这意味着你可以在声明了一个父类型的地方使用一个子类型。在我的例子中,这意味着你可以在声明了 Container[Animal] 方法参数的地方传递一个 Container[Dog]

逆变(-A)
本质上与协变相反,你可以使用一个比指定的类型更通用(更少的派生)的类型。例如,你可以在指定使用 Container[Dog] 的地方使用 Container[Animal]

不变(A)
这意味着类型不能变化 —— 你只能使用指定的类型。如果一个方法需要一个 Container[Dog] 类型的参数,你只能给它一个 Container[Dog];如果你试图给它一个 Container[Animal],它将编译失败。

用implicitly测试型变

正如Stack Overflow的这篇帖子( https://oreil.ly/lYWr4 )以及John De Goes和Adam Fraser(Ziverge)的 Zionomicon 一书中所展示的那样,你可以使用 implicitly 方法 —— 它被定义在 Predef 对象( Scaladoc https://oreil.ly/FB6Zw )中,你所的有代码都可访问它 —— 以此来测试型变的定义。

例如,使用我最初的型变例子中的这段代码:

    sealed trait Animal:
        def name: String
    case class Dog(name: String) extends Animal
    class Container[+A](a: A):
        def get: A = a

这些REPL例子表明,通过使用 implicitly,Scala编译器确认了 Container[Dog]Container[Animal] 的一个子类型:

    scala> implicitly[Dog <:< Animal]
    val res0: Dog <:< Animal = generalized constraint

    scala> implicitly[Container[Dog] <:< Container[Animal]]
    val res1: Container[Dog] <:< Container[Animal] = generalized constraint

如你所见,这些例子均是有效的,因为代码的编译通过了。相反,如果你用 -AA 来定义 Container,就像在这个例子中:

    class Container[A](a: A):
        def get: A = a

implicitly 相关代码将无法编译:

    scala> implicitly[Container[Dog] <:< Container[Animal]] 
    1 |implicitly[Container[Dog] <:< Container[Animal]]
      |                                                ^
      |                   Cannot prove that Container[Dog] <:< Container[Animal].

这变成了一个很好的技巧/技术,你可以用来测试与型变有关的代码。

请注意,在这个例子中,表达式 A <:< B 意味着在处理隐式参数时,A 必须是 B 的子类型。这个 类型关系(type relation) 符号在本书中没有讨论,但请看Twitter的Scala School关于高级类型的页面( https://oreil.ly/ply5e ),以了解关于在什么时候(什么地方)需要它的好例子。

很少使用逆变

为了保持一致,我在前面的讨论中第二次提到了逆变,但在实际开发中,逆变类型很少被使用。例如,Scala Function1 类( https://oreil.ly/xI64U )是标准库中少数几个声明泛型参数为逆变的类之一,如本例中的 T1 参数:

    Function1[-T1, +R]

因为不经常使用,所以本书没有涉及逆变,但在免费的 Scala 3 Book 的“Variance”部分有一个很好的例子( https://oreil.ly/jCrB7 )。

具有型变的多个泛型类型参数 -- TODO 鸽子栏

    从 Function1 的例子中还可以注意到,一个类可以接受多个用型变声明的泛型参数。-T1 是一个只在 Function1 类中消耗的参数,而 +R 是一个只在 Function1 中产生的类型。

鉴于所有这些背景信息,在23.3和23.4小节中展示了常见的型变问题的两种解决方案。

类型约束

除了边界和型变之外,Scala还允许你指定额外的类型约束。这些都是用这些符号写的:

    A =:= B   // A必须与B相等
    A <:< B   // A必须是B的子类型

这些符号在本书中没有涉及。详情和例子见 Programming in Scala。在Twitter的Scala School的高级类型页面( https://oreil.ly/ply5e )也展示了使用它们的简要例子,其中它们被称为 类型关系运算符(type relation operators)

其他几个类型的例子 -- TODO 鸽子栏

    在第一版Scala Cookbook中,我写了关于如何创建一个计时器以及如何创建自己的 Try 类( https://oreil.ly/QRdZr )。(我把它摘录在我的网站上)。那段代码大量使用了类型,它在Scala 3仍然适用。

23.1 创建一个接受简单泛型类型参数的方法

问题

你不关心类型的差异,同时想创建一个接受泛型类型参数的方法(或函数),比如一个接受 Seq[A] 参数的方法。

解决方案

在括号中指定泛型参数,如 [A]。例如,当创建一个抽奖式的应用程序,从一个名字列表中抽出一个随机的名字时,你可能会遵循“尽可能做最简单的工作”的信条,最初创建一个没有使用泛型的方法:

    def randomName(names: Seq[String]): String =
        val randomNum = util.Random.nextInt(names.length)
        names(randomNum)

正如所写的那样,这对一个值为 String 的序列起作用:

    val names = Seq("Aleka", "Christina", "Emily", "Hannah")
    val winner = randomName(names)

然后在未来的某个时刻,你意识到你真的需要使用一个通用用途的方法,以从任何类型的序列中返回一个随机元素。所以,你修改了这个方法,像这样下面这样使用一个泛型类型参数:

    def randomElement[A](seq: Seq[A]): A =
        val randomNum = util.Random.nextInt(seq.length)
        seq(randomNum)

有了这个变化,现在你可以用不可变序列中的各种类型调用该方法:

    randomElement(Seq("Emily", "Hannah", "Aleka", "Christina"))
    randomElement(List(1,2,3))
    randomElement(List(1.0,2.0,3.0))
    randomElement(Vector.range('a', 'z'))

讨论

这是一个相对简单的例子,它展示了如何将一个泛型集合传递给一个不会尝试修改集合的方法。关于你可能遇到的更复杂的情况,请参阅23.3和23.4小节。

23.2 创建一个使用简单的泛型类型的类

问题

你想创建一个使用简单泛型的类(和相关的方法)。

解决方案

作为一个类库的作者,你在声明类时要定义泛型类型参数。例如,这里有一个小型的链接列表(linked-list)类,它被写成可以向其添加新的元素。这样的链表是可变的,就像 ArrayBuffer

    class LinkedList[A]:

        private class Node[A] (elem: A):
            var next: Node[A] = _
            override def toString = elem.toString

        private var head: Node[A] = _

        def add(elem: A): Unit =
            val n = new Node(elem)
            n.next = head
            head = n

        private def printNodes(n: Node[A]): Unit =
            if n != null then
                println(n)
                printNodes(n.next)

        def printAll() = printNodes(head)

请注意泛型类型参数 A 是如何出现在整个类定义中的。这个泛型类型参数是实际类型的占位符,比如 IntString,你的类的用户可以指定这些类型。

例如,要用这个类创建一个整数列表,首先要创建一个实例,并声明它所包含的类型是 Int 类型:

    val ints = LinkedList[Int]()

然后用 Int 值来填充它:

    ints.add(1)
    ints.add(2)

因为该类使用了一个泛型类型参数,所以你也可以创建一个 String 类型的 LinkedList

    val strings = LinkedList[String]()
    strings.add("Emily")
    strings.add("Hannah")
    strings.printAll()

或任何你想使用的其他类型:

    val doubles = LinkedList[Double]()
    doubles.add(1.1)
    doubles.add(2.2)

这展示了创建类时泛型类型参数的基本用法。

讨论

当你在定义一个类的时候使用一个简单的泛型参数如 A,你也可以在这个类的内部和外部定义方法,这些方法正是使用这个类型。为了解释这意味着什么,我们从这个类型的层次结构开始说明:

    trait Person { def name: String }
    class Employee(val name: String) extends Person
    class StoreEmployee(name: String) extends Employee(name)

当你为一家披萨连锁店的销售点应用程序建模时,你可能会使用这种类型层次结构,其中 StoreEmployee 是在一个商店地点工作的人。(你可能还有一个 OfficeEmployee 类型,用于在公司办公室工作的人。)

这种关系在图23-1的类图中得到了直观的表达。

23-1

图23-1. 个人和雇员类的类图

考虑到这个类型层次结构,你可以创建一个方法来打印一个 LinkedList[Employee],就像下面这样:

    def printEmps(es: LinkedList[Employee]) = es.printAll()

现在你可以给 printEmps方法传递一个 LinkedList[Employee],它将如愿以偿地工作:

    // 正常
    val emps = LinkedList[Employee]()
    emps.add(Employee("Al"))
    printEmps(emps)

到目前为止,情况还不错;这能如愿以偿地工作。

这种方式的局限性

这种简单的方法不奏效的地方是,如果你试图给 printEmps 一个 LinkListStoreEmployee

    val storeEmps = LinkedList[StoreEmployee]()
    storeEmps.add(StoreEmployee("Fred"))
    // 这行无法编译
    printEmps(storeEmps)

这就是你试图写这段代码时得到的编译错误:

   printEmps(storeEmps)
             ^^^^^^^^^
             Found:    (storeEmps : LinkedList[StoreEmployee])
             Required: LinkedList[Employee]

最后一行无法编译,因为:

  • printEmps 期望接受一个 LinkedList[Employee] 参数。
  • storeEmps 是一个 LinkedList[StoreEmployee]
  • LinkedList 中的元素是可变的。
  • 如果编译器允许这样做,printEmps 可以在 storeEmps 中的 StoreEmployee 元素中添加普通的 Employee 元素。这是不被允许的。

正如在“型变”中所讨论的,这里的问题是,当一个泛型参数在像 LinkedList 这样的类中被声明为 A 时,该参数是不变的,这意味着 A 在像 printEmps 这样的方法中使用时,其类型不允许变化。在23.3小节中展示了这个问题的详细解决方案。

类型参数的符号

如果一个类需要一个以上的类型参数,请使用表23-3中的符号。例如,在关于泛型类型的官方Java文档( https://oreil.ly/J5QvL )中,Oracle展示了一个名为 Pair 的接口,它需要两种类型:

    public interface Pair<K, V> {
        public K getKey();
        public V getValue();
    }

你可以将该接口移植到Scala,并使用Scala特质实现它,具体方法如下:

    trait Pair[K, V]:
        def getKey: K
        def getValue: V

如果你要进一步实现 Pair 类(或特质)的主体,类型参数 KV 将分布在你的类中,就像 LinkedList 例子中使用的符号 A一样。

泛型符号标准 --TODO 鸽子栏

    我一般喜欢在类的前两个泛型类型参数声明中使用符号 AB ,但在这种时候 —— 比如在 Map 类中,我更喜欢 KV,因为类型明显指的是

上述文档还列出了Java类型参数的命名方式。这些在Scala中也是类似的,只是Java在命名简单的类型参数时先用字母 T,然后用 UV 来命名后续类型。Scala的标准是,第一个类型应该被声明为 A,接下来是 B,以此类推,如表23-3所示。

表23-3. Scala中泛型类型参数的标准符号

Symbol Description
A 指的是一个简单的类型,例如List[A]。
B、C、D 用于第二、第三、第四个类型等。例如:
class List[A]:
    def map[B](f: A => B): List[B] = ???
K 通常是指Java map中的一个键。(这种时候,我也更喜欢K)。
N 指的是一个数字值。
V 通常是指Java map中的一个值。(这种时候,我也更喜欢V)。

另见

  • Oracle关于泛型类型的Java文档( https://oreil.ly/J5QvL )。
  • 可以在Scala Style Guide关于命名约定的页面上找到有关Scala泛型类型的命名约定的更多信息( https://oreil.ly/lAmou )。

23.3 将不可变的泛型参数改为协变

问题

你想创建一个类,其泛型参数不能被改变(它们是immutable的),并想了解如何指定泛型参数。

解决方案

想要声明泛型类型参数的元素不能被修改,可以用前面的 + 符号定义它们,如 +A,从而声明它们是 协变 的。作为一个例子,像 ListVectorSeq 这样的不可变的集合类都被定义为使用协变的泛型类型参数:

    class List[+T]
    class Vector[+A]
    trait Seq[+A]

通过使类型参数成为协变的,泛型参数不能被改变,但好处是以后可以以更灵活的方式使用该类。

为了证明这一点的用处,请稍微修改一下前面小节中的例子。首先,定义类的层次结构:

    trait Animal:
        def speak(): Unit

    class Dog(var name: String) extends Animal:
        def speak() = println("Dog says woof")

    class SuperDog(name: String) extends Dog(name):
        override def speak() = println("I’m a SuperDog")

接下来,定义一个 makeDogsSpeak 方法,但不是像前面的示例那样接受一个 可变的 ArrayBuffer[Dog],而是接受一个 不可变的 Seq[Dog]

    def makeDogsSpeak(dogs: Seq[Dog]): Unit = dogs.foreach(_.speak())

就像前面示例中的 ArrayBuffer 一样,你可以毫无问题地将 Seq[Dog] 传入 makeDogsSpeak

    // 这样做是可行的
    val dogs = Seq(Dog("Nansen"), Dog("Joshu"))
    makeDogsSpeak(dogs)

然而,在这种时候,你也可以成功地将一个 Seq[SuperDog] 传入 makeDogsSpeak 方法:

   // 这样做也是可行的
    val superDogs = Seq(
        SuperDog("Wonder Dog"),
        SuperDog("Scooby")
    )
    makeDogsSpeak(superDogs)

因为 Seq 是不可变的,并且 Seq[+A] 拥有协变的泛型类型参数 +A,所以 makeDogsSpeak 可以同时接受 Seq[Dog]Seq[SuperDog],而没有23.4小节那样的形式了冲突。

讨论

你可以通过创建一个带有协变的泛型参数的自定义类来进一步证明这一点。要做到这一点 —— 为了保持简单 —— 创建一个可以容纳一个元素的集合类。因为你不希望集合元素被修改,所以将参数定义为一个 val,并用 +A 使其成为协变的:

    class Container[+A] (val elem: A)
                   ----

使用与解决方案中相同的类型层次结构,修改 makeDogsSpeak 方法,以接受一个 Container[Dog]

    def makeDogsSpeak(dogHouse: Container[Dog]): Unit = dogHouse.elem.speak()

有了这个设置,你就可以把一个 Container[Dog] 传入 makeDogsSpeak

    val dogHouse = Container(Dog("Xena"))
    makeDogsSpeak(dogHouse)

最后,因为你用“+”符号声明该元素是协变的,所以你也可以将一个 Container[SuperDog] 传入 makeDogsSpeak

    val superDogHouse = Container(SuperDog("Wonder Dog"))
    makeDogsSpeak(superDogHouse)

因为 Container 的元素是不可变的,而且它的泛型类型参数被标记为协变的,因此所有这些代码都能成功运行。注意,如果你把 Container 的类型参数从 +A 改为 A,最后一行代码就不能编译了。

正如在“型变”中所讨论的以及在这些例子中所展示的,用一个不可变的泛型类型参数来定义一个容器类型类,使得集合在整个代码中更加灵活和有用。如本例所示,你可以将一个 Container[SuperDog] 传递给一个期望接收 Container[Dog] 的方法。

+A指的是“输出”的位置 -- TODO 鸽子栏

上面开头“型变”一小节还指出,+A 符号是你告诉编译器泛型参数 A 将只作为这个类中方法的返回类型(即“输出”位置)的方式。例如,在这个例子中,这段代码是有效的:

    class Container[+A] (val elem: A):
        // 使用正确,'A'在“输出”的位置上
        def getElemAsTuple: (A) = (elem)

但是任何试图在类中使用 A 类型的元素作为方法输入参数的尝试都会因为这个错误而失败:

    class Container[+A] (val elem: A):
        def foo(a: A) = ???
                ^^^^
    error: covariant type A occurs in contravariant position
    in type A of parameter a

正如那段代码所示,尽管我甚至没有尝试实现 foo 方法的主体,但编译器指出 A 类型不能用在“输入”的位置。

23.4 创建一个元素类型可以变化的类

问题

你想创建一个类似于集合的类,其元素可以被改变,并想知道如何为其元素指定泛型类型参数。

解决方案

当定义一个可以改变(mutated)的参数时,它的泛型类型参数应该被声明为不变的 [A]。因此,本小节与23.2小节中的例子类似。

一个例子是,Scala ArrayArrayBuffer 中的元素可以被修改,这样声明其签名:

    class Array[A] ...
    class ArrayBuffer[A] ...

讨论

将一个类型声明为不变主要有两个影响:

  • 容器可以容纳指定的类型,以及该类型的子类型。
  • 后来对方法如何使用容器有了限制。

为了创造一个关于第一点的例子,下面的类层次结构指出,DogSuperDog 类都扩展了 Animal特质:

    trait Animal:
        def speak(): Unit

    class Dog(var name: String) extends Animal:
        def speak() = println("woof")
        override def toString = name

    class SuperDog(name: String) extends Dog(name):
        def useSuperPower() = println("Using my superpower!")

考虑到这些类,你可以创建一只 Dog 和一只 SuperDog

    val fido = Dog("Fido")
    val wonderDog = SuperDog("Wonder Dog")

当你以后声明一个 ArrayBuffer[Dog] 时,你可以把 DogSuperDog 的实例都添加到其中:

    import collection.mutable.ArrayBuffer

    val dogs = ArrayBuffer[Dog]()
    dogs += fido
    dogs += wonderDog

所以一个有不变类型参数的集合可以包含(a)基本类型的元素和(b)基本类型的子类型。

声明不变类型的第二个效果是,对该类型以后的使用方式有限制。给出同样的代码,你可以定义一个接受 ArrayBuffer[Dog] 的方法,然后让每个 Dog 说话:

    import collection.mutable.ArrayBuffer
    def makeDogsSpeak(dogs: ArrayBuffer[Dog]) =
        dogs.foreach(_.speak())

当你传递给它一个 ArrayBuffer[Dog] 时,这个工作很好:

    val dogs = ArrayBuffer[Dog]()
    dogs += fido
    makeDogsSpeak(dogs)

然而,如果你试图传递给它一个 ArrayBuffer[SuperDog]makeDogsSpeak 的调用就会编译失败:

    val superDogs = ArrayBuffer[SuperDog]()
    superDogs += wonderDog
    makeDogsSpeak(superDogs)  // 错误: 无法编译

由于这种情况下形式的冲突,这段代码无法编译:

  • ArrayBuffer 中的元素可以被修改。
  • makeDogsSpeak 被定义为接受一个 ArrayBuffer[Dog] 类型的参数。
  • 你试图传入 superDogs,其类型是 ArrayBuffer[SuperDog]
  • 如果编译器允许的话,则 makeDogsSpeak 可以用普通的 Dog 元素替换 superDogs 中的 SuperDog 元素。但显然,这是不被允许的。

综上所述,产生这种冲突的一个主要原因是 ArrayBuffer 元素可以被修改。如果你想写一个方法来使所有的 Dog 类型和它的子类型可以说话,可以通过指定类型为 +A 来定义它接受不可变的元素集合,比如 ListSeqVector 这些类就是这么做的。关于这种方法的细节,请参阅23.3小节。

标准库中的例子

对于可变的集合类,诸如 ArrayArrayBufferListBuffer 的元素的类型参数被定义为不变:

    class Array[T]
    class ArrayBuffer[A]
    class ListBuffer[A]

相反,不可变的集合类是用 + 符号标识它们的泛型类型参数,如下所示:

    class List[+T]
    class Vector[+A]
    trait Seq[+A]

在不可变集合的类型参数上使用的 + 符号表明它们的参数是协变的。如23.3小节中所讨论的,因为它们的元素不能被修改,所以它们可以被更灵活地使用。

另见

  • 你可以通过Scaladoc中的“Source code”链接找到Scala类的源代码。
  • 要看一个类中的类型参数为不变的好例子,ArrayBuffer 类( https://oreil.ly/tOlvd )的源代码并不长,它显示了类型参数 A 最终是如何遍布在整个类中的。

23.5 创建一个参数实现了基类型的类

问题

你想指定一个类有一个泛型类型参数,而且这个参数是有限制的,所以它只能是(a)一个基类型或(b)该基类型的一个子类型。

解决方案

通过指定带有上限的类型参数来定义类或方法。例如,给定这个类型的层次结构:

    sealed trait CrewMember
    class Officer extends CrewMember
    class RedShirt extends CrewMember
    trait Captain
    trait FirstOfficer
    trait ShipsDoctor
    trait StarfleetTrained

这便是如何创建一个名为 Crew 的参数化类,它只能存储 CrewMemberCrewMember 的子类型的实例:

    class Crew[A <: CrewMember]:
        import scala.collection.mutable.ArrayBuffer
        private val list = ArrayBuffer[A]()
        def add(a: A): Unit = list += a
        def printAll(): Unit = list.foreach(println)

为了证明这一点,首先创建一些继承 Officer 的对象:

    val kirk = new Officer with Captain
    val spock = new Officer with FirstOfficer
    val bones = new Officer with ShipsDoctor

根据这些给定的类,下面你可以创建一个只包含 Officer 实例的 Crew

    val officers = Crew[Officer]()
    officers.add(kirk)
    officers.add(spock)
    officers.add(bones)

第一行让你创建一个 officers 集合,这个集合只能包含属于 officersofficers 的子类型的类型。这种方法的一个好处是,RedShirt 类型的实例将不允许出现在集合中,因为它们没有继承 Officer

    val redShirt = RedShirt()
    officers.add(redShirt)  // error: this won’t compile:
                           // Found: (redShirt), Required: (Officer)

这个解决方案的一个关键是参数 A 的定义方式:

    class Crew[A <: CrewMember] ...
               ---------------

这说明任何 Crew 的实例只能有 CrewMember 或其子类型之一的元素。然后,当我创建一个 Crew 的具体实例时,声明我只想让这个实例拥有实现 Officer 的类型:

    val officers = Crew[Officer]()
                       -------

它还可以防止你写这样的代码,因为 StarTrekFan 并没有继承 CrewMember

    class StarTrekFan
    val officers = Crew[StarTrekFan]()   // 错误: 无法编译

    // error message:
    // Type argument StarTrekFan does not conform to upper bound CrewMember

请注意,除了创建一个 Crew[Officer] 外,如果需要,你还可以创建一个 Crew[RedShirt]

    val redshirts = Crew[RedShirt]()
    val redShirt = RedShirt()
    redshirts.add(redShirt)

讨论

通常,你会定义一个像 Crew 这样的类,这样你就可以创建特定的实例,例如 Crew[Officer]Crew[RedShirt]。你创建的类通常也会有一些方法,如 add,这些方法是针对你所声明的参数类型,如本例中的 CrewMember。通过控制哪些类型被添加到 Crew 中,你可以保证方法诸如 Crew 可以有 beamUpbeamDowngoWhereNoOneElseHasGone 等方法都能够如愿以偿地工作 —— 对 CrewMember 来说,任何有意义的方法都可以。

继承多个特征

当你需要限制类接受一个继承了多个特质的类型时,可以使用同样的技巧。例如,要创建一个只允许继承 CrewMember StarfleetTrained 的类型的 Crew,请像这样声明 Crew 类的第一行:

    class Crew[A <: CrewMember & StarfleetTrained]:

现在,当你调整officer实例,使其与这个新的特质一起工作时:

    val kirk = new Officer with Captain with StarfleetTrained
    val spock = new Officer with FirstOfficer with StarfleetTrained
    val bones = new Officer with ShipsDoctor with StarfleetTrained

你仍然可以构建一个officer名单:

    class Crew[A <: CrewMember & StarfleetTrained]:
        import scala.collection.mutable.ArrayBuffer
        private val list = new ArrayBuffer[A]()
        def add(a: A): Unit = list += a
        def printAll(): Unit = list foreach println
    val officers = Crew[Officer & StarfleetTrained]()

    officers.add(kirk)
    officers.add(spock)
    officers.add(bones)

kirkspockbones 等实例可以工作的原因是,它们的类型层次中的某个位置具有 OfficerStarfleetTrained 类型。

23.6 使用Duck类型(结构化类型)

问题

你已经习惯了Python或Ruby等其他语言的 duck类型(结构化类型),并希望在Scala代码中使用这个功能。

解决方案

Scala版的duck类型被称为 using a structural type。作为这种方法的一个例子,下面的Scala 3代码展示了 callSpeak 方法如何要求其 obj 的类型参数有一个 speak() 方法:

    import reflect.Selectable.reflectiveSelectable

    def callSpeak[A <: {def speak(): Unit}](obj: A): Unit =
        obj.speak()

根据这个定义 —— 包括所需的 import 声明 —— 任何类的实例,只要具有一个不需要参数且没有返回值的 speak 方法就可以作为参数传递给 callSpeak。例如,下面的代码展示了如何对一个 Dog 和一个 Klingon 来调用 callSpeak方法:

    import reflect.Selectable.reflectiveSelectable
    def callSpeak[A <: {def speak(): Unit}](obj: A): Unit = obj.speak()

    class Dog:
        def speak() = println("woof")

    class Klingon:
        def speak() = println("Qapla!")

    callSpeak(Dog())
    callSpeak(Klingon())

在REPL中运行这段代码,会打印出以下输出:

    woof 
    Qapla!

实例 obj 的类型层次结构并不重要:对参数 obj 实例的唯一要求是它有一个 speak():Unit 方法。

讨论

在这个例子中,结构化类型的语法是必要的,因为 callSpeak 函数在被传入的对象上调用了一个 speak 方法。在静态类型的语言中,必须保证传入的对象有这个方法,而这个示例展示的便是Scala中如何保证该规则的语法。

如果 callSpeak 方法的泛型类型只使用 A,它便无法编译,因为编译器不能保证类型 A 有一个 speak 方法:

    import reflect.Selectable.reflectiveSelectable

    // 无法编译
    def callSpeak[A](obj: A): Unit = obj.speak()

这是Scala中类型安全的一大优势。

理解解决方案

为了理解这是如何工作的,分解结构化类型的语法可能会有帮助。首先,这里是整个方法:

    def callSpeak[A <: {def speak(): Unit}](obj: A): Unit = obj.speak()

在方法参数列表之前的类型参数 A 被定义为一个结构化类型,如下所示:

    [A <: { def speak(): Unit }]

代码中的 <: 符号用于定义一个上界。这在23.3小节中有详细描述。如该示例所示,上界的定义通常是这样的:

    class Stack[A <: Animal] (val elem: A)
                -----------

这说明类型参数 A 必须是 Animal 的一个子类型。

然而,在这个示例中,我使用了该语法的一个变体来声明 A 必须是具有 speak():Unit 方法的类型的子类型。具体来说,这段代码可以被理解为:“A 必须是具有 speak 方法的类型的子类型。这个 speak 方法不能接受任何参数,也不能返回任何内容”。

为了清楚起见,这段代码的下划线部分指出,传入的类型必须有一个不接受输入参数的 speak 方法:

    [A <: { def speak(): Unit }]
            -----------

而这段带下划线的代码指出,speak 必须返回 Unit(即,什么都不返回):

    [A <: { def speak(): Unit }]
                       ------

为了展示结构化类型签名的另一个例子,如果你想让 speak 方法必须接受一个 String 参数并返回一个 Boolean 值,那么结构化类型的签名会是下面这样的:

    [A <: {def speak(s: String): Boolean}]

结构化类型需要反射 -- TODO 耗子栏

    值得注意的是,在撰写本文时,这项技术只适用于Java虚拟机(JVM)上的Scala,并且需要Java反射。

23.7 使用不透明类型创建有意义的类型名称

问题

为了与领域驱动设计(DDD)等实践保持一致,你想给那些具有简单类型(如 StringInt )的值起个更有意义的类型名,以使你的代码更安全。

解决方案

在Scala 3中,可以使用不透明的类型来创建有意义的类型名称。这个问题的一个例子是,当客户在电子商务网站上订购东西时,可以使用 customerIdproductId 将其添加到购物车中:

    def addToCart(customerId: Int, productId: Int) = ...

因为这两种类型都是 Int,所以有可能混淆它们。例如,开发者会这样用整数调用这个方法:

    addToCart(1001, 1002)

而且由于这两个字段都是整数,所以在以后的代码中又有可能混淆它们的使用:

    // are you sure you have the right id here?
    if (id == 1000) ...

这个问题的解决方案是将自定义类型创建为不透明的类型。一个完整的解决方案看起来像这样:

    object DomainObjects:

        opaque type CustomerId = Int

        object CustomerId:
            def apply(i: Int): CustomerId = i
        given CanEqual[CustomerId, CustomerId] = CanEqual.derived

        opaque type ProductId = Int
        object ProductId:
            def apply(i: Int): ProductId = i
        given CanEqual[ProductId, ProductId] = CanEqual.derived

这使你可以写出这样的代码:

    @main def opaqueTypes =
        // 导入类型
        import DomainObjects.*

        // 使用`apply`方法
        val customerId = CustomerId(101)
        val productId = ProductId(101)

        // 使用
        def addToCart(customerId: CustomerId, productId: ProductId) = ...

        // 传递给函数
        addToCart(customerId, productId)

如果你在未来的某个时间尝试不正确的类型比较,解决方案中给出的 CanEqual 部分也会产生一个编译器错误:

    // error: values of types DomainObjects.CustomerId and Int
    // cannot be compared with == or !=
    if customerId == 1000

    // 也是一个错误:这段代码无法编译
    if customerId == productId ...

讨论

当你以DDD风格工作时,其中一个目标是你为类型使用的名称应该与业务领域中使用的名称相匹配。例如,当涉及到变量类型时,你可以说:

  • 领域专家会考虑诸如 CustomerIdProductIdUsernamePasswordSocialSecurityNumberCreditCardNumber 等。
  • 反之,他们不会考虑 IntStringDouble 这样的东西。

除了DDD,一个更重要的考虑因素是函数式编程。使用函数式写代码的好处之一便是,其他程序员应该能够看到我们的函数签名,并迅速了解我们的函数是干什么的。例如,以这个函数签名为例:

    def f(s: String): Int

假设这个函数是纯函数的,我们可以看到它接收一个 String 并返回一个 Int。仅仅考虑到这些事实,我们可以迅速推断出该函数可能是做这些事情中的一件:

  • 确定字符串的长度
  • 做一些类似于计算字符串的校验和的事情

我们还知道,该函数并没有试图将字符串转换为一个int,因为这个过程可能会失败,所以一个将字符串转换为int的纯函数会以错误处理类型返回可能的结果,比如说:

    def f(s: String): Option[Int]
    def f(s: String): Try[Int]
    def f(s: String): Either[Throwable, Int]

鉴于纯函数签名是如此重要,我们也不想编写这样的类型:

    def validate(
        username: String,
        email: String,
        password: String
    )

相反,如果我们像这样创建类型,代码将更容易阅读,而且类型也更安全:

    def validate(
        username: Username,
        email: EmailAddress,
        password: Password
    )

第二种方法 —— 使用不透明的类型 —— 在几个方面改进了我们的代码:

  • 在第一个例子中,所有三个参数都是字符串,所以很容易以错误的顺序调用 validate 的参数。相反,以错误的顺序将参数传入第二个 validate 方法就会困难得多。
  • validate 类型签名对于在IDE和Scaladoc查看代码的其他程序员来说更有意义。
  • 我们可以为自定义类型添加验证器,所以我们可以在创建 usernameemail 地址和 password 字段时进行验证。
  • 通过在创建不透明类型时派生 CanEqual,你可以使两个不同的类型无法使用 ==!= 进行比较。(关于使用 CanEqual 的更多细节,请参阅23.12小节)。
  • 你的代码更准确地反映了领域的详细信息。

如解决方案中所示,不透明类型是创建 UsernameEmailAddressPassword 等类型的绝佳方式。

三步法解决方案的优点

解决方案中的代码看起来像这样:

    opaque type CustomerId = Int
    object CustomerId:
        def apply(i: Int): CustomerId = i
    given CanEqual[CustomerId, CustomerId] = CanEqual.derived

虽然用这一行代码就可以创建一个不透明的类型:

    opaque type CustomerId = Int

三步法中的每一步都有其作用:

  • 这个不透明的类型声明创建了一个名为 CustomerId 的新类型。在背后,CustomerId 是一个 Int。)
  • 带有 apply 方法的 object 创建了一个工厂方法(构造函数)用于新的 CustomerId 实例。
  • given CanEqual 声明指出,一个 CustomerId 只能与另一个 CustomerId 比较。试图将 CustomerIdProductIdInt 进行比较会产生一个编译器错误;不可能对它们进行比较。(关于使用 CanEqual 的更多细节,请参阅23.12小节)。

历史

在Scala 2中曾有过几次尝试,试图实现类似的解决方案:

  • 类型别名
  • 值类型
  • 样例类

不幸的是,正如Scala改进过程(SIP)中关于不透明类型的页面( https://oreil.ly/lXr6d )所述,所有这些方法都有弱点。正如SIP中描述的那样,不透明类型的目标是:“对这些包装类型的操作必须在运行时不产生任何额外的开销,同时在编译期仍然提供类型的安全使用。” Scala 3中的不透明类型已经实现了这个目标。

规则

关于不透明类型有几条规则需要了解:

  • 它们必须被定义在一个对象、特质或类的范围内。
  • 类型别名的定义只在该作用域内可见。(在这个范围内,你的代码可以看到 CustomerId 实际上是一个 Int )。
  • 在该作用域之外,只有定义的别名是可见的。(在这个作用域之外,其他的代码不能看出 CustomerId 实际上是一个 Int。)

作为对高性能场景的重要说明,SIP还指出“不透明的类型别名被编译掉了,没有运行时的开销”。

23.8 使用given和using的术语推断

问题

你有一个值被传递到一系列函数调用中,例如在处理futures或Akka actors时使用 ExecutionContext

    doX(a, executionContext)
    doY(b, c, executionContext)
    doZ(d, executionContext)

因为这种类型的代码是重复的,而且使代码更难读,所以你最好改写成这样:

    doX(a)
    doY(b, c)
    doZ(d)

因此,你想知道如何使用Scala 3的 term inference,这在Scala 2中被称为 implicits

解决方案

这一解决方案涉及多个步骤:

  1. 使用Scala 3 given 关键字定义你的 given 实例。
    这通常涉及到使用一个基础特性和多个实现该特性的 given 实例。
  2. 在声明你的函数将使用的隐式参数时,把它放在一个单独的参数组中,并用 using 关键字来定义它。
  3. 确保当你的函数被调用时,given 是在当前的上下文中。

在下面的例子中,我将演示如何使用一个 Adder 特质和两个实现 Adder 特质的 add 方法的 given 值。

1. 定义你的given实例

在第一步中,你通常会创建一个像这样的参数化特质:

    trait Adder[T]:
        def add(a: T, b: T): T

然后,你将使用一个或多个 given 的实例来实现该特质,这样定义这些实例:

    given intAdder: Adder[Int] with
        def add(a: Int, b: Int): Int = a + b

    given stringAdder: Adder[String] with
        def add(a: String, b: String): String = s"${a.toInt + b.toInt}"

在这个例子中,intAdderAdder[Int] 的一个实例,并定义了一个对 Int 值进行操作的 add 方法。同样,stringAdderAdder[String] 的一个实例,并提供了一个 add 方法,该方法接收两个字符串,将它们转换为 Int 值,将它们相加,并将和作为一个 String 返回。(为了使事情简单,这段代码中没有考虑异常)。

如果你熟悉Scala 2中的创建隐式,这种新方法与该过程类似。想法是一样的,只是语法发生了变化。

2. 用using关键字声明你的函数将使用的参数

接下来,声明你使用 Adder 实例的函数。当这样做时,用 using 关键字指定 Adder 参数。把这个参数放在一个单独的参数组中,如下所示:

    def genericAdder[T](x: T, y: T)(using adder: Adder[T]): T =
        adder.add(x, y)

这里的关键是,adder 参数是在该独立参数组中用 using 关键字定义的:

    def genericAdder[A](x: A, y: A)(using adder: Adder[A]): A =
                                   -----------------------

还注意到 genericAdder 声明了泛型类型 A。这个函数不知道它将被用于添加两个整数还是两个字符串;它只是调用 adder 参数的 add 方法。

上下文参数 -- TODO 耗子栏

    在Scala 2中,像这样的参数是用 implicit 关键字来声明的,但现在,由于整个编程行业对这个概念有不同的实现,所以它被称为 context parameter,而且是用 using 关键字来声明的,如上所示。

3. 确保一切都在当前上下文中

最后,假设 intAdderstringAddergenericAdder 都在范围内,你的代码可以用 IntString 的值调用 genericAdder 函数,而不必将 intAdderstringAdder 的实例传入 genericAdder

    println(genericAdder(1, 1))       // 2
    println(genericAdder("2", "2"))   // 4

Scala编译器非常聪明,知道 intAdder 应该用在第一个实例中,而 stringAdder 应该用在第二个实例中。这是因为第一个例子使用了两个 Int 参数,第二个例子使用了两个 String 值。

匿名的given和未命名的参数

通常没有理由给一个given取名字,所以你也可以用这个“anonymous given”的语法来代替之前的语法:

    given Adder[Int] with
        def add(a: Int, b: Int): Int = a + b

    given Adder[String] with
        def add(a: String, b: String): String = "" + (a.toInt + b.toInt)

如果你在方法中没有引用上下文参数,它就不需要名字,所以如果 genericAdder 没有引用 adder 参数,即,没有这一行:

    def genericAdder[A](x: A, y: A)(using adder: Adder[A]): A = ...

可以改成这样:

    def genericAdder[A](x: A, y: A)(using Adder[A]): A = ...

讨论

在解决方案中展示的例子中,你可以手动传入 intAdderstringAdder 的值:

    println(genericAdder(1, 1)(using intAdder))
    println(genericAdder("2", "2")(using stringAdder))

但在Scala 3中使用 given 值的意义在于避免这种重复的代码。

Scala 3中语法发生重大变化的原因是,Scala的创建者认为 implicit 关键字在Scala 2中被过度使用:它可以被用在几个不同的地方,每个地方的含义都不一样。

相反,新的 givenusing 语法更加一致,也更易理解。例如,你可以将先前的代码理解为:“给定一个 intAdder 和一个 stringAdder,使用这些值作为 genericAdder 方法中的 Adder 参数”。

使用扩展方法创建自己的API

你可以将这种技术与扩展方法结合起来 —— 在8.9小节中展示了这种方法 —— 来创建你的API。例如,鉴于这个特质有两个扩展方法:

    trait Math[T]:
        def add(a: T, b: T): T
        def subtract(a: T, b: T): T
        // 拓展方法: 创建自己的api
        extension (a: T)
            def + (b: T) = add(a, b)
            def - (b: T) = subtract(a, b)

你可以像以前一样,创建两个 given 的实例:

    given intMath: Math[Int] with
        def add(a: Int, b: Int): Int = a + b
        def subtract(a: Int, b: Int): Int = a - b

    given stringMath: Math[String] with
        def add(a: String, b: String): String = "" + (a.toInt + b.toInt)
        def subtract(a: String, b: String): String = "" + (a.toInt - b.toInt)

然后你可以创建 genericAddgenericSubtract 函数:

    // 这里的`+`指的是扩展方法
    def genericAdd[T](x: T, y: T)(using Math: Math[T]): T =
        x + y

    // 这里的`-`指的是扩展方法
    def genericSubtract[T](x: T, y: T)(using Math: Math[T]): T =
        x - y

现在你可以使用 genericAddgenericSubtract 函数,而无需手动将 intMathstringMath 实例传递给它们:

    println("add ints: " + genericAdd(1, 1))                    // 2
    println("subtract ints:    " + genericSubtract(1, 1))       // 0
    println("add strings:      " + genericAdd("2", "2"))        // 4
    println("subtract strings: " + genericSubtract("2", "2"))   // 0

编译器仍然可以确定前两个例子需要 intMath 实例,而后两个例子需要 stringMath 实例。

alias givens

given 的文档指出“一个别名(alias)可以用来定义一个等同于某些表达式的 given 实例”。为了证明这一点,想象一下,你正在创建一个能够理解不同语境的搜索引擎。这可能是一个像谷歌这样的搜索引擎,或者像Siri和Alexa这样的工具,你希望你的算法能够参与到与人类的持续对话中。

例如,当某人进行一系列搜索时,他们可能对“食物”或“生活”等特定背景感兴趣:

    enum Context:
        case Food, Life

考虑到这些可能的语境,你可以写一个搜索功能,根据语境来查找单词的定义:

    import Context.*

    // 一些大型的决策树,它使用Context来确定传入的单词的含义。
    def search(s: String)(using ctx: Context): String = ctx match
        case Food =>
            s.toUpperCase match
                case "DATE" => "like a big raisin"
                case "FOIL" => "wrap food in foil before baking"
                case _      => "something else"
        case Life =>
            s.toUpperCase match
                case "DATE" => "like going out to dinner"
                case "FOIL" => "argh, foiled again!"
                case _      => "something else"

在人类和你的算法之间正在进行的对话中,如果当前的context是 Food,你会有一个这样的 given

    given foodContext: Context = Food

这种语法被称为 alias given,而 foodContext 是一个 Context 类型的 given。现在当你调用 search 函数时,这个context被神奇地拉了进来:

    val date = search("date")

这将导致 date 被赋予“looks like a big raisin.”值。请注意,如果你愿意,你仍然可以明确地传入 Context

    val date = search("date")(using Food)   // "looks like a big raisin"
    val date = search("date")(using Life)   // "like going out to dinner"

但是,这里的假设是,像 search 这样的函数可能会被多次调用,而我们希望避免手动声明 context 参数。

导入given

当一个 given 的东西被定义在一个单独的模块中时(通常是这样的),它必须用一个特殊的导入语句导入到范围中。这个语法在9.7小节中展示过,下面例子也展示了这个技术:

    object Adder:
        trait Adder[T]:
            def add(a: T, b: T): T
        given intAdder: Adder[Int] with
            def add(a: Int, b: Int): Int = a + b

    @main def givenImports =
        import Adder.*       // 导入所有不是 given 的定义
        import Adder.given   // 导入所有 `given` 定义

        def genericAdder[A](x: A, y: A)(using adder: Adder[A]): A = adder.add(x, y)
        println(genericAdder(1, 1))

根据导入givens的文档( https://oreil.ly/hnsz6 ),这种新的导入语法有两个好处:

  • 与Scala 2相比,它更清楚范围内的 given 是来自哪里。
  • 它可以在不导入其他东西的情况下导入所有的 given

请注意,这两个 import 语句可以合并为一个:

    import Adder.{given, *}

也可以按其类型导入 given 的值:

    object Adder:
        trait Adder[T]:
            def add(a: T, b: T): T
        given Adder[Int] with
            def add(a: Int, b: Int): Int = a + b
        given Adder[String] with
            def add(a: String, b: String): String =
                s"${a.toInt + b.toInt}"

    @main def givenImports =
        // 当放在不同的行上时,import的顺序是很重要的。
        import Adder.*
        import Adder.{given Adder[Int], given Adder[String]}

        def genericAdder[A](x: A, y: A)(using adder: Adder[A]): A = adder.add(x, y)

        println(genericAdder(1, 1))       // 2
        println(genericAdder("2", "2"))   // 4

在这个例子中,given 的导入也可以这样指定:

    import Adder.{given Adder[?]}

或者这样:

    import Adder.{given Adder[_]}

关于所有最新的 import 用法,请参阅导入Givens的文档( https://oreil.ly/hnsz6 )。

另见

  • 关于givens的更多细节,请参阅Scala 3关于 given 实例的文档( https://oreil.ly/4wqhm )。
  • 请参阅Scala 3关于导入 given 的文档( https://oreil.ly/hnsz6 ),以了解导入 given 的更多细节。
  • Scala 3关于上下文抽象的这一页( https://oreil.ly/Qneiu )详细介绍了从 implicitgiven 实例的背后动机。

23.9 使用联合类型模拟动态类型

问题

如果你有这种情况,即一个值能代表七种不同的类型之一,而不要求这些类型是类层次结构的一部分,那联合类型对你来说将会很有帮助。因为这些类型不是类层次结构的一部分,所以尽管Scala是静态类型语言,但本质上你是以动态的方式声明它们。

解决方案

在Scala 3中,一个 union type 可以是几种不同类型之一的值。联合类型可以有几种使用方式。

有一种用法是,联合类型能让我们可以实现一个函数,它的参数有可能是几种不同类型之一。例如,这个函数实现了 Perltruefalse 的定义,它接受的参数可以是 IntString类型:

    // Perl version of "true"
    def isTrue(a: Int | String): Boolean = a match
        case 0  => false
        case "0" => false
        case "" => false
        case _ => true

尽管 IntString 类型不共享任何直接的父(超)类型 —— 至少在你把层次结构向上追溯到 MatchableAny 类之前是这样 —— 这是一个类型安全的解决方案。如果我试图添加一个case来测试参数与 Double 的关系,编译器很聪明的知道,并将它标记为一个错误:

    case 1.0 = > false // 错误: 这一行无法编译

在这个例子中,声明参数 aIntString 类型的,这是一种在静态类型语言中表示 动态类型 的一种方式。如果你想匹配更多的类型,直接列出更多的case即可,即使它们没有共同的父类型(除了 MatchableAny ):

    class Person
    class Planet
    class BeachBall

    // the type parameter:
    a: Int | String | Person | Planet | BeachBall

Scala 3中的改进 -- TODO 鸽子栏

在Scala 3之前,编写该函数的唯一方法是将函数的参数定义为 Any 类型,然后在匹配表达式中匹配 IntString 情况。(在Scala 2中,你可以使用 Any 类型,而在Scala 3中,你可以使用 Matchable 类型)。

在其他用法中,一个函数可以返回一个联合类型,而一个变量可以是一个联合类型。例如,这个函数返回一个联合类型:

    def aFunction(): Int | String =
        val x = scala.util.Random.nextInt(100)
        if (x < 50) then x else s"string: $x"

然后你可以将该函数的结果分配给一个变量:

    val x = aFunction()
    val x: Int | String = aFunction()

在这两种用法中,x 的类型是 Int | String,并且将包含一个 IntString 类型的值。

讨论

一个联合类型可以是几种不同类型之一的值。如示例所示,它是一种创建函数参数、函数返回值和变量的方式,可以是多种类型中的一种,而不需要该类型的传统继承形式。实际上,联合类型是提供了一种组合类型的特别方式。

将联合类型与字面(literal)类型相结合

在另一种用法中,你可以将联合类型与字面类型结合起来,形成这样的代码:

    // 使用2个字面类型创建一个联合类型
    type Bool = "True" | "False"

    // a function to use the union type
    def handle(b: Bool): Unit = b match
        case "True"  => println("true")
        case "False" => println("false")

    handle("True")
    handle("False")
    handle("Fudge")  // 错误,无法编译

    // this also works
    val t: Bool = "True"
    val f: Bool = "False"
    val x: Bool = "Fudge"  // error, won’t compile

这使得你能使用字面类型和联合类型的综合能力来创建自己的类型,这给了你更多的灵活性来编写自己的API。

另见

23.10 声明一个多种类型的组合的值

问题

你需要一种方式来声明一个值是由多种类型组合而组成。

解决方案

与联合类型允许你声明值可以是许多可能的类型之一的方式类似,相交类型(intersection types) 提供了一种特殊的方式来表示值是类型的组合。

例如,根据这些特质:

    trait A:
        def a = "a"
    trait B:
        def b = "b"
    trait C:
        def c = "c"

你可以定义一个方法,要求其参数的类型是这些类型的组合:

    def handleABC(x: A & B & C): Unit =
        println(x.a)
        println(x.b)
        println(x.c)

现在,你可以创建一个与该类型相匹配的变量,并将其传入 handleABC

    class D extends A, B, C
    val d = D()
    handleABC(d)

相交类型的一个好处是,你所创建的类实现其他类型的顺序并不重要,所以像这样的其他例子也可以:

    class BCA extends B, C, A
    class CAB extends C, A, B

    // 这里使用`new`使代码更容易阅读
    handleABC(new BCA)
    handleABC(new CAB)

讨论

相交类型允许你声明一个值是多种类型的组合。如例子所示,相交类型具有交换性,所以声明类型的顺序并不影响匹配结果。Scala 3的意图是,A & B 将用 A with B 替换。

下面是另一个例子,展示了联合类型和相交类型之间的区别:

    trait HasLegs:
        def run(): Unit
    trait HasWings:
        def flapWings(): Unit

    class Pterodactyl extends HasLegs, HasWings:
        def flapWings() = println("Flapping my wings")
        def run() = println("I’m trying to run")
        override def toString = "Pterodactyl"

    class Dog extends HasLegs:
        def run() = println("I’m running")
        override def toString = "Dog"

    // 返回一个联合类型
    def getThingWithLegsOrWings(i: Int): HasLegs | HasWings =
        if i == 1 then Pterodactyl() else Dog()

    // 返回一个相交类型
    def getThingWithLegsAndWings(): HasLegs & HasWings =
      Pterodactyl()

    @main def unionAndIntersection =

        // 联合类型
        val d1 = getThingWithLegsOrWings(0)
        val p1: HasLegs | HasWings = getThingWithLegsOrWings(1)

        // 相交类型
        val p2 = getThingWithLegsAndWings()
        val p3: HasLegs & HasWings = getThingWithLegsAndWings()

        // 这些True/NotTrue测试使用的是我的SimpleTest库。它们都评估为 `true`
        True(d1.isInstanceOf[Dog])
        NotTrue(d1.isInstanceOf[Pterodactyl])

        True(p1.isInstanceOf[Pterodactyl])
        True(p1.isInstanceOf[HasLegs])
        True(p1.isInstanceOf[HasWings])
        NotTrue(p1.isInstanceOf[Dog])

        // p2和p3是一样的,所以没有展示p3的测试
        True(p2.isInstanceOf[Pterodactyl])
        True(p2.isInstanceOf[HasLegs])
        True(p2.isInstanceOf[HasWings])
        True(p2.isInstanceOf[HasLegs & HasWings])        

正如源代码中的注释所提到的,@main 方法中的所有这些测试都评估为 true,证实了这些类型按照预期工作。而且正如这些例子所展示的,临时使用联合和相交类型使得Scala更像是一种动态的编程语言。

另见

23.11 控制类如何使用跨界相等性进行比较

问题

在Scala 2中,以及默认的Scala 3中,任何自定义对象都可以与任何其他对象进行比较:

    class Person(var name: String)
    class Customer(var name: String)

    val p = Person("Kim")     // Scala2中,`Person`和`Customer`前面需要`new`
    val c = Customer("Kim") 
    p == c

你可能会收到一条警告信息,大意是“这个比较将永远是false”,但像这样的代码仍然可以编译。

在这种时候,为了防止可能出现的错误,你想在Scala 3中限制对象之间进行比较的方式。

解决方案

要想完全禁止不同类型之间的相互比较,请通过以下两种方式之一启用Scala 3的 严格的相等性比较(strict equality) 功能:

  • 在你想控制相等比较的文件中导入 scala.language.strictEquality
  • 使用 -language:strictEquality 命令行选项,在所有代码中的启用严格的相等性比较。

之后,使用Scala 3 CanEqual 类型来控制哪些实例可以被比较。

导入scala.language.strictEquality

这个例子展示了如何通过导入 strictEquality 设置来禁止对从不同类创建的对象进行比较:

    import scala.language.strictEquality

    case class Dog(name: String)
    case class Cat(name: String)

    val d = Dog("Fido")
    val c = Cat("Morris")

    // 如果启用了strictEquality,这行代码就会无法编译
    println(d == c)

该代码的最后一行导致了编译器错误“Values of types Dog and Cat cannot be compared with == or !=.”

请注意,这个设置是非常有局限性的。启用 strictEquality 后,你甚至不能比较同一自定义类型的两个实例:

    case class Person(name: String)

    scala> Person("Ken") == Person("Ken") 
    1 |Person("Ken") == Person("Ken")
      |Values of types Person and Person cannot be compared with == or !=

你必须启用相等比较

在启用了 strictEquality 之后,你只能比较同一个类的两个实例,这是通过确保自定义的类派生了Scala 3的 CanEqual 类型类来实现的:

    import scala.language.strictEquality
    case class Person(name: String) derives CanEqual

    // 编译正常,结果为 `true`
    Person("Ken") == Person("Ken")

关于使用 CanEqual 的更多细节,请参阅下一小节。

讨论

这个示例和下个示例都是与类型安全有关的。因为Scala是一种类型安全的语言,所以这些示例的目标是利用类型安全来消除编译时的潜在的错误。

23.12 使用CanEqual类型族限制相等比较

问题

前面的示例展示了如何禁用不同类型之间的比较。现在你想启用一个自定义类型的两个实例之间的比较,或不同类型的两个实例之间的比较。

解决方案

当你使用前面小节中描述的 strictEquality 设置时,你甚至不能比较同一个类的两个实例:

    import scala.language.strictEquality
    case class Person(name: String)

    Person("Al") == Person("Al")
    // error: Values of types Person and Person cannot be compared with == or !=

假设你已经用 strictEquality 设置禁用了自定义类型的比较,然而你想使自定义类的实例能够相互比较,现在有两个可能的解决方案:

  • 让类派生出 CanEqual 类型。
  • 使用 CanEqual 的一个 given 的方法来完成同样的事情。

派生CanEqual

第一个解决方案很简单:只要在类定义的末尾添加 derives CanEqual

    case class Person(name: String) derives CanEqual

现在, Person 之间的比较就能正常工作:

    import scala.language.strictEquality
    Person("Al") == Person("Al")      // 工作正常,结果为`true`
    Person("Joe") == Person("Fred")   // false

given+CanEqual的方式

第二种方法是使用 given 的语法来完成同样的结果:

    case class Person(name: String)
    given CanEqual[Person, Person] = CanEqual.derived

given 的代码指出,你要允许两个 Person 类型相互比较,所以这个代码仍然编译正常:

    import scala.language.strictEquality
    Person("Al") == Person("Al")   // 工作正常,结果为`true`

这种方法比第一种方法更灵活,因为你可以把它应用于对象以及类,而且你还可以用它来声明你希望能够比较两种不同的类型。

例如,想象一下这样一种情况:你有一个 Customer 类和一个 Employee 类。如果你是一个巨大的商店,你可能想知道一个顾客是否也是一个雇员,这样你就可以给他们一个折扣。因此,你想让 Customer 实例与 Employee 实例进行比较,所以你要写一些这样的代码:

    import scala.language.strictEquality

    case class Customer(name: String):
        def canEqual(a: Any) = a.isInstanceOf[Customer] || a.isInstanceOf[Employee]
        override def equals(that: Any): Boolean =
            if !canEqual(that) then return false
            that match
                case c: Customer => this.name == c.name
                case e: Employee => this.name == e.name
                case _ => false

    case class Employee(name: String):
        def canEqual(a: Any) = a.isInstanceOf[Employee] || a.isInstanceOf[Customer]
        override def equals(that: Any): Boolean =
            if !canEqual(that) then return false
            that match
                case c: Customer => this.name == c.name
                case e: Employee => this.name == e.name
                case _ => false

    given CanEqual[Customer, Customer] = CanEqual.derived
    given CanEqual[Employee, Employee] = CanEqual.derived
    given CanEqual[Customer, Employee] = CanEqual.derived
    given CanEqual[Employee, Customer] = CanEqual.derived

    val c = Customer("Barb S.")
    val e = Employee("Barb S.")
    c == c  // true
    e == e  // true
    c == e  // true
    e == c  // true
    Customer("Cheryl") == Employee("Barb")  // false

我在写这段代码时走了一些捷径,但关键是:

  • strictEquality 设置限制了哪些类型可以被比较。
  • CustomerEmployee 类中的 equals 方法允许自己被比较。
  • 前两行 given 允许客户与客户进行比较,雇员与雇员进行比较。
  • 后面的两行 given 允许客户与雇员进行比较,雇员与客户进行比较。
  • 代码的其余部分展示了一些等级的比较例子。

注意,如果你想把一个客户和一个雇员( c == e )以及一个雇员和一个客户( e == c )进行比较,你必须包括最后两个 CanEqual 表达式:

    given CanEqual[Customer, Employee] = CanEqual.derived  // Customer to Employee
    given CanEqual[Employee, Customer] = CanEqual.derived  // Employee to Customer

关于编写 equals 方法的正确方法,请参阅5.9小节,“定义equals方法(对象相等性)”。

比较的自反性和对称性 -- TODO 鸽子栏

    当我们说一个类型的顾客可以与另一个顾客比较时,这是一个 reflexive 属性(例如,a == a )。当我们说一个顾客可以和一个雇员比较,而一个雇员可以和一个顾客比较时,这是一个 symmetric 属性(例如,a == b b == a)。

讨论

和前面的小节所展示的一样,这个方法也是关于类型安全的。如果你只想为要比较的类型,开启类型间的比较,首先你要启用 strictEquality,如前面的示例所示。这种方法会在编译时检查比较的类型,并可以产生错误提示,因此,如果是没有启用的类型,则不可能进行比较。

注意,通过使用 given 的方法,你可以为那些没有控制类型比较的类型,启用类型间的比较。例如,最初这种比较是不允许的:

    2 == "2"   // error: Values of types Int and String cannot be
               // compared with == or !=

但是,当你声明你想允许 StringInt 进行比较时,编译器就允许它们进行比较了:

    given CanEqual[String, Int] = CanEqual.derived
    given CanEqual[Int, String] = CanEqual.derived

    2 == "2"   // false, 但是编译正常
    "2" == 2   // 也是 false