使用 Scala 实现基于泛型的抽象

1,476 阅读16分钟

我们总是通过多态复用或拓展代码。多态,简单来说就是 "一个对象在不同语境下表达的不同种状态"。比如,继承与重写体现了一个类在不同抽象级别时的多态,而重载实现了一个函数 / 方法在应对不同输入时的多态。而当类型本身也变得 "多态" 时,泛型 的概念就诞生了。我们可以围绕着泛型做顶层设计,甚至可以在几个看似不相关的问题域当中提取共同点,并实现统一的建模。这有助于我们用更加精简的代码编写更加通用,底层的代码库。

泛型

某些类的定义不完全由自身决定。比如,对于列表类,我们还要强调 "这是一个什么元素类型的列表"。列表内的所有元素应当一致,好方便我们妥善地区别处理 "学生列表","商品列表"。列表的定义包括了其元素的类型,不妨在这里给定一个占位符参数 E 来描述,它被称之 类型参数

在 Scala 中,类型参数使用中括号 [] 来表示,而在 Java 中则是 <>

class List[E]:
  def get(index: Int): E = ???

List[E] 是泛型 ( generic )。 List 是被泛化的类,或者称类型构造器 ( type constructor )。我们可以通过变换 [E] 类型参数的方式构造出不同的 类型:比如 List[Int]List[Double]。泛型的意义就是用一个被泛化的类定义出许许多多具体的类型。

类型参数 E 将区分 List 在装载不同元素类型时表现的多态行为。比如,get 方法的返回值将取决于 E,它的真正形式只有在构建具体实例的时候才会确定:

new List[Apple]()
new List[Fruit]()

当一个泛型被继承时,它的子类有两种选择:1) 填充类型参数;2) 传递类型参数。

abstract class Box[A]:
  def get() : A

class BigBox[A] extends Box[A]:
  override def get(): A = ???
  
class IntBox extends Box[Int]:
  override def get(): Int = ???

类似地,一些函数 / 方法自身也携带类型参数,它们俗称泛型函数 / 方法。比如:

def map[A,B](list : List[A], f: A => B) : List[B] = ???

上述的 map 转换子的目标是引入一个函数 f,将 List[A] 变换为 List[B]。而这个转换子的真实输入输出将取决于两个类型参数 AB

val xs : List[Int] = ???
val f : Int => String = ???

map[Int, String](xs, f)

当输入的 listf 的参数类型在上下文中足够明确时,Scala 编译器将有足够的线索自动实现类型推导,因此可以省去类型参数的列表。

map(juniorList, f)

Scala 3 支持函数表达式携带类型参数。写法上,它直观地表达了这样的逻辑:首先推导类型参数,其次再传入参数计算表达式。

val g: [X] => (List[X], (X, X) => X) => X = 
    [X] => (list: List[X], reduceF: (X, X) => X) => list.reduce(reduceF)
    

g[Int](List(1, 2, 3), (a, b) => a + b)

Scala 3 还在类型系统上新增了许多设计,可以移步至笔者的:Scala 3 新特性一览 - 掘金 (juejin.cn)

类型约束

我们可以通过施加上下界限定类型参数 E

  1. [E <: T1] 表示某个具体的 T1 类型是它的上界。
  2. [E >: T2] 表示某个具体的 T2 类型是它的下界。
  3. [E >: T2 <: T1] 表示 T2 作为它的下界,T1 作为它的上界。

上界 是更加常用的概念。比如在下面的 invoke() 函数中,由于已知参数 e 是继承 Animal 的类型,因此可以直接调用其 yarn() 方法。

class Animal:
  def yarn() : Unit = println("Animal yarn")
  
def invoke[E <: Animal](e : E) : Unit =
  e.yarn()

但是,只限定下界不会直接为变量 e 提供可用的线索。这里需要进一步讨论,见下方的例子:

class Animal:
  def yarn() : Unit = println("Animal yarn")

class Cat extends Animal:
  def mew() : Unit = println("Cat mew")

class Kitty extends Cat:
  override def mew() : Unit = println("kitty mew")
  def toddle() : Unit = println("kitty toddle")

class TomCat extends Cat:
  override def mew(): Unit = println("tomcat meu")

class Bird extends Animal:
  def fly() : Unit = println("bird fly")

def getAndReturn[E >: Kitty](e : E) : E = e

getAndReturn() 方法中,E 的类型推断将取决于传参的实际类型与下界 Kitty 类型的关系,同时,我们还要考虑到 Java 的动态绑定机制对方法调用的影响。

  1. 如果传入的参数和下界类型有共同的最近父类 ( 也可以就是下界本身 ),或者是该下界的直接父类,则该父类就作为 E 的类型。如:

    // 这两种情况 [E] 被推导为 Cat
    getAndReturn(new TomCat()).mew()  // 调用 TomCat 的 mew  
    getAndReturn(new Cat()).mew()     // 调用 Cat 的 mew
    
    // 在这种情况 [E] 被推导为 Animal
    getAndReturn(new Bird()).yarn()
    
  2. 如果传参是下界类型的子类,那么它会被视作下界类型的上转型对象。如:

    def getAndReturn[E >: Cat](e : E): E = e
    // 无法调用 Kitty 类的 toddle() 方法
    getAndReturn(new Kitty()).yarn()
    
  3. 如果传参和下界类型无关,那么编译器只能将 Any 作为类型推导的结果。

    // 错误,此时的类型推导为 Object
    getAndReturn(new Bird()).yarn()
    

Scala 还提供了一种独特的,借助隐式转换实现边界约束的方法,即 视图界定。在更早的 Scala 2 版本中,视图界定使用 <% 符号,但该符号在 Scala 3 中被废弃了。实际上,视图界定的本质是适配器模式,它的定义如下:假设有 [T <% U],则上下文环境中必须存在一个 T => U 的隐式转换。

class Money(val value : Int)
class Book
class Card

// Scala 3 语法, given <type> = <value>
given Money = Money(10)

// Conversion[-A, +B] 是一个函数式接口,表示由 A 到 B 的隐式转换
given Conversion[Book,Money] = _ => Money(10)
given Conversion[Card,Money] = _ => Money(20)

def compare[E](e1 : E, e2 : E)(using f : E => Money): Int =
  f(e1).value - f(e2).value

视图界定的更进一步延伸是上下文界定。

类型族与上下文界定

首先,类型族表示一组类型的集合,这些类型没有任何公共的父类,但是均满足某一个通用的概念或功能。举个例子,Ordering[T] 特质就 以非侵入的方式 描述了一个满足 "可比较性" 的类型族。任何被接口拓展的 T 都属于这个类型族的范畴。在下方的例子中,这个 "可比较" 的类型族里包含了 PetBook 类。

case class Pet(var weight : Int)
case class Book(var year : Int)

// 这里的 with 关键字只是快速实现接口的简便表示,不代表混入特质。
given Ordering[Book] with
  override def compare(x: Book, y: Book): Int = x.year - y.year

given Ordering[Pet] with
  override def compare(x: Pet, y: Pet): Int = x.weight - y.weight

// given/using 是 Scala 3 中隐式导出的写法,等价于 Scala 2 的 implicit.
def min[T](t1 : T, t2 : T)(using ctx : Ordering[T]): T = ctx.min(t1, t2)

@main def main() : Unit =
  println(min(new Book(2022), new Book(2019)))
  println(min(new Pet(20), new Pet(30)))

min 函数的 using 闭包相当于为参数类型 T 限定一个了这样的边界:它必须属于 Ordering[T] 定义的 "可比较" 的类型族,因为该函数依赖 Ordering[T] 接口提供比较功能。这种对类型约束的方式又称之为 上下文界定,虽然从形式上它和视图界定一样通过隐式变量实现,但是在概念上却存在一些不同。

上下文界定具有更加简洁的表示方法 [T : M],其中 M 表示了 T 的类型族。

def min[T : Ordering](t1 : T, t2 : T): T = summon[Ordering[T]].min(t1, t2)

T 需要多个上下文界定时,使用多个冒号进行排列,比如:[T : C1 : C2 : C3 ...]

高阶类型与类型 Lambda

类型参数还可以是携带其它类型参数的高阶类型。比如下面代码中的 C[E]

// 如果我们在最开始不关心 C 携带的类型参数,则可以写成:
// class Box[E,C[_]](val c : C[E])
class Box[E, C[E]](val c : C[E])
val box = new Box[Int,List](List())

需要注意,自 Scala 3 开始,高阶类型的通配符表示将不允许出现在类型参数定义以外的地方,在下面代码中,c 的类型表示被认为是错误的,但在 Scala 2 版本中却可以通过编译。

class Box[C[_]](val c : C[_])

见:Other Changed Features | Scala 3 Migration Guide | Scala Documentation (scala-lang.org)

类型参数 C[E]C 表达的含义显然不同。比如,我们可以使用 Map[K ,V] 代替类型参数 C,但是不能使用 Map[K, V] 表示 C[E],因为 C[E] 表明 C 只携带一个类型参数。类型 Lambda 能够解决这个问题。思路其实很简单:只要事先确定好 Map[K, V] 的其中一个类型参数就可以了。

这里通过 type 关键字声明新的类型别名:

type IntKeyMap[E] = Map[Int,E]

IntKeyMap 只携带一个类型参数,因此下面的代码是可行的:

class Box[E,C[E]](c : C[E])

// IntKeyMap 代表 C,
// IntKeyMap[String] 代表了 C[E] 的其中一种形式。
val intMap : IntKeyMap[String] = Map[Int,String]()
val box = new Box[String,IntKeyMap](intMap)

类型 Lambda 就像是在做类型的柯里化操作。在 Scala 2 中,如果要声明一个简洁的类型 Lambda ,需要用下面晦涩的语法表示:

val box = new Box[String,({type IntKeyMap[E] = Map[Int,E]})#IntKeyMap](Map[Int,String]())

该写法在 Scala 3 中得到了优化:

val box = new Box[String,[Key]=>>Map[Int,Key]](Map[Int,String]())

型变及其注意事项

简单的例子可参考官方: 型变 | Scala 3 — Book | Scala Documentation (scala-lang.org)

假设 AppleFruit 的子类,那么 List[Apple] 也应该作为 List[Fruit] 的子类吗?为了回答这个问题,这里引入 型变 的概念。型变描述了类型参数和其宿主类型的关系。在无特殊标记的情况下,类型参数是 不变 的。

class InvList[E](e : E*)
// ERROR
val invList : InvList[Fruit] = new InvList[Apple]() 

因此,InvList[Fruit]IntList[Apple] 被认为是两个独立的类型,上述变量的赋值语句是错误的。

而如果我们希望下面的 CovList[E] 类型能够顺延参数类型 E 的继承关系,那么就要将参数类型声明为 协变 的,使用 +E 作为标记。

class CovList[+E](e : E*)
val conList : CovList[Fruit] = new CovList[Apple]()

现在 CovList[Apple] 被认为是 CovList[Fruit] 的子类型,因此这条语句是正确的,conList 变量相当于是一个上转型对象。不过,当我们引入型变之后,就必须要考虑到类型安全问题。协变在以下两处地方不能使用:

首先:协变的类型参数不能用于可变的属性。举个例子:

trait Fruit
class Apple extends Fruit
class Orange extends Fruit
// 编译不通过
class Box[+E](var e : E)

@main def main() =
  val appleBox : Box[Apple] = new Box(new Apple())
  val fruitBox : Box[Fruit] = appleBox
  // 会导致错误的赋值。
  fruitBox.e = new Orange()

假设这段代码编译通过,程序最终会将一个 Orange 类型的变量赋值给实际类型为 appleBox 容器的 e 属性,但显然它是一个 Apple 类型。

其次,协变的类型参数不能出现在函数的入参列表中。这里再举一个反例:

trait Pair[+E]:
  // 编译不通过
  def calculate(x : E, y : E) : E

class IntPair extends Pair[Int]:
  def calculate(x: Int, y: Int): Int = x + y

@main def main() =
  // 协变允许这样的赋值。
  val pair : Pair[Any] = new IntPair()
  pair.calculate("S","Y")

如果假设这段代码也编译通过,由于 Java 的方法动态绑定机制,程序将会调用 IntPaircalculate() 方法处理两个字符串,我们不会得到可解释的结果。

逆变 的定义和协变相反,见下面的赋值语句。和协变相比,这种关系似乎有悖于直觉,但在一部分情况下它是必要的。

class ContraList[-E](e : E*)
val contraList : ContraList[Apple] = new ContraList[Fruit]()

我们在此先不考虑 《Programming in Scala》中提及的复杂的翻转情况,仅从里氏替换原则的角度出发,简单讨论协变,逆变应当出现的位置,这已经足够应付日常的开发需求了。

里氏替换原则是这样阐述的:如果在任何需要 U 类型的地方,都可以使用 V 类型替换,那么就可以安全地认为 V 是类型 U 的子类型。这套规则也同样适用于函数 / 方法。在那些需要函数 f 的地方,如果函数 g 满足以下俩个条件:

  1. 函数 g 接受更宽松的输入;
  2. 函数 g 产生更具体地输出;

则称函数 f 可以被函数 g 安全地替换。假定现在有一个类型 UnaryFunction[T, R],根据上面的原则,T 应当是逆变的;位于返回值的 R 应当是协变的,因此,它最终的形式是 UnaryFunction[-T, +R]

class UnaryFunction[-T, +R]:
  def apply(t : T): R = ???  

引入型变的 UnaryFunction[-T, +R] 显然要比不变的 InvariableUnaryFunction[T, R] 更加泛用。可以观察下面的例子:UnaryFunction[-T, +R] 相比之下能安全地接收更多的输入。

// PianoPlayer <: Player <: People
class People
class Player extends People
class PianoPlayer extends Player
// Piano <: Instrument <: Item
class Item
class Instrument extends Item
class Piano extends Instrument

class UnaryFunction[-T, +R]:
  def apply(t : T): R = ???  
  
class InvariableUnaryFunction[T, R]:
  def apply(t : T): R = ???

@main def main(): Unit =
  var ufunc1 : UnaryFunction[Player,Instrument] = new UnaryFunction[Player, Instrument]  // OK
  ufunc1 = new UnaryFunction[People,Piano] // OK
  
  var ifunc1 : InvariableUnaryFunction[Player,Instrument] = new InvariableUnaryFunction[Player, Instrument] // Ok
  ifunc1 = new InvariableUnaryFunction[People, Piano] // ERROR

下面的图片从生产者消费者的角度生动地总结了型变的概念:

variant.jpg

*型变翻转

选读内容,见 《 Programming in Scala 》第五版,P398。

我们现在知道了型变的类型参数必须得出现在正确的位置,这些位置我们称之为 。比如,入参位置称之为逆变点,返回值位置被称之为协变点。协变的类型参数只能出现在协变点,逆变点同理;不变的类型参数可以出现在逆变点和协变点,但反过来不成立。

当类型声明变得复杂时,型变合法性的分析也会变得十分困难。比如:

class UnaryFunction[-T, +R]:
  def apply(t : T): R = ???

class HOF[-T, +R]:
  def apply(t : T) : UnaryFunction[UnaryFunction[T, R], R] = ???

首先,编译器会替我们分析出这是不安全的型变,因此这段代码不会通过编译,问题出在 f 的类型声明上。编译器按照如下的规则对型变进行归类:

  1. 逆变点会翻转 ( flip ) 内部嵌套的参数类型的归类。
  2. 不变点和协变点不改变内部嵌套的参数类型的归类。

按照这个逻辑去分析:首先,最外层的 UnaryFunction[_, _] 出现在了返回值位置,即协变点,按照规则以及 UnaryFunction 类型本身的定义,第一个参数位置的 UnaryFunction[T, R] 处于逆变点,而第二个参数位置的 R 处于协变点。

又因内部嵌套的 UnaryFunction[T, R] 处于逆变点,它内部参数类型的归类被翻转了:内部的嵌套的 [T, R] 依次处于协变点和逆变点。

又因为 HOF[-T, +R] 定义了 T 是逆变的,R 是逆变的,所以当前的类型声明是不安全的。

*通过类型约束处理特殊情况

有些型变的类型参数可能同时出现在逆变点和协变点的位置。比如下面的 T 是协变的,但是它同时出现在了逆变点:

class Mapper[+T]:
  def apply(t : T) : Mapper[T] = Mapper[T]

这段代码显然不能编译。在这个时候,我们必须单独再对 apply 进行泛化,引入一个新的类型参数 U,并对其进行 下界 约束:

class Mapper[+T]:
  def apply[U >: T](u : U) : Mapper[U] = Mapper[U]

我们通过例子来阐述原因。

var m1 : Mapper[Instrument] = new Mapper[Piano]
val m2 = m1.apply(10)

因为 InstrumentPiano 的父类型,因此 [U >: Instrument] 一定是 [U >: Piano] 的子集,或者说,m1 最终接收的参数 u 一定是更加泛化的类型 ( 相当于等效替代了逆变的语义 )。另一方面,当 Mapper[Instrument] 接受了一个 Int 值时,根据下界约束我们还可以推断 m2 将是一个 Mapper[Any]。即,在不确定输出的边界时,程序尽可能返回更抽象的类型保证兼容性,这也是安全且合理的决策。

代数优先设计 / 类型驱动设计

泛型为我们展现了一个面向抽象类型编程的视角,更何况 Scala 本身就是一个支持高阶类型的语言。在下一阶段的 Monad 学习中,我们很快就会将这种高级抽象发挥地淋漓尽致。

到目前为止,我们已经在 Scala 中接触了各种各样的数据类型,比如 List[T]Option[T]Future[T] 等。不难发现它们均具备一些相似的模式,比如都应用了 mapflatMapfilter 这类组合子。虽然这类组合子在不同数据类型中的语义是不同的,但是代数角度来看,这些组合子的形式是统一的。如果将这些数据类型视作更加抽象的 M[_] 类型,我们或许就能够更清晰一点,见下方的代码。

trait Algebra[M[_]]:
  extension [A](m : M[A])
    def map[B](f : A => B) : M[B]
    def flatMap[B](f : A => M[B]) : M[B]
    def filter(f : A => Boolean) : M[A]

而另一个细节则是,拓展方法中的组合子最终的结果仍然是 M[_],那么这些组合子之间可以任意组合,从而构成一套连续的,复杂的函数级联,我们事实上也是这么使用的。比如,仅通过 flatMapmapfilter 操作,我们能基于普通列表实现不亚于 SQL 表达力的 API 调用:

"hello scala hello java hello python hello scala"
  .split(" ").toList
  .groupBy(word => word)
  .map((key, list) => s"key = ${key}, count = ${list.size}")
  .foreach{println}

这种特性或许能够对我们的一些函数库设计启发灵感。比如说,先尝试着建立一套精简原语集,然后利用这些原语组合出更高阶的语义 ( 这种组合称之代数 ),最后再去决定 M[_] 的具体形式。这种代数先于形式的观点和思路被称之代数设计。它适用于任何问题,我们一开始就能明确地知道什么样的输入需要什么样的组合子来进行解析,而不至于在开始就陷入细节的泥潭当中。而又由于每个代数的类型可以引导我们做出细节的设计和实现,因此也可以称由类型驱动设计。

可以移步至 基于代数式设计构建 JSON 语法解析器 - 掘金 (juejin.cn) 去了解一个实践的例子:我们先是花了较多的时间编写了一套解析器组合子原语,然后仅用了一点功夫就拼出了一个 JSON 解析器。解析器的真正类型可能会令人惊讶,但用户无需关心这一点:因为我们将上层的代数和与解析器的具体形式相分离了。