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 表示类型参数,当然也可以用 T、K 等。在调用时,我们会传入具体类型,如 Int、String 等:
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)。与参数化多态不同,子类型多态限制了函数可以处理的类型范围。
下面的例子中,我们将 Circle 和 Square 定义为 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 上找到。