Martin Odersky 向 Frank Sommers 和 Bill Venners 谈论 Scala 类型系统背后的设计动机。
Scala 是一种新兴的通用用途、类型安全的 Java 平台语言,结合了面向对象和函数式编程。它是洛桑联邦理工大学教授 Martin Odersky 的心血结晶。本访谈系列由多部分组成,由 Artima 网站的 Frank Sommers 和 Bill Venners 向 Martin Odersky 讨教 Scala。在第一部分 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
当我们结合泛型和子类型时,型变是个核心功能。然而它也很复杂。并没有什么办法可以完全化解其复杂度。我们能比 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
感谢魏星对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群)。
评论