Scala的类型系统:取代复杂的通配符

51CTO编辑推荐:Scala编程语言专题

【51CTO独家特稿】上次我们讲了Scala的类型系统,讲到了它的可扩展性,它的Duck Typing类型推理功能,展示了在类型系统上它比Java更加的灵活。本文中,Martin Odersky将继续讲解Scala的类型系统。今天的内容是映射Java通配符的Existential类型,类型的可变性,以及抽象类的功能。

Existential类型

Bill Venners:最近Scala中添加了一些Existential(存在)类型。我听说添加Existential类型的理由是为了可以映射所有Java类型到Scala类型,特别是Java的通配符类型。Existential类型数量是否多于Java通配符类型?它们是否是Java通配符类型的一个扩展集?是否存在其他人们应该了解它的理由? 

Martin Odersky:这很难说,因为人们并没有一个真正的关于什么是通配符概念。最初由Atsushi Igarashi和Mirko Viroli设计的通配符,其灵感来源于Existential类型。事实上,最初的论文中存在一个使用Existential类型的编码。但后来当实际最终设计在Java中实现时,这种联系就减少了一些。所以,现在我们真的不了解这些通配符类型的状况。

Existential类型已经出现许多年了,至今为止大约有20年左右。这种类型可以很简单地表达信息。例如你有一个类型,也许是list(列表)类型,其中一个列表项的类型你不知道,你只知道它是一些特殊元素类型的列表,但你不知道元素的类型。在Scala中,这就可以表示成一个Existential类型。语法是List[T] forSome { type T }.。这看起来有点繁琐。这种繁琐的语法实际上是故意的,因为它产生的Existential类型通常有点难以处理。现在,Scala有了更好的选择。它并不需要这么多Existential类型,因为我们可以使用包含其他类型成员的类型。 

Scala需要Existential类型有3个本质上的理由。首先,我们需要弄清一些Java通配符的意思,Existential类型就是我们所理解的意思。其次,我们需要弄清一些Java raw(原始)类型的意思,因为它们仍处于类库中,是ungenerified(非属性的)类型。如果你使用一个Java原始类型,如java.util.List,这是一个列表,你不知道列表元素的类型。在Scala中这可以被表示成一个Existential类型。最后,我们需要使用Existential类型来解释在虚拟机上发生着什么事情。Scala像Java一样,使用泛型擦除模式,所以当程序运行时,我们不再能看到类型参数。为了能与Java互用,我们需要进行擦除操作。但是,当我们做映射或想要表示时,在虚拟机上会发生什么事情?我们需要能够表达虚拟机在使用Scala中的类型时做了什么事情,Existential类型让我们做到了这一点。Existential类型可以让你在不了解类型中某些方面的情况下使用它们。

Bill Venners:您能举一个具体的例子吗? 

Martin Odersky:以Scala lists(列表)为例。我希望能够描述方法的返回类型,head,它会返回列表第一个元素(头一个)。在VM水平,这是一个List[T] forSome { type T }。我们不知道T是什么。Existential类型理论告诉我们,这是一个适合某个类型T的T。这相当于根类型――对象。因此,我们从head方法得到这个类型。因此在Scala中,当我们知道某个类型时,我们可以消除这些Existential限制。当我们不知道某个类型时,我们就可以使用Existential,Existential类型理论就是在这里给予我们帮助。 

Bill Venners:如果您没有必要担心与Java通配符、原始类型和擦除的兼容性,还会添加Existential类型吗?如果Java拥有具体化的类型,没有原始类型和通配符,那么Scala还会有Existential类型吗? 

Martin Odersky:如果Java拥有具体化的类型,没有原始类型和通配符,我认为Existential类型的使用量就没那么大了,那么我会考虑Scala不使用它。

可变性

Bill Venners:在Scala中,是在定义类的时候定义可变性(variance),而在Java中,是在使用通配符的地方定义它。您能否谈谈这一差异? 

Martin Odersky:由于我们可以在Scala中使用Existential类型建模通配符,实际上如果你想,你也可以在Java中做同样的事情。但是,我们不鼓励你这么做,而是建议使用定义地点可变性(definition site variance)来代替。这是为什么?首先,什么是定义地点可变性?当你定义一个带有一个类型参数的类时,例如List[T],这就带来了一个问题。如果你有一个苹果列表,那么它同样也是一个水果列表吗?你会说,当然是的。如果苹果是水果的一个子类型,那么List[Apple]应该是List[Fruit]的一个子类型。这种子类型关系被称为协变(covariance)。但在某些情况下,这种关系并不有效。如果我有一个变量,变量可以保存一个苹果,可以保存苹果类型的一个引用。但,这不是水果类型的一个引用,因为我不能分配任何其它水果给这个变量,它只能是一个苹果。所以,你可以看到,有些情况下我们应该有子类型,而有些情况下,子类型就不应该有。

Scala的解决方案是注释类型参数。如果List在T上是协变的,我们就可以写成List[+T]。这将意味着Lists在T上是协变的。当然这存在一些附属条件。例如,只有当没有人改变List的情况下,我们才可以这么做,否则我们将遇到使用引用时遇到的同样问题。  

在Scala中会发生什么事,这是程序员说的,我认为Lists应该是协变的,这意味着尊重子类型关系。然后,程序员将会在声明的地方,用一个加号修饰类型参数T,针对所有使用的List只修饰一次。然后编译器将去找出是否List内的所有定义都与其一致。如果存在某些与协变不符的地方,Scala编译器将会提示错误。Scala拥有一系列的技术来处理这些错误,一个有能力的Scala程序员将会很快注意到这些错误,并应用这些技术,最终生成一个错误处理类。使用者就不必再去顾虑这些错误了。他们只需知道如果我有一个List,我就可以在任何地方协变地使用它。因此,这意味着只有一个人在写list类,只有他需要考虑有点难度的问题,但这也不至于太糟糕,因为编译器会用错误提示帮助他。 

相比之下,Java带有通配符的方法意味着在类中你什么都做不了。你只是写ListT>。然后,如果用户想要一个协变list,他们不写ListFruit>,而是写List? extends Fruit>。所以这是一个通配符。问题是,这是用户代码。这些用户通常都没有类库设计人员那么专业。此外,这些注释间一个单一的不匹配将会带来类型错误。因此,难怪你会得到大量与通配符有关的非常棘手的错误信息,我认为这是Java泛型最重要的罪魁祸首。因为这种通配符的方法对于普通人来说确实是太复杂、太难于处理。 

可变性是当你结合泛型和子类型时非常重要的东西,但它也很复杂。没有办法能完全让它变成一件小事。我们做的比Java好的地方是,可以让你只在类库中做一次,使得用户不需要考虑和处理它。 

抽象类

Bill Venners:在Scala中,一个类型可以是另一个类型的成员,就如同方法和域可以是一个类型的成员。在Scala中,这些类型成员可以是抽象的,就如同在Java中方法可以抽象。在抽象类型成员和泛型参数之间是否存在重叠?为什么Scala两者都包含?抽象类型具有哪些泛型所不具有的功能?

Martin Odersky:抽象类型确实具有一些泛型所不具有的功能,但首先让我陈述一个稍微普遍的原理。一直都存在两个抽象概念:参数和抽象成员。在Java中,两者都有,但它取决于你在抽象什么。在Java中你可以有抽象方法,但你不能把方法作为参数传递。你并不拥有抽象域,但可以传值作为参数。同样,你没有抽象类型成员,但你可以指定一种类型作为参数。因此,在Java中你可以有以上3种方式,但使用什么抽象原则是有区别的。你可以争辩说,这种区别是相当武断的。 

我们在Scala中所做的是力求更全面和垂直。我们决定对以上所有3种成员都采用同样的构造原则。所以,你可以有抽象域,也可以有值参数。你可以传递方法(或“函数”)作为参数,或者也可以抽象它们。您可以指定类型作为参数,或者也可以抽象它们。我们概念性地得到的是,我们可以按照其它的建模另一个。至少在原则上,我们可以表达各种参数为一种面向对象的抽象。因此,在某种意义上可以说Scala是一种更垂直、更全面的语言。 

现在,问题仍然存在,这能给你带来什么好处?抽象类型是对以上我们谈到的问题的很好的处理,一个已经存在了很长一段时间的标准问题是动物和食物。让人不解的是,有个动物类,带有一个吃一些食物的方法。问题是,如果我们建立一个动物类的子类,如牛,那么它们将只吃草,而不是任意食物。例如,牛不会吃鱼。你真正想要的是一个牛类,带有一个只吃草而不吃其它东西的方法。实际上,在Java中你不能这样做,如像前面提到的分配一个任意的水果给苹果变量的问题。 

问题是,你怎么办?答案是,你为动物类添加一个抽象类型。你说,新的动物类中含有一个SuitableFood(适当食物)的类型,这我不知道。因此这是一个抽象类型。你并不给出类型实现。然后,你就可以有一个吃的方法,只吃适当的食物。然后在牛类中,我会说,好吧,我有一个牛类,它继承于动物类,并且对于牛类型来说,适当的食物就是草。因此,在子类中就可以实现这些抽象。 

现在,你可以说,我可以用参数完成同样的事情。事实上,你确实可以。你可以给动物类添加参数,参数为各种所吃的食物。但在实践中,当你要完成很多事情的时候,这就导致了参数爆炸,而且通常更重要的是,参数的范围。在1998年的ECOOP ,Kim Bruce, Phil Wadler和我一起发过一个文章,我们指出,随着你增加你所不知道的东西的数量,典型的程序将会以2次方程式的数量增加。因此,我们有理由尽量不用参数,而是使用抽象成员。 

适应新的语法

Bill Venners:当人们随机查看Scala代码时,我认为有两件事可以使它看上去有点神秘。一个是DSL是他们不熟悉的,就像是解析器或XML类库。另一个是类型系统的各种各样的表达式,特别是表达式的联合。Scala程序员如何能掌握这样的语法? 

Martin Odersky:当然这里存在很多新东西,必须进行学习和吸收。因此,这将花费一些时间。我相信我们需要继续努力研究的一件事是更好的工具支持。现在,当你获得类型错误时,我们试图给你一个不错的错误信息。有时候,错误信息有很多行,能够解释得更好。我们尽力做好,但我认为如果我们能有更好的交互性,我们将可以做的更好。

试想一下,如果有一个动态类型语言,对于一个错误信息,只有3到4行的错误提示。可能不会有调试器,不会有堆栈跟踪,只有3到4行提示信息,如“空指针废弃,”也许会有发生错误的行号。在这种情况下,我不认为动态语言会是非常受欢迎的。当然,这不是真实发生的事情。实际上,你拥有一个调试器,可以让你快速找到错误根源。 

对于类型,我们还没有这些设施。我们所有的只是一些错误信息。如果你有一个非常丰富和富有表现力的类型系统,它需要更多的知识来理解这些错误信息,你想要更多帮助。因此,在未来我们要研究的一件事是,我们是否能够真正给你一个更具有互动性的环境,例如,如果类型出现问题,你可以找出错误原因。例如,如何让编译器指出这个表达式的类型应该是这个,以及它为什么不认为这个类型符合其它预期类型。你可以交互式探索这些东西。如果这样,我想,由于类型所导致的错误将能够更容易被发现。

相关推荐