<
Scala 类型系统01:从参数化类型讲起
>
上一篇

Scala 类型系统02:抽象类型
下一篇

Scala 类型系统预热

我们都知道,如果方法带有类型 A,那么传入 A 的子类 B也是有效的,Scala 中,String 是 AnyRef 的子类,那么假设一个方法带有的参数类型为 List[AnyRef],它可以传入 List[String] 吗?换句话说,在这个方法定义中,List[String] 可以视为 List[AnyRef] 的子类么?

1 参数化类型

Java 和 Scala 均支持协变,逆变和非转化类型,在 Scala 中,转化行为的定义是类型声明的一部分,我们使用变异标记来控制类型转化。

Java Scala 描述
+T ? extend T 协变(可以用[子类]代替[父类])
-T ? super T 逆变(可以将[子类]视为[父类]的父类)
T T 非转化(只能使用给定的[类型])
class W[+A] {...} // 协变
class X[-A] {...} // 逆变
class Y[A] {...}  // 非转化
class Z[-A, +B, +C] {...} // 混合

变异标记只有在类型生命中的类型参数里才有意义,对参数化的方法没有意义,因为该标记影响的是子类继承行为,而方法没有子类。

我们约定使用下面方式作为泛型参数:
T - 用于表示类型
E - 用于表示元素
K - 键
V - 值
N - 表示数字

2 逆变

协变和非转化比较符合常识,那么什么时候该用逆变呢?

我们以这样一个例子来描述逆变的实际适用情况:

1.8 以前的 Java 并不支持可变参数,而 Scala 中往往会出现向一个方法传入带有任意数量参数的匿名函数这样的情况,为了让 Scala 能在早期版本 Java 中运行,Scala 对定义了含有 0 个参数到 22 个参数的 23 个类型去解决任意数量参数的问题。

以 scala.Fuction1 为例

// 以下是 Function1 的声明
trait Function2[-T1, +R] extends AnyRef

最后一个参数类型 +R 是返回类型,它是协变的。开头的类型参数是逆变的,在 Function2 中,开头的两个类型参数 T1 和 T2 也是逆变的,同理,其他 FunctionN 特征中,对应参数的类型参数也都是逆变的,返回值是协变的。

我们以 Function1 为例,定义一个基本的映射关系:

// 定义
class ASuper
class A extends ASuper
class ASub extends A

// 继承关系为:
ASuper > A > ASub

var func: A => A = (a: A)       => new A
                 = (a: ASuper)  => new A
                 = (a: A)       => new ASub
                 = (a: ASuper)  => new ASub
                 = (a: ASub)    => new ASuper  // 编译出错

我们定义了一个输入 C 返回 C 的函数。

  1. 如果实际的函数类型为(a: ASuper) => new A,该函数不仅可以接受任何 A 类值作为参数,也可以处理 A 的父类型的实例,或其父类型的其他子类型的实例(如果存在的话),那么只传入 A 的实例时永远不会超过其定义的范围。从这种意义上这样定义比我们需要的更加宽容
  2. 同理,实际的函数类型为(a: A) => new ASub时,对于 A 类型来说也是安全的,因为调用方能够处理 A 的实例,也就一定能处理 ASub 的实例。从这种意义上,这样定义比我们需要的更加严格
  3. 示例的最后一行同时打破了关于输入和输出类型的两个规则。我们尝试在(a: ASub) => new ASuper情况下进行处理:
    • 在这种情况下,实际的函数只知道如何处理 ASub 实例,但调用者对此一无所知,比如假设实际函数通过调用 ASub 中有而 A 中没有的方法来完成处理,当传入 A 类型对象时就会出现意外而导致失败。
    • 同样的,实际函数返回一个 ASuper 实例,会超过调用者预期的返回值范围,比如假设预期返回 String 类型,而实际返回了 Object 类型。

这也就解释了为什么FunctionN 要设计成参数逆变而返回值协变。同时这也是逆变的典型应用情况。

3 可变参数化类型

而对于可变类型,只允许非变异行为:

scala> class ContainerPlus[+A](var value: A)
<console>:34: error: 协变类型 A 出现在逆变位置 发生在 value_= 方法中
            class ContainerPlus[+A](var value: A)
                  ^
scala> class ContainerMinus[-A](var value: A)
<console>:34: error: 逆变类型 A 出现在协变位置 发生在 value 方法中
            class ContainerMinus[-A](var value: A)
                                         ^

我们将这个类以显式方法重写:

 class ContainerPlus[+/-A](var a: A) {
       private var _value: A = a                  // (1)
       def value_=(newA: A): Unit = _value = newA // (2)
       def value: A = _value                      // (3)
}

假设协变有效:

val ia1 :ContainerPlus[A] = new ContainerPlus(new ASub) // 如果协变有效
ia1.value = new A // 根据类型声明,这一行有效,然而如同 val a: String = new Object() 一样,这是不该被允许的(协变类型出现在了逆变位置)

假设逆变有效:

val ia2 :ContainerPlus[A] = new ContainerPlus(new ASuper) // 如果逆变有效
val a = ia2.value // ia2(根据定义)判断会返回 A 类型,然而实际上返回的是 ASuper 类型(逆变类型出现在了协变位置)

对于 Getter 和 Setter 方法,在读方法中处于协变位置,在写方法中处于逆变位置,对于既存在于协变位置又存在于逆变位置的情况,唯一的选择就是非变异。

4 类型构造器

反映了参数化类型创建特定类型的方式:

eg:List 是 List[String] 和 List[Int] 的类型构造器。

实际上所有的类都是类型构造器,那些不带参数的可以看做带了零个类型参数的“参数化类型”。

5 类型参数名称约定

Scala 库 / 文档中对往往会用特定的字母简化类型参数的书写,它遵循一些简单的规则:

  1. 非通用的类型参数(如:表示容器中的元素类型的类型参数),使用 A,B,T1,T2等单字母或双字母表示。此时元素类型与容器类型之间没有太紧密的联系,比如 List 中的元素使用 Int 或者 String 都不影响 List 的工作方式。
  2. 对于与容器密切相关的类型,直接使用更具有描述性的名称(比如直接使用 Int)。

6 类型边界

定义了协变或逆变的参数类型,有时可能需要指定边界,比如:自定义一种容器,要求其中元素都含有某一方法。

6.1 类型边界上界

类型边界上界是指:某一类型必须是另一类型的子类型(或该类型本身)。

定义方法为:[T <: SomeType] 表示:任意 T 都是 SomeType 的子类型(或本身)

比如Predef 默认定义的:

implicit def refArrayOps[T <: AnyRef](xs: Array[T]): ArrayOps[T] = new ArrayOps.ofRef[T](xs)
implicit def longArrayOps(xs: Array[Long]): ArrayOps[Long] = new ArrayOps.ofLong(xs)

<: 操作符表示的是其左边的类型必须派生自其右边的类型,或者两者是同一类型。是一个保留字

6.2 类型边界下界

与上界相反,类型边界下界表示某个类型必须是另一个类型的父类型(或该类型本身)。

定义方法为:[T >: SomeType] 表示:任意 T 都是 SomeType 的父类型(或本身)

比如 Option 中定义的 getOrElse 方法:

sealed abstract class Option[+A] extends Product with Serializable {
  ...
  @inline final def getOrElse[B >: A](default: => B): B = {...}
  ... 
}

如果 Option 实例是 Some[A],方法就返回 Some[A] 包含的值。否则,就对命名参数 default 求值,并将其返回。

通过以下例子,我们将解释为什么这样声明:

class Parent(val value: Int) {
  override def toString = s"${this.getClass.getName}($value)"
}
class Child(value: Int) extends Parent(value)

// (1) op1 认为自己指向 Option[Parent] 但其实指向的是 Option[Child],因为 Option[+T] 协变,所以 Option[Child] 是 Option[Parent] 的子类型
val op1: Option[Parent] = Option(new Child(1)) // Some(Child(1))
val p1: Parent = op1.getOrElse(new Parent(10)) // Child(1)

// (2) 指定其返回 None,此时的引用是 Option[Parent] 类型的
val op2: Option[Parent] = Option[Parent](null) 	// None
val p2a: Parent = op2.getOrElse(new Parent(10)) // Parent(10)
val p2b: Parent = op2.getOrElse(new Child(100)) // Child(100)

// (3) 指定其返回 None,但此时的引用其实是 Option[Child] 类型的
val op3: Option[Parent] = Option[Child](null)		// None
val p3a: Parent = op3.getOrElse(new Parent(20))	// Parent(20)
val p3b: Parent = op3.getOrElse(new Child(200))	// Child(200)

重点在于 (3) 中,在这个例子中,我们显示的传给 op3 Option[Parent] 的子类 Option[Child],因为逆变,在这种情况下,向其传入 new Parent(20) 对象能获得输出,这在我们的控制范围内。但如果这个赋值来自一个“黑盒”方法呢,我们就无法知道其真实类型是什么。

为了解决这个问题,我们要在逆变的同时,为其加入界限,即 [T >: SomeType]。

试图理解变异标记和类型边界的工作方式,我们应当从调用方的角度了解这 些类型的实例发生了什么。调用时,引用可能指向父类,但该实例其实是个子类实例。

6.3 一个特殊情况

在有些方法中,当我们像一个不可变集合添加新元素以构造一个新的集合时,其类型参数必须具有逆变行为,但传入的是协变的参数化类型。

举例:

Seq.+: 方法用于在序列头部插入新元素,并返回插入后生成的新序列。

scala> 1 +: Seq(2, 3)
res0: Seq[Int] = List(1, 2, 3)

Scaladoc 中给出的是简化的方法签名,它假定我们插入的元素与其他元素类型相同,均为 A。但方法的实际声明更通用些,这里给出了这两种声明:

def +:(elem: A): Seq[A] = {...} // 简化的签名
def +:[B >: A, That](elem: B)(
	implicit bf: CanBuildFrom[Seq[A], B, That)]): That = {...})
// 实际的方法签名,允许插入 A 及 A 的任意父类型

向 Seq[Int] 序列插入一个 Double 值:

scala> 0.1 +: res0
<console>:9: warning: a type was inferred to be `AnyVal`; this may
       indicate a programming error.
                   0.1 +: res0
                       ^
     res1: Seq[AnyVal] = List(0.1, 1, 2, 3)

(如果使用的是 2.11 版本之前的 scala,将不会看到这条警告)

Int 与插入的元素类型 Double 不同,新序列的元素类型会被推断为最近类型上限(Least Upper Bound,LUB),也就是 Int 和 Double 的最近公共父类,所以新的序列被推断为 Seq[AnyVal] 类型。

尽管这种隐式推断很方便,但得到一个如此宽泛的 LUB 类型有时会令人惊讶,因为你可能不想改变原始的类型,这就是为什么 Scala 在这种情况下会添加一个警告信息的原因。

解决方法是显式声明期望的返回值类型:

scala> val l2: List[AnyVal] = 0.1 +: res0 
l2: List[AnyVal] = List(0.1, 1, 2, 3)

这样,编译器就知道你希望得到一个更宽泛的 LUB 类型,于是警告就消失了

补充一点,实际上类型边界的上下界是可以混用的

case class C[A >: Lower <: Upper](a: A)

不过类型边界的下界必须在上界之前出现

7 上下文边界与视图边界

上下文边界:

上下文边界是在Scala 2.8.0中引入的,通常与所谓的类型类模式一起使用,这种模式是模拟Haskell类型类提供的功能的代码模式,但是更加冗长。

import math.Ordering
case class MyList[A](list: List[A]) {
  // 类型类模式下正常隐式声明
  def sortBy1[B](f: A => B)(implicit ord: Ordering[B]): List[A] =
    list.sortBy(f)(ord)
  
  // 采用上下文边界(其实是因隐式类型过于繁琐而提供的简化版本的语法)
  def sortBy2[B : Ordering](f: A => B): List[A] =
    list.sortBy(f)(implicitly[Ordering[B]])
  											^ implicity 方法用于按类型检索我们需要的隐式值 
}

val list = MyList(List(1,3,5,2,4))
list sortBy1 (i => -i)
list sortBy2 (i => -i)

sortBy2 方法签名中的类型参数:B : Ordering 被称为上下文边界(Context Bound),它暗指第二个参数列表(即隐式参数)将接受 Ordering[B] 实例。

视图边界(现已弃用):

视图边界类似于上下文边界,可以被认为是上下文边界的一个特例,可以通过以下任意方式声明:

class C[A] {
  def m1[B](...)(implicit view: A => B): ReturnType = {...}
  def m2[A <% B](...): ReturnType = {...}
}

视图可以使 A 仿佛 B 一样使用 B 的方法:

def f[A <% B](a: A) = a.bMethod

所以要求 A B 之间必须是可转化关系。

视图边界可以用上下文边界实现,尽管视图边界提供了更简洁的语法,但上下文边界更通用,因此,在 Scala 社区中出现了一些废弃视图边界的讨论 ,导致该特性已被新版本弃用

Top
Foot