Scala型变

"There are two ways of constructing a software design. One way is to make it so simple that there are obviously no deficiencies. And the other way is to make it so complicated that there are no obvious deficiencies." -- C.A.R. Hoare

「型变(Variance)」是一个令人费解的概念,但它却是理解类型系统的重要基石。本文首先讨论型变的基本概念,深入理解型变的基本形态。然后以List, Option为例讲解型变在Scala中的应用;最后通过ScalaHamcrest的实战,加深对此概念的理解和运用。

1. 定义

1.1 术语表

英语 中文 示例
Variance 型变 Function[-T, +R]
Nonvariant 不变 Array[A]
Covariant 协变 Supplier[+A]
Contravariant 逆变 Consumer[-A]
Immutable 不可变的 String
Mutable 可变的 StringBuilder

其中,Mutable常常意味着Nonvariant,但是NoncovariantMutable分别表示两个不同的范畴。

1.2 ****形式化****

「型变(Variance)」拥有三种基本形态:协变(Covariant), 逆变(Contravariant), 不变(Nonconviant),可以形式化地描述为:

一般地,假设类型C[T]持有类型参数T;给定两个类型AB,如果满足A <: B,则C[A]C[B]之间存在三种关系:

  • 如果C[A] <: C[B],那么C是协变的(Covariant);
  • 如果C[A] :> C[B],那么C是逆变的(Contravariant);
  • 否则,C是不变的(Nonvariant)。

1.3 Scala****表示****

Scala的类型参数使用+标识「协变」,-标识「逆变」,而不带任何标识的表示「不变」(Nonvariable)。

trait C[+A]   // C is covariant
trait C[-A]   // C is contravariant
trait C[A]    // C is nonvariant

2. 准则

事实上,判定一个类型是否拥有型变能力的准则非常简单。

一般地,「不可变的」(Immutable)类型意味着「型变」(Variant),而「可变的」(Mutable)意味着「不变」(Nonvariant)。

其中,对于不可变的(Immutable)类型C[T]

  • 如果它是一个生产者,其类型参数应该是协变的,即C[+T]
  • 如果它是一个消费者,其类型参数应该是逆变的,即C[-T]

2.1 生产者

Supplier是一个生成者,它生产T类型的实例。

trait Supplier[+T] {
  def get: T
}

2.2 消费者

Consumer是一个消费者,它消费T类型的实例。

trait Consumer[-T] {
  def accept(t: T): Unit
}

2.3 函数

Function1是一个一元函数,它既是一个生产者,又是一个消费者,但它是不可变的(Immutable)。其中,入参类型为-T,返回值类型为+R;对于参数类型,函数是逆变的,而对于返回值类型,函数则是协变的。

trait Function1[-T, +R] {
  def apply(t: T): R
}

2.4 数组

Function1不同,虽然数组类型既是一个生产者,又是一个消费者。但是,它是一个可变的(Mutable)类型,因此它是不变的(Nonvariant)。

final class Array[T](val length: Int) {
  def apply(i: Int): T = ???
  def update(i: Int, x: T): Unit = ???
}

综上述,可以得到2个简单的结论。

2.5 结论1

对于不可变的(Immutable)类型:C[-T, +R, S]

  1. 逆变(Contravariant)的类型参数T只可能作为函数的参数;
  2. 协变(Covariant)的类型参数R只可能作为函数的返回值;
  3. 不变的(Nonvariable)类型参数S则没有限制,即可以作为函数的参数,也可以作为返回值。

幸运的是,Scala编译器能够完成这个约束的检查。例如,

trait Array[+A] {
  def update(a: A): Unit
}

编译器将检测到编译错误。

error: covariant type A occurs in contravariant position in type A of value a
  def update(a: A): Unit
             ^

2.6 结论2

如果T2 <: T1,且R1 <: R2,那么(T1 => R1) <: (T2 => R2)

例如,给定两个函数F1, F2

type F1 = Option[Int] => Some[Int]
type F2 = Some[Int] => Option[Int]

F1 <: F2成立。

3. 函数式的数据结构

3.1 自制Option

Option是一个递归的数据结构,它要么是Some,要么是None。其中,None表示为空,是递归结束的标识。

Option: Is the Bucket Empty or Full?

使用Scala,可以很直观地完成Option的递归定义。

sealed trait Option[+A]
case class Some[+A](get: A) extends Option[A]
case object None extends Option[Nothing]

因为Option是不可变的(Immutable),因此Option应该设计为协变的,即Option[+A]。也就是说,对于任意的类型AOption[Nothing] <: Option[A],即None <: Option[A]都成立。

3.2 自制List

Option类似,List也是一个递归的数据结构,它由头部和尾部组成。其中,Nil表示为空,是递归结束的标识。

List的递归结构

使用Scala,可以很直观地完成List的递归定义。

sealed trait List[+A]
case class Cons[A](head: A, tail: List[A]) extends List[A]
case object Nil extends List[Nothing]

因为List是不可变的(Immutable),因此List应该设计为协变的,即List[+A]。也就是说,对于任意的类型AList[Nothing] <: List[A],即Nil <: List[A]都成立。

3.2.1 实现cons

可以在List中定义了cons算子,用于在List头部追求元素。

sealed trait List[+A] {
  def cons(a: A): List[A] = Cons(a, this)
}

此时,编译器将报告协变类型A出现在逆变的位置上的错误。因此,在遵循「里氏替换」的基本原则,使用「下界(Lower Bound)」对A进行界定,转变为「不变的(Nonvariable)」的类型参数A1

sealed trait List[+A] {
  def cons[A1 :> A](a: A1): List[A1] = Cons(a, this)
}

至此,又可以得到一个重要的结论。

3.2.2 结论3

对于不可变的(Immutable)类型:C[-T, +R]

  1. 当协变类型参数R出现在函数参数时,使用「下界」R1 >: R进行界定,将其转变为不变的(Nonvariable)类型参数R1
  2. 当逆变类型参数T出现在函数返回值时,使用「上界」T1 <: T进行界定,将其转变为不变的(Nonvariable)类型参数T1

Listcons算子就是通过使用「下界」界定协变类型参数A,将其转变为不变的(Nonvariable)类型参数A1的。而对于「上界」,通过实现ScalaHamcrest的基本功能进行讲述,并完成整个型变理论知识的回顾和应用。

4. 实战ScalaHamcrest

对于任意的类型AA => Boolean常常称为「谓词」;如果该谓词用于匹配类型A的某个值,也常常称该谓词为「匹配器」。

ScalaHamcrest首先定义一个Matcher,并添加了&&, ||, !的基本操作,用于模拟谓词的基本功能。

class Matcher[A](pred: A => Boolean) extends (A => Boolean) {
  self =>

  def &&(that: Matcher[A]): Matcher[A] =
    new Matcher[A](x => self(x) && that(x))

  def ||(that: Matcher[A]): Matcher[A] =
    new Matcher[A](x => self(x) || that(x))

  def unary_! : Matcher[A] =
    new Matcher[A](x => !self(x))

  def apply(x: A): Boolean = pred(x)
}

4.1 支持型变

对于函数A => Boolean,类型参数A是逆变的。因此,为了得到支持型变能力的Matcher,应该将类型参数A声明为逆变。

class Matcher[-A](pred: A => Boolean) extends (A => Boolean) {
  self =>

  // error: contravariant type A occurs in covariant position.
  def &&(that: Matcher[A]): Matcher[A] =
    new Matcher[A](x => self(x) && that(x))

  // error: contravariant type A occurs in covariant position.
  def ||(that: Matcher[A]): Matcher[A] =
    new Matcher[A](x => self(x) || that(x))

  def unary_! : Matcher[A] =
    new Matcher[A](x => !self(x))

  def apply(x: A): Boolean = pred(x)
}

但是,此时&&, ||将报告逆变类型A出现在协变的位置上。为此,可以使用「上界」对A进行界定,转变为不变的(Nonvariant)类型A1

对于逆变的类型Matcher[-A],当它作为函数函数参数时,其型变能力将置反。因此,定义def &&(that: Matcher[A]): Matcher[A]时,that的类型实际为Matcher[+A]

class Matcher[-A](pred: A => Boolean) extends (A => Boolean) {
  self =>

  def &&[A1 <: A](that: Matcher[A1]): Matcher[A1] =
    new Matcher[A1](x => self(x) && that(x))

  def ||[A1 <: A](that: Matcher[A1]): Matcher[A1] =
    new Matcher[A1](x => self(x) || that(x))

  def unary_![A1 <: A]: Matcher[A1] =
    new Matcher[A1](x => !self(x))

  def apply(x: A): Boolean = pred(x)
}

4.2 原子匹配器

基于Matcher,可以定义特定的原子匹配器。例如:

case object Always extends Matcher[Any](_ => true)
case object Never  extends Matcher[Any](_ => false)

也可以定义EqualTo的原子匹配器,用于比较对象间的相等性。

class EqualTo[-A](expected: A) extends Matcher[A] (
  _ == expected
)

object EqualTo {
  def apply[A](expected: A) = new EqualTo(expected)
}

EqualTo类似,可以定义原子匹配器Same,用于比较对象间的一致性。

class Same[-A <: AnyRef](expected: A) extends Matcher[A] (
  expected eq _
)

object Same {
  def apply[A <: AnyRef](expected: A) = new Same(expected)
}

其中,A <: AnyRef类型A进行界定,排除AnyVal的子类误操作Same。类似于类型上界,也可以使用其他的类型界定形式;例如,可以定义InstanceOf,对类型A进行上下文界定,用于匹配某个实例的类型。

class InstanceOf[-T : ClassTag] extends Matcher[Any] (
  _ match {
    case _: T => true
    case _    => false
  }
)

object InstanceOf {
  def apply[T : ClassTag] = new InstanceOf[T]
}

有时候,基于既有的原子可以很方便地构造出新的原子。

case object IsNil extends EqualTo[AnyRef](null)
case object Empty extends EqualTo("")

4.3 组合匹配器

也可以将各个原子或者组合器进行组装,形成威力更为强大的组合器。

case class AllOf[-A](matchers: Matcher[A]*) extends Matcher[A] (
  actual => matchers.forall { _(actual) }
)

case class AnyOf[-A](matchers: Matcher[A]*) extends Matcher[A] (
  actual => matchers.exists { _(actual) }
)

特殊地,基于AnyOf/AllOf,可以构造很多特定的匹配器。

object Blank extends Matcher[String] (
  """\s*""".r.pattern.matcher(_).matches
)

object EmptyOrNil extends AnyOf(IsNil, Empty)
object BlankOrNil extends AnyOf(IsNil, Blank)

4.4 修饰匹配器

修饰也是一种特殊的组合行为,用于完成既有功能的增强和补充。

case class Not[-A](matcher: Matcher[A]) extends Matcher[A] (
  !matcher(_)
)

case class Is[-A](matcher: Matcher[A]) extends Matcher[A] (
  matcher(_)
)

其中,Not, Is是两个普遍的修饰器,可以修饰任意的匹配器;也可以定义针对特定类型的修饰器。例如,可以定义针对字符串操作的原子匹配器和修饰匹配器。

case class Starts(prefix: String) extends Matcher[String] (
  _ startsWith prefix
)

case class Ends(suffix: String) extends Matcher[String] (
  _ endsWith suffix
)

case class Contains(substr: String) extends Matcher[String] (
  _ contains substr
)

如果要忽略大小写,则可以通过定义IgnoringCase,修饰既有的字符串的原子匹配器。

case class IgnoringCase(matcher: Matcher[String]) extends Matcher[String] (
  s => matcher(s.toLowerCase)
)

object IgnoringCase {
  def equalTo(str: String)  = IgnoringCase(EqualTo(str.toLowerCase))
  def starts(str: String)   = IgnoringCase(Starts(str.toLowerCase))
  def ends(str: String)     = IgnoringCase(Ends(str.toLowerCase))
  def contains(str: String) = IgnoringCase(Contains(str.toLowerCase))
}

4.5 语法糖

有时候,可以通过定义语法糖,提升用户感受。例如,可以使用Not替换Not(EqualTo)Is替代Is(EqualTo),不仅减轻用户的负担,而且还能提高表达力。

object Not {
  def apply[A](expected: A): Not[A] = Not(EqualTo(expected))
}

object Is {
  def apply[A](expected: A): Is[A] = Is(EqualTo(expected))
}

4.6 测试用例

至此,还不知道ScalaHamcrest如何使用呢?可以定义一个实用方法assertThat

def assertThat[A](actual: A, matcher: Matcher[A]) {
  assert(matcher(actual))
}

其中,assert定义于Predef之中。例如存在如下一个测试用例。

assertThat(2, AllOf(Always, InstanceOf[Int], Is(2), EqualTo(2)))

也可以使用&&直接连接多个匹配器形成调用链,替代AllOf匹配器。

assertThat(2, Always && InstanceOf[Int] && Is(2) && EqualTo(2))

5. 未来演进

此处为了演示「型变」的作用,ScalaHamcrest采用了OOFP相结合的设计手法,在下一章讲解「Scala函数论」时,ScalaHamcrest将采用纯函数式的设计手法实现,敬请关注。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容