1. 引言

在这篇教程中,我们将讨论 Scala(以及更广泛的函数式编程领域)中的多态类型。

值得注意的是,像 Scala 这样的函数式编程语言具有一些在 Java 等面向对象语言中不可用的能力

这些独特能力扩展了我们可以在程序中应用的多态可能性。如果你感兴趣,可以对比我们之前写的 Java 中的多态 教程。

2. 多态是什么

简单来说,多态意味着一个对象可以有多种形态,或者说一个对象能够表现出多种行为。在计算机领域中,这表现为 使用同一个接口或方法处理不同的数据类型。更正式的定义是:

多态 是指为不同类型的实体提供统一接口,或使用单个符号表示多种不同类型的能力。

接下来,我们将探讨几种不同类型的多态。

3. 参数化多态

如果要从本节中提取一句话,那就是:参数化多态其实就是泛型,它在 Java、C# 和 Scala 等语言中广泛使用。

在 Scala 中,我们可以很容易地通过方法签名中的方括号 [...] 识别出参数化多态的方法。这些类型参数让我们可以对不同数据类型复用相同的逻辑。

我们来看一个例子。假设我们要实现一个方法,对 List 进行两两翻转。例如,输入 *List(1,2,3,4,5,6)*,输出应为 *List(2,1,4,3,6,5)*。如果长度为奇数,最后一个元素保持原位,比如 List(1,2,3,4,5) 变成 *List(2,1,4,3,5)*。

3.1. 朴素实现

def pairWiseReverseInt(xs: List[Int]): List[Int] = xs.grouped(2).flatMap(_.reverse).toList

我们为这个方法编写一个测试:

it should "reverse an even length int list" in {
    val input = List(1,2,3,4,5,6)
    val expected = List(2,1,4,3,5,6)
    val actual = pairWiseReverseInt(input)
    assert(actual == expected)
}

这个方法只适用于整数列表,无法复用于其他类型。比如,如果我们想对字符串或双精度浮点数列表做同样的操作,就必须重复编写逻辑:

def pairWiseReverseString(xs: List[String]): List[String] = xs.grouped(2).flatMap(_.reverse).toList

再写个测试验证:

it should "pair-wise reverse a string list" in {
    val original = List("a","b","c","d","e")
    val expected = List("b","a","d","c","e")
    val actual = pairWiseReverseString(original)
    assertResult(expected)(actual)
}

3.2. DRY 的实现

参数化多态通过引入类型参数来消除这种重复代码。在参数化多态中,同样的逻辑可以适用于所有类型。我们将上面的方法重构为一个通用版本:

def pairWiseReverse[A](xs: List[A]): List[A] = xs.grouped(2).flatMap(_.reverse).toList

我们使用字母 A 表示类型参数,当然也可以用 TK 等。在调用时,我们会传入具体类型,如 IntString 等:

it should "pair-wise reverse lists of any type" in {
    val originalInts = List(1,2,3,4,5)
    val expectedInts = List(2,1,4,3,5)
    val originalStrings = List("a","b","c","d","e")
    val expectedStrings = List("b","a","d","c","e")
    val originalDoubles = List(1.2,2.7,3.4,4.3,5.0)
    val expectedDoubles = List(2.7,1.2,4.3,3.4,5.0)

    assertResult(expectedInts)(pairWiseReverse[Int](originalInts))
    assertResult(expectedStrings)(pairWiseReverse[String](originalStrings))
    assertResult(expectedDoubles)(pairWiseReverse[Double](originalDoubles))
}

⚠️ 注意:Scala 编译器可以根据传入参数自动推断类型,因此我们不必显式传入类型参数。

4. 子类型多态

子类型多态的核心是可替换性,即 里氏替换原则 所定义的内容。在 Scala 中,如果一个函数的参数类型有子类型(即它至少是某个类型的父类型),我们就说这个函数体现了子类型多态。

这种类型关系通常写作 S <: T*,表示 S 是 T 的子类型;或 *T :> S,表示 T 是 S 的父类型。有时我们也称之为 包含多态(inclusion polymorphism)。与参数化多态不同,子类型多态限制了函数可以处理的类型范围。

下面的例子中,我们将 CircleSquare 定义为 Shape 的子类型。方法 printArea() 接收一个 Shape 类型的参数,但也可以正确处理其子类型:

trait Shape {
    def getArea: Double
}
case class Square(side: Double) extends Shape {
    override def getArea: Double = side * side
}
case class Circle(radius: Double) extends Shape {
    override def getArea: Double = Math.PI * radius * radius
}

def printArea[T <: Shape](shape: T): Double = (math.floor(shape.getArea) * 100)/100

添加一个测试:

"Shapes" should "compute correct area" in {
    val square = Square(10.0)
    val circle = Circle(12.0)

    assertResult(expected = 100.00)(printArea(square))
    assertResult(expected = 452.39)(printArea(circle))
}

在上面的例子中,子类型关系写作 T <: Shape,表示任何类型为 T 的参数都可以安全地用于期望 Shape 类型的上下文中。我们限制 T 只能是 Shape 的子类型。因此,我们说函数 printArea()Shape 的子类型上实现了子类型多态。

5. 特设多态

特设多态最容易理解的方式是:它像是在后台执行一个 switch 语句,但没有 default 分支。在这种情况下,编译器会根据输入类型切换不同的代码实现

✅ 参数化多态和特设多态都使用泛型,使代码能处理不同类型。区别在于,参数化多态对所有类型都执行相同的逻辑,而特设多态会根据类型执行不同的逻辑,因此有了“switch”的类比。

要理解 Scala 如何支持特设多态,有三个关键概念需要掌握:函数重载操作符重载隐式(implicits)

关于隐式类,可以参考我们的 隐式类入门。接下来我们介绍前两个概念。

5.1. 函数重载

我们来看一个 Scala 内置的特设多态例子。假设我们有一个整数列表,需要排序:

it should "sort integers correctly" in {
    val intList = List(3,5,2,1,4)
    val sortedIntList = intList.sorted
    assertResult(expected = List(1,2,3,4,5))(actual = sortedIntList)
}

我们直接调用了 sorted 方法,它知道如何对整数排序,因为整数是基本类型。但如果我们想对自定义类型排序,比如封装了学生 ID 的类:

case class StudentId(id: Int)

尽管底层类型是 Int,Scala 编译器并不知道如何排序 StudentId。按照 switch 的类比,它遇到了一个没有实现的 case,而且没有 default 分支。

我们来尝试一下:

it should "sort custom types correctly" in {
    val studentIds = List(StudentId(5), StudentId(1),StudentId(4), StudentId(3), StudentId(2))
    val sortedStudentIds = studentIds.sorted
    assertResult(
      expected = List(
        StudentId(1), 
        StudentId(2),
        StudentId(3), 
        StudentId(4), 
        StudentId(5)
      )
    )(actual = sortedStudentIds)
}

这段代码会编译失败,并提示错误:

No implicit arguments of type: Ordering[StudentId]

原来,sorted 方法需要帮助才能排序它不知道的类型。Ordering 是一个 trait,其作用类似于 Java 中的 Comparator 接口,它定义了一个 compareTo 方法,用于提供排序策略。

✅ 正确的做法是创建一个隐式类或值,类型为 *Ordering[StudentId]*,并确保它在作用域内。

由于这不是一篇关于 隐式 的文章,我们直接在测试中为 StudentId 提供一个 Ordering 实现:

    ...
    val ord: Ordering[StudentId] = (x, y) => x.id.compareTo(y.id) 
    val sortedStudentIds = studentIds.sorted(ord)
    ...

5.2. 操作符重载

操作符重载和函数重载类似。这是 根据操作数类型不同,同一个操作符具有不同实现 的机制。

我们熟悉常见操作符的语义,特别是加法 + 和减法 -。操作符重载使得以下代码都能编译并运行:

val intSum = 1990 + 10
val dblSum = 13.37 + 15.81
val strConcat = "FirstName " + "LastName"

加法操作符能同时处理整数、浮点数和字符串,是因为它在这些类型上都被重载了。编译器可以根据操作数类型推断正确的实现。

我们也可以在 Scala 中重载操作符来满足自定义需求。比如我们正在开发数学软件,是希望写成这样:

a + b * c

还是这样:

Add(a, Multiply(b, c))

虽然两者等价,但第一种写法更贴近数学表达。因此,操作符重载本质上是语法糖,让我们可以用更贴近目标领域的语法来编程

5.3. Scala 如何支持操作符重载

从技术角度来说,操作符重载得益于 Scala 对方法命名的宽松支持和灵活的方法调用语法

Scala 允许我们使用一些操作符作为方法名,比如 +-,而 Java 不允许。我们以复数为例:

case class Complex(re: Double, im: Double) {
    def + (op: Complex): Complex = Complex(re + op.re, im + op.im)
    def - (op: Complex): Complex = Complex(re - op.re, im - op.im)
    override def toString: String = s"$re + ${im}i"
}

✅ 注意:我们可以直接使用操作符作为方法名,这在上下文中更清晰地表达了意图。其次,Scala 还支持灵活的方法调用语法:

it should "add and subtract complex numbers successfully" in {
    val cmpl1 = Complex(8.0, 3.0)
    val cmpl2 = Complex(6.0, 2.0)

    val cmplSum = cmpl1.+(cmpl2)
    val cmplDiff = cmpl1 - cmpl2

    assertResult(expected = "14.0 + 5.0i")(actual = cmplSum.toString)
    assertResult(expected = "2.0 + 1.0i")(actual = cmplDiff.toString)
}

我们可以使用后缀调用(如加法),也可以将对象、方法名和参数分开写(如减法)。这两种语法在 Scala 中都是合法的。

6. 总结

本文我们介绍了 Scala 中的三种多态类型:参数化多态、子类型多态和特设多态。完整的源码可以在 GitHub 上找到。


原始标题:Polymorphism in Scala

« 上一篇: Scala数据类型指南
» 下一篇: Scala中的伴生对象