变化(variance)

大体上说,允许自身元素变化的类型不应该是协变的。

C[T]是一个参数化的类型,AB类型满足A <: B。总体上,C[A]C[B]间有三种可能的关系:

  • C[A] <: C[B]C协变
  • C[A] >: C[B]C逆变(contravariant)
  • C[A]C[B]互不是对方的子类型:C不变(nonvariant)

Scala允许通过注解类型参数来声明类型的变化:

1
2
3
class C[+A] { … } // C is covariant
class C[-A] { … } // C is contravariant
class C[A] { … } // C is nonvariant

练习:假设有两个函数类型

1
2
type A = IntSet => NonEmpty
type B = NonEmpty => IntSet

那么依据里氏替换原则,它们的关系应该是什么?

答案是A <: B

函数的类型规则

通常,函数有下面的子类型规则:

如果A2 <: A1B1 <: B2,则A1 => B1 <: A2 => B2

所以说函数的参数类型是逆变的,而返回值类型是协变的。于是Function1这个trait可以修改成:

1
2
3
4
package scala
trait Function1[-T, +U] {
def apply(x: T): U
}

变化检查

如果把Array变成一个类,update变成一个方法,则大概会是这样:

1
2
3
class Array[+T] {
def update(x: T) …
}

Scala大体上会依据下面的内容检查变化注解是否满足:

  • 协变类型参数只能出现在方法的返回值中
  • 逆变类型参数只能出现在方法的参数中
  • 不变类型参数可以在任何地方出现

可以看到Function1是满足上面这些限制的。

练习:下面的代码有什么问题?

1
2
3
trait List[+T] {
def prepend(elem: T): List[T] = new Cons(elem, this)
}

问题是`prepend不能通过变化检查。(参数不能是协变的)

解决方法可以是:

1
def prepend[U >: T](elem: U): List[U] = new Cons(elem, this)

练习:下面函数的返回值类型是什么?(已知Empty <: IntSetNonEmpty <: IntSet

1
def f(xs: List[NonEmpty], x: Empty) = xs prepend x

答案是List[IntSet]

分解(Decomposition)

假设现在要写一个算术表达式解释器,为了简单它只能处理数和它们的和。表达式可以用类的层次结构来表示,一个基trait Expr和两个子类NumberSum

可以用下面的方法来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
trait Expr {
def isNumber: Boolean
def isSum: Boolean
def numValue: Int
def leftOp: Expr
def rightOp: Expr
}
class Number(n: Int) extends Expr {
def isNumber: Boolean = true
def isSum: Boolean = false
def numValue: Int = n
def leftOp: Expr = throw new Error("Number.leftOp")
def rightOp: Expr = throw new Error("Number.rightOp")
}
class Sum(e1: Expr, e2: Expr) extends Expr {
def isNumber: Boolean = false
def isSum: Boolean = true
def numValue: Int = throw new Error(”Sum.numValue”)
def leftOp: Expr = e1
def rightOp: Expr = e2
}
def eval(e: Expr): Int = {
if(e.isNumber) e.numValue
else if (e.isSum) eval(e.leftOp) + eval(e.rightOp)
else throw new Error("Unknown expression " + e)
}

但这有个问题,如果想新加一些表达式,比如:

1
2
class Prod(e1: Expr, e2: Expr) extends Expr // e1 * e2
class Var(x: String) extends Expr // Variable 'x'

就需要在从基trait开始的所有类中添加区分方法(isXXX)和访问方法(numValue, leftOp, etc)。实际上方法的数量是平方级增长的。

类型测试和类型转换

要解决上面的问题,有一个不是办法的办法:使用类型测试和类型转换。

Scala允许使用类Any中的方法:

1
2
def isInstanceOf[T]: Boolean // 检查此对象是否符合`T`类型
def asInstanceOf[T]: T // 把此对象当做`T`类型,如果失败则抛出ClassCastException

这和Java的类似:

1
2
3
Scala Java
x.isInstanceOf[T] x intanceof T
x.asInstanceOf[T] (T) x

使用类型测试和转换的eval方法这样写:

1
2
3
4
5
6
7
def eval(e: Expr): Int =
if (e.isInstanceOf[Number])
e.asInstanceOf[Number].numValue
else if (e.isInstanceOf[Sum])
eval(e.asInstanceOf[Sum].leftOp) +
eval(e.asInstanceOf[Sum].rightOp)
else throw new Error("Unknown expression " + e)

这种方法的优点是不再需要区别方法,缺点是代码抽象级别低且有潜在的不安全因素。

面向对象分解

也可以面向相对象的方法,这样写:

1
2
3
4
5
6
7
8
9
trait Expr {
def eval: Int
}
class Number(n: Int) extends Expr {
def eval: Int = n
}
class Sum(e1: Expr, e2: Expr) extends Expr {
def eval: Int = e1.eval + e2.eval
}

但是如果现在想显示表达式的值,就要在每个子类中定义相应的新方法。并且如果想化简表达式,比如:

1
a * b + a * c -> a * (b + c)

上面的方法是做不到的。因为这不是一个局部化简,不能被概括在单个对象的方法里。

模式匹配(pattern matching)

注意到:测试和访问方法的唯一目的是逆向类的构造过程:

  • 用了哪一个子类?
  • 构造器的参数是什么?

这种情况在函数式语言中很常见,包括Scala。Scala自动化了这个过程。

Case Class

一个case class的定义和普通的类定义类似,除了前面有一个修饰符case。比如:

1
2
3
trait Expr
case class Number(n: Int) extends Expr
case class Sum(e1: Expr, e2: Expr) extends Expr

这也隐式上定义了含有apply方法的同伴对象:

1
2
3
4
5
6
object Number {
def apply(n: Int) = new Number(n)
}
object Sum {
def apply(e1: Expr, e2: Expr) = new Sum(e1, e2)
}

所以可以用Number(1)替代new Number(1)

模式匹配是C/Java中的switch在类层次上的推广。在Scala中用match关键字。比如:

1
2
3
4
def eval(e: Expr): Int = e match {
case Number(n) => n
case Sum(e1, e2) => eval(e1) + eval(e2)
}

匹配语法

  • match后跟一系列的casepat => expr
  • 每个case有一个表达式expr和一个模式pat
  • 如果所有模式均不匹配选择器的值,则会抛出MatchError异常

模式的格式

模式由下面的构成:

  • 构造器,比如NumberSum
  • 变量,比如e1e2
  • 通配符,_
  • 常量,比如1true

变量始终由小写字母开头,同名变量只能在模式中出现一次。

常量由大写字母开头,除了nulltruefalse是小写。

求值匹配表达式

1
e match { case p1 => e1 … case pn => en }

e会匹配第一个符合的模式p,整个匹配表达式会被重写成右边的形式,模式中的变量也会被替换为相应的部分。

  • 一个构造器模式C(p1, …, pn)匹配所有由p1, …, pn参数构造的C类型的值
  • 一个变量模式x匹配任何值,并且绑定到那个值
  • 一个常量模式c匹配和c相等的值(==

例子:

假设eval函数为:

1
2
3
4
5
def eval(e) = e match {
case Number(n) => n
case Sum(e1, e2) =>
eval(e1) + eval(e2)
}

则匹配过程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
eval(Sum(Number(1), Number(2)))
->
Sum(Number(1), Number(2)) match {
case Number(n) => n
case Sum(e1, e2) => eval(e1) + eval(e2)
}
->
eval(Number(1)) + eval(Number(2))
->
Number(1) match {
case Number(n) => n
case Sum(e1, e2) => eval(e1) + eval(e2)
} + eval(Number(2))
->
1 + eval(Number(2))
->>
3

eval写成方法也是可以的:

1
2
3
4
5
6
trait Expr {
def eval: Int = this match {
case Number(n) => n
case Sum(e1, e2) => e1.eval + e2.eval
}
}

面向对象分解和模式匹配都是不错的方法,在使用时应根据具体情境选择。如果扩展上更多的是创建新子类,则面向对象分解更适合;如果扩展上更多的是创建新方法,那么模式匹配更有优势。