1. 概述

在本教程中,我们将探讨 Scala 泛型在实现容器类时的优势。我们将看到 Scala 泛型如何在保证类型安全的同时,帮助我们遵循 DRY 原则

我们会逐步讲解如何编写 泛型类 和泛型方法,并探索 Scala 标准库中常见的泛型类型。

2. 容器类

使用泛型最常见的场景就是容器类。

假设我们要设计一个魔术师帽子的类。这个帽子里可能装着一只可爱的小兔子,也可能只是一个苹果,甚至其他任何东西。我们可以用几种不同的方式来实现它。

2.1. 针对特定类型的实现

我们希望编译器能保证帽子里的“魔法物品”类型,这样我们就可以放心地使用它。可以为特定类型写一个类:

case class AppleMagicHat(magic: Apple)

def run(): Unit = {
  val someHat = AppleMagicHat(Apple("gala"))
  val apple: Apple = someHat.magic
  println(apple.name)
}

此时,编译器会确保 someHat 中的魔法物品只能是 Apple 类型的实例。

但如果我们也有一种叫 Rabbit 的类型:

case class Rabbit(cuteness: Int)

为了支持 Rabbit 类型,我们就得再写一个 RabbitMagicHat 类。这种方式会导致大量结构相似但类型不同的容器类,明显违背了 DRY 原则。✅

所以,我们尝试使用一个通用的容器类来解决这个问题。

2.2. 不带类型安全的通用实现

我们可以定义一个通用的魔术帽类,它可以容纳任何类型的对象:

case class MagicHat(magic: AnyRef)

val someHat = MagicHat(Rabbit(2))
val apple: Apple = someHat.magic.asInstanceOf[Apple]
println(apple.name)

这里使用 AnyRef 是因为帽子里的对象可能是多种类型之一。在 Scala 中,所有对象都继承自 AnyRef

但这种方式很容易出错。比如上面代码中,我们在第 4 行将 Rabbit 强转为 Apple,会抛出类型转换异常。❌

我们希望有一种类型安全的实现方式,只需要一个容器类就能搞定所有类型。 这就是 Scala 泛型的作用。

3. 使用泛型类实现类型安全

在 Scala 中定义类时,我们可以指定类型参数。使用方括号包裹这些类型参数。

例如,我们可以定义 *class Foo[A]*。这里的占位符 A 可以在类体中表示类型。在使用泛型类时,调用者需要指定具体的类型。

与值参数不同的是,类型参数通常只是一个字母,按照惯例,从字母 A 开始。

双参数的例子是:*class Bar[A, B]*。

使用类型参数的好处是我们能明确知道帽子里装的是什么类型,编译器会帮我们做类型检查:

case class MagicHat[A](magic: A)

val rabbitHat = MagicHat[Rabbit](Rabbit(2))
val rabbit: Rabbit = rabbitHat.magic
println(rabbit.cuteness)

这里我们定义了一个类型参数 A,在第 3 行调用构造函数时指定了类型为 Rabbit

现在,MagicHat[Rabbit] 提供了和我们专门为 Rabbit 写一个类一样的类型安全保障。而且我们只写了一个 MagicHat 类,也无需进行强制类型转换。✅

4. Scala 标准库中的泛型示例

理解泛型类使用场景的最好方式之一就是看 Scala 标准库中的例子。

大多数 Scala 泛型类都是集合类,比如不可变的 ListQueueSetMap,或者它们的可变版本,还有 Stack

集合是零个或多个对象的容器。也有一些不太明显的泛型容器,比如:

  • Option:可以包含零个或一个对象;
  • Try:包含一个值或一个 Throwable
  • Future:表示异步操作完成后可能返回的值。

5. 泛型方法

当我们编写接收泛型输入或返回泛型值的 Scala 方法时,方法本身可以是泛型,也可以不是。

5.1. 声明语法

声明泛型方法与声明泛型类非常相似。我们依然使用方括号包裹类型参数

方法的返回值也可以是参数化的:

def middle[A](input: Seq[A]): A = input(input.size / 2)

这个方法接收一个包含某种类型元素的 Seq,并返回中间位置的元素:

val rabbits = List[Rabbit](Rabbit(2), Rabbit(3), Rabbit(7)) 
val middleRabbit: Rabbit = middle[Rabbit](rabbits)

再来看一个多参数的例子:

def itemsAt[A, B](index: Int, seq1: Seq[A], seq2: Seq[B]): (A, B) = (seq1(index), seq2(index))

该方法从两个 Seq 中提取指定索引的元素,返回一个元组:

val apples = List[Apple](Apple("gala"), Apple("pink lady"))
val items: (Rabbit, Apple) = itemsAt[Rabbit, Apple](1, rabbits, apples)

⚠️ 注意:以上示例未处理边界情况(如空列表),不适合直接用于生产环境。但它们很好地展示了类型参数如何帮助我们控制参数和返回值的类型。

5.2. 在非泛型方法中使用泛型类

使用泛型类时,我们并不总是需要写泛型方法。例如,一个接收两个任意类型的 List 并返回它们总长度的方法可以这样定义:

def totalSize(list1: List[_], list2: List[_]): Int

这里的 _ 表示我们不关心 List 中具体是什么类型。该方法没有类型参数,调用时也无需指定类型:

val rabbits = List[Rabbit](Rabbit(2), Rabbit(3), Rabbit(7))
val strings = List("a", "b")
val size: Int = totalSize(rabbits, strings)

在这个例子中,调用方法时并未指定任何类型。

6. 上界(Upper Type Bounds)

为了说明 Scala 的上界类型,我们来“重新发明轮子”——编写一个简单的泛型函数,用于查找集合中的最大元素:

def findMax[T](xs: List[T]): Option[T] = xs.reduceOption((x1, x2) => if (x1 >= x2) x1 else x2)

虽然逻辑看起来没问题,但 >= 操作符在泛型类型 T 上未定义,因此该函数无法编译。

我们知道 Ordered[T] 是定义比较操作的地方。我们需要告诉编译器,TOrdered[T] 的子类型。

Scala 的上界类型正好能帮我们做到这一点:

def findMax[T <: Ordered[T]](xs: List[T]): Option[T] = xs.reduceOption((x1, x2) => if (x1 >= x2) x1 else x2)

通过 T <: Ordered[T] 语法,我们告诉编译器:Ordered[T] 是类型参数 T 的父类型。 也就是说,xs 中的每个元素都必须是 Ordered[T] 的子类型。这样我们就可以安全地使用 >= 和其他比较操作了。✅

7. 下界(Lower Type Bounds)

我们借用《Programming in Scala》书中的一个例子:

class Queue[+T](private val leading: List[T], trailing: List[T]) {
  def head(): T = // 返回第一个元素
  def tail(): List[T] = // 除了第一个元素的所有元素
  def enqueue(x: T): Queue[T] = // 添加到队列尾部
}

这里我们用两个列表来表示一个队列。

假设我们需要使用 *Queue[String]*。为了使 Queue[String] 成为 Queue[Any] 的子类型,我们使用了协变注解 *[+T]*。但这时 enqueue 方法会编译失败:

covariant type T occurs in contravariant position in type T of value x
  def enqueue(x: T): Queue[T] = new Queue(leading, x :: trailing)

类型参数 [+T] 是协变的,但我们把它用在了逆变位置(函数参数),编译器会报错。

一种解决方式是使用下界类型:

def enqueue[U >: T](x: U): Queue[U] = new Queue(leading, x :: trailing)

*这里我们定义了一个新的类型参数 UU >: T 表示 UT 的父类型。* 现在我们可以使用 enqueue 方法了:

val empty = new Queue[String](Nil, Nil)
val stringQ: Queue[String] = empty.enqueue("The answer")
val intQ: Queue[Any] = stringQ.enqueue(42)

当我们向 Queue[String] 中添加一个 Int 时,编译器会自动推断出 StringInt 的最近公共父类型,返回类型变为 *Queue[Any]*。

8. 总结

本文中我们展示了如何使用泛型类在保证类型安全的同时避免为每种类型都写一个容器类。

我们讲解了泛型类的声明方式,并通过标准库中的例子加深理解。

最后还比较了泛型方法与非泛型方法的声明方式。

一如既往,本文中的示例代码可以在 GitHub 上找到。


原始标题:Basics of Generics in Scala