1. 引言

在本教程中,我们将深入探讨 Scala 中的 类型类(Type Class) 概念。类型类是一种强大的编程范式,在函数式编程中被广泛使用。它最初是在 Haskell 中提出的,用于实现 特设多态(ad-hoc polymorphism)。虽然 Scala 本身没有原生支持类型类,但我们可以借助一些内置特性,比如 特质(traits)隐式类(implicit classes),来实现类似的功能。

2. 什么是类型类?

简单来说,类型类是一组满足某个契约(contract)的类型,这个契约通常由一个 trait 来定义。通过类型类,我们可以在不修改原有函数代码的前提下,让函数具备更强的特设多态能力。这种灵活性正是类型类模式的最大优势。

更正式地说:

类型类是一种类型系统构造,用于支持特设多态。它是通过给参数化多态类型中的类型变量添加约束来实现的。这种约束通常涉及一个类型类 T 和一个类型变量 a,意味着 a 只能被实例化为支持与 T 关联的重载操作的类型。

为了更好地理解这个定义,我们先解释一下其中的关键术语:

  • 类型系统(Type System):这是编译器中的一个逻辑系统,用于为变量、表达式或函数等编程结构分配“类型”这一属性。例如 IntStringLong 都是我们常见的类型。
  • 特设多态(Ad-hoc Polymorphism)和参数化多态(Parametric Polymorphism):这部分内容我们在 Scala 中的多态性 一文中已有介绍。

在接下来的章节中,我们将通过具体示例来进一步理解类型变量及其约束。

3. 示例问题

我们先构造一个简单的例子,来演示如何定义和使用类型类。假设我们要为一个学校信息系统设计一个打印机制,用于展示若干对象的信息。

一开始,我们只有 StudentId 这个类型,它封装了整数形式的学生 ID,以提升类型安全性。但随着时间推移,我们可能会新增 StaffIdScore 等数据封装类型,而我们不希望每次新增类型时都修改原有的打印逻辑。

此时,类型类模式的灵活性就派上用场了。我们先定义这些类型:

case class StudentId(id: Int)
case class StaffId(id: Int)
case class Score(s: Double)

有了这些类型,我们就可以开始构建类型类来解决打印问题了。

4. 定义类型类 T

在这一节中,我们将定义类型类,即定义一个契约或约束:

trait Printer[A] {
  def getString(a: A): String
}

你可能会疑惑:为什么我们只定义了一个 trait,就称之为类型类了?其实,类型类不仅仅是一个 trait,而是在特定上下文中使用 trait 的一种方式 —— 即“类型类构造器模式”。单独使用 trait 并不构成类型类。

回顾之前的定义:这是通过给参数化多态类型中的类型变量添加约束来实现的。在我们的例子中,上面的 trait 是一个参数化多态类型,因为它使用了未绑定的类型参数 A,这意味着在继承它时我们可以用任何类型来替代 A

我们将在下一节中定义具体的类型变量。

5. 定义打印函数

我们使用类型类的目的,是为了实现特设多态。因此,我们定义一个函数来使用这个类型类:

def show[A](a: A)(implicit printer: Printer[A]): String = printer.getString(a)

每当我们需要以人类友好的方式展示数据对象时,都可以调用这个函数。但目前它还不能打印任何内容,因为我们还没有为它定义任何类型变量(即定义中提到的 a)。

注意,这个方法有两个参数列表。第一个列表是普通的函数参数(虽然是抽象的),第二个参数列表是带有 implicit 关键字的 printer 参数。这部分内容在 隐式类 一文中已有详细介绍。

6. 定义类型变量 a

再次引用类型类的定义:***a 只能被实例化为支持与 T 关联的重载操作的类型***。

在我们的例子中,这意味着类型变量 a 必须是 Printer 的子类型,或者直接实现它,以便可以重载 getString 方法。此外,由于 show 方法期望的是隐式参数,因此类型变量也必须是隐式的:

implicit val studentPrinter: Printer[StudentId] = new Printer[StudentId] {
  def getString(a: StudentId): String = s"StudentId: ${a.id}"
}

现在我们就可以调用 show 方法来打印 StudentId 类型了:

it should "print StudentId types" in {
  val studentId = StudentId(25)

  assertResult(expected = "StudentId: 25")(actual = show(studentId))
}

7. 扩展类型类

到目前为止,我们已经实现了一个可用的类型类。在本节中,我们将看到类型类的真正威力:在不修改原有函数的前提下,扩展其支持的类型

我们之前提到,类型类模式能让我们实现特设多态,也就是说,我们可以在不改动 show 方法的情况下,让它支持更多类型。

我们通过添加新的类型变量来实现这一点。现在我们希望 show 方法也能支持 StaffIdScore 类型,使下面的测试能够通过:

it should "custom print different types" in {
  val studentId = StudentId(25)
  val staffId = StaffId(12)
  val score = Score(94.2)

  assertResult(expected = "StudentId: 25")(actual = show(studentId))
  assertResult(expected = "StaffId: 12")(actual = show(staffId))
  assertResult(expected = "Score: 94.2%")(actual = show(score))
}

现在我们来定义这些类型变量。通常,我们会将它们放在类型类的伴生对象中(这也是为什么有些人称类型类模式为 隐式对象 的原因):

object Printer {
  implicit val studentPrinter: Printer[StudentId] = new Printer[StudentId] {
    def getString(a: StudentId): String = s"StudentId: ${a.id}"
  }

  implicit val staffPrinter: Printer[StaffId] = new Printer[StaffId] {
    def getString(a: StaffId): String = s"StaffId: ${a.id}"
  }

  implicit val scorePrinter: Printer[Score] = new Printer[Score] {
    def getString(a: Score): String = s"Score: ${a.s}%"
  }
}

✅ 完成!以后每当我们需要支持新的类型时,只需在 Printer 的伴生对象中添加一个新的隐式值即可。

8. 总结

在本文中,我们探讨了函数式编程中的类型类模式在 Scala 中的实现方式。

类型类是一种非常强大的工具,它不仅提升了代码的可复用性,还能在不修改原有逻辑的前提下扩展功能,是 Scala 函数式编程的重要组成部分。

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


原始标题:Type Classes in Scala

« 上一篇: Scala地图指南
» 下一篇: Slick简介