Scala类型系统的目的——Martin Odersky访谈(三)

Martin Odersky向Frank Sommers和Bill Venners谈论Scala类型系统背后的设计动机。

Scala是一种新兴的通用用途、类型安全的Java平台语言,结合了面向对象和函数式编程。它是洛桑联邦理工大学教授Martin Odersky的心血结晶。本访谈系列由多部分组成,由Artima网站的Frank Sommers和Bill Venners向Martin Odersky讨教Scala。在第一部分Scala起源中,Odersky讲述了导致Scala诞生的那些历史。在第二部分Scala的设计目标中,Odersky讨论Scala设计中的妥协、目标、创新和优势。在本期中,他将挖掘Scala的类型系统的设计动机。

Scala的“可伸缩性(scalability)”价值

Frank Sommers: 去年JavaOne大会上,你声称Scala是一种“可伸缩的语言”,既可以用于小规模程序,也可以用于大规模程序。像我这样的程序员,使用类似这样的语言,有什么好处?

Martin Odersky: Scala带来的帮助,就是让你不必混用多种专用语言。无论小型程序还是大型程序,无论通用用途还是特定应用领域,你都可以只用这一种语言。这意味着,你不用操心如何在不同语言环境中传递数据。

如果你想要跨越边界传递数据,按现在的业界惯例,你往往会陷入底层实现的重重泥潭中。比如,如果你想用JDBC,从Java向数据库发起一次SQL查询,那么你发出的查询最终会是个字符串。这就意味着你的程序中只要有小小的拼写错误,在最终运行时,就会表现为一次非法查询,很可能就导致最终客户网站出错。整个过程中编译器或类型系统并不会告诉你,你某处写错了。这非常脆弱和危险。所以,如果你只用一种语言,会有很多好处。

另一个问题是工具。如果您只使用一种语言,那么你只需要面对一套环境和工具。而如果你有多种不同的语言,你就必须混合并适配多套环境,构建过程也变得更复杂、更困难。

Scala的可扩展性(extensibility)

Frank Sommers: 上次演讲你还提到了可扩展性。你说Scala很容易扩展。你能解释一下吗?同样再问一句,这对程序员有什么好处?

Martin Odersky: 可伸缩性的维度是“从小到大”。除此之外,我觉得还有另一概念“可扩展性”,表示“从通用用途到特定需求”。你需要强化Scala语言,使之涵盖你特别关注的领域。

举个例子,数字类型。不同领域有很多特殊的数字类型——比如,密码学家用的大数、商务人士用的十进制大数,科学家用的复数——这样的例子不胜枚举。上述每个群体都会真正深切关注他们所需的类型,但作为一门语言,如果加入了所有类型,就会过于笨重。

怎么办呢?当然我们可以说,这样吧,把这些类型留给外部库实现吧。不过,如果你真的关心某个应用领域,那么你会希望,调用这些库的代码,看起来能像调用内置类型的代码一样干净、优雅。为此,你需要语言提供某些扩展机制,使你可以编写用起来感觉不像库的库。对库用户来说,比方说,使用某个十进制大数库中的BigDecimal类型时,应该像使用内置的Int一样方便。

小规模编程中的类型

Frank Sommers: 先前你提到,在使用单一语言而非混用多语言的场合,类型尤为重要。我觉得大部分人都认可,大规模编程时,类型确有其效。在超大型程序中,类型能帮你组织程序,保证改代码不会把程序搞坏。但是,小规模编程的场合下我们为什么还要用类型?比如只编写一段脚本时?对这种程度的编程,类型重要吗?

Martin Odersky: 小规模程序恐怕类型真没那么重要。类型的价值分布在一条长长的光谱上,一端表示超级有用,一端表示极度麻烦。通常情况下,说它麻烦,是因为类型定义可能会太过冗余,要求你手动指定大量类型。说它有用,则是因为,类型能避免运行时错误,类型能提供API签名文档,类型能为代码重构提供安全底线。

Scala的类型推断,试图尽可能减少类型的麻烦之处。这意味着,你编写脚本时并不需要涉及类型。因为即使你不指名类型,系统仍会为你推断出类型。同时,编译器内部仍然会考虑类型。所以你写的脚本如果有类型错误,编译器将发现错误,为你提供错误信息。而且,我相信,不管脚本还是大型系统,依靠编译器提示及早修复错误,总比推后错误要好。

单元测试和随心所欲的表达式

您仍然需要单元测试来测试你的程序逻辑。但相比动态类型语言,你不需要那么多针对类型的琐碎单元测试。根据很多人的经验,Scala所需的单元测试比动态语言少得多。你的经历可能有所不同,但我们在大量案例中所得体验的确如此。

另一条针对静态类型系统的反对意见是:静态类型系统对表达方式限制太严。人们说,“我想自由地表达自己。我不想要静态类型系统的条条框框”。根据我的Scala经验,这种意见不靠谱,我认为有两个原因。第一个原因是,Scala的类型系统实际上非常灵活,所以通常它可以让你用非常灵活的模式排列组合。反之,像Java这样的语言,类型系统表达能力偏弱,就难以实现。第二个原因是,通过模式匹配,你可以通过非常灵活的方式抽回类型信息,甚至根本感觉不到类型信息的损失。

Scala模式匹配的亮点在于,我可以对我一无所知的对象,用类似switch语句的结构,提供若干模式,进行匹配。只要这些模式之一匹配成功,我还能够立刻取出其中的字段,存到局部变量上。模式匹配是Scala核心中内置的结构。许多Scala程序都用了它。这属于用Scala干活的日常惯例。模式匹配还有个有趣的功能:自动抽回类型。当对你不知道类型的对象进行模式匹配时,如果匹配成功,实际上模式本身其实就可以提供一些类型信息。而类型系统可以利用这些信息。

有了模式匹配,你可以很容易拥有一套系统,在系统中,你只使用通用类型(甚至通用到了极致,所有变量都是Object),但你仍然可以靠使用模式匹配获得所有类型信息。因此,在这个意义上,在Scala程序中,你可以像动态类型语言一样编写完美的动态代码。你可以到处都用Object,只要到处都模式匹配即可。现在一般人不这样做,是为了更好的利用静态类型的优势。但这种做法算是一种非常平滑的备用方案,平滑到了不知不觉的程度。相比之下,在Java中,类似情况下,你必须使用大量的类型检测(instanceof)和类型转换,可谓是又笨又重。我当然完全理解人们为什么反对到处滥用这种写法。

聒噪的鸭子

Bill Venners: 我观察到一件有关Scala的事情,相比Java的类型系统,在Scala类型系统中,我可以让程序表达出更多东西。从Java逃向动态语言的人往往会解释说,他们对类型系统感到沮丧,扔掉静态类型后他们感觉更好了。然而Scala似乎给出了另一个答案:尝试去改善类型系统,让它用途更广,用着更爽。哪些事情是在Scala类型系统能做到但在Java类型系统中却做不到?

Martin Odersky: 针对Java类型系统有一条反对意见:缺了所谓的鸭子类型。可以这样解释鸭子类型:“如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子。”翻译一下:只要它具备我所需的功能,那么就可以把它当真。举例来说,我希望取得某种支持关闭操作的资源。我希望以这种方式声明:“它必须有close方法。”我不在乎它是File、Channel还是其他任何东西。

要想在Java中实现的话,你需要定义一个包含该方法的通用接口,而大家都需要实现这个接口。首先,为了实现这一切,将导致大量接口和大量样板代码。其次,如果有了既成事实之后,你才想到要提个接口,那么几乎就不可能做到。如果事先就写了一些类,鉴于这些类早已存在,那么你不改代码就没法加入新接口,除非修改所有客户代码。所以,这一切困难都是类型强加给你的限制。

另一方面,Scala比Java表现力更强。Scala能表达鸭子类型。你完全可以用Scala把某一类型定义为:“任何拥有close方法且close返回Unit(类似Java的void)的类型”。你还可以把类型定义与其他约束结合起来。你可以这样定义类型:任何继承自某个类型,且拥有某某方法签名的类型。你还可以这样定义:任何继承自某某类,且拥有某某类型的内部类的类型。从本质上讲,你可以通过定义类型中有哪些东西将为你所用,来描绘类型的结构。

既存类型(existential types)

Bill Venners: 既存类型加入Scala的时间比较晚。我听说Scala加入既存类型的理由是,为了把所有Java类型映射到Scala中。具体到既存类型,可以对应到Java的通配符类型。既存类型是否比通配符更强大?是不是Java通配符类型的超集?还有哪些理由要告诉大家?

Martin Odersky: 不好说。因为大家对通配符并没有真正靠谱的构想。原先的通配符由Atsushi Igarashi和Mirko Viroli设计。他们的灵感来自既存类型。实际上原先的论文中的确包含了既存类型的字节码编码方案。但后来当实际最终设计加进Java时,二者的联系有所削弱。所以,我们也不知道通配符类型的确切现状了。

既存类型早已发明,有着距今约20年的历史。既存类型要表达的概念很简单。它表示,给定某种类型,比如List,但其内部元素是未知类型。你只知道它是由某种特定类型元素组成的List,然而你并不知道元素的“特定类型”具体是哪种类型。在Scala中,这种概念可以用既存类型来表达。语法如下:List[T] forSome { type T }。稍微有点笨重。笨重的语法其实算是有意为之。因为事实证明,既存类型往往不大好处理。Scala有更好的选择。Scala不是那么需要既存类型,因为Scala的类型可以包含其它类型作为内部成员。

归根结底,Scala只有三种情况需要既存类型。第一,Scala需要能表示Java通配符的语义。既存类型能提供这种语义。第二,Scala需要能表示Java的raw类型,因为有许多库使用了非泛型类型,会出现raw类型。如果有一个Java raw类型(如 java.util.List),那么它其实是未知元素类型的List。它也可以用Scala中的既存类型表示。第三,既存类型可以用来把虚拟机中的实现细节反映到上层。类似Java,Scala使用的泛型模型是“擦除模型”。所以在程序运行起来以后,我们就再也找不着类型参数了。之所以要进行擦除,是为了与Java的对象模型可以互相操作。可是,如果我们需要反射,或者要表达虚拟机的实现细节时,怎么办?我们需要有能力用Scala中的某些类型表示Java虚拟机的行为。既存类型就提供了这种能力。有了既存类型,即使某一类型中的某些方面尚不可知,我们仍然可以操作该类型。

Bill Venners: 你能举个具体例子吗?

Martin Odersky: 以Scala的List为例。我希望能够描述head方法的返回类型 。该方法返回List的第一个元素(即首元素)。在虚拟机级别,List类型是List[T] forSome { type T }。我们不知道T是什么,只知道head返回T 。既存类型理论告诉我们,该类型是“某些类型T中的T”,也就相当于根类型, Object 。那么我们从head方法取回的就是Object。因此,在Scala中,要是我们知道更多信息,我们可以直接指定具体类型而不用既存类型的限定规则。但要是没有更多信息,我们就留着既存类型,让既存类型理论帮我们推断出返回类型。

Bill Venners: 如果当初你不需要考虑Java兼容性,比如通配符类型、raw类型和类型擦除,那么你还会加入既存类型吗?如果Java采用的是具现化的泛型类型系统,不支持raw类型或通配符类型,那么Scala还会有既存类型吗?

Martin Odersky: 如果Java采用的是具现化的泛型类型系统,不支持raw类型或通配符类型,那么我觉得既存类型用处不大,恐怕Scala中不会加入。

Java和Scala中的型变(Variance)

Bill Venners: Scala中的型变定义位于类型定义之处,而Java的型变则要定义在使用通配符的代码之处。你能否谈谈二者的差异?

Martin Odersky: Scala的既存类型一样支持通配符,所以,只要你愿意,你照样可以在Scala中使用与Java相同的写法。但是,我们建议你不要这样做,我们鼓励你改用位于类型定义之处的型变语法。为什么呢?首先,什么是“类型定义之处的型变”?当你定义某个类的类型参数时,例如List[T]时,会有一个问题。如果给你一个“苹果(Apple)列表”,那么,它算不算“水果(Fruit)列表”呢?你会说,当然算。只要Apple是Fruit的子类型, List[Apple]就应该是List[Fruit]子类型。这种子类型的关系称为协变(covariance) 。但在某些情况下,这种关系不成立。比方说,我有一个变量,只能用来保存Apple,那么这个变量就是对类型Apple的引用。这个变量并不能当做Fruit类型的引用 ,因为我并不能把任意Fruit赋值给这个变量。它只能是Apple。所以,你可以看到,上述的子类型关系,在有一些情况下适用,另一些情况下不适用。

Scala中的解决方案是,给类型参数加个标志。如果List中的T支持协变 ,我们可以写做List[+T]。这将意味着任意List之间的关系都可以随着其T的关系而协变。要想支持协变,必须遵守协变的附加条件。举例来说,只有List内容不可变时,List才能支持协变,因为若非如此,就会遇上刚才引用变量例子中类似的问题,而导致无法协变。

Scala中的机制是这样的:程序员可以声明“我觉得List应该支持协变”,即,要求List必须遵守子类型关系。那么,程序员把在类型声明之处,给类型参数T标上加号——只标注一次。而List的任何用户,都只需直接使用即可。然后,编译器会去找出List内的所有定义实际上是否兼容于协变,确保List中不存在导致冲突的成员签名。如果Scala编译器发现了不兼容协变之处,就触发编译错误。Scala社区有一系列的惯用技术来解决这些错误。称职的Scala程序员通常可以很快掌握这些技术。当称职的Scala程序员用上这些技术时,只要他编写的类最终通过了编译,就能为用户提供协变性。而用户就不再需要考虑协变问题了。用户只知道,只要给定一个List,就能以协变方式到处使用了。因此,这意味着,仅仅只有编写List类的那一个人必须多思考一点。但这其实不算太难,因为编译器会输出错误信息来帮助他。

相比之下,以Java的方式使用通配符,这就意味着库的提供者对协变爱莫能助,只能草草定义成List<T>了事。但接下来如果用户需要协变List,却不能写做List<Fruit>而必须写做List<? extends Fruit>。通配符就是这样用的。问题在于,这是用户代码啊!用户总不可能人人都像设计库的人那么专业吧。此外,这些标注之间,只要有一处不匹配,就会导致类型错误。就算通配符搞出了海量极晦涩的错误信息,那也毫不称奇。我觉得这是Java泛型为人诟病的首要原因了。因为,通配符用起来实在是相当复杂,正常人类根本无从把握、无法处理。

当我们结合泛型和子类型时,型变是个核心功能。然而它也很复杂。并没有什么办法可以完全化解其复杂度。我们能比Java做得好点,就在于,我们让你可以在库中处理型变,使用户感觉不到型变存在,不必手动处理型变。

抽象类型成员

Bill Venners: 在Scala中,一个类型可以是另一种类型的内部成员,正如方法和字段可以是类型的内部成员。而且,Scala中的这些类型成员可以是抽象成员,就像Java方法那样抽象。那么抽象类型成员和泛型参数不就成了重复功能吗?为什么Scala两个功能都支持?抽象类型,相比泛型,能额外给你们带来什么好处?

Martin Odersky: 抽象类型,相比泛型,的确有些额外好处。不过还是让我先说几句通用原理吧。对于抽象,业界一直有两套不同机制:参数化和抽象成员。Java也一样支持两套抽象,只不过Java的两套抽象取决于对什么进行抽象。Java支持抽象方法,但不支持把方法作为参数;Java不支持抽象字段,但支持把值作为参数;Java不支持抽象类型成员,但支持把类型作为参数。所以,在Java中,三者都可以抽象。但是对三者进行抽象时,原理有所区别。所以,你可以批判Java,三者区别太过武断。

我们在Scala中,试图把这些抽象支持得更完备、更正交。我们决定对上述三类成员都采用相同的构造原理。所以,你既可以使用抽象字段,也可以使用值参数;既可以把方法(即“函数”)作为参数,也可以声明抽象方法;既可以指定类型参数也可以声明抽象类型。总之,我们找到了三者的统一概念,可以按某一类成员的相同用法来使用另一类成员。至少在原则上,我们可以用同一种面向对象抽象成员的形式,表达全部三类参数。因此,在某种意义上可以说Scala是一种更正交、更完备的语言。

现在的问题来了,这对你有什么好处?具体到抽象类型,能带来的好处是,它能很好地处理我们先前谈到的协变问题。举个老生常谈的例子:动物和食物的问题。这道题是这样的:从前有个Animal类,其中有个eat方法,可以用来吃东西。问题是,如果从Animal派生出一个类,比如Cow,那么就只能吃某一种食物,比如Grass。Cow不可以吃Fish之类的其他食物。你希望有办法可以声明,Cow拥有一个eat方法,且该方法只能用来吃Grass,而不能吃其他东西。实际上,这个需求在Java中实现不了,因为你最终一定会构造出有矛盾的情形,类似我先前讲过的把Fruit赋值给Apple一样。

请问你该怎么做?Scala的答案是,在Animal类中增加一个抽象类型成员。比方说,Scala版的Animal类内部可以声明一个SuitableFood类型,但不定义它具体是什么。那么这就是抽象类型。你不给出类型实现,直接让Animal的eat方法吃下SuitableFood即可。然后,在Cow中声明:“好!这是一只Cow,派生自Animal。对Cow来说,其SuitableFood是Grass。”所以,抽象类型提供了一种机制:先在父类中声明未知类型,稍后再在子类中填上某种已知类型。

现在你可能会说,哎呀,我用参数也可以实现同样功能。确实可以。你可以给Animal增加参数,表示它能吃的食物。但实践中,当你需要支持许多不同功能时,就会导致参数爆炸。而且通常情况下,更要命的问题是,参数的边界。在1998年的ECOOP(欧洲面向对象编程会议)上,我和Kim Bruce、Phil Wadler发表了一篇论文。我们证明,当你线性增加未知概念数量时,一般来说程序规模会呈二次方增长。所以,我们有了很好的理由不用参数而用抽象成员,即为了避免二次方级别的代码膨胀。

用惯Scala的语法

Bill Venners: 大家随便翻些Scala代码来读时,会有两件事情,让大家觉得Scala有点隐晦。首先,可能会遇上某种不熟悉的DSL(领域特定语言),比如Scala的parser combinators库或是XML库。其次,可能会遇上类型系统中的各种怪符号,尤其当这些怪符号一起出现时。Scala程序员怎么才能找到处理这类语法的诀窍呢。

Martin Odersky: 当然,需要学习和吸收的新东西不少。因此,这需要一定的时间。我相信有一件事我们必须努力:更好的工具支持。目前,如果你触发了了类型错误,我们尽量给你提供友好的错误信息。有时,为了能解释为何出错,错误信息会横跨多行。我们一直在努力,但我觉得我们还可以做得更好:我们可以增加错误信息的交互性。

试想一下,假如你用动态类型语言时发生了运行时错误,每条错误信息最多三四行。而且又没有调试器、没有调用栈信息,就只有三四行的“未将对象引用设置到对象的实例”,可能最多再给你一个行号。那么这种情况下,我觉得动态类型语言不可能流行起来。当然啦,现实中的动态类型语言没这么弱,它会把你扔到调试器中,让你可以快速找出错误根源。

我们的类型系统目前还做不到。我们只能得到这点错误信息。Scala的类型系统非常丰富、表达力强,需要更多知识才能让错误信息靠谱,程序员就会需要更多工具的协助。所以,我们今后会调研一件事,我们能不能真正提供更具交互性的环境,让程序员能在类型系统出错时找出错误原因。例如,如何让编译器能找出表达式的类型、能知道为什么实际类型与所需类型没匹配上。我们可以通过交互方式挖出这些信息。我想,到了那时,程序员就可以比现在更容易找到类型错误的原因了。

另一方面,一些语法还很新,需要一些时间适应。这一点我们可能无法回避。我们只希望若干年后,这些语法能被大家不假思索、完全自然的接受。目前主流语言中的一些东西,当初大家接受时,也花了不少时间。我清楚的记得,异常刚出现时,大家也觉得很奇怪,花了很多时间才适应。而到了现在,每个人都觉得异常用起来相当自然了,不再觉得新奇。Scala确实引入了一些新东西(主要是在类型方面),这些新东西需要一些时间来适应。

查看英文原文:The Purpose of Scala's Type System

你可能感兴趣的:(Scala类型系统的目的——Martin Odersky访谈(三))